comments.js
664 lines
| 21.6 KiB
| application/javascript
|
JavascriptLexer
r1 | // # Copyright (C) 2010-2016 RhodeCode GmbH | |||
// # | ||||
// # This program is free software: you can redistribute it and/or modify | ||||
// # it under the terms of the GNU Affero General Public License, version 3 | ||||
// # (only), as published by the Free Software Foundation. | ||||
// # | ||||
// # This program is distributed in the hope that it will be useful, | ||||
// # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
// # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||||
// # GNU General Public License for more details. | ||||
// # | ||||
// # You should have received a copy of the GNU Affero General Public License | ||||
// # along with this program. If not, see <http://www.gnu.org/licenses/>. | ||||
// # | ||||
// # This program is dual-licensed. If you wish to learn more about the | ||||
// # RhodeCode Enterprise Edition, including its added features, Support services, | ||||
// # and proprietary license terms, please see https://rhodecode.com/licenses/ | ||||
var firefoxAnchorFix = function() { | ||||
// hack to make anchor links behave properly on firefox, in our inline | ||||
// comments generation when comments are injected firefox is misbehaving | ||||
// when jumping to anchor links | ||||
if (location.href.indexOf('#') > -1) { | ||||
location.href += ''; | ||||
} | ||||
}; | ||||
// returns a node from given html; | ||||
var fromHTML = function(html){ | ||||
var _html = document.createElement('element'); | ||||
_html.innerHTML = html; | ||||
return _html; | ||||
}; | ||||
var tableTr = function(cls, body){ | ||||
var _el = document.createElement('div'); | ||||
var _body = $(body).attr('id'); | ||||
var comment_id = fromHTML(body).children[0].id.split('comment-')[1]; | ||||
var id = 'comment-tr-{0}'.format(comment_id); | ||||
var _html = ('<table><tbody><tr id="{0}" class="{1}">'+ | ||||
'<td class="add-comment-line"><span class="add-comment-content"></span></td>'+ | ||||
'<td></td>'+ | ||||
'<td></td>'+ | ||||
'<td>{2}</td>'+ | ||||
'</tr></tbody></table>').format(id, cls, body); | ||||
$(_el).html(_html); | ||||
return _el.children[0].children[0].children[0]; | ||||
}; | ||||
var removeInlineForm = function(form) { | ||||
form.parentNode.removeChild(form); | ||||
}; | ||||
var createInlineForm = function(parent_tr, f_path, line) { | ||||
var tmpl = $('#comment-inline-form-template').html(); | ||||
tmpl = tmpl.format(f_path, line); | ||||
var form = tableTr('comment-form-inline', tmpl); | ||||
var form_hide_button = $(form).find('.hide-inline-form'); | ||||
$(form_hide_button).click(function(e) { | ||||
$('.inline-comments').removeClass('hide-comment-button'); | ||||
var newtr = e.currentTarget.parentNode.parentNode.parentNode.parentNode.parentNode; | ||||
if ($(newtr.nextElementSibling).hasClass('inline-comments-button')) { | ||||
$(newtr.nextElementSibling).show(); | ||||
} | ||||
$(newtr).parents('.comment-form-inline').remove(); | ||||
$(parent_tr).removeClass('form-open'); | ||||
$(parent_tr).removeClass('hl-comment'); | ||||
}); | ||||
return form; | ||||
}; | ||||
var getLineNo = function(tr) { | ||||
var line; | ||||
// Try to get the id and return "" (empty string) if it doesn't exist | ||||
var o = ($(tr).find('.lineno.old').attr('id')||"").split('_'); | ||||
var n = ($(tr).find('.lineno.new').attr('id')||"").split('_'); | ||||
if (n.length >= 2) { | ||||
line = n[n.length-1]; | ||||
} else if (o.length >= 2) { | ||||
line = o[o.length-1]; | ||||
} | ||||
return line; | ||||
}; | ||||
/** | ||||
* make a single inline comment and place it inside | ||||
*/ | ||||
var renderInlineComment = function(json_data, show_add_button) { | ||||
show_add_button = typeof show_add_button !== 'undefined' ? show_add_button : true; | ||||
try { | ||||
var html = json_data.rendered_text; | ||||
var lineno = json_data.line_no; | ||||
var target_id = json_data.target_id; | ||||
placeInline(target_id, lineno, html, show_add_button); | ||||
} catch (e) { | ||||
console.error(e); | ||||
} | ||||
}; | ||||
function bindDeleteCommentButtons() { | ||||
$('.delete-comment').one('click', function() { | ||||
var comment_id = $(this).data("comment-id"); | ||||
if (comment_id){ | ||||
deleteComment(comment_id); | ||||
} | ||||
}); | ||||
} | ||||
/** | ||||
* Inject inline comment for on given TR this tr should be always an .line | ||||
* tr containing the line. Code will detect comment, and always put the comment | ||||
* block at the very bottom | ||||
*/ | ||||
var injectInlineForm = function(tr){ | ||||
if (!$(tr).hasClass('line')) { | ||||
return; | ||||
} | ||||
var _td = $(tr).find('.code').get(0); | ||||
if ($(tr).hasClass('form-open') || | ||||
$(tr).hasClass('context') || | ||||
$(_td).hasClass('no-comment')) { | ||||
return; | ||||
} | ||||
$(tr).addClass('form-open'); | ||||
$(tr).addClass('hl-comment'); | ||||
var node = $(tr.parentNode.parentNode.parentNode).find('.full_f_path').get(0); | ||||
var f_path = $(node).attr('path'); | ||||
var lineno = getLineNo(tr); | ||||
var form = createInlineForm(tr, f_path, lineno); | ||||
var parent = tr; | ||||
while (1) { | ||||
var n = parent.nextElementSibling; | ||||
// next element are comments ! | ||||
if ($(n).hasClass('inline-comments')) { | ||||
parent = n; | ||||
} | ||||
else { | ||||
break; | ||||
} | ||||
} | ||||
var _parent = $(parent).get(0); | ||||
$(_parent).after(form); | ||||
$('.comment-form-inline').prev('.inline-comments').addClass('hide-comment-button'); | ||||
var f = $(form).get(0); | ||||
var _form = $(f).find('.inline-form').get(0); | ||||
$('.switch-to-chat', _form).on('click', function(evt){ | ||||
var fParent = $(_parent).closest('.injected_diff').parent().prev('*[fid]'); | ||||
var fid = fParent.attr('fid'); | ||||
// activate chat and trigger subscription to channels | ||||
$.Topic('/chat_controller').publish({ | ||||
action:'subscribe_to_channels', | ||||
data: ['/chat${0}$/fid/{1}/{2}'.format(templateContext.repo_name, fid, lineno)] | ||||
}); | ||||
$(_form).closest('td').find('.comment-inline-form').addClass('hidden'); | ||||
$(_form).closest('td').find('.chat-holder').removeClass('hidden'); | ||||
}); | ||||
var pullRequestId = templateContext.pull_request_data.pull_request_id; | ||||
var commitId = templateContext.commit_data.commit_id; | ||||
var commentForm = new CommentForm(_form, commitId, pullRequestId, lineno, false); | ||||
var cm = commentForm.getCmInstance(); | ||||
// set a CUSTOM submit handler for inline comments. | ||||
commentForm.setHandleFormSubmit(function(o) { | ||||
var text = commentForm.cm.getValue(); | ||||
if (text === "") { | ||||
return; | ||||
} | ||||
if (lineno === undefined) { | ||||
alert('missing line !'); | ||||
return; | ||||
} | ||||
if (f_path === undefined) { | ||||
alert('missing file path !'); | ||||
return; | ||||
} | ||||
var excludeCancelBtn = false; | ||||
var submitEvent = true; | ||||
commentForm.setActionButtonsDisabled(true, excludeCancelBtn, submitEvent); | ||||
commentForm.cm.setOption("readOnly", true); | ||||
var postData = { | ||||
'text': text, | ||||
'f_path': f_path, | ||||
'line': lineno, | ||||
'csrf_token': CSRF_TOKEN | ||||
}; | ||||
var submitSuccessCallback = function(o) { | ||||
$(tr).removeClass('form-open'); | ||||
removeInlineForm(f); | ||||
renderInlineComment(o); | ||||
$('.inline-comments').removeClass('hide-comment-button'); | ||||
// re trigger the linkification of next/prev navigation | ||||
linkifyComments($('.inline-comment-injected')); | ||||
timeagoActivate(); | ||||
tooltip_activate(); | ||||
bindDeleteCommentButtons(); | ||||
commentForm.setActionButtonsDisabled(false); | ||||
}; | ||||
var submitFailCallback = function(){ | ||||
commentForm.resetCommentFormState(text) | ||||
}; | ||||
commentForm.submitAjaxPOST( | ||||
commentForm.submitUrl, postData, submitSuccessCallback, submitFailCallback); | ||||
}); | ||||
setTimeout(function() { | ||||
// callbacks | ||||
if (cm !== undefined) { | ||||
cm.focus(); | ||||
} | ||||
}, 10); | ||||
}; | ||||
var deleteComment = function(comment_id) { | ||||
var url = AJAX_COMMENT_DELETE_URL.replace('__COMMENT_ID__', comment_id); | ||||
var postData = { | ||||
'_method': 'delete', | ||||
'csrf_token': CSRF_TOKEN | ||||
}; | ||||
var success = function(o) { | ||||
window.location.reload(); | ||||
}; | ||||
ajaxPOST(url, postData, success); | ||||
}; | ||||
var createInlineAddButton = function(tr){ | ||||
var label = _TM['Add another comment']; | ||||
var html_el = document.createElement('div'); | ||||
$(html_el).addClass('add-comment'); | ||||
html_el.innerHTML = '<span class="btn btn-secondary">{0}</span>'.format(label); | ||||
var add = new $(html_el); | ||||
add.on('click', function(e) { | ||||
injectInlineForm(tr); | ||||
}); | ||||
return add; | ||||
}; | ||||
var placeAddButton = function(target_tr){ | ||||
if(!target_tr){ | ||||
return; | ||||
} | ||||
var last_node = target_tr; | ||||
// scan | ||||
while (1){ | ||||
var n = last_node.nextElementSibling; | ||||
// next element are comments ! | ||||
if($(n).hasClass('inline-comments')){ | ||||
last_node = n; | ||||
// also remove the comment button from previous | ||||
var comment_add_buttons = $(last_node).find('.add-comment'); | ||||
for(var i=0; i<comment_add_buttons.length; i++){ | ||||
var b = comment_add_buttons[i]; | ||||
b.parentNode.removeChild(b); | ||||
} | ||||
} | ||||
else{ | ||||
break; | ||||
} | ||||
} | ||||
var add = createInlineAddButton(target_tr); | ||||
// get the comment div | ||||
var comment_block = $(last_node).find('.comment')[0]; | ||||
// attach add button | ||||
$(add).insertAfter(comment_block); | ||||
}; | ||||
/** | ||||
* Places the inline comment into the changeset block in proper line position | ||||
*/ | ||||
var placeInline = function(target_container, lineno, html, show_add_button) { | ||||
show_add_button = typeof show_add_button !== 'undefined' ? show_add_button : true; | ||||
var lineid = "{0}_{1}".format(target_container, lineno); | ||||
var target_line = $('#' + lineid).get(0); | ||||
var comment = new $(tableTr('inline-comments', html)); | ||||
// check if there are comments already ! | ||||
var parent_node = target_line.parentNode; | ||||
var root_parent = parent_node; | ||||
while (1) { | ||||
var n = parent_node.nextElementSibling; | ||||
// next element are comments ! | ||||
if ($(n).hasClass('inline-comments')) { | ||||
parent_node = n; | ||||
} | ||||
else { | ||||
break; | ||||
} | ||||
} | ||||
// put in the comment at the bottom | ||||
$(comment).insertAfter(parent_node); | ||||
$(comment).find('.comment-inline').addClass('inline-comment-injected'); | ||||
// scan nodes, and attach add button to last one | ||||
if (show_add_button) { | ||||
placeAddButton(root_parent); | ||||
} | ||||
return target_line; | ||||
}; | ||||
var linkifyComments = function(comments) { | ||||
for (var i = 0; i < comments.length; i++) { | ||||
var comment_id = $(comments[i]).data('comment-id'); | ||||
var prev_comment_id = $(comments[i - 1]).data('comment-id'); | ||||
var next_comment_id = $(comments[i + 1]).data('comment-id'); | ||||
// place next/prev links | ||||
if (prev_comment_id) { | ||||
$('#prev_c_' + comment_id).show(); | ||||
$('#prev_c_' + comment_id + " a.arrow_comment_link").attr( | ||||
'href', '#comment-' + prev_comment_id).removeClass('disabled'); | ||||
} | ||||
if (next_comment_id) { | ||||
$('#next_c_' + comment_id).show(); | ||||
$('#next_c_' + comment_id + " a.arrow_comment_link").attr( | ||||
'href', '#comment-' + next_comment_id).removeClass('disabled'); | ||||
} | ||||
// place a first link to the total counter | ||||
if (i === 0) { | ||||
$('#inline-comments-counter').attr('href', '#comment-' + comment_id); | ||||
} | ||||
} | ||||
}; | ||||
/** | ||||
* Iterates over all the inlines, and places them inside proper blocks of data | ||||
*/ | ||||
var renderInlineComments = function(file_comments, show_add_button) { | ||||
show_add_button = typeof show_add_button !== 'undefined' ? show_add_button : true; | ||||
for (var i = 0; i < file_comments.length; i++) { | ||||
var box = file_comments[i]; | ||||
var target_id = $(box).attr('target_id'); | ||||
// actually comments with line numbers | ||||
var comments = box.children; | ||||
for (var j = 0; j < comments.length; j++) { | ||||
var data = { | ||||
'rendered_text': comments[j].outerHTML, | ||||
'line_no': $(comments[j]).attr('line'), | ||||
'target_id': target_id | ||||
}; | ||||
renderInlineComment(data, show_add_button); | ||||
} | ||||
} | ||||
// since order of injection is random, we're now re-iterating | ||||
// from correct order and filling in links | ||||
linkifyComments($('.inline-comment-injected')); | ||||
bindDeleteCommentButtons(); | ||||
firefoxAnchorFix(); | ||||
}; | ||||
/* Comment form for main and inline comments */ | ||||
var CommentForm = (function() { | ||||
"use strict"; | ||||
function CommentForm(formElement, commitId, pullRequestId, lineNo, initAutocompleteActions) { | ||||
this.withLineNo = function(selector) { | ||||
var lineNo = this.lineNo; | ||||
if (lineNo === undefined) { | ||||
return selector | ||||
} else { | ||||
return selector + '_' + lineNo; | ||||
} | ||||
}; | ||||
this.commitId = commitId; | ||||
this.pullRequestId = pullRequestId; | ||||
this.lineNo = lineNo; | ||||
this.initAutocompleteActions = initAutocompleteActions; | ||||
this.previewButton = this.withLineNo('#preview-btn'); | ||||
this.previewContainer = this.withLineNo('#preview-container'); | ||||
this.previewBoxSelector = this.withLineNo('#preview-box'); | ||||
this.editButton = this.withLineNo('#edit-btn'); | ||||
this.editContainer = this.withLineNo('#edit-container'); | ||||
this.cancelButton = this.withLineNo('#cancel-btn'); | ||||
this.statusChange = '#change_status'; | ||||
this.cmBox = this.withLineNo('#text'); | ||||
this.cm = initCommentBoxCodeMirror(this.cmBox, this.initAutocompleteActions); | ||||
this.submitForm = formElement; | ||||
this.submitButton = $(this.submitForm).find('input[type="submit"]'); | ||||
this.submitButtonText = this.submitButton.val(); | ||||
this.previewUrl = pyroutes.url('changeset_comment_preview', | ||||
{'repo_name': templateContext.repo_name}); | ||||
// based on commitId, or pullReuqestId decide where do we submit | ||||
// out data | ||||
if (this.commitId){ | ||||
this.submitUrl = pyroutes.url('changeset_comment', | ||||
{'repo_name': templateContext.repo_name, | ||||
'revision': this.commitId}); | ||||
} else if (this.pullRequestId) { | ||||
this.submitUrl = pyroutes.url('pullrequest_comment', | ||||
{'repo_name': templateContext.repo_name, | ||||
'pull_request_id': this.pullRequestId}); | ||||
} else { | ||||
throw new Error( | ||||
'CommentForm requires pullRequestId, or commitId to be specified.') | ||||
} | ||||
this.getCmInstance = function(){ | ||||
return this.cm | ||||
}; | ||||
var self = this; | ||||
this.getCommentStatus = function() { | ||||
return $(this.submitForm).find(this.statusChange).val(); | ||||
}; | ||||
this.isAllowedToSubmit = function() { | ||||
return !$(this.submitButton).prop('disabled'); | ||||
}; | ||||
this.initStatusChangeSelector = function(){ | ||||
var formatChangeStatus = function(state, escapeMarkup) { | ||||
var originalOption = state.element; | ||||
return '<div class="flag_status ' + $(originalOption).data('status') + ' pull-left"></div>' + | ||||
'<span>' + escapeMarkup(state.text) + '</span>'; | ||||
}; | ||||
var formatResult = function(result, container, query, escapeMarkup) { | ||||
return formatChangeStatus(result, escapeMarkup); | ||||
}; | ||||
var formatSelection = function(data, container, escapeMarkup) { | ||||
return formatChangeStatus(data, escapeMarkup); | ||||
}; | ||||
$(this.submitForm).find(this.statusChange).select2({ | ||||
placeholder: _TM['Status Review'], | ||||
formatResult: formatResult, | ||||
formatSelection: formatSelection, | ||||
containerCssClass: "drop-menu status_box_menu", | ||||
dropdownCssClass: "drop-menu-dropdown", | ||||
dropdownAutoWidth: true, | ||||
minimumResultsForSearch: -1 | ||||
}); | ||||
$(this.submitForm).find(this.statusChange).on('change', function() { | ||||
var status = self.getCommentStatus(); | ||||
if (status && !self.lineNo) { | ||||
$(self.submitButton).prop('disabled', false); | ||||
} | ||||
//todo, fix this name | ||||
var placeholderText = _TM['Comment text will be set automatically based on currently selected status ({0}) ...'].format(status); | ||||
self.cm.setOption('placeholder', placeholderText); | ||||
}) | ||||
}; | ||||
// reset the comment form into it's original state | ||||
this.resetCommentFormState = function(content) { | ||||
content = content || ''; | ||||
$(this.editContainer).show(); | ||||
$(this.editButton).hide(); | ||||
$(this.previewContainer).hide(); | ||||
$(this.previewButton).show(); | ||||
this.setActionButtonsDisabled(true); | ||||
self.cm.setValue(content); | ||||
self.cm.setOption("readOnly", false); | ||||
}; | ||||
this.submitAjaxPOST = function(url, postData, successHandler, failHandler) { | ||||
failHandler = failHandler || function() {}; | ||||
var postData = toQueryString(postData); | ||||
var request = $.ajax({ | ||||
url: url, | ||||
type: 'POST', | ||||
data: postData, | ||||
headers: {'X-PARTIAL-XHR': true} | ||||
}) | ||||
.done(function(data) { | ||||
successHandler(data); | ||||
}) | ||||
.fail(function(data, textStatus, errorThrown){ | ||||
alert( | ||||
"Error while submitting comment.\n" + | ||||
"Error code {0} ({1}).".format(data.status, data.statusText)); | ||||
failHandler() | ||||
}); | ||||
return request; | ||||
}; | ||||
// overwrite a submitHandler, we need to do it for inline comments | ||||
this.setHandleFormSubmit = function(callback) { | ||||
this.handleFormSubmit = callback; | ||||
}; | ||||
// default handler for for submit for main comments | ||||
this.handleFormSubmit = function() { | ||||
var text = self.cm.getValue(); | ||||
var status = self.getCommentStatus(); | ||||
if (text === "" && !status) { | ||||
return; | ||||
} | ||||
var excludeCancelBtn = false; | ||||
var submitEvent = true; | ||||
self.setActionButtonsDisabled(true, excludeCancelBtn, submitEvent); | ||||
self.cm.setOption("readOnly", true); | ||||
var postData = { | ||||
'text': text, | ||||
'changeset_status': status, | ||||
'csrf_token': CSRF_TOKEN | ||||
}; | ||||
var submitSuccessCallback = function(o) { | ||||
if (status) { | ||||
location.reload(true); | ||||
} else { | ||||
$('#injected_page_comments').append(o.rendered_text); | ||||
self.resetCommentFormState(); | ||||
bindDeleteCommentButtons(); | ||||
timeagoActivate(); | ||||
tooltip_activate(); | ||||
} | ||||
}; | ||||
var submitFailCallback = function(){ | ||||
self.resetCommentFormState(text) | ||||
}; | ||||
self.submitAjaxPOST( | ||||
self.submitUrl, postData, submitSuccessCallback, submitFailCallback); | ||||
}; | ||||
this.previewSuccessCallback = function(o) { | ||||
$(self.previewBoxSelector).html(o); | ||||
$(self.previewBoxSelector).removeClass('unloaded'); | ||||
// swap buttons | ||||
$(self.previewButton).hide(); | ||||
$(self.editButton).show(); | ||||
// unlock buttons | ||||
self.setActionButtonsDisabled(false); | ||||
}; | ||||
this.setActionButtonsDisabled = function(state, excludeCancelBtn, submitEvent) { | ||||
excludeCancelBtn = excludeCancelBtn || false; | ||||
submitEvent = submitEvent || false; | ||||
$(this.editButton).prop('disabled', state); | ||||
$(this.previewButton).prop('disabled', state); | ||||
if (!excludeCancelBtn) { | ||||
$(this.cancelButton).prop('disabled', state); | ||||
} | ||||
var submitState = state; | ||||
if (!submitEvent && this.getCommentStatus() && !this.lineNo) { | ||||
// if the value of commit review status is set, we allow | ||||
// submit button, but only on Main form, lineNo means inline | ||||
submitState = false | ||||
} | ||||
$(this.submitButton).prop('disabled', submitState); | ||||
if (submitEvent) { | ||||
$(this.submitButton).val(_TM['Submitting...']); | ||||
} else { | ||||
$(this.submitButton).val(this.submitButtonText); | ||||
} | ||||
}; | ||||
// lock preview/edit/submit buttons on load, but exclude cancel button | ||||
var excludeCancelBtn = true; | ||||
this.setActionButtonsDisabled(true, excludeCancelBtn); | ||||
// anonymous users don't have access to initialized CM instance | ||||
if (this.cm !== undefined){ | ||||
this.cm.on('change', function(cMirror) { | ||||
if (cMirror.getValue() === "") { | ||||
self.setActionButtonsDisabled(true, excludeCancelBtn) | ||||
} else { | ||||
self.setActionButtonsDisabled(false, excludeCancelBtn) | ||||
} | ||||
}); | ||||
} | ||||
$(this.editButton).on('click', function(e) { | ||||
e.preventDefault(); | ||||
$(self.previewButton).show(); | ||||
$(self.previewContainer).hide(); | ||||
$(self.editButton).hide(); | ||||
$(self.editContainer).show(); | ||||
}); | ||||
$(this.previewButton).on('click', function(e) { | ||||
e.preventDefault(); | ||||
var text = self.cm.getValue(); | ||||
if (text === "") { | ||||
return; | ||||
} | ||||
var postData = { | ||||
'text': text, | ||||
'renderer': DEFAULT_RENDERER, | ||||
'csrf_token': CSRF_TOKEN | ||||
}; | ||||
// lock ALL buttons on preview | ||||
self.setActionButtonsDisabled(true); | ||||
$(self.previewBoxSelector).addClass('unloaded'); | ||||
$(self.previewBoxSelector).html(_TM['Loading ...']); | ||||
$(self.editContainer).hide(); | ||||
$(self.previewContainer).show(); | ||||
// by default we reset state of comment preserving the text | ||||
var previewFailCallback = function(){ | ||||
self.resetCommentFormState(text) | ||||
}; | ||||
self.submitAjaxPOST( | ||||
self.previewUrl, postData, self.previewSuccessCallback, previewFailCallback); | ||||
}); | ||||
$(this.submitForm).submit(function(e) { | ||||
e.preventDefault(); | ||||
var allowedToSubmit = self.isAllowedToSubmit(); | ||||
if (!allowedToSubmit){ | ||||
return false; | ||||
} | ||||
self.handleFormSubmit(); | ||||
}); | ||||
} | ||||
return CommentForm; | ||||
})(); | ||||