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> <script>
import markdownField from '~/vue_shared/components/markdown/field.vue'; import markdownField from '~/vue_shared/components/markdown/field.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import updateMixin from '../../mixins/update'; import updateMixin from '../../mixins/update';
export default { export default {
components: { components: {
markdownField, markdownField,
}, },
mixins: [glFeatureFlagsMixin(), updateMixin], mixins: [updateMixin],
props: { props: {
formState: { formState: {
type: Object, type: Object,
...@@ -56,7 +55,7 @@ export default { ...@@ -56,7 +55,7 @@ export default {
v-model="formState.description" v-model="formState.description"
class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea" class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea"
dir="auto" dir="auto"
:data-supports-quick-actions="!glFeatures.tributeAutocomplete" data-supports-quick-actions="true"
:aria-label="__('Description')" :aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')" :placeholder="__('Write a comment or drag your files here…')"
@keydown.meta.enter="updateIssuable" @keydown.meta.enter="updateIssuable"
......
...@@ -369,7 +369,7 @@ export default { ...@@ -369,7 +369,7 @@ export default {
class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area" class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area"
data-qa-selector="comment_field" data-qa-selector="comment_field"
data-testid="comment-field" data-testid="comment-field"
:data-supports-quick-actions="!glFeatures.tributeAutocomplete" data-supports-quick-actions="true"
:aria-label="$options.i18n.comment" :aria-label="$options.i18n.comment"
:placeholder="$options.i18n.bodyPlaceholder" :placeholder="$options.i18n.bodyPlaceholder"
@keydown.up="editCurrentUserLastNote()" @keydown.up="editCurrentUserLastNote()"
......
...@@ -5,7 +5,6 @@ import { getDraft, updateDraft } from '~/lib/utils/autosave'; ...@@ -5,7 +5,6 @@ import { getDraft, updateDraft } from '~/lib/utils/autosave';
import { mergeUrlParams } from '~/lib/utils/url_utility'; import { mergeUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale'; import { __ } from '~/locale';
import markdownField from '~/vue_shared/components/markdown/field.vue'; 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 eventHub from '../event_hub';
import issuableStateMixin from '../mixins/issuable_state'; import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable'; import resolvable from '../mixins/resolvable';
...@@ -20,7 +19,7 @@ export default { ...@@ -20,7 +19,7 @@ export default {
GlSprintf, GlSprintf,
GlLink, GlLink,
}, },
mixins: [glFeatureFlagsMixin(), issuableStateMixin, resolvable], mixins: [issuableStateMixin, resolvable],
props: { props: {
noteBody: { noteBody: {
type: String, type: String,
...@@ -349,7 +348,7 @@ export default { ...@@ -349,7 +348,7 @@ export default {
ref="textarea" ref="textarea"
v-model="updatedNoteBody" v-model="updatedNoteBody"
:disabled="isSubmitting" :disabled="isSubmitting"
:data-supports-quick-actions="!isEditing && !glFeatures.tributeAutocomplete" :data-supports-quick-actions="!isEditing"
name="note[note]" name="note[note]"
class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form" class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form"
data-qa-selector="reply_field" 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'; ...@@ -8,9 +8,7 @@ import GLForm from '~/gl_form';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { stripHtml } from '~/lib/utils/text_utility'; import { stripHtml } from '~/lib/utils/text_utility';
import { __, sprintf } from '~/locale'; 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 Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MarkdownHeader from './header.vue'; import MarkdownHeader from './header.vue';
import MarkdownToolbar from './toolbar.vue'; import MarkdownToolbar from './toolbar.vue';
...@@ -20,13 +18,11 @@ function cleanUpLine(content) { ...@@ -20,13 +18,11 @@ function cleanUpLine(content) {
export default { export default {
components: { components: {
GfmAutocomplete,
MarkdownHeader, MarkdownHeader,
MarkdownToolbar, MarkdownToolbar,
GlIcon, GlIcon,
Suggestions, Suggestions,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
/** /**
* This prop should be bound to the value of the `<textarea>` element * This prop should be bound to the value of the `<textarea>` element
...@@ -212,14 +208,14 @@ export default { ...@@ -212,14 +208,14 @@ export default {
return new GLForm( return new GLForm(
$(this.$refs['gl-form']), $(this.$refs['gl-form']),
{ {
emojis: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, emojis: this.enableAutocomplete,
members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, members: this.enableAutocomplete,
issues: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, issues: this.enableAutocomplete,
mergeRequests: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, mergeRequests: this.enableAutocomplete,
epics: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, epics: this.enableAutocomplete,
milestones: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, milestones: this.enableAutocomplete,
labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, labels: this.enableAutocomplete,
snippets: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, snippets: this.enableAutocomplete,
vulnerabilities: this.enableAutocomplete, vulnerabilities: this.enableAutocomplete,
}, },
true, true,
...@@ -311,10 +307,7 @@ export default { ...@@ -311,10 +307,7 @@ export default {
/> />
<div v-show="!previewMarkdown" class="md-write-holder"> <div v-show="!previewMarkdown" class="md-write-holder">
<div class="zen-backdrop"> <div class="zen-backdrop">
<gfm-autocomplete v-if="glFeatures.tributeAutocomplete"> <slot name="textarea"></slot>
<slot name="textarea"></slot>
</gfm-autocomplete>
<slot v-else name="textarea"></slot>
<a <a
class="zen-control zen-control-leave js-zen-leave gl-text-gray-500" class="zen-control zen-control-leave js-zen-leave gl-text-gray-500"
href="#" 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 ...@@ -42,7 +42,6 @@ class Projects::IssuesController < Projects::ApplicationController
if: -> { Feature.disabled?('rate_limited_service_issues_create', project, default_enabled: :yaml) } if: -> { Feature.disabled?('rate_limited_service_issues_create', project, default_enabled: :yaml) }
before_action do 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(:improved_emoji_picker, project, default_enabled: :yaml)
push_frontend_feature_flag(:vue_issues_list, project&.group, 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) 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 = { ...@@ -260,8 +260,7 @@ module.exports = {
{ {
test: /\.js$/, test: /\.js$/,
exclude: (modulePath) => exclude: (modulePath) =>
/node_modules\/(?!tributejs)|node_modules|vendor[\\/]assets/.test(modulePath) && /node_modules|vendor[\\/]assets/.test(modulePath) && !/\.vue\.js/.test(modulePath),
!/\.vue\.js/.test(modulePath),
loader: 'babel-loader', loader: 'babel-loader',
options: { options: {
cacheDirectory: path.join(CACHE_PATH, 'babel-loader'), 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 ...@@ -13,8 +13,6 @@ RSpec.describe 'Issue promotion', :js do
let(:user) { create(:user) } let(:user) { create(:user) }
before do before do
stub_feature_flags(tribute_autocomplete: false)
sign_in(user) sign_in(user)
end end
......
...@@ -5,9 +5,7 @@ require 'spec_helper' ...@@ -5,9 +5,7 @@ require 'spec_helper'
RSpec.describe 'GFM autocomplete EE', :js do RSpec.describe 'GFM autocomplete EE', :js do
let_it_be(:user) { create(:user, name: '💃speciąl someone💃', username: 'someone.special') } 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(:another_user) { create(:user, name: 'another user', username: 'another.user') }
let_it_be(:group) { create(:group) } let_it_be(:project) { create(:project) }
let_it_be(:epic) { create(:epic, group: group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:issue) { create(:issue, project: project, assignees: [user]) } let_it_be(:issue) { create(:issue, project: project, assignees: [user]) }
before do before do
...@@ -15,57 +13,21 @@ RSpec.describe 'GFM autocomplete EE', :js do ...@@ -15,57 +13,21 @@ RSpec.describe 'GFM autocomplete EE', :js do
end end
context 'assignees' do context 'assignees' do
describe 'when tribute_autocomplete feature flag is off' do before do
before do sign_in(user)
stub_feature_flags(tribute_autocomplete: false)
sign_in(user) visit project_issue_path(project, issue)
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
end end
describe 'when tribute_autocomplete feature flag is on' do it 'only lists users who are currently assigned to the issue when using /unassign' do
before do fill_in 'Comment', with: '/una'
stub_licensed_features(epics: true)
stub_feature_flags(tribute_autocomplete: true)
sign_in(user)
visit project_issue_path(project, issue) find_highlighted_autocomplete_item.click
end
it 'only lists users who are currently assigned to the issue when using /unassign' do wait_for_requests
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
expect(find_tribute_autocomplete_menu).to have_text(user.username) expect(find_autocomplete_menu).to have_text(user.username)
expect(find_tribute_autocomplete_menu).not_to have_text(another_user.username) expect(find_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
end end
end end
...@@ -78,8 +40,4 @@ RSpec.describe 'GFM autocomplete EE', :js do ...@@ -78,8 +40,4 @@ RSpec.describe 'GFM autocomplete EE', :js do
def find_highlighted_autocomplete_item def find_highlighted_autocomplete_item
find('.atwho-view li.cur', visible: true) find('.atwho-view li.cur', visible: true)
end end
def find_tribute_autocomplete_menu
find('.tribute-container ul', visible: true)
end
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 "" ...@@ -3818,9 +3818,6 @@ msgstr ""
msgid "An error occurred while fetching this tab." msgid "An error occurred while fetching this tab."
msgstr "" 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}" msgid "An error occurred while getting files for - %{branchId}"
msgstr "" msgstr ""
......
...@@ -6,10 +6,7 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -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(:user) { create(:user, name: '💃speciąl someone💃', username: 'someone.special') }
let_it_be(:user2) { create(:user, name: 'Marge Simpson', username: 'msimpson') } let_it_be(:user2) { create(:user, name: 'Marge Simpson', username: 'msimpson') }
let_it_be(:group) { create(:group, name: 'Ancestor') } let_it_be(:project) { create(:project) }
let_it_be(:child_group) { create(:group, parent: group, name: 'My group') }
let_it_be(:project) { create(:project, group: child_group) }
let_it_be(:issue) { create(:issue, project: project, assignees: [user]) } let_it_be(:issue) { create(:issue, project: project, assignees: [user]) }
let_it_be(:label) { create(:label, project: project, title: 'special+') } let_it_be(:label) { create(:label, project: project, title: 'special+') }
let_it_be(:label_scoped) { create(:label, project: project, title: 'scoped::label') } let_it_be(:label_scoped) { create(:label, project: project, title: 'scoped::label') }
...@@ -27,670 +24,361 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -27,670 +24,361 @@ RSpec.describe 'GFM autocomplete', :js do
project.add_maintainer(user2) project.add_maintainer(user2)
end end
describe 'when tribute_autocomplete feature flag is off' do describe 'new issue page' do
describe 'new issue page' do before do
before do sign_in(user)
stub_feature_flags(tribute_autocomplete: false) visit new_project_issue_path(project)
sign_in(user)
visit new_project_issue_path(project)
wait_for_requests wait_for_requests
end end
it 'allows quick actions' do it 'allows quick actions' do
fill_in 'Description', with: '/' fill_in 'Description', with: '/'
expect(find_autocomplete_menu).to be_visible expect(find_autocomplete_menu).to be_visible
end
end end
end
describe 'issue description' do describe 'issue description' do
let(:issue_to_edit) { create(:issue, project: project) } let(:issue_to_edit) { create(:issue, project: project) }
before do before do
stub_feature_flags(tribute_autocomplete: false) sign_in(user)
visit project_issue_path(project, issue_to_edit)
sign_in(user) wait_for_requests
visit project_issue_path(project, issue_to_edit) end
wait_for_requests it 'updates with GFM reference' do
end click_button 'Edit title and description'
it 'updates with GFM reference' do wait_for_requests
click_button 'Edit title and description'
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) expect(find('.description')).to have_text(user.to_reference)
end end
it 'allows quick actions' do it 'allows quick actions' do
click_button 'Edit title and description' click_button 'Edit title and description'
fill_in 'Description', with: '/' fill_in 'Description', with: '/'
expect(find_autocomplete_menu).to be_visible expect(find_autocomplete_menu).to be_visible
end
end end
end
describe 'issue comment' do describe 'issue comment' do
before do before do
stub_feature_flags(tribute_autocomplete: false) sign_in(user)
visit project_issue_path(project, issue)
sign_in(user)
visit project_issue_path(project, issue)
wait_for_requests wait_for_requests
end end
describe 'triggering autocomplete' do describe 'triggering autocomplete' do
it 'only opens autocomplete menu when trigger character is after whitespace', :aggregate_failures do it 'only opens autocomplete menu when trigger character is after whitespace', :aggregate_failures do
fill_in 'Comment', with: 'testing@' fill_in 'Comment', with: 'testing@'
expect(page).not_to have_css('.atwho-view') expect(page).not_to have_css('.atwho-view')
fill_in 'Comment', with: '@@' fill_in 'Comment', with: '@@'
expect(page).not_to have_css('.atwho-view') expect(page).not_to have_css('.atwho-view')
fill_in 'Comment', with: "@#{user.username[0..2]}!" fill_in 'Comment', with: "@#{user.username[0..2]}!"
expect(page).not_to have_css('.atwho-view') expect(page).not_to have_css('.atwho-view')
fill_in 'Comment', with: "hello:#{user.username[0..2]}" fill_in 'Comment', with: "hello:#{user.username[0..2]}"
expect(page).not_to have_css('.atwho-view') expect(page).not_to have_css('.atwho-view')
fill_in 'Comment', with: '7:' fill_in 'Comment', with: '7:'
expect(page).not_to have_css('.atwho-view') expect(page).not_to have_css('.atwho-view')
fill_in 'Comment', with: 'w:' fill_in 'Comment', with: 'w:'
expect(page).not_to have_css('.atwho-view') expect(page).not_to have_css('.atwho-view')
fill_in 'Comment', with: 'Ё:' fill_in 'Comment', with: 'Ё:'
expect(page).not_to have_css('.atwho-view') expect(page).not_to have_css('.atwho-view')
fill_in 'Comment', with: "test\n\n@" fill_in 'Comment', with: "test\n\n@"
expect(find_autocomplete_menu).to be_visible expect(find_autocomplete_menu).to be_visible
end
end end
end
context 'xss checks' do context 'xss checks' do
it 'opens autocomplete menu for Issues when field starts with text with item escaping HTML characters' 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;' 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) 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
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') expect(find_autocomplete_menu).to have_text(issue_xss_title)
end
end end
describe 'autocomplete highlighting' do it 'opens autocomplete menu for Username when field starts with text with item escaping HTML characters' 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: '@ev'
fill_in 'Comment', with: ':'
wait_for_requests
expect(find_autocomplete_menu).not_to have_css('.cur')
fill_in 'Comment', with: ':1' wait_for_requests
wait_for_requests
expect(find_autocomplete_menu).to have_css('.cur:first-of-type')
fill_in 'Comment', with: '@' expect(find_highlighted_autocomplete_item).to have_text(user_xss.username)
wait_for_requests
expect(find_autocomplete_menu).to have_css('.cur:first-of-type')
end
end end
describe 'assignees' do it 'opens autocomplete menu for Milestone when field starts with text with item escaping HTML characters' do
it 'does not wrap with quotes for assignee values' do milestone_xss_title = 'alert milestone &lt;img src=x onerror="alert(\'Hello xss\');" a'
fill_in 'Comment', with: "@#{user.username}" create(:milestone, project: project, title: milestone_xss_title)
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'
wait_for_requests fill_in 'Comment', with: '%'
expect(find_highlighted_autocomplete_item).to have_text(user.name) wait_for_requests
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
expect(find_autocomplete_menu).not_to have_text(user.username) expect(find_autocomplete_menu).to have_text('alert milestone')
expect(find_autocomplete_menu).to have_text(user2.username)
end
end
end end
context 'if a selected value has special characters' do it 'opens autocomplete menu for Labels when field starts with text with item escaping HTML characters' do
it 'wraps the result in double quotes' do fill_in 'Comment', with: '~'
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_'
find_highlighted_autocomplete_item.click wait_for_requests
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: '/'
expect(find_autocomplete_menu).to have_css('li', minimum: 6) expect(find_autocomplete_menu).to have_text('alert label')
end
end end
end
context 'labels' do describe 'autocomplete highlighting' do
it 'allows colons when autocompleting scoped labels' 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: '~scoped:' fill_in 'Comment', with: ':'
wait_for_requests
wait_for_requests expect(find_autocomplete_menu).not_to have_css('.cur')
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'
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') fill_in 'Comment', with: '@'
end 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 describe 'assignees' do
fill_in 'Comment', with: 'Accepting merge' 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') expect(find_field('Comment').value).to have_text("@#{user.username}")
end
end end
context 'when other notes are destroyed' do it 'includes items for assignee dropdowns with non-ASCII characters in name' do
let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) } fill_in 'Comment', with: "@#{user.name[0...8]}"
# This is meant to protect against this issue https://gitlab.com/gitlab-org/gitlab/-/issues/228729 wait_for_requests
it 'keeps autocomplete key listeners' do
note = find_field('Comment')
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: '') wait_for_requests
start_comment_with_emoji(note, '.atwho-view li')
note.native.send_keys(:enter)
expect(note.value).to eql('Hello :100: ') expect(find_highlighted_autocomplete_item).to have_text(user.name)
end
end end
shared_examples 'autocomplete suggestions' do it 'shows names that start with the query as the top result' do
it 'suggests objects correctly' do fill_in 'Comment', with: '@mar'
fill_in 'Comment', with: object.class.reference_prefix
find_autocomplete_menu.find('li').click wait_for_requests
expect(find_field('Comment').value).to have_text(expected_body) expect(find_highlighted_autocomplete_item).to have_text(user2.name)
end
end end
context 'issues' do it 'shows usernames that start with the query as the top result' do
let(:object) { issue } fill_in 'Comment', with: '@msi'
let(:expected_body) { object.to_reference }
it_behaves_like 'autocomplete suggestions'
end
context 'merge requests' do wait_for_requests
let(:object) { create(:merge_request, source_project: project) }
let(:expected_body) { object.to_reference }
it_behaves_like 'autocomplete suggestions' expect(find_highlighted_autocomplete_item).to have_text(user2.name)
end end
context 'project snippets' do # Regression test for https://gitlab.com/gitlab-org/gitlab/-/issues/321925
let!(:object) { snippet } it 'shows username when pasting then pressing Enter' do
let(:expected_body) { object.to_reference } fill_in 'Comment', with: "@#{user.username}\n"
it_behaves_like 'autocomplete suggestions' expect(find_field('Comment').value).to have_text "@#{user.username}"
end end
context 'milestone' do it 'does not show `@undefined` when pressing `@` then Enter' do
let_it_be(:milestone_expired) { create(:milestone, project: project, due_date: 5.days.ago) } fill_in 'Comment', with: "@\n"
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 %'
wait_for_requests expect(find_field('Comment').value).to have_text '@'
end expect(find_field('Comment').value).not_to have_text '@undefined'
end
it 'shows milestons list in the autocomplete menu' do context 'when /assign quick action is selected' do
page.within(find_autocomplete_menu) do it 'triggers user autocomplete and lists users who are currently not assigned to the issue' do
expect(page).to have_selector('li', count: 5) fill_in 'Comment', with: '/as'
end
end
it 'shows expired milestone at the bottom of the list' do find_highlighted_autocomplete_item.click
page.within(find_autocomplete_menu) do
expect(page.find('li:last-child')).to have_content milestone_expired.title
end
end
it 'shows milestone due earliest at the top of the list' do expect(find_autocomplete_menu).not_to have_text(user.username)
page.within(find_autocomplete_menu) do expect(find_autocomplete_menu).to have_text(user2.username)
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 end
end end
end
describe 'when tribute_autocomplete feature flag is on' do context 'if a selected value has special characters' do
describe 'issue description' do it 'wraps the result in double quotes' do
let(:issue_to_edit) { create(:issue, project: project) } fill_in 'Comment', with: "~#{label.title[0..2]}"
before do find_highlighted_autocomplete_item.click
stub_feature_flags(tribute_autocomplete: true)
sign_in(user)
visit project_issue_path(project, issue_to_edit)
wait_for_requests expect(find_field('Comment').value).to have_text("~\"#{label.title}\"")
end end
it 'updates with GFM reference' do it 'doesn\'t wrap for emoji values' do
click_button 'Edit title and description' fill_in 'Comment', with: ':cartwheel_'
wait_for_requests
fill_in 'Description', with: "@#{user.name[0...3]}"
wait_for_requests
find_highlighted_tribute_autocomplete_menu.click
click_button 'Save changes' find_highlighted_autocomplete_item.click
wait_for_requests
expect(find('.description')).to have_text(user.to_reference) expect(find_field('Comment').value).to have_text('cartwheel_tone1')
end end
end end
describe 'issue comment' do context 'quick actions' do
before do it 'does not limit quick actions autocomplete list to 5' do
stub_feature_flags(tribute_autocomplete: true) fill_in 'Comment', with: '/'
sign_in(user) expect(find_autocomplete_menu).to have_css('li', minimum: 6)
visit project_issue_path(project, issue)
wait_for_requests
end end
end
describe 'triggering autocomplete' do context 'labels' do
it 'only opens autocomplete menu when trigger character is after whitespace', :aggregate_failures do it 'allows colons when autocompleting scoped labels' do
fill_in 'Comment', with: 'testing@' fill_in 'Comment', with: '~scoped:'
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')
fill_in 'Comment', with: 'Ё:' wait_for_requests
expect(page).not_to have_css('.tribute-container')
fill_in 'Comment', with: "test\n\n@" expect(find_autocomplete_menu).to have_text('scoped::label')
expect(find_tribute_autocomplete_menu).to be_visible
end
end end
context 'xss checks' do it 'allows spaces when autocompleting multi-word labels' do
it 'opens autocomplete menu for Issues when field starts with text with item escaping HTML characters' do fill_in 'Comment', with: '~Accepting merge'
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)
fill_in 'Comment', with: '%' wait_for_requests
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')
fill_in 'Comment', with: '@' expect(find_autocomplete_menu).to have_text('Accepting merge requests')
wait_for_requests
expect(find_tribute_autocomplete_menu).to have_css('.highlight:first-of-type')
end
end end
describe 'assignees' do it 'only autocompletes the last label' do
it 'does not wrap with quotes for assignee values' do fill_in 'Comment', with: '~scoped:: foo bar ~Accepting merge'
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 'includes items for assignee dropdowns with non-ASCII characters in name' do wait_for_requests
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
it 'does not show the group when searching for the name of the parent of the group' do expect(find_autocomplete_menu).to have_text('Accepting merge requests')
fill_in 'Comment', with: '@ancestor' end
expect(find_tribute_autocomplete_menu).not_to have_text('My group') it 'does not autocomplete labels if no tilde is typed' do
end fill_in 'Comment', with: 'Accepting merge'
end
context 'when /assign quick action is selected' do wait_for_requests
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
it 'lists users who are currently not assigned to the issue when using /assign on the second line' do expect(page).not_to have_css('.atwho-view')
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
end end
end
context 'if a selected value has special characters' do context 'when other notes are destroyed' do
it 'wraps the result in double quotes' do let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) }
fill_in 'Comment', with: "~#{label.title[0..2]}"
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}\"") start_comment_with_emoji(note, '.atwho-view li')
end
it 'does not wrap for emoji values' do start_and_cancel_discussion
fill_in 'Comment', with: ':cartwheel_'
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') expect(note.value).to eql('Hello :100: ')
end
end end
end
context 'quick actions' do shared_examples 'autocomplete suggestions' do
it 'autocompletes for quick actions' do it 'suggests objects correctly' do
fill_in 'Comment', with: '/as' 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') expect(find_field('Comment').value).to have_text(expected_body)
end
end end
end
context 'labels' do context 'issues' do
it 'allows colons when autocompleting scoped labels' do let(:object) { issue }
fill_in 'Comment', with: '~scoped:' let(:expected_body) { object.to_reference }
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'
wait_for_requests it_behaves_like 'autocomplete suggestions'
end
expect(find_tribute_autocomplete_menu).to have_text('Accepting merge requests') context 'merge requests' do
end let(:object) { create(:merge_request, source_project: project) }
let(:expected_body) { object.to_reference }
it 'only autocompletes the last label' do it_behaves_like 'autocomplete suggestions'
fill_in 'Comment', with: '~scoped:: foo bar ~Acceptingmerge' end
# Invoke autocompletion
find_field('Comment').native.send_keys(:right)
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') it_behaves_like 'autocomplete suggestions'
end end
it 'does not autocomplete labels if no tilde is typed' do context 'milestone' do
fill_in 'Comment', with: 'Accepting' 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') wait_for_requests
end
end end
context 'when other notes are destroyed' do it 'shows milestons list in the autocomplete menu' do
let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) } page.within(find_autocomplete_menu) do
expect(page).to have_selector('li', count: 5)
# 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: ')
end end
end end
shared_examples 'autocomplete suggestions' do it 'shows expired milestone at the bottom of the list' do
it 'suggests objects correctly' do page.within(find_autocomplete_menu) do
fill_in 'Comment', with: object.class.reference_prefix expect(page.find('li:last-child')).to have_content milestone_expired.title
find_tribute_autocomplete_menu.find('li').click
expect(find_field('Comment').value).to have_text(expected_body)
end end
end end
context 'issues' do it 'shows milestone due earliest at the top of the list' do
let(:object) { issue } page.within(find_autocomplete_menu) do
let(:expected_body) { object.to_reference } aggregate_failures do
expect(page.all('li')[0]).to have_content milestone3.title
it_behaves_like 'autocomplete suggestions' expect(page.all('li')[1]).to have_content milestone2.title
end expect(page.all('li')[2]).to have_content milestone1.title
expect(page.all('li')[3]).to have_content milestone_no_duedate.title
context 'merge requests' do end
let(:object) { create(:merge_request, source_project: project) } end
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'
end end
end end
end end
...@@ -723,12 +411,4 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -723,12 +411,4 @@ RSpec.describe 'GFM autocomplete', :js do
def find_highlighted_autocomplete_item def find_highlighted_autocomplete_item
find('.atwho-view li.cur', visible: true) find('.atwho-view li.cur', visible: true)
end 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 end
...@@ -10,7 +10,6 @@ RSpec.describe "User comments on issue", :js do ...@@ -10,7 +10,6 @@ RSpec.describe "User comments on issue", :js do
let(:user) { create(:user) } let(:user) { create(:user) }
before do before do
stub_feature_flags(tribute_autocomplete: false)
stub_feature_flags(sandboxed_mermaid: false) stub_feature_flags(sandboxed_mermaid: false)
project.add_guest(user) project.add_guest(user)
sign_in(user) sign_in(user)
......
...@@ -33,31 +33,12 @@ RSpec.describe 'Member autocomplete', :js do ...@@ -33,31 +33,12 @@ RSpec.describe 'Member autocomplete', :js do
let(:noteable) { create(:issue, author: author, project: project) } let(:noteable) { create(:issue, author: author, project: project) }
before do before do
stub_feature_flags(tribute_autocomplete: false)
visit project_issue_path(project, noteable) visit project_issue_path(project, noteable)
end end
include_examples "open suggestions when typing @", 'issue' include_examples "open suggestions when typing @", 'issue'
end 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 context 'adding a new note on a Merge Request' do
let(:noteable) do let(:noteable) do
create(:merge_request, source_project: project, create(:merge_request, source_project: project,
...@@ -91,8 +72,4 @@ RSpec.describe 'Member autocomplete', :js do ...@@ -91,8 +72,4 @@ RSpec.describe 'Member autocomplete', :js do
def find_autocomplete_menu def find_autocomplete_menu
find('.atwho-view ul', visible: true) find('.atwho-view ul', visible: true)
end end
def find_tribute_autocomplete_menu
find('.tribute-container ul', visible: true)
end
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 @@ ...@@ -961,11 +961,6 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-2.2.0.tgz#95cf58d6ae634d535145159f08f5cff6241d4013" resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-2.2.0.tgz#95cf58d6ae634d535145159f08f5cff6241d4013"
integrity sha512-mCwR3KfNPsxRoojtTjMIZwdd4FFlBh5DlR9AeodP+7+k8rILdWGYxTZbJMPNXoPbZx16R94nG8c5bR7toD4QBw== 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": "@gitlab/ui@33.1.0":
version "33.1.0" version "33.1.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-33.1.0.tgz#45ac2e6362546530b5756b1973f97f74a9c920da" 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