Commit d7e4222e authored by Coung Ngo's avatar Coung Ngo Committed by Andrew Fontaine

Convert labels autocomplete from atjs to tribute

Convert to tribute so we can remove the unmaintained
jQuery-based atjs library
parent 8cebe375
...@@ -5,39 +5,88 @@ import axios from '~/lib/utils/axios_utils'; ...@@ -5,39 +5,88 @@ import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from '~/lib/utils/common_utils'; import { spriteIcon } from '~/lib/utils/common_utils';
import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarMediator from '~/sidebar/sidebar_mediator';
/** const AutoComplete = {
* Creates the HTML template for each row of the mentions dropdown. Labels: 'labels',
* Members: 'members',
* @param original - An object from the array returned from the `autocomplete_sources/members` API };
* @returns {string} - An HTML template
*/ function doesCurrentLineStartWith(searchString, fullText, selectionStart) {
function menuItemTemplate({ original }) { const currentLineNumber = fullText.slice(0, selectionStart).split('\n').length;
const rectAvatarClass = original.type === 'Group' ? 'rect-avatar' : ''; const currentLine = fullText.split('\n')[currentLineNumber - 1];
return currentLine.startsWith(searchString);
const avatarClasses = `avatar avatar-inline center s26 ${rectAvatarClass}
gl-display-inline-flex! gl-align-items-center gl-justify-content-center`;
const avatarTag = original.avatar_url
? `<img
src="${original.avatar_url}"
alt="${original.username}'s avatar"
class="${avatarClasses}"/>`
: `<div class="${avatarClasses}">${original.username.charAt(0).toUpperCase()}</div>`;
const name = escape(original.name);
const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : '';
const icon = original.mentionsDisabled
? spriteIcon('notifications-off', 's16 gl-vertical-align-middle gl-ml-3')
: '';
return `${avatarTag}
${original.username}
<small class="gl-text-small gl-font-weight-normal gl-reset-color">${name}${count}</small>
${icon}`;
} }
const autoCompleteMap = {
[AutoComplete.Labels]: {
filterValues() {
const fullText = this.$slots.default?.[0]?.elm?.value;
const selectionStart = this.$slots.default?.[0]?.elm?.selectionStart;
if (doesCurrentLineStartWith('/label', fullText, selectionStart)) {
return this.labels.filter(label => !label.set);
}
if (doesCurrentLineStartWith('/unlabel', fullText, selectionStart)) {
return this.labels.filter(label => label.set);
}
return this.labels;
},
menuItemTemplate({ original }) {
return `
<span class="dropdown-label-box" style="background: ${escape(original.color)};"></span>
${escape(original.title)}`;
},
},
[AutoComplete.Members]: {
filterValues() {
const fullText = this.$slots.default?.[0]?.elm?.value;
const selectionStart = this.$slots.default?.[0]?.elm?.selectionStart;
if (!this.assignees) {
this.assignees =
SidebarMediator.singleton?.store?.assignees?.map(assignee => assignee.username) || [];
}
if (doesCurrentLineStartWith('/assign', fullText, selectionStart)) {
return this.members.filter(member => !this.assignees.includes(member.username));
}
if (doesCurrentLineStartWith('/unassign', fullText, selectionStart)) {
return this.members.filter(member => this.assignees.includes(member.username));
}
return this.members;
},
menuItemTemplate({ original }) {
const rectAvatarClass = original.type === 'Group' ? 'rect-avatar' : '';
const avatarClasses = `avatar avatar-inline center s26 ${rectAvatarClass}
gl-display-inline-flex! gl-align-items-center gl-justify-content-center`;
const avatarTag = original.avatar_url
? `<img
src="${original.avatar_url}"
alt="${original.username}'s avatar"
class="${avatarClasses}"/>`
: `<div class="${avatarClasses}">${original.username.charAt(0).toUpperCase()}</div>`;
const name = escape(original.name);
const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : '';
const icon = original.mentionsDisabled
? spriteIcon('notifications-off', 's16 gl-vertical-align-middle gl-ml-3')
: '';
return `${avatarTag}
${original.username}
<small class="gl-text-small gl-font-weight-normal gl-reset-color">${name}${count}</small>
${icon}`;
},
},
};
export default { export default {
name: 'GlMentions', name: 'GlMentions',
props: { props: {
...@@ -47,67 +96,57 @@ export default { ...@@ -47,67 +96,57 @@ export default {
default: () => gl.GfmAutoComplete?.dataSources || {}, default: () => gl.GfmAutoComplete?.dataSources || {},
}, },
}, },
data() {
return {
assignees: undefined,
members: undefined,
};
},
mounted() { mounted() {
const NON_WORD_OR_INTEGER = /\W|^\d+$/;
this.tribute = new Tribute({ this.tribute = new Tribute({
trigger: '@', collection: [
fillAttr: 'username', {
lookup: value => value.name + value.username, trigger: '@',
menuItemTemplate, fillAttr: 'username',
values: this.getMembers, lookup: value => value.name + value.username,
menuItemTemplate: autoCompleteMap[AutoComplete.Members].menuItemTemplate,
values: this.getValues(AutoComplete.Members),
},
{
trigger: '~',
lookup: 'title',
menuItemTemplate: autoCompleteMap[AutoComplete.Labels].menuItemTemplate,
selectTemplate: ({ original }) =>
NON_WORD_OR_INTEGER.test(original.title)
? `~"${original.title}"`
: `~${original.title}`,
values: this.getValues(AutoComplete.Labels),
},
],
}); });
const input = this.$slots.default[0].elm; const input = this.$slots.default?.[0]?.elm;
this.tribute.attach(input); this.tribute.attach(input);
}, },
beforeDestroy() { beforeDestroy() {
const input = this.$slots.default[0].elm; const input = this.$slots.default?.[0]?.elm;
this.tribute.detach(input); this.tribute.detach(input);
}, },
methods: { methods: {
/** getValues(autoCompleteType) {
* Creates the list of users to show in the mentions dropdown. return (inputText, processValues) => {
* if (this[autoCompleteType]) {
* @param inputText - The text entered by the user in the mentions input field const filteredValues = autoCompleteMap[autoCompleteType].filterValues.call(this);
* @param processValues - Callback function to set the list of users to show in the mentions dropdown processValues(filteredValues);
*/ } else if (this.dataSources[autoCompleteType]) {
getMembers(inputText, processValues) { axios
if (this.members) { .get(this.dataSources[autoCompleteType])
processValues(this.getFilteredMembers()); .then(response => {
} else if (this.dataSources.members) { this[autoCompleteType] = response.data;
axios const filteredValues = autoCompleteMap[autoCompleteType].filterValues.call(this);
.get(this.dataSources.members) processValues(filteredValues);
.then(response => { })
this.members = response.data; .catch(() => {});
processValues(this.getFilteredMembers()); } else {
}) processValues([]);
.catch(() => {}); }
} else { };
processValues([]);
}
},
getFilteredMembers() {
const fullText = this.$slots.default[0].elm.value;
if (!this.assignees) {
this.assignees =
SidebarMediator.singleton?.store?.assignees?.map(assignee => assignee.username) || [];
}
if (fullText.startsWith('/assign @')) {
return this.members.filter(member => !this.assignees.includes(member.username));
}
if (fullText.startsWith('/unassign @')) {
return this.members.filter(member => this.assignees.includes(member.username));
}
return this.members;
}, },
}, },
render(createElement) { render(createElement) {
......
...@@ -171,7 +171,7 @@ export default { ...@@ -171,7 +171,7 @@ export default {
mergeRequests: this.enableAutocomplete, mergeRequests: this.enableAutocomplete,
epics: this.enableAutocomplete, epics: this.enableAutocomplete,
milestones: this.enableAutocomplete, milestones: this.enableAutocomplete,
labels: this.enableAutocomplete, labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
snippets: this.enableAutocomplete, snippets: this.enableAutocomplete,
}); });
}, },
......
...@@ -487,7 +487,7 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -487,7 +487,7 @@ RSpec.describe 'GFM autocomplete', :js do
wait_for_requests wait_for_requests
find('.tribute-container .highlight').click find('.tribute-container .highlight', visible: true).click
click_button 'Save changes' click_button 'Save changes'
...@@ -501,7 +501,7 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -501,7 +501,7 @@ RSpec.describe 'GFM autocomplete', :js do
find('#note-body').native.send_keys('@') find('#note-body').native.send_keys('@')
end end
expect(page).to have_selector('.tribute-container') expect(page).to have_selector('.tribute-container', visible: true)
end end
it 'opens autocomplete menu for Username when field starts with text with item escaping HTML characters' do it 'opens autocomplete menu for Username when field starts with text with item escaping HTML characters' do
...@@ -511,20 +511,9 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -511,20 +511,9 @@ RSpec.describe 'GFM autocomplete', :js do
wait_for_requests wait_for_requests
expect(page).to have_selector('.tribute-container') expect(page).to have_selector('.tribute-container', visible: true)
page.within '.tribute-container ul' do expect(find('.tribute-container ul', visible: true).text).to have_content(user_xss.username)
expect(find('li').text).to have_content(user_xss.username)
end
end
it 'doesnt open autocomplete menu character is prefixed with text' do
page.within '.timeline-content-form' do
find('#note-body').native.send_keys('testing')
find('#note-body').native.send_keys('@')
end
expect(page).not_to have_selector('.tribute-container')
end end
it 'selects the first item for assignee dropdowns' do it 'selects the first item for assignee dropdowns' do
...@@ -532,11 +521,11 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -532,11 +521,11 @@ RSpec.describe 'GFM autocomplete', :js do
find('#note-body').native.send_keys('@') find('#note-body').native.send_keys('@')
end end
expect(page).to have_selector('.tribute-container') expect(page).to have_selector('.tribute-container', visible: true)
wait_for_requests wait_for_requests
expect(find('.tribute-container ul')).to have_selector('.highlight:first-of-type') expect(find('.tribute-container ul', visible: true)).to have_selector('.highlight:first-of-type')
end end
it 'includes items for assignee dropdowns with non-ASCII characters in name' do it 'includes items for assignee dropdowns with non-ASCII characters in name' do
...@@ -545,14 +534,26 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -545,14 +534,26 @@ RSpec.describe 'GFM autocomplete', :js do
simulate_input('#note-body', "@#{user.name[0...8]}") simulate_input('#note-body', "@#{user.name[0...8]}")
end end
expect(page).to have_selector('.tribute-container') expect(page).to have_selector('.tribute-container', visible: true)
wait_for_requests wait_for_requests
expect(find('.tribute-container')).to have_content(user.name) expect(find('.tribute-container ul', visible: true)).to have_content(user.name)
end end
context 'if a selected value has special characters' do context 'if a selected value has special characters' do
it 'wraps the result in double quotes' do
note = find('#note-body')
page.within '.timeline-content-form' do
find('#note-body').native.send_keys('')
simulate_input('#note-body', "~#{label.title[0]}")
end
label_item = find('.tribute-container ul', text: label.title, visible: true)
expect_to_wrap(true, label_item, note, label.title)
end
it "shows dropdown after a new line" do it "shows dropdown after a new line" do
note = find('#note-body') note = find('#note-body')
page.within '.timeline-content-form' do page.within '.timeline-content-form' do
...@@ -562,7 +563,7 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -562,7 +563,7 @@ RSpec.describe 'GFM autocomplete', :js do
note.native.send_keys('@') note.native.send_keys('@')
end end
expect(page).to have_selector('.tribute-container') expect(page).to have_selector('.tribute-container', visible: true)
end end
it "does not show dropdown when preceded with a special character" do it "does not show dropdown when preceded with a special character" do
...@@ -571,12 +572,21 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -571,12 +572,21 @@ RSpec.describe 'GFM autocomplete', :js do
note.native.send_keys("@") note.native.send_keys("@")
end end
expect(page).to have_selector('.tribute-container') expect(page).to have_selector('.tribute-container', visible: true)
page.within '.timeline-content-form' do page.within '.timeline-content-form' do
note.native.send_keys("@") note.native.send_keys("@")
end end
expect(page).not_to have_selector('.tribute-container')
end
it "does not throw an error if no labels exist" do
note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('~')
end
expect(page).to have_selector('.tribute-container', visible: false) expect(page).to have_selector('.tribute-container', visible: false)
end end
...@@ -586,7 +596,7 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -586,7 +596,7 @@ RSpec.describe 'GFM autocomplete', :js do
note.native.send_keys("@#{user.username[0]}") note.native.send_keys("@#{user.username[0]}")
end end
user_item = find('.tribute-container li', text: user.username) user_item = find('.tribute-container ul', text: user.username, visible: true)
expect_to_wrap(false, user_item, note, user.username) expect_to_wrap(false, user_item, note, user.username)
end end
...@@ -611,7 +621,7 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -611,7 +621,7 @@ RSpec.describe 'GFM autocomplete', :js do
wait_for_requests wait_for_requests
user_item = find('.tribute-container li', text: user.username) user_item = find('.tribute-container ul', text: user.username, visible: true)
expect(user_item).to have_content(user.username) expect(user_item).to have_content(user.username)
end end
end end
...@@ -640,8 +650,99 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -640,8 +650,99 @@ RSpec.describe 'GFM autocomplete', :js do
wait_for_requests wait_for_requests
expect(find('.tribute-container ul')).not_to have_content(user.username) expect(find('.tribute-container ul', visible: true)).not_to have_content(user.username)
expect(find('.tribute-container ul')).to have_content(unassigned_user.username) expect(find('.tribute-container ul', visible: true)).to have_content(unassigned_user.username)
end
it 'lists users who are currently not assigned to the issue when using /assign on the second line' do
visit project_issue_path(project, issue_assignee)
note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('/assign @user2')
note.native.send_keys(:enter)
note.native.send_keys('/assign @')
note.native.send_keys(:right)
end
wait_for_requests
expect(find('.tribute-container ul', visible: true)).not_to have_content(user.username)
expect(find('.tribute-container ul', visible: true)).to have_content(unassigned_user.username)
end
end
context 'labels' do
it 'opens autocomplete menu for Labels when field starts with text with item escaping HTML characters' do
label_xss_title = 'alert label &lt;img src=x onerror="alert(\'Hello xss\');" a'
create(:label, project: project, title: label_xss_title)
note = find('#note-body')
# It should show all the labels on "~".
type(note, '~')
wait_for_requests
expect(find('.tribute-container ul', visible: true).text).to have_content('alert label')
end
it 'allows colons when autocompleting scoped labels' do
create(:label, project: project, title: 'scoped:label')
note = find('#note-body')
type(note, '~scoped:')
wait_for_requests
expect(find('.tribute-container ul', visible: true).text).to have_content('scoped:label')
end
it 'allows colons when autocompleting scoped labels with double colons' do
create(:label, project: project, title: 'scoped::label')
note = find('#note-body')
type(note, '~scoped::')
wait_for_requests
expect(find('.tribute-container ul', visible: true).text).to have_content('scoped::label')
end
it 'autocompletes multi-word labels' do
create(:label, project: project, title: 'Accepting merge requests')
note = find('#note-body')
type(note, '~Acceptingmerge')
wait_for_requests
expect(find('.tribute-container ul', visible: true).text).to have_content('Accepting merge requests')
end
it 'only autocompletes the latest label' do
create(:label, project: project, title: 'documentation')
create(:label, project: project, title: 'feature')
note = find('#note-body')
type(note, '~documentation foo bar ~feat')
note.native.send_keys(:right)
wait_for_requests
expect(find('.tribute-container ul', visible: true).text).to have_content('feature')
expect(find('.tribute-container ul', visible: true).text).not_to have_content('documentation')
end
it 'does not autocomplete labels if no tilde is typed' do
create(:label, project: project, title: 'documentation')
note = find('#note-body')
type(note, 'document')
wait_for_requests
expect(page).not_to have_selector('.tribute-container')
end end
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment