Commit e1253828 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '230805-convert-labels-autocomplete-from-at-js-to-tribute' into 'master'

Convert labels autocomplete from at.js to tribute

See merge request gitlab-org/gitlab!37487
parents 4dba37b7 d7e4222e
......@@ -5,39 +5,88 @@ import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from '~/lib/utils/common_utils';
import SidebarMediator from '~/sidebar/sidebar_mediator';
/**
* Creates the HTML template for each row of the mentions dropdown.
*
* @param original - An object from the array returned from the `autocomplete_sources/members` API
* @returns {string} - An HTML template
*/
function 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}`;
const AutoComplete = {
Labels: 'labels',
Members: 'members',
};
function doesCurrentLineStartWith(searchString, fullText, selectionStart) {
const currentLineNumber = fullText.slice(0, selectionStart).split('\n').length;
const currentLine = fullText.split('\n')[currentLineNumber - 1];
return currentLine.startsWith(searchString);
}
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 {
name: 'GlMentions',
props: {
......@@ -47,67 +96,57 @@ export default {
default: () => gl.GfmAutoComplete?.dataSources || {},
},
},
data() {
return {
assignees: undefined,
members: undefined,
};
},
mounted() {
const NON_WORD_OR_INTEGER = /\W|^\d+$/;
this.tribute = new Tribute({
trigger: '@',
fillAttr: 'username',
lookup: value => value.name + value.username,
menuItemTemplate,
values: this.getMembers,
collection: [
{
trigger: '@',
fillAttr: 'username',
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);
},
beforeDestroy() {
const input = this.$slots.default[0].elm;
const input = this.$slots.default?.[0]?.elm;
this.tribute.detach(input);
},
methods: {
/**
* Creates the list of users to show in the mentions dropdown.
*
* @param inputText - The text entered by the user in the mentions input field
* @param processValues - Callback function to set the list of users to show in the mentions dropdown
*/
getMembers(inputText, processValues) {
if (this.members) {
processValues(this.getFilteredMembers());
} else if (this.dataSources.members) {
axios
.get(this.dataSources.members)
.then(response => {
this.members = response.data;
processValues(this.getFilteredMembers());
})
.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;
getValues(autoCompleteType) {
return (inputText, processValues) => {
if (this[autoCompleteType]) {
const filteredValues = autoCompleteMap[autoCompleteType].filterValues.call(this);
processValues(filteredValues);
} else if (this.dataSources[autoCompleteType]) {
axios
.get(this.dataSources[autoCompleteType])
.then(response => {
this[autoCompleteType] = response.data;
const filteredValues = autoCompleteMap[autoCompleteType].filterValues.call(this);
processValues(filteredValues);
})
.catch(() => {});
} else {
processValues([]);
}
};
},
},
render(createElement) {
......
......@@ -171,7 +171,7 @@ export default {
mergeRequests: this.enableAutocomplete,
epics: this.enableAutocomplete,
milestones: this.enableAutocomplete,
labels: this.enableAutocomplete,
labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
snippets: this.enableAutocomplete,
});
},
......
......@@ -487,7 +487,7 @@ RSpec.describe 'GFM autocomplete', :js do
wait_for_requests
find('.tribute-container .highlight').click
find('.tribute-container .highlight', visible: true).click
click_button 'Save changes'
......@@ -501,7 +501,7 @@ RSpec.describe 'GFM autocomplete', :js do
find('#note-body').native.send_keys('@')
end
expect(page).to have_selector('.tribute-container')
expect(page).to have_selector('.tribute-container', visible: true)
end
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
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('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')
expect(find('.tribute-container ul', visible: true).text).to have_content(user_xss.username)
end
it 'selects the first item for assignee dropdowns' do
......@@ -532,11 +521,11 @@ RSpec.describe 'GFM autocomplete', :js do
find('#note-body').native.send_keys('@')
end
expect(page).to have_selector('.tribute-container')
expect(page).to have_selector('.tribute-container', visible: true)
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
it 'includes items for assignee dropdowns with non-ASCII characters in name' do
......@@ -545,14 +534,26 @@ RSpec.describe 'GFM autocomplete', :js do
simulate_input('#note-body', "@#{user.name[0...8]}")
end
expect(page).to have_selector('.tribute-container')
expect(page).to have_selector('.tribute-container', visible: true)
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
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
note = find('#note-body')
page.within '.timeline-content-form' do
......@@ -562,7 +563,7 @@ RSpec.describe 'GFM autocomplete', :js do
note.native.send_keys('@')
end
expect(page).to have_selector('.tribute-container')
expect(page).to have_selector('.tribute-container', visible: true)
end
it "does not show dropdown when preceded with a special character" do
......@@ -571,12 +572,21 @@ RSpec.describe 'GFM autocomplete', :js do
note.native.send_keys("@")
end
expect(page).to have_selector('.tribute-container')
expect(page).to have_selector('.tribute-container', visible: true)
page.within '.timeline-content-form' do
note.native.send_keys("@")
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)
end
......@@ -586,7 +596,7 @@ RSpec.describe 'GFM autocomplete', :js do
note.native.send_keys("@#{user.username[0]}")
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)
end
......@@ -611,7 +621,7 @@ RSpec.describe 'GFM autocomplete', :js do
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)
end
end
......@@ -640,8 +650,99 @@ RSpec.describe 'GFM autocomplete', :js do
wait_for_requests
expect(find('.tribute-container ul')).not_to have_content(user.username)
expect(find('.tribute-container ul')).to have_content(unassigned_user.username)
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
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
......
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