Merge branch '27614-improve-instant-comments-exp' into 'master'
Improve user experience around slash commands in instant comments Closes #32464 See merge request !11612
This commit is contained in:
commit
e90ca0f0fc
6 changed files with 233 additions and 57 deletions
|
@ -2,6 +2,7 @@ import emojiMap from 'emojis/digests.json';
|
|||
import emojiAliases from 'emojis/aliases.json';
|
||||
import { glEmojiTag } from '~/behaviors/gl_emoji';
|
||||
import glRegexp from '~/lib/utils/regexp';
|
||||
import AjaxCache from '~/lib/utils/ajax_cache';
|
||||
|
||||
function sanitize(str) {
|
||||
return str.replace(/<(?:.|\n)*?>/gm, '');
|
||||
|
@ -35,6 +36,7 @@ class GfmAutoComplete {
|
|||
// This triggers at.js again
|
||||
// Needed for slash commands with suffixes (ex: /label ~)
|
||||
$input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup'));
|
||||
$input.on('clear-commands-cache.atwho', () => this.clearCache());
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -375,11 +377,14 @@ class GfmAutoComplete {
|
|||
} else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
|
||||
this.loadData($input, at, Object.keys(emojiMap).concat(Object.keys(emojiAliases)));
|
||||
} else {
|
||||
$.getJSON(this.dataSources[GfmAutoComplete.atTypeMap[at]], (data) => {
|
||||
this.loadData($input, at, data);
|
||||
}).fail(() => { this.isLoadingData[at] = false; });
|
||||
AjaxCache.retrieve(this.dataSources[GfmAutoComplete.atTypeMap[at]], true)
|
||||
.then((data) => {
|
||||
this.loadData($input, at, data);
|
||||
})
|
||||
.catch(() => { this.isLoadingData[at] = false; });
|
||||
}
|
||||
}
|
||||
|
||||
loadData($input, at, data) {
|
||||
this.isLoadingData[at] = false;
|
||||
this.cachedData[at] = data;
|
||||
|
@ -389,6 +394,10 @@ class GfmAutoComplete {
|
|||
return $input.trigger('keyup');
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
this.cachedData = {};
|
||||
}
|
||||
|
||||
static isLoading(data) {
|
||||
let dataToInspect = data;
|
||||
if (data && data.length > 0) {
|
||||
|
|
|
@ -6,8 +6,8 @@ class AjaxCache extends Cache {
|
|||
this.pendingRequests = { };
|
||||
}
|
||||
|
||||
retrieve(endpoint) {
|
||||
if (this.hasData(endpoint)) {
|
||||
retrieve(endpoint, forceRetrieve) {
|
||||
if (this.hasData(endpoint) && !forceRetrieve) {
|
||||
return Promise.resolve(this.get(endpoint));
|
||||
}
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@ import autosize from 'vendor/autosize';
|
|||
import Dropzone from 'dropzone';
|
||||
import 'vendor/jquery.caret'; // required by jquery.atwho
|
||||
import 'vendor/jquery.atwho';
|
||||
import AjaxCache from '~/lib/utils/ajax_cache';
|
||||
import CommentTypeToggle from './comment_type_toggle';
|
||||
import './autosave';
|
||||
import './dropzone_input';
|
||||
|
@ -66,7 +67,6 @@ const normalizeNewlines = function(str) {
|
|||
this.notesCountBadge || (this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge'));
|
||||
this.basePollingInterval = 15000;
|
||||
this.maxPollingSteps = 4;
|
||||
this.flashErrors = [];
|
||||
|
||||
this.cleanBinding();
|
||||
this.addBinding();
|
||||
|
@ -325,6 +325,9 @@ const normalizeNewlines = function(str) {
|
|||
if (Notes.isNewNote(noteEntity, this.note_ids)) {
|
||||
this.note_ids.push(noteEntity.id);
|
||||
|
||||
if ($notesList.length) {
|
||||
$notesList.find('.system-note.being-posted').remove();
|
||||
}
|
||||
const $newNote = Notes.animateAppendNote(noteEntity.html, $notesList);
|
||||
|
||||
this.setupNewNote($newNote);
|
||||
|
@ -1118,12 +1121,14 @@ const normalizeNewlines = function(str) {
|
|||
};
|
||||
|
||||
Notes.prototype.addFlash = function(...flashParams) {
|
||||
this.flashErrors.push(new Flash(...flashParams));
|
||||
this.flashInstance = new Flash(...flashParams);
|
||||
};
|
||||
|
||||
Notes.prototype.clearFlash = function() {
|
||||
this.flashErrors.forEach(flash => flash.flashContainer.remove());
|
||||
this.flashErrors = [];
|
||||
if (this.flashInstance && this.flashInstance.flashContainer) {
|
||||
this.flashInstance.flashContainer.hide();
|
||||
this.flashInstance = null;
|
||||
}
|
||||
};
|
||||
|
||||
Notes.prototype.cleanForm = function($form) {
|
||||
|
@ -1187,7 +1192,7 @@ const normalizeNewlines = function(str) {
|
|||
Notes.prototype.getFormData = function($form) {
|
||||
return {
|
||||
formData: $form.serialize(),
|
||||
formContent: $form.find('.js-note-text').val(),
|
||||
formContent: _.escape($form.find('.js-note-text').val()),
|
||||
formAction: $form.attr('action'),
|
||||
};
|
||||
};
|
||||
|
@ -1206,20 +1211,47 @@ const normalizeNewlines = function(str) {
|
|||
return formContent.replace(REGEX_SLASH_COMMANDS, '').trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets appropriate description from slash commands found in provided `formContent`
|
||||
*/
|
||||
Notes.prototype.getSlashCommandDescription = function (formContent, availableSlashCommands = []) {
|
||||
let tempFormContent;
|
||||
|
||||
// Identify executed slash commands from `formContent`
|
||||
const executedCommands = availableSlashCommands.filter((command, index) => {
|
||||
const commandRegex = new RegExp(`/${command.name}`);
|
||||
return commandRegex.test(formContent);
|
||||
});
|
||||
|
||||
if (executedCommands && executedCommands.length) {
|
||||
if (executedCommands.length > 1) {
|
||||
tempFormContent = 'Applying multiple commands';
|
||||
} else {
|
||||
const commandDescription = executedCommands[0].description.toLowerCase();
|
||||
tempFormContent = `Applying command to ${commandDescription}`;
|
||||
}
|
||||
} else {
|
||||
tempFormContent = 'Applying command';
|
||||
}
|
||||
|
||||
return tempFormContent;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create placeholder note DOM element populated with comment body
|
||||
* that we will show while comment is being posted.
|
||||
* Once comment is _actually_ posted on server, we will have final element
|
||||
* in response that we will show in place of this temporary element.
|
||||
*/
|
||||
Notes.prototype.createPlaceholderNote = function({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname }) {
|
||||
Notes.prototype.createPlaceholderNote = function ({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname, currentUserAvatar }) {
|
||||
const discussionClass = isDiscussionNote ? 'discussion' : '';
|
||||
const escapedFormContent = _.escape(formContent);
|
||||
const $tempNote = $(
|
||||
`<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry">
|
||||
<div class="timeline-entry-inner">
|
||||
<div class="timeline-icon">
|
||||
<a href="/${currentUsername}"><span class="avatar dummy-avatar"></span></a>
|
||||
<a href="/${currentUsername}">
|
||||
<img class="avatar s40" src="${currentUserAvatar}">
|
||||
</a>
|
||||
</div>
|
||||
<div class="timeline-content ${discussionClass}">
|
||||
<div class="note-header">
|
||||
|
@ -1232,7 +1264,7 @@ const normalizeNewlines = function(str) {
|
|||
</div>
|
||||
<div class="note-body">
|
||||
<div class="note-text">
|
||||
<p>${escapedFormContent}</p>
|
||||
<p>${formContent}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1243,6 +1275,23 @@ const normalizeNewlines = function(str) {
|
|||
return $tempNote;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create Placeholder System Note DOM element populated with slash command description
|
||||
*/
|
||||
Notes.prototype.createPlaceholderSystemNote = function ({ formContent, uniqueId }) {
|
||||
const $tempNote = $(
|
||||
`<li id="${uniqueId}" class="note system-note timeline-entry being-posted fade-in-half">
|
||||
<div class="timeline-entry-inner">
|
||||
<div class="timeline-content">
|
||||
<i>${formContent}</i>
|
||||
</div>
|
||||
</div>
|
||||
</li>`
|
||||
);
|
||||
|
||||
return $tempNote;
|
||||
};
|
||||
|
||||
/**
|
||||
* This method does following tasks step-by-step whenever a new comment
|
||||
* is submitted by user (both main thread comments as well as discussion comments).
|
||||
|
@ -1274,7 +1323,9 @@ const normalizeNewlines = function(str) {
|
|||
const isDiscussionForm = $form.hasClass('js-discussion-note-form');
|
||||
const isDiscussionResolve = $submitBtn.hasClass('js-comment-resolve-button');
|
||||
const { formData, formContent, formAction } = this.getFormData($form);
|
||||
const uniqueId = _.uniqueId('tempNote_');
|
||||
let noteUniqueId;
|
||||
let systemNoteUniqueId;
|
||||
let hasSlashCommands = false;
|
||||
let $notesContainer;
|
||||
let tempFormContent;
|
||||
|
||||
|
@ -1295,16 +1346,28 @@ const normalizeNewlines = function(str) {
|
|||
tempFormContent = formContent;
|
||||
if (this.hasSlashCommands(formContent)) {
|
||||
tempFormContent = this.stripSlashCommands(formContent);
|
||||
hasSlashCommands = true;
|
||||
}
|
||||
|
||||
// Show placeholder note
|
||||
if (tempFormContent) {
|
||||
// Show placeholder note
|
||||
noteUniqueId = _.uniqueId('tempNote_');
|
||||
$notesContainer.append(this.createPlaceholderNote({
|
||||
formContent: tempFormContent,
|
||||
uniqueId,
|
||||
uniqueId: noteUniqueId,
|
||||
isDiscussionNote,
|
||||
currentUsername: gon.current_username,
|
||||
currentUserFullname: gon.current_user_fullname,
|
||||
currentUserAvatar: gon.current_user_avatar_url,
|
||||
}));
|
||||
}
|
||||
|
||||
// Show placeholder system note
|
||||
if (hasSlashCommands) {
|
||||
systemNoteUniqueId = _.uniqueId('tempSystemNote_');
|
||||
$notesContainer.append(this.createPlaceholderSystemNote({
|
||||
formContent: this.getSlashCommandDescription(formContent, AjaxCache.get(gl.GfmAutoComplete.dataSources.commands)),
|
||||
uniqueId: systemNoteUniqueId,
|
||||
}));
|
||||
}
|
||||
|
||||
|
@ -1322,7 +1385,13 @@ const normalizeNewlines = function(str) {
|
|||
gl.utils.ajaxPost(formAction, formData)
|
||||
.then((note) => {
|
||||
// Submission successful! remove placeholder
|
||||
$notesContainer.find(`#${uniqueId}`).remove();
|
||||
$notesContainer.find(`#${noteUniqueId}`).remove();
|
||||
|
||||
// Reset cached commands list when command is applied
|
||||
if (hasSlashCommands) {
|
||||
$form.find('textarea.js-note-text').trigger('clear-commands-cache.atwho');
|
||||
}
|
||||
|
||||
// Clear previous form errors
|
||||
this.clearFlashWrapper();
|
||||
|
||||
|
@ -1359,7 +1428,11 @@ const normalizeNewlines = function(str) {
|
|||
$form.trigger('ajax:success', [note]);
|
||||
}).fail(() => {
|
||||
// Submission failed, remove placeholder note and show Flash error message
|
||||
$notesContainer.find(`#${uniqueId}`).remove();
|
||||
$notesContainer.find(`#${noteUniqueId}`).remove();
|
||||
|
||||
if (hasSlashCommands) {
|
||||
$notesContainer.find(`#${systemNoteUniqueId}`).remove();
|
||||
}
|
||||
|
||||
// Show form again on UI on failure
|
||||
if (isDiscussionForm && $notesContainer.length) {
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
title: Improve user experience around slash commands in instant comments
|
||||
merge_request: 11612
|
||||
author:
|
|
@ -154,5 +154,36 @@ describe('AjaxCache', () => {
|
|||
.then(done)
|
||||
.catch(fail);
|
||||
});
|
||||
|
||||
it('makes Ajax call even if matching data exists when forceRequest parameter is provided', (done) => {
|
||||
const oldDummyResponse = {
|
||||
important: 'old dummy data',
|
||||
};
|
||||
|
||||
AjaxCache.internalStorage[dummyEndpoint] = oldDummyResponse;
|
||||
|
||||
ajaxSpy = (url) => {
|
||||
expect(url).toBe(dummyEndpoint);
|
||||
const deferred = $.Deferred();
|
||||
deferred.resolve(dummyResponse);
|
||||
return deferred.promise();
|
||||
};
|
||||
|
||||
// Call without forceRetrieve param
|
||||
AjaxCache.retrieve(dummyEndpoint)
|
||||
.then((data) => {
|
||||
expect(data).toBe(oldDummyResponse);
|
||||
})
|
||||
.then(done)
|
||||
.catch(fail);
|
||||
|
||||
// Call with forceRetrieve param
|
||||
AjaxCache.retrieve(dummyEndpoint, true)
|
||||
.then((data) => {
|
||||
expect(data).toBe(dummyResponse);
|
||||
})
|
||||
.then(done)
|
||||
.catch(fail);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -13,6 +13,23 @@ import '~/notes';
|
|||
window.gl = window.gl || {};
|
||||
gl.utils = gl.utils || {};
|
||||
|
||||
const htmlEscape = (comment) => {
|
||||
const escapedString = comment.replace(/["&'<>]/g, (a) => {
|
||||
const escapedToken = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'`': '`'
|
||||
}[a];
|
||||
|
||||
return escapedToken;
|
||||
});
|
||||
|
||||
return escapedString;
|
||||
};
|
||||
|
||||
describe('Notes', function() {
|
||||
const FLASH_TYPE_ALERT = 'alert';
|
||||
var commentsTemplate = 'issues/issue_with_comment.html.raw';
|
||||
|
@ -445,11 +462,17 @@ import '~/notes';
|
|||
});
|
||||
|
||||
describe('getFormData', () => {
|
||||
it('should return form metadata object from form reference', () => {
|
||||
let $form;
|
||||
let sampleComment;
|
||||
|
||||
beforeEach(() => {
|
||||
this.notes = new Notes('', []);
|
||||
|
||||
const $form = $('form');
|
||||
const sampleComment = 'foobar';
|
||||
$form = $('form');
|
||||
sampleComment = 'foobar';
|
||||
});
|
||||
|
||||
it('should return form metadata object from form reference', () => {
|
||||
$form.find('textarea.js-note-text').val(sampleComment);
|
||||
const { formData, formContent, formAction } = this.notes.getFormData($form);
|
||||
|
||||
|
@ -457,6 +480,18 @@ import '~/notes';
|
|||
expect(formContent).toEqual(sampleComment);
|
||||
expect(formAction).toEqual($form.attr('action'));
|
||||
});
|
||||
|
||||
it('should return form metadata with sanitized formContent from form reference', () => {
|
||||
spyOn(_, 'escape').and.callFake(htmlEscape);
|
||||
|
||||
sampleComment = '<script>alert("Boom!");</script>';
|
||||
$form.find('textarea.js-note-text').val(sampleComment);
|
||||
|
||||
const { formContent } = this.notes.getFormData($form);
|
||||
|
||||
expect(_.escape).toHaveBeenCalledWith(sampleComment);
|
||||
expect(formContent).toEqual('<script>alert("Boom!");</script>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasSlashCommands', () => {
|
||||
|
@ -512,30 +547,42 @@ import '~/notes';
|
|||
});
|
||||
});
|
||||
|
||||
describe('getSlashCommandDescription', () => {
|
||||
const availableSlashCommands = [
|
||||
{ name: 'close', description: 'Close this issue', params: [] },
|
||||
{ name: 'title', description: 'Change title', params: [{}] },
|
||||
{ name: 'estimate', description: 'Set time estimate', params: [{}] }
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
this.notes = new Notes();
|
||||
});
|
||||
|
||||
it('should return executing slash command description when note has single slash command', () => {
|
||||
const sampleComment = '/close';
|
||||
expect(this.notes.getSlashCommandDescription(sampleComment, availableSlashCommands)).toBe('Applying command to close this issue');
|
||||
});
|
||||
|
||||
it('should return generic multiple slash command description when note has multiple slash commands', () => {
|
||||
const sampleComment = '/close\n/title [Duplicate] Issue foobar';
|
||||
expect(this.notes.getSlashCommandDescription(sampleComment, availableSlashCommands)).toBe('Applying multiple commands');
|
||||
});
|
||||
|
||||
it('should return generic slash command description when available slash commands list is not populated', () => {
|
||||
const sampleComment = '/close\n/title [Duplicate] Issue foobar';
|
||||
expect(this.notes.getSlashCommandDescription(sampleComment)).toBe('Applying command');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPlaceholderNote', () => {
|
||||
const sampleComment = 'foobar';
|
||||
const uniqueId = 'b1234-a4567';
|
||||
const currentUsername = 'root';
|
||||
const currentUserFullname = 'Administrator';
|
||||
const currentUserAvatar = 'avatar_url';
|
||||
|
||||
beforeEach(() => {
|
||||
this.notes = new Notes('', []);
|
||||
spyOn(_, 'escape').and.callFake((comment) => {
|
||||
const escapedString = comment.replace(/["&'<>]/g, (a) => {
|
||||
const escapedToken = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'`': '`'
|
||||
}[a];
|
||||
|
||||
return escapedToken;
|
||||
});
|
||||
|
||||
return escapedString;
|
||||
});
|
||||
});
|
||||
|
||||
it('should return constructed placeholder element for regular note based on form contents', () => {
|
||||
|
@ -544,35 +591,25 @@ import '~/notes';
|
|||
uniqueId,
|
||||
isDiscussionNote: false,
|
||||
currentUsername,
|
||||
currentUserFullname
|
||||
currentUserFullname,
|
||||
currentUserAvatar,
|
||||
});
|
||||
const $tempNoteHeader = $tempNote.find('.note-header');
|
||||
|
||||
expect($tempNote.prop('nodeName')).toEqual('LI');
|
||||
expect($tempNote.attr('id')).toEqual(uniqueId);
|
||||
expect($tempNote.hasClass('being-posted')).toBeTruthy();
|
||||
expect($tempNote.hasClass('fade-in-half')).toBeTruthy();
|
||||
$tempNote.find('.timeline-icon > a, .note-header-info > a').each(function() {
|
||||
expect($(this).attr('href')).toEqual(`/${currentUsername}`);
|
||||
});
|
||||
expect($tempNote.find('.timeline-icon .avatar').attr('src')).toEqual(currentUserAvatar);
|
||||
expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeFalsy();
|
||||
expect($tempNoteHeader.find('.hidden-xs').text().trim()).toEqual(currentUserFullname);
|
||||
expect($tempNoteHeader.find('.note-headline-light').text().trim()).toEqual(`@${currentUsername}`);
|
||||
expect($tempNote.find('.note-body .note-text p').text().trim()).toEqual(sampleComment);
|
||||
});
|
||||
|
||||
it('should escape HTML characters from note based on form contents', () => {
|
||||
const commentWithHtml = '<script>alert("Boom!");</script>';
|
||||
const $tempNote = this.notes.createPlaceholderNote({
|
||||
formContent: commentWithHtml,
|
||||
uniqueId,
|
||||
isDiscussionNote: false,
|
||||
currentUsername,
|
||||
currentUserFullname
|
||||
});
|
||||
|
||||
expect(_.escape).toHaveBeenCalledWith(commentWithHtml);
|
||||
expect($tempNote.find('.note-body .note-text p').html()).toEqual('<script>alert("Boom!");</script>');
|
||||
});
|
||||
|
||||
it('should return constructed placeholder element for discussion note based on form contents', () => {
|
||||
const $tempNote = this.notes.createPlaceholderNote({
|
||||
formContent: sampleComment,
|
||||
|
@ -587,6 +624,29 @@ import '~/notes';
|
|||
});
|
||||
});
|
||||
|
||||
describe('createPlaceholderSystemNote', () => {
|
||||
const sampleCommandDescription = 'Applying command to close this issue';
|
||||
const uniqueId = 'b1234-a4567';
|
||||
|
||||
beforeEach(() => {
|
||||
this.notes = new Notes('', []);
|
||||
spyOn(_, 'escape').and.callFake(htmlEscape);
|
||||
});
|
||||
|
||||
it('should return constructed placeholder element for system note based on form contents', () => {
|
||||
const $tempNote = this.notes.createPlaceholderSystemNote({
|
||||
formContent: sampleCommandDescription,
|
||||
uniqueId,
|
||||
});
|
||||
|
||||
expect($tempNote.prop('nodeName')).toEqual('LI');
|
||||
expect($tempNote.attr('id')).toEqual(uniqueId);
|
||||
expect($tempNote.hasClass('being-posted')).toBeTruthy();
|
||||
expect($tempNote.hasClass('fade-in-half')).toBeTruthy();
|
||||
expect($tempNote.find('.timeline-content i').text().trim()).toEqual(sampleCommandDescription);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendFlash', () => {
|
||||
beforeEach(() => {
|
||||
this.notes = new Notes();
|
||||
|
@ -595,7 +655,7 @@ import '~/notes';
|
|||
it('shows a flash message', () => {
|
||||
this.notes.addFlash('Error message', FLASH_TYPE_ALERT, this.notes.parentTimeline);
|
||||
|
||||
expect(document.querySelectorAll('.flash-alert').length).toBe(1);
|
||||
expect($('.flash-alert').is(':visible')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -605,13 +665,12 @@ import '~/notes';
|
|||
this.notes = new Notes();
|
||||
});
|
||||
|
||||
it('removes all the associated flash messages', () => {
|
||||
it('hides visible flash message', () => {
|
||||
this.notes.addFlash('Error message 1', FLASH_TYPE_ALERT, this.notes.parentTimeline);
|
||||
this.notes.addFlash('Error message 2', FLASH_TYPE_ALERT, this.notes.parentTimeline);
|
||||
|
||||
this.notes.clearFlash();
|
||||
|
||||
expect(document.querySelectorAll('.flash-alert').length).toBe(0);
|
||||
expect($('.flash-alert').is(':visible')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue