Commit b2115644 authored by Coung Ngo's avatar Coung Ngo

Delete tributejs for autocomplete

The tributejs library is deprecated and we will go forward with
another option for replacing our current at.js autocomplete
library.

Changelog: other
parent 53343524
<script>
import markdownField from '~/vue_shared/components/markdown/field.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import updateMixin from '../../mixins/update';
export default {
components: {
markdownField,
},
mixins: [glFeatureFlagsMixin(), updateMixin],
mixins: [updateMixin],
props: {
formState: {
type: Object,
......@@ -56,7 +55,7 @@ export default {
v-model="formState.description"
class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea"
dir="auto"
:data-supports-quick-actions="!glFeatures.tributeAutocomplete"
data-supports-quick-actions="true"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
@keydown.meta.enter="updateIssuable"
......
......@@ -369,7 +369,7 @@ export default {
class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area"
data-qa-selector="comment_field"
data-testid="comment-field"
:data-supports-quick-actions="!glFeatures.tributeAutocomplete"
data-supports-quick-actions="true"
:aria-label="$options.i18n.comment"
:placeholder="$options.i18n.bodyPlaceholder"
@keydown.up="editCurrentUserLastNote()"
......
......@@ -5,7 +5,6 @@ import { getDraft, updateDraft } from '~/lib/utils/autosave';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import markdownField from '~/vue_shared/components/markdown/field.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub';
import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
......@@ -20,7 +19,7 @@ export default {
GlSprintf,
GlLink,
},
mixins: [glFeatureFlagsMixin(), issuableStateMixin, resolvable],
mixins: [issuableStateMixin, resolvable],
props: {
noteBody: {
type: String,
......@@ -349,7 +348,7 @@ export default {
ref="textarea"
v-model="updatedNoteBody"
:disabled="isSubmitting"
:data-supports-quick-actions="!isEditing && !glFeatures.tributeAutocomplete"
:data-supports-quick-actions="!isEditing"
name="note[note]"
class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form"
data-qa-selector="reply_field"
......
<script>
import Tribute from '@gitlab/tributejs';
import {
GfmAutocompleteType,
tributeConfig,
} from 'ee_else_ce/vue_shared/components/gfm_autocomplete/utils';
import * as Emoji from '~/emoji';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import SidebarMediator from '~/sidebar/sidebar_mediator';
export default {
errorMessage: __(
'An error occurred while getting autocomplete data. Please refresh the page and try again.',
),
props: {
autocompleteTypes: {
type: Array,
required: false,
default: () => Object.values(GfmAutocompleteType),
},
dataSources: {
type: Object,
required: false,
default: () => gl.GfmAutoComplete?.dataSources || {},
},
},
computed: {
config() {
return this.autocompleteTypes.map((type) => ({
...tributeConfig[type].config,
loadingItemTemplate: `<span class="gl-spinner gl-vertical-align-text-bottom gl-ml-3 gl-mr-2"></span>${__(
'Loading',
)}`,
requireLeadingSpace: true,
values: this.getValues(type),
}));
},
},
mounted() {
this.cache = {};
this.tribute = new Tribute({ collection: this.config });
const input = this.$slots.default?.[0]?.elm;
this.tribute.attach(input);
},
beforeDestroy() {
const input = this.$slots.default?.[0]?.elm;
this.tribute.detach(input);
},
methods: {
cacheAssignees() {
const isAssigneesLengthSame =
this.assignees?.length === SidebarMediator.singleton?.store?.assignees?.length;
if (!this.assignees || !isAssigneesLengthSame) {
this.assignees =
SidebarMediator.singleton?.store?.assignees?.map((assignee) => assignee.username) || [];
}
},
filterValues(type) {
// The assignees AJAX response can come after the user first invokes autocomplete
// so we need to check more than once if we need to update the assignee cache
this.cacheAssignees();
return tributeConfig[type].filterValues
? tributeConfig[type].filterValues({
assignees: this.assignees,
collection: this.cache[type],
fullText: this.$slots.default?.[0]?.elm?.value,
selectionStart: this.$slots.default?.[0]?.elm?.selectionStart,
})
: this.cache[type];
},
getValues(type) {
return (inputText, processValues) => {
if (this.cache[type]) {
processValues(this.filterValues(type));
} else if (type === GfmAutocompleteType.Emojis) {
Emoji.initEmojiMap()
.then(() => {
const emojis = Emoji.getValidEmojiNames();
this.cache[type] = emojis;
processValues(emojis);
})
.catch(() => createFlash({ message: this.$options.errorMessage }));
} else if (this.dataSources[type]) {
axios
.get(this.dataSources[type])
.then((response) => {
this.cache[type] = response.data;
processValues(this.filterValues(type));
})
.catch(() => createFlash({ message: this.$options.errorMessage }));
} else {
processValues([]);
}
};
},
},
render(createElement) {
return createElement('div', this.$slots.default);
},
};
</script>
import { escape, last } from 'lodash';
import * as Emoji from '~/emoji';
import { spriteIcon } from '~/lib/utils/common_utils';
const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings
// Number of users to show in the autocomplete menu to avoid doing a mass fetch of 100+ avatars
const memberLimit = 10;
const nonWordOrInteger = /\W|^\d+$/;
export const menuItemLimit = 100;
export const GfmAutocompleteType = {
Emojis: 'emojis',
Issues: 'issues',
Labels: 'labels',
Members: 'members',
MergeRequests: 'mergeRequests',
Milestones: 'milestones',
QuickActions: 'commands',
Snippets: 'snippets',
};
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);
}
export const tributeConfig = {
[GfmAutocompleteType.Emojis]: {
config: {
trigger: ':',
lookup: (value) => value,
menuItemLimit,
menuItemTemplate: ({ original }) => `${original} ${Emoji.glEmojiTag(original)}`,
selectTemplate: ({ original }) => `:${original}:`,
},
},
[GfmAutocompleteType.Issues]: {
config: {
trigger: '#',
lookup: (value) => `${value.iid}${value.title}`,
menuItemLimit,
menuItemTemplate: ({ original }) =>
`<small>${original.reference || original.iid}</small> ${escape(original.title)}`,
selectTemplate: ({ original }) => original.reference || `#${original.iid}`,
},
},
[GfmAutocompleteType.Labels]: {
config: {
trigger: '~',
lookup: 'title',
menuItemLimit,
menuItemTemplate: ({ original }) => `
<span class="dropdown-label-box" style="background: ${escape(original.color)};"></span>
${escape(original.title)}`,
selectTemplate: ({ original }) =>
nonWordOrInteger.test(original.title)
? `~"${escape(original.title)}"`
: `~${escape(original.title)}`,
},
filterValues({ collection, fullText, selectionStart }) {
if (doesCurrentLineStartWith('/label', fullText, selectionStart)) {
return collection.filter((label) => !label.set);
}
if (doesCurrentLineStartWith('/unlabel', fullText, selectionStart)) {
return collection.filter((label) => label.set);
}
return collection;
},
},
[GfmAutocompleteType.Members]: {
config: {
trigger: '@',
fillAttr: 'username',
lookup: (value) =>
value.type === groupType ? last(value.name.split(' / ')) : `${value.name}${value.username}`,
menuItemLimit: memberLimit,
menuItemTemplate: ({ original }) => {
const commonClasses = 'gl-avatar gl-avatar-s32 gl-flex-shrink-0';
const noAvatarClasses = `${commonClasses} gl-rounded-small
gl-display-flex gl-align-items-center gl-justify-content-center`;
const avatar = original.avatar_url
? `<img class="${commonClasses} gl-avatar-circle" src="${original.avatar_url}" alt="" />`
: `<div class="${noAvatarClasses}" aria-hidden="true">
${original.username.charAt(0).toUpperCase()}</div>`;
let displayName = original.name;
let parentGroupOrUsername = `@${original.username}`;
if (original.type === groupType) {
const splitName = original.name.split(' / ');
displayName = splitName.pop();
parentGroupOrUsername = splitName.pop();
}
const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : '';
const disabledMentionsIcon = original.mentionsDisabled
? spriteIcon('notifications-off', 's16 gl-ml-3')
: '';
return `
<div class="gl-display-flex gl-align-items-center">
${avatar}
<div class="gl-line-height-normal gl-ml-4">
<div>${escape(displayName)}${count}</div>
<div class="gl-text-gray-700">${escape(parentGroupOrUsername)}</div>
</div>
${disabledMentionsIcon}
</div>
`;
},
},
filterValues({ assignees, collection, fullText, selectionStart }) {
if (doesCurrentLineStartWith('/assign', fullText, selectionStart)) {
return collection.filter((member) => !assignees.includes(member.username));
}
if (doesCurrentLineStartWith('/unassign', fullText, selectionStart)) {
return collection.filter((member) => assignees.includes(member.username));
}
return collection;
},
},
[GfmAutocompleteType.MergeRequests]: {
config: {
trigger: '!',
lookup: (value) => `${value.iid}${value.title}`,
menuItemLimit,
menuItemTemplate: ({ original }) =>
`<small>${original.reference || original.iid}</small> ${escape(original.title)}`,
selectTemplate: ({ original }) => original.reference || `!${original.iid}`,
},
},
[GfmAutocompleteType.Milestones]: {
config: {
trigger: '%',
lookup: 'title',
menuItemLimit,
menuItemTemplate: ({ original }) => escape(original.title),
selectTemplate: ({ original }) => `%"${escape(original.title)}"`,
},
},
[GfmAutocompleteType.QuickActions]: {
config: {
trigger: '/',
fillAttr: 'name',
lookup: (value) => `${value.name}${value.aliases.join()}`,
menuItemLimit,
menuItemTemplate: ({ original }) => {
const aliases = original.aliases.length
? `<small>(or /${original.aliases.join(', /')})</small>`
: '';
const params = original.params.length ? `<small>${original.params.join(' ')}</small>` : '';
let description = '';
if (original.warning) {
const confidentialIcon =
original.icon === 'confidential' ? spriteIcon('eye-slash', 's16 gl-mr-2') : '';
description = `<small>${confidentialIcon}<em>${original.warning}</em></small>`;
} else if (original.description) {
description = `<small><em>${original.description}</em></small>`;
}
return `<div>/${original.name} ${aliases} ${params}</div>
<div>${description}</div>`;
},
},
},
[GfmAutocompleteType.Snippets]: {
config: {
trigger: '$',
fillAttr: 'id',
lookup: (value) => `${value.id}${value.title}`,
menuItemLimit,
menuItemTemplate: ({ original }) => `<small>${original.id}</small> ${escape(original.title)}`,
},
},
};
......@@ -8,9 +8,7 @@ import GLForm from '~/gl_form';
import axios from '~/lib/utils/axios_utils';
import { stripHtml } from '~/lib/utils/text_utility';
import { __, sprintf } from '~/locale';
import GfmAutocomplete from '~/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue';
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MarkdownHeader from './header.vue';
import MarkdownToolbar from './toolbar.vue';
......@@ -20,13 +18,11 @@ function cleanUpLine(content) {
export default {
components: {
GfmAutocomplete,
MarkdownHeader,
MarkdownToolbar,
GlIcon,
Suggestions,
},
mixins: [glFeatureFlagsMixin()],
props: {
/**
* This prop should be bound to the value of the `<textarea>` element
......@@ -212,14 +208,14 @@ export default {
return new GLForm(
$(this.$refs['gl-form']),
{
emojis: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
issues: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
mergeRequests: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
epics: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
milestones: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
snippets: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
emojis: this.enableAutocomplete,
members: this.enableAutocomplete,
issues: this.enableAutocomplete,
mergeRequests: this.enableAutocomplete,
epics: this.enableAutocomplete,
milestones: this.enableAutocomplete,
labels: this.enableAutocomplete,
snippets: this.enableAutocomplete,
vulnerabilities: this.enableAutocomplete,
},
true,
......@@ -311,10 +307,7 @@ export default {
/>
<div v-show="!previewMarkdown" class="md-write-holder">
<div class="zen-backdrop">
<gfm-autocomplete v-if="glFeatures.tributeAutocomplete">
<slot name="textarea"></slot>
</gfm-autocomplete>
<slot v-else name="textarea"></slot>
<slot name="textarea"></slot>
<a
class="zen-control zen-control-leave js-zen-leave gl-text-gray-500"
href="#"
......
.tribute-container {
background: $white;
border: 1px solid $gray-100;
border-radius: $border-radius-base;
box-shadow: 0 0 5px $issue-boards-card-shadow;
color: $black;
margin-top: $gl-padding-12;
max-height: 200px;
min-width: 120px;
overflow-y: auto;
z-index: 11110 !important;
ul {
list-style: none;
margin-bottom: 0;
padding: $gl-padding-8 1px;
}
li {
cursor: pointer;
padding: $gl-padding-8 $gl-padding;
white-space: nowrap;
small {
color: $gray-500;
}
&.highlight {
background-color: $gray-darker;
.avatar {
@include disable-all-animation;
border: 1px solid $white;
}
small {
color: inherit;
}
}
}
}
......@@ -42,7 +42,6 @@ class Projects::IssuesController < Projects::ApplicationController
if: -> { Feature.disabled?('rate_limited_service_issues_create', project, default_enabled: :yaml) }
before_action do
push_frontend_feature_flag(:tribute_autocomplete, @project)
push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml)
push_frontend_feature_flag(:vue_issues_list, project&.group, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml)
......
---
name: tribute_autocomplete
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/32671
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/292804
milestone: '13.2'
type: development
group: group::project management
default_enabled: false
......@@ -260,8 +260,7 @@ module.exports = {
{
test: /\.js$/,
exclude: (modulePath) =>
/node_modules\/(?!tributejs)|node_modules|vendor[\\/]assets/.test(modulePath) &&
!/\.vue\.js/.test(modulePath),
/node_modules|vendor[\\/]assets/.test(modulePath) && !/\.vue\.js/.test(modulePath),
loader: 'babel-loader',
options: {
cacheDirectory: path.join(CACHE_PATH, 'babel-loader'),
......
import { escape } from 'lodash';
import {
GfmAutocompleteType as GfmAutocompleteTypeFoss,
menuItemLimit,
tributeConfig as tributeConfigFoss,
} from '~/vue_shared/components/gfm_autocomplete/utils';
export const GfmAutocompleteType = {
...GfmAutocompleteTypeFoss,
Epics: 'epics',
};
export const tributeConfig = {
...tributeConfigFoss,
[GfmAutocompleteType.Epics]: {
config: {
trigger: '&',
fillAttr: 'iid',
lookup: (value) => `${value.iid}${value.title}`,
menuItemLimit,
menuItemTemplate: ({ original }) =>
`<small>${original.iid}</small> ${escape(original.title)}`,
selectTemplate: ({ original }) => original.reference || `&${original.iid}`,
},
},
};
......@@ -13,8 +13,6 @@ RSpec.describe 'Issue promotion', :js do
let(:user) { create(:user) }
before do
stub_feature_flags(tribute_autocomplete: false)
sign_in(user)
end
......
......@@ -5,9 +5,7 @@ require 'spec_helper'
RSpec.describe 'GFM autocomplete EE', :js do
let_it_be(:user) { create(:user, name: '💃speciąl someone💃', username: 'someone.special') }
let_it_be(:another_user) { create(:user, name: 'another user', username: 'another.user') }
let_it_be(:group) { create(:group) }
let_it_be(:epic) { create(:epic, group: group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:project) { create(:project) }
let_it_be(:issue) { create(:issue, project: project, assignees: [user]) }
before do
......@@ -15,57 +13,21 @@ RSpec.describe 'GFM autocomplete EE', :js do
end
context 'assignees' do
describe 'when tribute_autocomplete feature flag is off' do
before do
stub_feature_flags(tribute_autocomplete: false)
before do
sign_in(user)
sign_in(user)
visit project_issue_path(project, issue)
end
it 'only lists users who are currently assigned to the issue when using /unassign' do
fill_in 'Comment', with: '/una'
find_highlighted_autocomplete_item.click
wait_for_requests
expect(find_autocomplete_menu).to have_text(user.username)
expect(find_autocomplete_menu).not_to have_text(another_user.username)
end
visit project_issue_path(project, issue)
end
describe 'when tribute_autocomplete feature flag is on' do
before do
stub_licensed_features(epics: true)
stub_feature_flags(tribute_autocomplete: true)
sign_in(user)
it 'only lists users who are currently assigned to the issue when using /unassign' do
fill_in 'Comment', with: '/una'
visit project_issue_path(project, issue)
end
find_highlighted_autocomplete_item.click
it 'only lists users who are currently assigned to the issue when using /unassign' do
note = find_field('Comment')
note.native.send_keys('/unassign ')
# The `/unassign` ajax response might replace the one by `@` below causing a failed test
# so we need to wait for the `/assign` ajax request to finish first
wait_for_requests
note.native.send_keys('@')
wait_for_requests
wait_for_requests
expect(find_tribute_autocomplete_menu).to have_text(user.username)
expect(find_tribute_autocomplete_menu).not_to have_text(another_user.username)
end
it 'shows epics' do
fill_in 'Comment', with: '&'
wait_for_requests
expect(find_tribute_autocomplete_menu).to have_text(epic.title)
end
expect(find_autocomplete_menu).to have_text(user.username)
expect(find_autocomplete_menu).not_to have_text(another_user.username)
end
end
......@@ -78,8 +40,4 @@ RSpec.describe 'GFM autocomplete EE', :js do
def find_highlighted_autocomplete_item
find('.atwho-view li.cur', visible: true)
end
def find_tribute_autocomplete_menu
find('.tribute-container ul', visible: true)
end
end
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ee gfm_autocomplete/utils epics config shows the iid and title in the menu item 1`] = `"<small>123456</small> Epic title &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"`;
import {
GfmAutocompleteType,
tributeConfig,
} from 'ee/vue_shared/components/gfm_autocomplete/utils';
describe('ee gfm_autocomplete/utils', () => {
describe('epics config', () => {
const epicsConfig = tributeConfig[GfmAutocompleteType.Epics].config;
const epic = {
id: null,
iid: 123456,
title: "Epic title <script>alert('hi')</script>",
};
const subgroupEpic = {
iid: 987654,
reference: 'gitlab&987654',
title: "Subgroup context epic title <script>alert('hi')</script>",
};
it('uses & as the trigger', () => {
expect(epicsConfig.trigger).toBe('&');
});
it('inserts the iid on autocomplete selection', () => {
expect(epicsConfig.fillAttr).toBe('iid');
});
it('searches using both the iid and title', () => {
expect(epicsConfig.lookup(epic)).toBe(`${epic.iid}${epic.title}`);
});
it('limits the number of rendered items to 100', () => {
expect(epicsConfig.menuItemLimit).toBe(100);
});
it('shows the iid and title in the menu item', () => {
expect(epicsConfig.menuItemTemplate({ original: epic })).toMatchSnapshot();
});
it('inserts the iid on autocomplete selection within a top level group context', () => {
expect(epicsConfig.selectTemplate({ original: epic })).toBe(`&${epic.iid}`);
});
it('inserts the reference on autocomplete selection within a group context', () => {
expect(epicsConfig.selectTemplate({ original: subgroupEpic })).toBe(subgroupEpic.reference);
});
});
});
......@@ -3818,9 +3818,6 @@ msgstr ""
msgid "An error occurred while fetching this tab."
msgstr ""
msgid "An error occurred while getting autocomplete data. Please refresh the page and try again."
msgstr ""
msgid "An error occurred while getting files for - %{branchId}"
msgstr ""
......
......@@ -6,10 +6,7 @@ RSpec.describe 'GFM autocomplete', :js do
let_it_be(:user) { create(:user, name: '💃speciąl someone💃', username: 'someone.special') }
let_it_be(:user2) { create(:user, name: 'Marge Simpson', username: 'msimpson') }
let_it_be(:group) { create(:group, name: 'Ancestor') }
let_it_be(:child_group) { create(:group, parent: group, name: 'My group') }
let_it_be(:project) { create(:project, group: child_group) }
let_it_be(:project) { create(:project) }
let_it_be(:issue) { create(:issue, project: project, assignees: [user]) }
let_it_be(:label) { create(:label, project: project, title: 'special+') }
let_it_be(:label_scoped) { create(:label, project: project, title: 'scoped::label') }
......@@ -27,670 +24,361 @@ RSpec.describe 'GFM autocomplete', :js do
project.add_maintainer(user2)
end
describe 'when tribute_autocomplete feature flag is off' do
describe 'new issue page' do
before do
stub_feature_flags(tribute_autocomplete: false)
sign_in(user)
visit new_project_issue_path(project)
describe 'new issue page' do
before do
sign_in(user)
visit new_project_issue_path(project)
wait_for_requests
end
wait_for_requests
end
it 'allows quick actions' do
fill_in 'Description', with: '/'
it 'allows quick actions' do
fill_in 'Description', with: '/'
expect(find_autocomplete_menu).to be_visible
end
expect(find_autocomplete_menu).to be_visible
end
end
describe 'issue description' do
let(:issue_to_edit) { create(:issue, project: project) }
describe 'issue description' do
let(:issue_to_edit) { create(:issue, project: project) }
before do
stub_feature_flags(tribute_autocomplete: false)
before do
sign_in(user)
visit project_issue_path(project, issue_to_edit)
sign_in(user)
visit project_issue_path(project, issue_to_edit)
wait_for_requests
end
wait_for_requests
end
it 'updates with GFM reference' do
click_button 'Edit title and description'
it 'updates with GFM reference' do
click_button 'Edit title and description'
wait_for_requests
wait_for_requests
fill_in 'Description', with: "@#{user.name[0...3]}"
fill_in 'Description', with: "@#{user.name[0...3]}"
wait_for_requests
wait_for_requests
find_highlighted_autocomplete_item.click
find_highlighted_autocomplete_item.click
click_button 'Save changes'
click_button 'Save changes'
wait_for_requests
wait_for_requests
expect(find('.description')).to have_text(user.to_reference)
end
expect(find('.description')).to have_text(user.to_reference)
end
it 'allows quick actions' do
click_button 'Edit title and description'
it 'allows quick actions' do
click_button 'Edit title and description'
fill_in 'Description', with: '/'
fill_in 'Description', with: '/'
expect(find_autocomplete_menu).to be_visible
end
expect(find_autocomplete_menu).to be_visible
end
end
describe 'issue comment' do
before do
stub_feature_flags(tribute_autocomplete: false)
sign_in(user)
visit project_issue_path(project, issue)
describe 'issue comment' do
before do
sign_in(user)
visit project_issue_path(project, issue)
wait_for_requests
end
wait_for_requests
end
describe 'triggering autocomplete' do
it 'only opens autocomplete menu when trigger character is after whitespace', :aggregate_failures do
fill_in 'Comment', with: 'testing@'
expect(page).not_to have_css('.atwho-view')
describe 'triggering autocomplete' do
it 'only opens autocomplete menu when trigger character is after whitespace', :aggregate_failures do
fill_in 'Comment', with: 'testing@'
expect(page).not_to have_css('.atwho-view')
fill_in 'Comment', with: '@@'
expect(page).not_to have_css('.atwho-view')
fill_in 'Comment', with: '@@'
expect(page).not_to have_css('.atwho-view')
fill_in 'Comment', with: "@#{user.username[0..2]}!"
expect(page).not_to have_css('.atwho-view')
fill_in 'Comment', with: "@#{user.username[0..2]}!"
expect(page).not_to have_css('.atwho-view')
fill_in 'Comment', with: "hello:#{user.username[0..2]}"
expect(page).not_to have_css('.atwho-view')
fill_in 'Comment', with: "hello:#{user.username[0..2]}"
expect(page).not_to have_css('.atwho-view')
fill_in 'Comment', with: '7:'
expect(page).not_to have_css('.atwho-view')
fill_in 'Comment', with: '7:'
expect(page).not_to have_css('.atwho-view')
fill_in 'Comment', with: 'w:'
expect(page).not_to have_css('.atwho-view')
fill_in 'Comment', with: 'w:'
expect(page).not_to have_css('.atwho-view')
fill_in 'Comment', with: 'Ё:'
expect(page).not_to have_css('.atwho-view')
fill_in 'Comment', with: 'Ё:'
expect(page).not_to have_css('.atwho-view')
fill_in 'Comment', with: "test\n\n@"
expect(find_autocomplete_menu).to be_visible
end
fill_in 'Comment', with: "test\n\n@"
expect(find_autocomplete_menu).to be_visible
end
end
context 'xss checks' do
it 'opens autocomplete menu for Issues when field starts with text with item escaping HTML characters' do
issue_xss_title = 'This will execute alert<img src=x onerror=alert(2)&lt;img src=x onerror=alert(1)&gt;'
create(:issue, project: project, title: issue_xss_title)
fill_in 'Comment', with: '#'
wait_for_requests
expect(find_autocomplete_menu).to have_text(issue_xss_title)
end
it 'opens autocomplete menu for Username when field starts with text with item escaping HTML characters' do
fill_in 'Comment', with: '@ev'
wait_for_requests
expect(find_highlighted_autocomplete_item).to have_text(user_xss.username)
end
it 'opens autocomplete menu for Milestone when field starts with text with item escaping HTML characters' do
milestone_xss_title = 'alert milestone &lt;img src=x onerror="alert(\'Hello xss\');" a'
create(:milestone, project: project, title: milestone_xss_title)
fill_in 'Comment', with: '%'
wait_for_requests
expect(find_autocomplete_menu).to have_text('alert milestone')
end
context 'xss checks' do
it 'opens autocomplete menu for Issues when field starts with text with item escaping HTML characters' do
issue_xss_title = 'This will execute alert<img src=x onerror=alert(2)&lt;img src=x onerror=alert(1)&gt;'
create(:issue, project: project, title: issue_xss_title)
it 'opens autocomplete menu for Labels when field starts with text with item escaping HTML characters' do
fill_in 'Comment', with: '~'
fill_in 'Comment', with: '#'
wait_for_requests
wait_for_requests
expect(find_autocomplete_menu).to have_text('alert label')
end
expect(find_autocomplete_menu).to have_text(issue_xss_title)
end
describe 'autocomplete highlighting' do
it 'auto-selects the first item when there is a query, and only for assignees with no query', :aggregate_failures do
fill_in 'Comment', with: ':'
wait_for_requests
expect(find_autocomplete_menu).not_to have_css('.cur')
it 'opens autocomplete menu for Username when field starts with text with item escaping HTML characters' do
fill_in 'Comment', with: '@ev'
fill_in 'Comment', with: ':1'
wait_for_requests
expect(find_autocomplete_menu).to have_css('.cur:first-of-type')
wait_for_requests
fill_in 'Comment', with: '@'
wait_for_requests
expect(find_autocomplete_menu).to have_css('.cur:first-of-type')
end
expect(find_highlighted_autocomplete_item).to have_text(user_xss.username)
end
describe 'assignees' do
it 'does not wrap with quotes for assignee values' do
fill_in 'Comment', with: "@#{user.username}"
find_highlighted_autocomplete_item.click
expect(find_field('Comment').value).to have_text("@#{user.username}")
end
it 'includes items for assignee dropdowns with non-ASCII characters in name' do
fill_in 'Comment', with: "@#{user.name[0...8]}"
wait_for_requests
expect(find_autocomplete_menu).to have_text(user.name)
end
it 'searches across full name for assignees' do
fill_in 'Comment', with: '@speciąlsome'
it 'opens autocomplete menu for Milestone when field starts with text with item escaping HTML characters' do
milestone_xss_title = 'alert milestone &lt;img src=x onerror="alert(\'Hello xss\');" a'
create(:milestone, project: project, title: milestone_xss_title)
wait_for_requests
fill_in 'Comment', with: '%'
expect(find_highlighted_autocomplete_item).to have_text(user.name)
end
it 'shows names that start with the query as the top result' do
fill_in 'Comment', with: '@mar'
wait_for_requests
expect(find_highlighted_autocomplete_item).to have_text(user2.name)
end
it 'shows usernames that start with the query as the top result' do
fill_in 'Comment', with: '@msi'
wait_for_requests
expect(find_highlighted_autocomplete_item).to have_text(user2.name)
end
# Regression test for https://gitlab.com/gitlab-org/gitlab/-/issues/321925
it 'shows username when pasting then pressing Enter' do
fill_in 'Comment', with: "@#{user.username}\n"
expect(find_field('Comment').value).to have_text "@#{user.username}"
end
it 'does not show `@undefined` when pressing `@` then Enter' do
fill_in 'Comment', with: "@\n"
expect(find_field('Comment').value).to have_text '@'
expect(find_field('Comment').value).not_to have_text '@undefined'
end
context 'when /assign quick action is selected' do
it 'triggers user autocomplete and lists users who are currently not assigned to the issue' do
fill_in 'Comment', with: '/as'
find_highlighted_autocomplete_item.click
wait_for_requests
expect(find_autocomplete_menu).not_to have_text(user.username)
expect(find_autocomplete_menu).to have_text(user2.username)
end
end
expect(find_autocomplete_menu).to have_text('alert milestone')
end
context 'if a selected value has special characters' do
it 'wraps the result in double quotes' do
fill_in 'Comment', with: "~#{label.title[0..2]}"
find_highlighted_autocomplete_item.click
expect(find_field('Comment').value).to have_text("~\"#{label.title}\"")
end
it 'doesn\'t wrap for emoji values' do
fill_in 'Comment', with: ':cartwheel_'
it 'opens autocomplete menu for Labels when field starts with text with item escaping HTML characters' do
fill_in 'Comment', with: '~'
find_highlighted_autocomplete_item.click
expect(find_field('Comment').value).to have_text('cartwheel_tone1')
end
end
context 'quick actions' do
it 'does not limit quick actions autocomplete list to 5' do
fill_in 'Comment', with: '/'
wait_for_requests
expect(find_autocomplete_menu).to have_css('li', minimum: 6)
end
expect(find_autocomplete_menu).to have_text('alert label')
end
end
context 'labels' do
it 'allows colons when autocompleting scoped labels' do
fill_in 'Comment', with: '~scoped:'
wait_for_requests
expect(find_autocomplete_menu).to have_text('scoped::label')
end
it 'allows spaces when autocompleting multi-word labels' do
fill_in 'Comment', with: '~Accepting merge'
wait_for_requests
expect(find_autocomplete_menu).to have_text('Accepting merge requests')
end
it 'only autocompletes the last label' do
fill_in 'Comment', with: '~scoped:: foo bar ~Accepting merge'
describe 'autocomplete highlighting' do
it 'auto-selects the first item when there is a query, and only for assignees with no query', :aggregate_failures do
fill_in 'Comment', with: ':'
wait_for_requests
expect(find_autocomplete_menu).not_to have_css('.cur')
wait_for_requests
fill_in 'Comment', with: ':1'
wait_for_requests
expect(find_autocomplete_menu).to have_css('.cur:first-of-type')
expect(find_autocomplete_menu).to have_text('Accepting merge requests')
end
fill_in 'Comment', with: '@'
wait_for_requests
expect(find_autocomplete_menu).to have_css('.cur:first-of-type')
end
end
it 'does not autocomplete labels if no tilde is typed' do
fill_in 'Comment', with: 'Accepting merge'
describe 'assignees' do
it 'does not wrap with quotes for assignee values' do
fill_in 'Comment', with: "@#{user.username}"
wait_for_requests
find_highlighted_autocomplete_item.click
expect(page).not_to have_css('.atwho-view')
end
expect(find_field('Comment').value).to have_text("@#{user.username}")
end
context 'when other notes are destroyed' do
let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) }
it 'includes items for assignee dropdowns with non-ASCII characters in name' do
fill_in 'Comment', with: "@#{user.name[0...8]}"
# This is meant to protect against this issue https://gitlab.com/gitlab-org/gitlab/-/issues/228729
it 'keeps autocomplete key listeners' do
note = find_field('Comment')
wait_for_requests
start_comment_with_emoji(note, '.atwho-view li')
expect(find_autocomplete_menu).to have_text(user.name)
end
start_and_cancel_discussion
it 'searches across full name for assignees' do
fill_in 'Comment', with: '@speciąlsome'
note.fill_in(with: '')
start_comment_with_emoji(note, '.atwho-view li')
note.native.send_keys(:enter)
wait_for_requests
expect(note.value).to eql('Hello :100: ')
end
expect(find_highlighted_autocomplete_item).to have_text(user.name)
end
shared_examples 'autocomplete suggestions' do
it 'suggests objects correctly' do
fill_in 'Comment', with: object.class.reference_prefix
it 'shows names that start with the query as the top result' do
fill_in 'Comment', with: '@mar'
find_autocomplete_menu.find('li').click
wait_for_requests
expect(find_field('Comment').value).to have_text(expected_body)
end
expect(find_highlighted_autocomplete_item).to have_text(user2.name)
end
context 'issues' do
let(:object) { issue }
let(:expected_body) { object.to_reference }
it_behaves_like 'autocomplete suggestions'
end
it 'shows usernames that start with the query as the top result' do
fill_in 'Comment', with: '@msi'
context 'merge requests' do
let(:object) { create(:merge_request, source_project: project) }
let(:expected_body) { object.to_reference }
wait_for_requests
it_behaves_like 'autocomplete suggestions'
expect(find_highlighted_autocomplete_item).to have_text(user2.name)
end
context 'project snippets' do
let!(:object) { snippet }
let(:expected_body) { object.to_reference }
# Regression test for https://gitlab.com/gitlab-org/gitlab/-/issues/321925
it 'shows username when pasting then pressing Enter' do
fill_in 'Comment', with: "@#{user.username}\n"
it_behaves_like 'autocomplete suggestions'
expect(find_field('Comment').value).to have_text "@#{user.username}"
end
context 'milestone' do
let_it_be(:milestone_expired) { create(:milestone, project: project, due_date: 5.days.ago) }
let_it_be(:milestone_no_duedate) { create(:milestone, project: project, title: 'Foo - No due date') }
let_it_be(:milestone1) { create(:milestone, project: project, title: 'Milestone-1', due_date: 20.days.from_now) }
let_it_be(:milestone2) { create(:milestone, project: project, title: 'Milestone-2', due_date: 15.days.from_now) }
let_it_be(:milestone3) { create(:milestone, project: project, title: 'Milestone-3', due_date: 10.days.from_now) }
before do
fill_in 'Comment', with: '/milestone %'
it 'does not show `@undefined` when pressing `@` then Enter' do
fill_in 'Comment', with: "@\n"
wait_for_requests
end
expect(find_field('Comment').value).to have_text '@'
expect(find_field('Comment').value).not_to have_text '@undefined'
end
it 'shows milestons list in the autocomplete menu' do
page.within(find_autocomplete_menu) do
expect(page).to have_selector('li', count: 5)
end
end
context 'when /assign quick action is selected' do
it 'triggers user autocomplete and lists users who are currently not assigned to the issue' do
fill_in 'Comment', with: '/as'
it 'shows expired milestone at the bottom of the list' do
page.within(find_autocomplete_menu) do
expect(page.find('li:last-child')).to have_content milestone_expired.title
end
end
find_highlighted_autocomplete_item.click
it 'shows milestone due earliest at the top of the list' do
page.within(find_autocomplete_menu) do
aggregate_failures do
expect(page.all('li')[0]).to have_content milestone3.title
expect(page.all('li')[1]).to have_content milestone2.title
expect(page.all('li')[2]).to have_content milestone1.title
expect(page.all('li')[3]).to have_content milestone_no_duedate.title
end
end
expect(find_autocomplete_menu).not_to have_text(user.username)
expect(find_autocomplete_menu).to have_text(user2.username)
end
end
end
end
describe 'when tribute_autocomplete feature flag is on' do
describe 'issue description' do
let(:issue_to_edit) { create(:issue, project: project) }
context 'if a selected value has special characters' do
it 'wraps the result in double quotes' do
fill_in 'Comment', with: "~#{label.title[0..2]}"
before do
stub_feature_flags(tribute_autocomplete: true)
sign_in(user)
visit project_issue_path(project, issue_to_edit)
find_highlighted_autocomplete_item.click
wait_for_requests
expect(find_field('Comment').value).to have_text("~\"#{label.title}\"")
end
it 'updates with GFM reference' do
click_button 'Edit title and description'
wait_for_requests
fill_in 'Description', with: "@#{user.name[0...3]}"
wait_for_requests
find_highlighted_tribute_autocomplete_menu.click
it 'doesn\'t wrap for emoji values' do
fill_in 'Comment', with: ':cartwheel_'
click_button 'Save changes'
wait_for_requests
find_highlighted_autocomplete_item.click
expect(find('.description')).to have_text(user.to_reference)
expect(find_field('Comment').value).to have_text('cartwheel_tone1')
end
end
describe 'issue comment' do
before do
stub_feature_flags(tribute_autocomplete: true)
context 'quick actions' do
it 'does not limit quick actions autocomplete list to 5' do
fill_in 'Comment', with: '/'
sign_in(user)
visit project_issue_path(project, issue)
wait_for_requests
expect(find_autocomplete_menu).to have_css('li', minimum: 6)
end
end
describe 'triggering autocomplete' do
it 'only opens autocomplete menu when trigger character is after whitespace', :aggregate_failures do
fill_in 'Comment', with: 'testing@'
expect(page).not_to have_css('.tribute-container')
fill_in 'Comment', with: "hello:#{user.username[0..2]}"
expect(page).not_to have_css('.tribute-container')
fill_in 'Comment', with: '7:'
expect(page).not_to have_css('.tribute-container')
fill_in 'Comment', with: 'w:'
expect(page).not_to have_css('.tribute-container')
context 'labels' do
it 'allows colons when autocompleting scoped labels' do
fill_in 'Comment', with: '~scoped:'
fill_in 'Comment', with: 'Ё:'
expect(page).not_to have_css('.tribute-container')
wait_for_requests
fill_in 'Comment', with: "test\n\n@"
expect(find_tribute_autocomplete_menu).to be_visible
end
expect(find_autocomplete_menu).to have_text('scoped::label')
end
context 'xss checks' do
it 'opens autocomplete menu for Issues when field starts with text with item escaping HTML characters' do
issue_xss_title = 'This will execute alert<img src=x onerror=alert(2)&lt;img src=x onerror=alert(1)&gt;'
create(:issue, project: project, title: issue_xss_title)
fill_in 'Comment', with: '#'
wait_for_requests
expect(find_tribute_autocomplete_menu).to have_text(issue_xss_title)
end
it 'opens autocomplete menu for Username when field starts with text with item escaping HTML characters' do
fill_in 'Comment', with: '@ev'
wait_for_requests
expect(find_tribute_autocomplete_menu).to have_text(user_xss.username)
end
it 'opens autocomplete menu for Milestone when field starts with text with item escaping HTML characters' do
milestone_xss_title = 'alert milestone &lt;img src=x onerror="alert(\'Hello xss\');" a'
create(:milestone, project: project, title: milestone_xss_title)
it 'allows spaces when autocompleting multi-word labels' do
fill_in 'Comment', with: '~Accepting merge'
fill_in 'Comment', with: '%'
wait_for_requests
expect(find_tribute_autocomplete_menu).to have_text('alert milestone')
end
it 'opens autocomplete menu for Labels when field starts with text with item escaping HTML characters' do
fill_in 'Comment', with: '~'
wait_for_requests
expect(find_tribute_autocomplete_menu).to have_text('alert label')
end
end
describe 'autocomplete highlighting' do
it 'auto-selects the first item with query', :aggregate_failures do
fill_in 'Comment', with: ':1'
wait_for_requests
expect(find_tribute_autocomplete_menu).to have_css('.highlight:first-of-type')
wait_for_requests
fill_in 'Comment', with: '@'
wait_for_requests
expect(find_tribute_autocomplete_menu).to have_css('.highlight:first-of-type')
end
expect(find_autocomplete_menu).to have_text('Accepting merge requests')
end
describe 'assignees' do
it 'does not wrap with quotes for assignee values' do
fill_in 'Comment', with: "@#{user.username[0..2]}"
find_highlighted_tribute_autocomplete_menu.click
expect(find_field('Comment').value).to have_text("@#{user.username}")
end
it 'only autocompletes the last label' do
fill_in 'Comment', with: '~scoped:: foo bar ~Accepting merge'
it 'includes items for assignee dropdowns with non-ASCII characters in name' do
fill_in 'Comment', with: "@#{user.name[0...8]}"
wait_for_requests
expect(find_tribute_autocomplete_menu).to have_text(user.name)
end
context 'when autocompleting for groups' do
it 'shows the group when searching for the name of the group' do
fill_in 'Comment', with: '@mygroup'
expect(find_tribute_autocomplete_menu).to have_text('My group')
end
wait_for_requests
it 'does not show the group when searching for the name of the parent of the group' do
fill_in 'Comment', with: '@ancestor'
expect(find_autocomplete_menu).to have_text('Accepting merge requests')
end
expect(find_tribute_autocomplete_menu).not_to have_text('My group')
end
end
it 'does not autocomplete labels if no tilde is typed' do
fill_in 'Comment', with: 'Accepting merge'
context 'when /assign quick action is selected' do
it 'lists users who are currently not assigned to the issue' do
note = find_field('Comment')
note.native.send_keys('/assign ')
# The `/assign` ajax response might replace the one by `@` below causing a failed test
# so we need to wait for the `/assign` ajax request to finish first
wait_for_requests
note.native.send_keys('@')
wait_for_requests
expect(find_tribute_autocomplete_menu).not_to have_text(user.username)
expect(find_tribute_autocomplete_menu).to have_text(user2.username)
end
wait_for_requests
it 'lists users who are currently not assigned to the issue when using /assign on the second line' do
note = find_field('Comment')
note.native.send_keys('/assign @user2')
note.native.send_keys(:enter)
note.native.send_keys('/assign ')
# The `/assign` ajax response might replace the one by `@` below causing a failed test
# so we need to wait for the `/assign` ajax request to finish first
wait_for_requests
note.native.send_keys('@')
wait_for_requests
expect(find_tribute_autocomplete_menu).not_to have_text(user.username)
expect(find_tribute_autocomplete_menu).to have_text(user2.username)
end
end
expect(page).not_to have_css('.atwho-view')
end
end
context 'if a selected value has special characters' do
it 'wraps the result in double quotes' do
fill_in 'Comment', with: "~#{label.title[0..2]}"
context 'when other notes are destroyed' do
let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) }
find_highlighted_tribute_autocomplete_menu.click
# This is meant to protect against this issue https://gitlab.com/gitlab-org/gitlab/-/issues/228729
it 'keeps autocomplete key listeners' do
note = find_field('Comment')
expect(find_field('Comment').value).to have_text("~\"#{label.title}\"")
end
start_comment_with_emoji(note, '.atwho-view li')
it 'does not wrap for emoji values' do
fill_in 'Comment', with: ':cartwheel_'
start_and_cancel_discussion
find_highlighted_tribute_autocomplete_menu.click
note.fill_in(with: '')
start_comment_with_emoji(note, '.atwho-view li')
note.native.send_keys(:enter)
expect(find_field('Comment').value).to have_text('cartwheel_tone1')
end
expect(note.value).to eql('Hello :100: ')
end
end
context 'quick actions' do
it 'autocompletes for quick actions' do
fill_in 'Comment', with: '/as'
shared_examples 'autocomplete suggestions' do
it 'suggests objects correctly' do
fill_in 'Comment', with: object.class.reference_prefix
find_highlighted_tribute_autocomplete_menu.click
find_autocomplete_menu.find('li').click
expect(find_field('Comment').value).to have_text('/assign')
end
expect(find_field('Comment').value).to have_text(expected_body)
end
end
context 'labels' do
it 'allows colons when autocompleting scoped labels' do
fill_in 'Comment', with: '~scoped:'
wait_for_requests
expect(find_tribute_autocomplete_menu).to have_text('scoped::label')
end
it 'autocompletes multi-word labels' do
fill_in 'Comment', with: '~Acceptingmerge'
context 'issues' do
let(:object) { issue }
let(:expected_body) { object.to_reference }
wait_for_requests
it_behaves_like 'autocomplete suggestions'
end
expect(find_tribute_autocomplete_menu).to have_text('Accepting merge requests')
end
context 'merge requests' do
let(:object) { create(:merge_request, source_project: project) }
let(:expected_body) { object.to_reference }
it 'only autocompletes the last label' do
fill_in 'Comment', with: '~scoped:: foo bar ~Acceptingmerge'
# Invoke autocompletion
find_field('Comment').native.send_keys(:right)
it_behaves_like 'autocomplete suggestions'
end
wait_for_requests
context 'project snippets' do
let!(:object) { snippet }
let(:expected_body) { object.to_reference }
expect(find_tribute_autocomplete_menu).to have_text('Accepting merge requests')
end
it_behaves_like 'autocomplete suggestions'
end
it 'does not autocomplete labels if no tilde is typed' do
fill_in 'Comment', with: 'Accepting'
context 'milestone' do
let_it_be(:milestone_expired) { create(:milestone, project: project, due_date: 5.days.ago) }
let_it_be(:milestone_no_duedate) { create(:milestone, project: project, title: 'Foo - No due date') }
let_it_be(:milestone1) { create(:milestone, project: project, title: 'Milestone-1', due_date: 20.days.from_now) }
let_it_be(:milestone2) { create(:milestone, project: project, title: 'Milestone-2', due_date: 15.days.from_now) }
let_it_be(:milestone3) { create(:milestone, project: project, title: 'Milestone-3', due_date: 10.days.from_now) }
wait_for_requests
before do
fill_in 'Comment', with: '/milestone %'
expect(page).not_to have_css('.tribute-container')
end
wait_for_requests
end
context 'when other notes are destroyed' do
let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) }
# This is meant to protect against this issue https://gitlab.com/gitlab-org/gitlab/-/issues/228729
it 'keeps autocomplete key listeners' do
note = find_field('Comment')
start_comment_with_emoji(note, '.tribute-container li')
start_and_cancel_discussion
note.fill_in(with: '')
start_comment_with_emoji(note, '.tribute-container li')
note.native.send_keys(:enter)
expect(note.value).to eql('Hello :100: ')
it 'shows milestons list in the autocomplete menu' do
page.within(find_autocomplete_menu) do
expect(page).to have_selector('li', count: 5)
end
end
shared_examples 'autocomplete suggestions' do
it 'suggests objects correctly' do
fill_in 'Comment', with: object.class.reference_prefix
find_tribute_autocomplete_menu.find('li').click
expect(find_field('Comment').value).to have_text(expected_body)
it 'shows expired milestone at the bottom of the list' do
page.within(find_autocomplete_menu) do
expect(page.find('li:last-child')).to have_content milestone_expired.title
end
end
context 'issues' do
let(:object) { issue }
let(:expected_body) { object.to_reference }
it_behaves_like 'autocomplete suggestions'
end
context 'merge requests' do
let(:object) { create(:merge_request, source_project: project) }
let(:expected_body) { object.to_reference }
it_behaves_like 'autocomplete suggestions'
end
context 'project snippets' do
let!(:object) { snippet }
let(:expected_body) { object.to_reference }
it_behaves_like 'autocomplete suggestions'
end
context 'milestone' do
let!(:object) { create(:milestone, project: project) }
let(:expected_body) { object.to_reference }
it_behaves_like 'autocomplete suggestions'
it 'shows milestone due earliest at the top of the list' do
page.within(find_autocomplete_menu) do
aggregate_failures do
expect(page.all('li')[0]).to have_content milestone3.title
expect(page.all('li')[1]).to have_content milestone2.title
expect(page.all('li')[2]).to have_content milestone1.title
expect(page.all('li')[3]).to have_content milestone_no_duedate.title
end
end
end
end
end
......@@ -723,12 +411,4 @@ RSpec.describe 'GFM autocomplete', :js do
def find_highlighted_autocomplete_item
find('.atwho-view li.cur', visible: true)
end
def find_tribute_autocomplete_menu
find('.tribute-container ul', visible: true)
end
def find_highlighted_tribute_autocomplete_menu
find('.tribute-container li.highlight', visible: true)
end
end
......@@ -10,7 +10,6 @@ RSpec.describe "User comments on issue", :js do
let(:user) { create(:user) }
before do
stub_feature_flags(tribute_autocomplete: false)
stub_feature_flags(sandboxed_mermaid: false)
project.add_guest(user)
sign_in(user)
......
......@@ -33,31 +33,12 @@ RSpec.describe 'Member autocomplete', :js do
let(:noteable) { create(:issue, author: author, project: project) }
before do
stub_feature_flags(tribute_autocomplete: false)
visit project_issue_path(project, noteable)
end
include_examples "open suggestions when typing @", 'issue'
end
describe 'when tribute_autocomplete feature flag is on' do
context 'adding a new note on a Issue' do
let(:noteable) { create(:issue, author: author, project: project) }
before do
stub_feature_flags(tribute_autocomplete: true)
visit project_issue_path(project, noteable)
fill_in 'Comment', with: '@'
end
it 'suggests noteable author and note author' do
expect(find_tribute_autocomplete_menu).to have_content(author.username)
expect(find_tribute_autocomplete_menu).to have_content(note.author.username)
end
end
end
context 'adding a new note on a Merge Request' do
let(:noteable) do
create(:merge_request, source_project: project,
......@@ -91,8 +72,4 @@ RSpec.describe 'Member autocomplete', :js do
def find_autocomplete_menu
find('.atwho-view ul', visible: true)
end
def find_tribute_autocomplete_menu
find('.tribute-container ul', visible: true)
end
end
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`gfm_autocomplete/utils emojis config shows the emoji name and icon in the menu item 1`] = `"raised_hands <gl-emoji data-name=\\"raised_hands\\"></gl-emoji>"`;
exports[`gfm_autocomplete/utils issues config shows the iid and title in the menu item within a project context 1`] = `"<small>123456</small> Project context issue title &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"`;
exports[`gfm_autocomplete/utils issues config shows the reference and title in the menu item within a group context 1`] = `"<small>gitlab#987654</small> Group context issue title &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"`;
exports[`gfm_autocomplete/utils labels config shows the title in the menu item 1`] = `
"
<span class=\\"dropdown-label-box\\" style=\\"background: #123456;\\"></span>
bug &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"
`;
exports[`gfm_autocomplete/utils members config shows an avatar character, name, parent name, and count in the menu item for a group 1`] = `
"
<div class=\\"gl-display-flex gl-align-items-center\\">
<div class=\\"gl-avatar gl-avatar-s32 gl-flex-shrink-0 gl-rounded-small
gl-display-flex gl-align-items-center gl-justify-content-center\\" aria-hidden=\\"true\\">
G</div>
<div class=\\"gl-line-height-normal gl-ml-4\\">
<div>1-1s &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt; (2)</div>
<div class=\\"gl-text-gray-700\\">GitLab Support Team</div>
</div>
</div>
"
`;
exports[`gfm_autocomplete/utils members config shows the avatar, name and username in the menu item for a user 1`] = `
"
<div class=\\"gl-display-flex gl-align-items-center\\">
<img class=\\"gl-avatar gl-avatar-s32 gl-flex-shrink-0 gl-avatar-circle\\" src=\\"/uploads/-/system/user/avatar/123456/avatar.png\\" alt=\\"\\" />
<div class=\\"gl-line-height-normal gl-ml-4\\">
<div>My Name &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;</div>
<div class=\\"gl-text-gray-700\\">@myusername</div>
</div>
</div>
"
`;
exports[`gfm_autocomplete/utils merge requests config shows the iid and title in the menu item within a project context 1`] = `"<small>123456</small> Project context merge request title &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"`;
exports[`gfm_autocomplete/utils merge requests config shows the reference and title in the menu item within a group context 1`] = `"<small>gitlab!456789</small> Group context merge request title &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"`;
exports[`gfm_autocomplete/utils milestones config shows the title in the menu item 1`] = `"13.2 &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"`;
exports[`gfm_autocomplete/utils quick actions config shows the name, aliases, params and description in the menu item 1`] = `
"<div>/unlabel <small>(or /remove_label)</small> <small>~label1 ~\\"label 2\\"</small></div>
<div><small><em>Remove all or specific label(s)</em></small></div>"
`;
exports[`gfm_autocomplete/utils snippets config shows the id and title in the menu item 1`] = `"<small>123456</small> Snippet title &lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;"`;
import Tribute from '@gitlab/tributejs';
import { shallowMount } from '@vue/test-utils';
import GfmAutocomplete from '~/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue';
describe('GfmAutocomplete', () => {
let wrapper;
describe('tribute', () => {
const mentions = '/gitlab-org/gitlab-test/-/autocomplete_sources/members?type=Issue&type_id=1';
beforeEach(() => {
wrapper = shallowMount(GfmAutocomplete, {
propsData: {
dataSources: {
mentions,
},
},
slots: {
default: ['<input/>'],
},
});
});
it('is set to tribute instance variable', () => {
expect(wrapper.vm.tribute instanceof Tribute).toBe(true);
});
it('contains the slot input element', () => {
wrapper.find('input').setValue('@');
expect(wrapper.vm.tribute.current.element).toBe(wrapper.find('input').element);
});
});
});
import { escape, last } from 'lodash';
import { GfmAutocompleteType, tributeConfig } from '~/vue_shared/components/gfm_autocomplete/utils';
describe('gfm_autocomplete/utils', () => {
describe('emojis config', () => {
const emojisConfig = tributeConfig[GfmAutocompleteType.Emojis].config;
const emoji = 'raised_hands';
it('uses : as the trigger', () => {
expect(emojisConfig.trigger).toBe(':');
});
it('searches using the emoji name', () => {
expect(emojisConfig.lookup(emoji)).toBe(emoji);
});
it('limits the number of rendered items to 100', () => {
expect(emojisConfig.menuItemLimit).toBe(100);
});
it('shows the emoji name and icon in the menu item', () => {
expect(emojisConfig.menuItemTemplate({ original: emoji })).toMatchSnapshot();
});
it('inserts the emoji name on autocomplete selection', () => {
expect(emojisConfig.selectTemplate({ original: emoji })).toBe(`:${emoji}:`);
});
});
describe('issues config', () => {
const issuesConfig = tributeConfig[GfmAutocompleteType.Issues].config;
const groupContextIssue = {
iid: 987654,
reference: 'gitlab#987654',
title: "Group context issue title <script>alert('hi')</script>",
};
const projectContextIssue = {
id: null,
iid: 123456,
time_estimate: 0,
title: "Project context issue title <script>alert('hi')</script>",
};
it('uses # as the trigger', () => {
expect(issuesConfig.trigger).toBe('#');
});
it('searches using both the iid and title', () => {
expect(issuesConfig.lookup(projectContextIssue)).toBe(
`${projectContextIssue.iid}${projectContextIssue.title}`,
);
});
it('limits the number of rendered items to 100', () => {
expect(issuesConfig.menuItemLimit).toBe(100);
});
it('shows the reference and title in the menu item within a group context', () => {
expect(issuesConfig.menuItemTemplate({ original: groupContextIssue })).toMatchSnapshot();
});
it('shows the iid and title in the menu item within a project context', () => {
expect(issuesConfig.menuItemTemplate({ original: projectContextIssue })).toMatchSnapshot();
});
it('inserts the reference on autocomplete selection within a group context', () => {
expect(issuesConfig.selectTemplate({ original: groupContextIssue })).toBe(
groupContextIssue.reference,
);
});
it('inserts the iid on autocomplete selection within a project context', () => {
expect(issuesConfig.selectTemplate({ original: projectContextIssue })).toBe(
`#${projectContextIssue.iid}`,
);
});
});
describe('labels config', () => {
const labelsConfig = tributeConfig[GfmAutocompleteType.Labels].config;
const labelsFilter = tributeConfig[GfmAutocompleteType.Labels].filterValues;
const label = {
color: '#123456',
textColor: '#FFFFFF',
title: `bug <script>alert('hi')</script>`,
type: 'GroupLabel',
};
const singleWordLabel = {
color: '#456789',
textColor: '#DDD',
title: `bug`,
type: 'GroupLabel',
};
const numericalLabel = {
color: '#abcdef',
textColor: '#AAA',
title: 123456,
type: 'ProjectLabel',
};
it('uses ~ as the trigger', () => {
expect(labelsConfig.trigger).toBe('~');
});
it('searches using `title`', () => {
expect(labelsConfig.lookup).toBe('title');
});
it('limits the number of rendered items to 100', () => {
expect(labelsConfig.menuItemLimit).toBe(100);
});
it('shows the title in the menu item', () => {
expect(labelsConfig.menuItemTemplate({ original: label })).toMatchSnapshot();
});
it('inserts the title on autocomplete selection', () => {
expect(labelsConfig.selectTemplate({ original: singleWordLabel })).toBe(
`~${escape(singleWordLabel.title)}`,
);
});
it('inserts the title enclosed with quotes on autocomplete selection when the title is numerical', () => {
expect(labelsConfig.selectTemplate({ original: numericalLabel })).toBe(
`~"${escape(numericalLabel.title)}"`,
);
});
it('inserts the title enclosed with quotes on autocomplete selection when the title contains multiple words', () => {
expect(labelsConfig.selectTemplate({ original: label })).toBe(`~"${escape(label.title)}"`);
});
describe('filter', () => {
const collection = [label, singleWordLabel, { ...numericalLabel, set: true }];
describe('/label quick action', () => {
describe('when the line starts with `/label`', () => {
it('shows labels that are not currently selected', () => {
const fullText = '/label ~';
const selectionStart = 8;
expect(labelsFilter({ collection, fullText, selectionStart })).toEqual([
collection[0],
collection[1],
]);
});
});
describe('when the line does not start with `/label`', () => {
it('shows all labels', () => {
const fullText = '~';
const selectionStart = 1;
expect(labelsFilter({ collection, fullText, selectionStart })).toEqual(collection);
});
});
});
describe('/unlabel quick action', () => {
describe('when the line starts with `/unlabel`', () => {
it('shows labels that are currently selected', () => {
const fullText = '/unlabel ~';
const selectionStart = 10;
expect(labelsFilter({ collection, fullText, selectionStart })).toEqual([collection[2]]);
});
});
describe('when the line does not start with `/unlabel`', () => {
it('shows all labels', () => {
const fullText = '~';
const selectionStart = 1;
expect(labelsFilter({ collection, fullText, selectionStart })).toEqual(collection);
});
});
});
});
});
describe('members config', () => {
const membersConfig = tributeConfig[GfmAutocompleteType.Members].config;
const membersFilter = tributeConfig[GfmAutocompleteType.Members].filterValues;
const userMember = {
type: 'User',
username: 'myusername',
name: "My Name <script>alert('hi')</script>",
avatar_url: '/uploads/-/system/user/avatar/123456/avatar.png',
availability: null,
};
const groupMember = {
type: 'Group',
username: 'gitlab-com/support/1-1s',
name: "GitLab.com / GitLab Support Team / 1-1s <script>alert('hi')</script>",
avatar_url: null,
count: 2,
mentionsDisabled: null,
};
it('uses @ as the trigger', () => {
expect(membersConfig.trigger).toBe('@');
});
it('inserts the username on autocomplete selection', () => {
expect(membersConfig.fillAttr).toBe('username');
});
it('searches using both the name and username for a user', () => {
expect(membersConfig.lookup(userMember)).toBe(`${userMember.name}${userMember.username}`);
});
it('searches using only its own name and not its ancestors for a group', () => {
expect(membersConfig.lookup(groupMember)).toBe(last(groupMember.name.split(' / ')));
});
it('limits the items in the autocomplete menu to 10', () => {
expect(membersConfig.menuItemLimit).toBe(10);
});
it('shows the avatar, name and username in the menu item for a user', () => {
expect(membersConfig.menuItemTemplate({ original: userMember })).toMatchSnapshot();
});
it('shows an avatar character, name, parent name, and count in the menu item for a group', () => {
expect(membersConfig.menuItemTemplate({ original: groupMember })).toMatchSnapshot();
});
describe('filter', () => {
const assignees = [userMember.username];
const collection = [userMember, groupMember];
describe('/assign quick action', () => {
describe('when the line starts with `/assign`', () => {
it('shows members that are not currently selected', () => {
const fullText = '/assign @';
const selectionStart = 9;
expect(membersFilter({ assignees, collection, fullText, selectionStart })).toEqual([
collection[1],
]);
});
});
describe('when the line does not start with `/assign`', () => {
it('shows all labels', () => {
const fullText = '@';
const selectionStart = 1;
expect(membersFilter({ assignees, collection, fullText, selectionStart })).toEqual(
collection,
);
});
});
});
describe('/unassign quick action', () => {
describe('when the line starts with `/unassign`', () => {
it('shows members that are currently selected', () => {
const fullText = '/unassign @';
const selectionStart = 11;
expect(membersFilter({ assignees, collection, fullText, selectionStart })).toEqual([
collection[0],
]);
});
});
describe('when the line does not start with `/unassign`', () => {
it('shows all members', () => {
const fullText = '@';
const selectionStart = 1;
expect(membersFilter({ assignees, collection, fullText, selectionStart })).toEqual(
collection,
);
});
});
});
});
});
describe('merge requests config', () => {
const mergeRequestsConfig = tributeConfig[GfmAutocompleteType.MergeRequests].config;
const groupContextMergeRequest = {
iid: 456789,
reference: 'gitlab!456789',
title: "Group context merge request title <script>alert('hi')</script>",
};
const projectContextMergeRequest = {
id: null,
iid: 123456,
time_estimate: 0,
title: "Project context merge request title <script>alert('hi')</script>",
};
it('uses ! as the trigger', () => {
expect(mergeRequestsConfig.trigger).toBe('!');
});
it('searches using both the iid and title', () => {
expect(mergeRequestsConfig.lookup(projectContextMergeRequest)).toBe(
`${projectContextMergeRequest.iid}${projectContextMergeRequest.title}`,
);
});
it('limits the number of rendered items to 100', () => {
expect(mergeRequestsConfig.menuItemLimit).toBe(100);
});
it('shows the reference and title in the menu item within a group context', () => {
expect(
mergeRequestsConfig.menuItemTemplate({ original: groupContextMergeRequest }),
).toMatchSnapshot();
});
it('shows the iid and title in the menu item within a project context', () => {
expect(
mergeRequestsConfig.menuItemTemplate({ original: projectContextMergeRequest }),
).toMatchSnapshot();
});
it('inserts the reference on autocomplete selection within a group context', () => {
expect(mergeRequestsConfig.selectTemplate({ original: groupContextMergeRequest })).toBe(
groupContextMergeRequest.reference,
);
});
it('inserts the iid on autocomplete selection within a project context', () => {
expect(mergeRequestsConfig.selectTemplate({ original: projectContextMergeRequest })).toBe(
`!${projectContextMergeRequest.iid}`,
);
});
});
describe('milestones config', () => {
const milestonesConfig = tributeConfig[GfmAutocompleteType.Milestones].config;
const milestone = {
id: null,
iid: 49,
title: "13.2 <script>alert('hi')</script>",
};
it('uses % as the trigger', () => {
expect(milestonesConfig.trigger).toBe('%');
});
it('searches using the title', () => {
expect(milestonesConfig.lookup).toBe('title');
});
it('limits the number of rendered items to 100', () => {
expect(milestonesConfig.menuItemLimit).toBe(100);
});
it('shows the title in the menu item', () => {
expect(milestonesConfig.menuItemTemplate({ original: milestone })).toMatchSnapshot();
});
it('inserts the title on autocomplete selection', () => {
expect(milestonesConfig.selectTemplate({ original: milestone })).toBe(
`%"${escape(milestone.title)}"`,
);
});
});
describe('quick actions config', () => {
const quickActionsConfig = tributeConfig[GfmAutocompleteType.QuickActions].config;
const quickAction = {
name: 'unlabel',
aliases: ['remove_label'],
description: 'Remove all or specific label(s)',
warning: '',
icon: '',
params: ['~label1 ~"label 2"'],
};
it('uses / as the trigger', () => {
expect(quickActionsConfig.trigger).toBe('/');
});
it('inserts the name on autocomplete selection', () => {
expect(quickActionsConfig.fillAttr).toBe('name');
});
it('searches using both the name and aliases', () => {
expect(quickActionsConfig.lookup(quickAction)).toBe(
`${quickAction.name}${quickAction.aliases.join(', /')}`,
);
});
it('limits the number of rendered items to 100', () => {
expect(quickActionsConfig.menuItemLimit).toBe(100);
});
it('shows the name, aliases, params and description in the menu item', () => {
expect(quickActionsConfig.menuItemTemplate({ original: quickAction })).toMatchSnapshot();
});
});
describe('snippets config', () => {
const snippetsConfig = tributeConfig[GfmAutocompleteType.Snippets].config;
const snippet = {
id: 123456,
title: "Snippet title <script>alert('hi')</script>",
};
it('uses $ as the trigger', () => {
expect(snippetsConfig.trigger).toBe('$');
});
it('inserts the id on autocomplete selection', () => {
expect(snippetsConfig.fillAttr).toBe('id');
});
it('searches using both the id and title', () => {
expect(snippetsConfig.lookup(snippet)).toBe(`${snippet.id}${snippet.title}`);
});
it('limits the number of rendered items to 100', () => {
expect(snippetsConfig.menuItemLimit).toBe(100);
});
it('shows the id and title in the menu item', () => {
expect(snippetsConfig.menuItemTemplate({ original: snippet })).toMatchSnapshot();
});
});
});
......@@ -961,11 +961,6 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-2.2.0.tgz#95cf58d6ae634d535145159f08f5cff6241d4013"
integrity sha512-mCwR3KfNPsxRoojtTjMIZwdd4FFlBh5DlR9AeodP+7+k8rILdWGYxTZbJMPNXoPbZx16R94nG8c5bR7toD4QBw==
"@gitlab/tributejs@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@gitlab/tributejs/-/tributejs-1.0.0.tgz#672befa222aeffc83e7d799b0500a7a4418e59b8"
integrity sha512-nmKw1+hB6MHvlmPz63yPwVs1qQkycHwsKgxpEbzmky16Y6mL4EJMk3w1b8QlOAF/AIAzjCERPhe/R4MJiohbZw==
"@gitlab/ui@33.1.0":
version "33.1.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-33.1.0.tgz#45ac2e6362546530b5756b1973f97f74a9c920da"
......
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