Skip to content
Snippets Groups Projects
Commit 27df2eef authored by Dheeraj Joshi's avatar Dheeraj Joshi
Browse files

Merge branch '420344-unassign-fix' into 'master'

parents 2b9013ee 3617b242
No related branches found
No related tags found
No related merge requests found
Showing
with 2005 additions and 244 deletions
Loading
Loading
@@ -6,6 +6,7 @@ import { VARIANT_DANGER } from '~/alert';
import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue';
import { CONTENT_EDITOR_READY_EVENT } from '~/vue_shared/constants';
import markdownEditorEventHub from '~/vue_shared/components/markdown/eventhub';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import { createContentEditor } from '../services/create_content_editor';
import { ALERT_EVENT, TIPTAP_AUTOFOCUS_OPTIONS } from '../constants';
import ContentEditorAlert from './content_editor_alert.vue';
Loading
Loading
@@ -157,6 +158,7 @@ export default {
enableAutocomplete,
autocompleteDataSources,
codeSuggestionsConfig,
sidebarMediator: SidebarMediator.singleton,
tiptapOptions: {
autofocus,
editable,
Loading
Loading
<script>
import { GlAvatarLabeled, GlLoadingIcon } from '@gitlab/ui';
import { GlAvatar, GlLoadingIcon } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
 
export default {
components: {
GlAvatarLabeled,
GlAvatar,
GlLoadingIcon,
},
 
directives: {
SafeHtml,
},
props: {
char: {
type: String,
Loading
Loading
@@ -38,6 +43,12 @@ export default {
required: false,
default: false,
},
query: {
type: String,
required: false,
default: '',
},
},
 
data() {
Loading
Loading
@@ -90,20 +101,30 @@ export default {
isEmoji() {
return this.nodeType === 'emoji';
},
shouldSelectFirstItem() {
return this.items.length && this.query;
},
},
 
watch: {
items() {
this.selectedIndex = -1;
this.selectedIndex = this.shouldSelectFirstItem ? 0 : -1;
},
selectedIndex() {
this.scrollIntoView();
},
},
 
mounted() {
if (this.shouldSelectFirstItem) {
this.selectedIndex = 0;
}
},
methods: {
getText(item) {
if (this.isEmoji) return item.e;
if (this.isEmoji) return item.emoji.e;
 
switch (this.isReference && this.nodeProps.referenceType) {
case 'user':
Loading
Loading
@@ -133,10 +154,10 @@ export default {
 
if (this.isEmoji) {
Object.assign(props, {
name: item.name,
unicodeVersion: item.u,
title: item.d,
moji: item.e,
name: item.emoji.name,
unicodeVersion: item.emoji.u,
title: item.emoji.d,
moji: item.emoji.e,
});
}
 
Loading
Loading
@@ -173,7 +194,7 @@ export default {
return true;
}
 
if (event.key === 'Enter') {
if (event.key === 'Enter' || event.key === 'Tab') {
this.enterHandler();
return true;
}
Loading
Loading
@@ -194,7 +215,7 @@ export default {
},
 
scrollIntoView() {
this.$refs.dropdownItems[this.selectedIndex]?.scrollIntoView({ block: 'nearest' });
this.$refs.dropdownItems?.[this.selectedIndex]?.scrollIntoView({ block: 'nearest' });
},
 
selectItem(index) {
Loading
Loading
@@ -211,7 +232,17 @@ export default {
avatarSubLabel(item) {
return item.count ? `${item.name} (${item.count})` : item.name;
},
highlight(text) {
return this.query
? String(text).replace(
new RegExp(this.query, 'i'),
(match) => `<strong class="gl-text-body!">${match}</strong>`,
)
: text;
},
},
safeHtmlConfig: { ALLOWED_TAGS: ['strong'] },
};
</script>
 
Loading
Loading
@@ -238,29 +269,45 @@ export default {
@click="selectItem(index)"
>
<div class="gl-new-dropdown-item-text-wrapper">
<gl-avatar-labeled
v-if="isUser"
:label="item.username"
:sub-label="avatarSubLabel(item)"
:src="item.avatar_url"
:entity-name="item.username"
:shape="item.type === 'Group' ? 'rect' : 'circle'"
:size="32"
/>
<span v-if="isUser" class="gl-flex">
<gl-avatar
:src="item.avatar_url"
:entity-name="item.username"
:size="24"
:shape="item.type === 'Group' ? 'rect' : 'circle'"
class="gl-vertical-align-middle gl-mx-2"
/>
<span class="gl-vertical-align-middle">
<span v-safe-html:safeHtmlConfig="highlight(item.username)"></span>
<small
v-safe-html:safeHtmlConfig="highlight(avatarSubLabel(item))"
class="gl-text-gray-500"
></small>
</span>
</span>
<span v-if="isIssue || isMergeRequest">
<small>{{ item.iid }}</small>
{{ item.title }}
<small
v-safe-html:safeHtmlConfig="highlight(item.iid)"
class="gl-text-gray-500"
></small>
<span v-safe-html:safeHtmlConfig="highlight(item.title)"></span>
</span>
<span v-if="isVulnerability || isSnippet">
<small>{{ item.id }}</small>
{{ item.title }}
<small
v-safe-html:safeHtmlConfig="highlight(item.id)"
class="gl-text-gray-500"
></small>
<span v-safe-html:safeHtmlConfig="highlight(item.title)"></span>
</span>
<span v-if="isEpic">
<small>{{ item.reference }}</small>
{{ item.title }}
<small
v-safe-html:safeHtmlConfig="highlight(item.reference)"
class="gl-text-gray-500"
></small>
<span v-safe-html:safeHtmlConfig="highlight(item.title)"></span>
</span>
<span v-if="isMilestone">
{{ item.title }}
<span v-safe-html:safeHtmlConfig="highlight(item.title)"></span>
</span>
<span v-if="isLabel" class="gl-display-flex">
<span
Loading
Loading
@@ -268,20 +315,31 @@ export default {
class="dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3"
:style="{ backgroundColor: item.color }"
></span>
{{ item.title }}
<span v-safe-html:safeHtmlConfig="highlight(item.title)"></span>
</span>
<div v-if="isCommand">
<div class="gl-mb-1">
<span class="gl-font-weight-bold">/{{ item.name }}</span>
<em class="gl-text-gray-500 gl-font-sm">{{ item.params[0] }}</em>
/<span v-safe-html:safeHtmlConfig="highlight(item.name)"></span>
<span class="gl-text-gray-500 gl-font-sm">{{ item.params[0] }}</span>
</div>
<small class="gl-text-gray-500"> {{ item.description }} </small>
<em
v-safe-html:safeHtmlConfig="highlight(item.description)"
class="gl-text-gray-500 gl-font-sm"
></em>
</div>
<div v-if="isEmoji" class="gl-display-flex gl-align-items-center">
<div class="gl-pr-4 gl-font-lg">{{ item.e }}</div>
<div class="gl-pr-4 gl-font-lg">
<gl-emoji
:key="item.emoji.e"
:data-name="item.emoji.name"
:title="item.emoji.d"
:data-unicode-version="item.emoji.u"
:data-fallback-src="item.emoji.src"
>{{ item.emoji.e }}</gl-emoji
>
</div>
<div class="gl-flex-grow-1">
{{ item.name }}<br />
<small>{{ item.d }}</small>
<span v-safe-html:safeHtmlConfig="highlight(item.fieldValue)"></span>
</div>
</div>
</div>
Loading
Loading
Loading
Loading
@@ -46,7 +46,7 @@ export default Node.create({
title: node.attrs.title,
'data-unicode-version': node.attrs.unicodeVersion,
},
node.attrs.moji,
node.attrs.moji || '',
];
},
 
Loading
Loading
Loading
Loading
@@ -3,29 +3,20 @@ import { VueRenderer } from '@tiptap/vue-2';
import tippy from 'tippy.js';
import Suggestion from '@tiptap/suggestion';
import { PluginKey } from '@tiptap/pm/state';
import { isFunction, uniqueId, memoize } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import { initEmojiMap, getAllEmoji } from '~/emoji';
import { uniqueId } from 'lodash';
import SuggestionsDropdown from '../components/suggestions_dropdown.vue';
 
function find(haystack, needle) {
return String(haystack).toLocaleLowerCase().includes(String(needle).toLocaleLowerCase());
}
function createSuggestionPlugin({
editor,
char,
dataSource,
search,
limit = 15,
limit = 5,
nodeType,
nodeProps = {},
referenceType,
cache = true,
insertionMap = {},
serializer,
autocompleteHelper,
}) {
const fetchData = memoize(
isFunction(dataSource) ? dataSource : async () => (await axios.get(dataSource)).data,
);
return Suggestion({
editor,
char,
Loading
Loading
@@ -42,16 +33,17 @@ function createSuggestionPlugin({
.run();
},
 
async items({ query }) {
if (!dataSource) return [];
try {
const items = await fetchData();
return items.filter(search(query)).slice(0, limit);
} catch {
return [];
}
async items({ query, editor: tiptapEditor }) {
const slice = tiptapEditor.state.doc.slice(0, tiptapEditor.state.selection.to);
const markdownLine = serializer.serialize({ doc: slice.content }).split('\n').pop();
return autocompleteHelper
.getDataSource(referenceType, {
command: markdownLine.match(/\/\w+/)?.[0],
cache,
limit,
})
.search(query);
},
 
render: () => {
Loading
Loading
@@ -76,7 +68,7 @@ function createSuggestionPlugin({
...props,
char,
nodeType,
nodeProps,
nodeProps: { referenceType },
loading: true,
},
editor: props.editor,
Loading
Loading
@@ -132,101 +124,38 @@ export default Node.create({
 
addOptions() {
return {
autocompleteDataSources: {},
autocompleteHelper: {},
serializer: null,
};
},
 
addProseMirrorPlugins() {
return [
createSuggestionPlugin({
editor: this.editor,
char: '@',
dataSource: this.options.autocompleteDataSources.members,
nodeType: 'reference',
nodeProps: {
referenceType: 'user',
},
search: (query) => ({ name, username }) => find(name, query) || find(username, query),
}),
createSuggestionPlugin({
editor: this.editor,
char: '#',
dataSource: this.options.autocompleteDataSources.issues,
nodeType: 'reference',
nodeProps: {
referenceType: 'issue',
},
search: (query) => ({ iid, title }) => find(iid, query) || find(title, query),
}),
createSuggestionPlugin({
editor: this.editor,
char: '$',
dataSource: this.options.autocompleteDataSources.snippets,
nodeType: 'reference',
nodeProps: {
referenceType: 'snippet',
},
search: (query) => ({ id, title }) => find(id, query) || find(title, query),
}),
createSuggestionPlugin({
editor: this.editor,
char: '~',
dataSource: this.options.autocompleteDataSources.labels,
nodeType: 'referenceLabel',
nodeProps: {
referenceType: 'label',
},
search: (query) => ({ title }) => find(title, query),
}),
createSuggestionPlugin({
editor: this.editor,
char: '&',
dataSource: this.options.autocompleteDataSources.epics,
nodeType: 'reference',
nodeProps: {
referenceType: 'epic',
},
search: (query) => ({ iid, title }) => find(iid, query) || find(title, query),
}),
createSuggestionPlugin({
editor: this.editor,
char: '[vulnerability:',
dataSource: this.options.autocompleteDataSources.vulnerabilities,
nodeType: 'reference',
nodeProps: {
referenceType: 'vulnerability',
},
search: (query) => ({ id, title }) => find(id, query) || find(title, query),
}),
createSuggestionPlugin({
editor: this.editor,
char: '!',
dataSource: this.options.autocompleteDataSources.mergeRequests,
nodeType: 'reference',
nodeProps: {
referenceType: 'merge_request',
},
search: (query) => ({ iid, title }) => find(iid, query) || find(title, query),
}),
createSuggestionPlugin({
editor: this.editor,
char: '%',
dataSource: this.options.autocompleteDataSources.milestones,
nodeType: 'reference',
nodeProps: {
referenceType: 'milestone',
},
search: (query) => ({ iid, title }) => find(iid, query) || find(title, query),
}),
const { serializer, autocompleteHelper } = this.options;
const createPlugin = (char, nodeType, referenceType, options = {}) =>
createSuggestionPlugin({
editor: this.editor,
char: '/',
dataSource: this.options.autocompleteDataSources.commands,
nodeType: 'reference',
nodeProps: {
referenceType: 'command',
},
search: (query) => ({ name }) => find(name, query),
char,
nodeType,
referenceType,
serializer,
autocompleteHelper,
...options,
});
return [
createPlugin('@', 'reference', 'user', { limit: 10 }),
createPlugin('#', 'reference', 'issue'),
createPlugin('$', 'reference', 'snippet'),
createPlugin('~', 'referenceLabel', 'label', { limit: 20 }),
createPlugin('&', 'reference', 'epic'),
createPlugin('!', 'reference', 'merge_request'),
createPlugin('[vulnerability:', 'reference', 'vulnerability'),
createPlugin('%', 'reference', 'milestone'),
createPlugin(':', 'emoji', 'emoji'),
createPlugin('/', 'reference', 'command', {
cache: false,
limit: 100,
insertionMap: {
'/label': '~',
'/unlabel': '~',
Loading
Loading
@@ -241,18 +170,6 @@ export default Node.create({
'/milestone': '%',
},
}),
createSuggestionPlugin({
editor: this.editor,
char: ':',
dataSource: () => getAllEmoji(),
nodeType: 'emoji',
search: (query) => ({ d, name }) => find(d, query) || find(name, query),
limit: 10,
}),
];
},
onCreate() {
initEmojiMap();
},
});
Loading
Loading
@@ -70,6 +70,7 @@ import createGlApiMarkdownDeserializer from './gl_api_markdown_deserializer';
import createRemarkMarkdownDeserializer from './remark_markdown_deserializer';
import AssetResolver from './asset_resolver';
import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
import DataSourceFactory from './data_source_factory';
 
const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
new Editor({
Loading
Loading
@@ -86,6 +87,7 @@ export const createContentEditor = ({
drawioEnabled = false,
enableAutocomplete,
autocompleteDataSources = {},
sidebarMediator = {},
codeSuggestionsConfig = {},
} = {}) => {
if (!isFunction(renderMarkdown)) {
Loading
Loading
@@ -95,6 +97,10 @@ export const createContentEditor = ({
const eventHub = eventHubFactory();
const assetResolver = new AssetResolver({ renderMarkdown });
const serializer = new MarkdownSerializer({ serializerConfig });
const autocompleteHelper = new DataSourceFactory({
dataSourceUrls: autocompleteDataSources,
sidebarMediator,
});
const deserializer = window.gon?.features?.preserveUnchangedMarkdown
? createRemarkMarkdownDeserializer()
: createGlApiMarkdownDeserializer({
Loading
Loading
@@ -166,7 +172,8 @@ export const createContentEditor = ({
 
const allExtensions = [...builtInContentEditorExtensions, ...extensions];
 
if (enableAutocomplete) allExtensions.push(Suggestions.configure({ autocompleteDataSources }));
if (enableAutocomplete)
allExtensions.push(Suggestions.configure({ autocompleteHelper, serializer }));
if (drawioEnabled) allExtensions.push(DrawioDiagram.configure({ uploadsPath, assetResolver }));
 
const trackedExtensions = allExtensions.map(trackInputRulesAndShortcuts);
Loading
Loading
import { identity, memoize, throttle } from 'lodash';
import { sprintf, __ } from '~/locale';
import { initEmojiMap, getAllEmoji, searchEmoji } from '~/emoji';
import { parsePikadayDate } from '~/lib/utils/datetime_utility';
import axios from '~/lib/utils/axios_utils';
export function defaultSorter(searchFields) {
return (items, query) => {
if (!query) return items;
const sortOrdersMap = new WeakMap();
items.forEach((item) => {
const sortOrders = searchFields.map((searchField) => {
const haystack = String(item[searchField]).toLocaleLowerCase();
const needle = query.toLocaleLowerCase();
const i = haystack.indexOf(needle);
if (i < 0) return i;
return Number.MAX_SAFE_INTEGER - i;
});
sortOrdersMap.set(item, Math.max(...sortOrders));
});
return items.sort((a, b) => sortOrdersMap.get(b) - sortOrdersMap.get(a));
};
}
export function customSorter(sorter) {
return (items) => items.sort(sorter);
}
const milestonesMap = new WeakMap();
function parseMilestone(milestone) {
if (!milestone.title) {
return milestone;
}
const dueDate = milestone.due_date ? parsePikadayDate(milestone.due_date) : null;
const expired = dueDate ? Date.now() > dueDate.getTime() : false;
return {
id: milestone.iid,
title: expired
? sprintf(__('%{milestone} (expired)'), {
milestone: milestone.title,
})
: milestone.title,
expired,
dueDate,
};
}
function mapMilestone(milestone) {
if (!milestonesMap.has(milestone)) {
milestonesMap.set(milestone, parseMilestone(milestone));
}
return milestonesMap.get(milestone);
}
function sortMilestones(milestoneA, milestoneB) {
const mappedA = mapMilestone(milestoneA);
const mappedB = mapMilestone(milestoneB);
// Move all expired milestones to the bottom.
if (milestoneA.expired) return 1;
if (milestoneB.expired) return -1;
// Move milestones without due dates just above expired milestones.
if (!milestoneA.dueDate) return 1;
if (!milestoneB.dueDate) return -1;
return mappedA.dueDate - mappedB.dueDate;
}
export function createDataSource({
source,
searchFields,
filter,
mapper = identity,
sorter = defaultSorter(searchFields),
cache = true,
limit = 15,
}) {
const fetchData = source ? async () => (await axios.get(source)).data : () => [];
let items = [];
const sync = async function sync() {
try {
items = await fetchData();
} catch {
items = [];
}
};
const init = memoize(sync);
const throttledSync = throttle(sync, 5000);
return {
search: async (query) => {
await init();
if (!cache) throttledSync();
let results = items.map(mapper);
if (filter) results = filter(items, query);
if (query) {
results = results.filter((item) => {
if (!searchFields.length) return true;
return searchFields.some((field) =>
String(item[field]).toLocaleLowerCase().includes(query.toLocaleLowerCase()),
);
});
}
return sorter(results, query).slice(0, limit);
},
};
}
export default class DataSourceFactory {
constructor({ dataSourceUrls, sidebarMediator }) {
this.dataSourceUrls = dataSourceUrls;
this.sidebarMediator = sidebarMediator;
initEmojiMap();
}
getDataSource = memoize(
(referenceType, config = {}) => {
const sources = {
user: this.dataSourceUrls.members,
issue: this.dataSourceUrls.issues,
snippet: this.dataSourceUrls.snippets,
label: this.dataSourceUrls.labels,
epic: this.dataSourceUrls.epics,
milestone: this.dataSourceUrls.milestones,
merge_request: this.dataSourceUrls.mergeRequests,
vulnerability: this.dataSourceUrls.vulnerabilities,
command: this.dataSourceUrls.commands,
};
const searchFields = {
user: ['username', 'name'],
issue: ['iid', 'title'],
snippet: ['id', 'title'],
label: ['title'],
epic: ['iid', 'title'],
vulnerability: ['id', 'title'],
merge_request: ['iid', 'title'],
milestone: ['title', 'iid'],
command: ['name'],
emoji: [],
};
const filters = {
label: (items) =>
items.filter((item) => {
if (config.command === '/unlabel') return item.set;
if (config.command === '/label') return !item.set;
return true;
}),
user: (items) =>
items.filter((item) => {
const assigned = this.sidebarMediator?.store?.assignees.some(
(assignee) => assignee.username === item.username,
);
const assignedReviewer = this.sidebarMediator?.store?.reviewers.some(
(reviewer) => reviewer.username === item.username,
);
if (config.command === '/assign') return !assigned;
if (config.command === '/assign_reviewer') return !assignedReviewer;
if (config.command === '/unassign') return assigned;
if (config.command === '/unassign_reviewer') return assignedReviewer;
return true;
}),
emoji: (_, query) =>
query
? searchEmoji(query)
: getAllEmoji().map((emoji) => ({ emoji, fieldValue: emoji.name })),
};
const sorters = {
milestone: customSorter(sortMilestones),
default: defaultSorter(searchFields[referenceType]),
// do not sort emoji
emoji: customSorter(() => 0),
};
const mappers = {
milestone: mapMilestone,
default: identity,
};
return createDataSource({
source: sources[referenceType],
searchFields: searchFields[referenceType],
mapper: mappers[referenceType] || mappers.default,
sorter: sorters[referenceType] || sorters.default,
filter: filters[referenceType],
cache: config.cache,
limit: config.limit,
});
},
(referenceType, config) => JSON.stringify({ referenceType, config }),
);
}
Loading
Loading
@@ -82,8 +82,8 @@ export function membersBeforeSave(members) {
const autoCompleteAvatar = member.avatar_url || member.username.charAt(0).toUpperCase();
 
const rectAvatarClass = member.type === GROUP_TYPE ? 'rect-avatar' : '';
const imgAvatar = `<img src="${member.avatar_url}" alt="${member.username}" class="avatar ${rectAvatarClass} avatar-inline center s26"/>`;
const txtAvatar = `<div class="avatar ${rectAvatarClass} center avatar-inline s26">${autoCompleteAvatar}</div>`;
const imgAvatar = `<img src="${member.avatar_url}" alt="${member.username}" class="avatar ${rectAvatarClass} avatar-inline s24 gl-mr-2"/>`;
const txtAvatar = `<div class="avatar ${rectAvatarClass} avatar-inline s24 gl-mr-2">${autoCompleteAvatar}</div>`;
const avatarIcon = member.mentionsDisabled
? spriteIcon('notifications-off', 's16 vertical-align-middle gl-ml-2')
: '';
Loading
Loading
.atwho-view {
overflow-y: auto;
overflow-x: hidden;
max-width: calc(100% - 6px);
min-width: $gl-new-dropdown-min-width;
max-width: $gl-new-dropdown-max-width;
@include gl-border-b-1;
@include gl-border-b-solid;
@include gl-border-b-gray-100;
@include gl-rounded-lg;
@include gl-shadow-md;
 
.name,
small.aliases,
small.params {
float: left;
}
 
small.aliases,
small.params {
padding: 2px 5px;
small {
@include gl-font-sm;
}
 
small.description {
float: right;
padding: 3px 5px;
display: block;
width: auto;
@include gl-mt-2;
}
 
.avatar-inline {
Loading
Loading
@@ -42,24 +39,22 @@
}
}
 
ul > li {
@include clearfix;
white-space: nowrap;
}
// TODO: fallback to global style
.atwho-view-ul {
@include gl-p-2;
@include gl-py-2;
max-height: $gl-max-dropdown-max-height;
 
li {
@include gl-px-3;
padding-top: $gl-padding-6;
padding-bottom: $gl-padding-6;
border: 0;
@include gl-rounded-base;
padding: $gl-padding-6;
@include gl-my-2;
@include gl-mx-3;
@include gl-rounded-small;
@include gl-line-height-normal;
 
&.cur {
@include gl-focus;
background-color: $gray-darker;
color: $gl-text-color;
 
Loading
Loading
@@ -78,10 +73,6 @@
align-items: center;
}
 
.center {
line-height: 14px;
}
strong {
color: $gl-text-color;
}
Loading
Loading
import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
import { EditorContent, Editor } from '@tiptap/vue-2';
import { nextTick } from 'vue';
import MockAdapter from 'axios-mock-adapter';
import axios from 'axios';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ContentEditor from '~/content_editor/components/content_editor.vue';
import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue';
Loading
Loading
@@ -16,11 +18,10 @@ import waitForPromises from 'helpers/wait_for_promises';
import { KEYDOWN_EVENT } from '~/content_editor/constants';
import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue';
 
jest.mock('~/emoji');
describe('ContentEditor', () => {
let wrapper;
let renderMarkdown;
let mock;
const uploadsPath = '/uploads';
 
const findEditorElement = () => wrapper.findByTestId('content-editor');
Loading
Loading
@@ -32,6 +33,7 @@ describe('ContentEditor', () => {
wrapper = shallowMountExtended(ContentEditor, {
propsData: {
renderMarkdown,
markdownDocsPath: '/docs/markdown',
uploadsPath,
markdown,
autofocus,
Loading
Loading
@@ -49,9 +51,17 @@ describe('ContentEditor', () => {
};
 
beforeEach(() => {
mock = new MockAdapter(axios);
// ignore /-/emojis requests
mock.onGet().reply(200, []);
renderMarkdown = jest.fn();
});
 
afterEach(() => {
mock.restore();
});
it('triggers initialized event', () => {
createWrapper();
 
Loading
Loading
import { GlAvatarLabeled, GlLoadingIcon } from '@gitlab/ui';
import { GlAvatar, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import SuggestionsDropdown from '~/content_editor/components/suggestions_dropdown.vue';
 
Loading
Loading
@@ -14,11 +15,17 @@ describe('~/content_editor/components/suggestions_dropdown', () => {
command: jest.fn(),
...propsData,
},
stubs: ['gl-emoji'],
}),
);
};
 
const exampleUser = { username: 'root', avatar_url: 'root_avatar.png', type: 'User' };
const exampleUser = {
username: 'root',
avatar_url: 'root_avatar.png',
type: 'User',
name: 'Administrator',
};
const exampleIssue = { iid: 123, title: 'Test Issue' };
const exampleMergeRequest = { iid: 224, title: 'Test MR' };
const exampleMilestone1 = { iid: 21, title: '13' };
Loading
Loading
@@ -61,11 +68,14 @@ describe('~/content_editor/components/suggestions_dropdown', () => {
title: 'Project creation QueryRecorder logs',
};
const exampleEmoji = {
c: 'people',
e: '😃',
d: 'smiling face with open mouth',
u: '6.0',
name: 'smiley',
emoji: {
c: 'people',
e: '😃',
d: 'smiling face with open mouth',
u: '6.0',
name: 'smiley',
},
fieldValue: 'smiley',
};
 
const insertedEmojiProps = {
Loading
Loading
@@ -95,6 +105,68 @@ describe('~/content_editor/components/suggestions_dropdown', () => {
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(loading);
});
 
it('selects first item if query is not empty and items are available', async () => {
buildWrapper({
propsData: {
char: '@',
nodeType: 'reference',
nodeProps: {
referenceType: 'member',
},
items: [exampleUser],
query: 'ro',
},
});
await nextTick();
expect(
wrapper.findByTestId('content-editor-suggestions-dropdown').find('li').classes(),
).toContain('focused');
});
describe('when query is defined', () => {
it.each`
nodeType | referenceType | reference | query | expectedHTML
${'reference'} | ${'user'} | ${exampleUser} | ${'r'} | ${'<strong class="gl-text-body!">r</strong>oot'}
${'reference'} | ${'user'} | ${exampleUser} | ${'r'} | ${'Administ<strong class="gl-text-body!">r</strong>ator'}
${'reference'} | ${'issue'} | ${exampleIssue} | ${'test'} | ${'<strong class="gl-text-body!">Test</strong> Issue'}
${'reference'} | ${'issue'} | ${exampleIssue} | ${'12'} | ${'<strong class="gl-text-body!">12</strong>3'}
${'reference'} | ${'merge_request'} | ${exampleMergeRequest} | ${'test'} | ${'<strong class="gl-text-body!">Test</strong> MR'}
${'reference'} | ${'merge_request'} | ${exampleMergeRequest} | ${'22'} | ${'<strong class="gl-text-body!">22</strong>4'}
${'reference'} | ${'epic'} | ${exampleEpic} | ${'rem'} | ${'❓ <strong class="gl-text-body!">Rem</strong>ote Development | Solution validation'}
${'reference'} | ${'epic'} | ${exampleEpic} | ${'88'} | ${'gitlab-org&amp;<strong class="gl-text-body!">88</strong>84'}
${'reference'} | ${'milestone'} | ${exampleMilestone1} | ${'1'} | ${'<strong class="gl-text-body!">1</strong>3'}
${'reference'} | ${'command'} | ${exampleCommand} | ${'due'} | ${'<strong class="gl-text-body!">due</strong>'}
${'reference'} | ${'command'} | ${exampleCommand} | ${'due'} | ${'Set <strong class="gl-text-body!">due</strong> date'}
${'reference'} | ${'label'} | ${exampleLabel1} | ${'c'} | ${'<strong class="gl-text-body!">C</strong>reate'}
${'reference'} | ${'vulnerability'} | ${exampleVulnerability} | ${'network'} | ${'System procs <strong class="gl-text-body!">network</strong> activity'}
${'reference'} | ${'vulnerability'} | ${exampleVulnerability} | ${'85'} | ${'60<strong class="gl-text-body!">85</strong>0147'}
${'reference'} | ${'snippet'} | ${exampleSnippet} | ${'project'} | ${'<strong class="gl-text-body!">Project</strong> creation QueryRecorder logs'}
${'reference'} | ${'snippet'} | ${exampleSnippet} | ${'242'} | ${'<strong class="gl-text-body!">242</strong>0859'}
${'emoji'} | ${'emoji'} | ${exampleEmoji} | ${'sm'} | ${'<strong class="gl-text-body!">sm</strong>iley'}
`(
'highlights query as bolded in $referenceType text',
({ nodeType, referenceType, reference, query, expectedHTML }) => {
buildWrapper({
propsData: {
char: '@',
nodeType,
nodeProps: {
referenceType,
},
items: [reference],
query,
},
});
expect(wrapper.findByTestId('content-editor-suggestions-dropdown').html()).toContain(
expectedHTML,
);
},
);
});
describe('on item select', () => {
it.each`
nodeType | referenceType | char | reference | insertedText | insertedProps
Loading
Loading
@@ -146,7 +218,7 @@ describe('~/content_editor/components/suggestions_dropdown', () => {
});
 
describe('rendering user references', () => {
it('displays avatar labeled component', () => {
it('displays avatar component', () => {
buildWrapper({
propsData: {
char: '@',
Loading
Loading
@@ -157,13 +229,11 @@ describe('~/content_editor/components/suggestions_dropdown', () => {
},
});
 
expect(wrapper.findComponent(GlAvatarLabeled).attributes()).toEqual(
expect.objectContaining({
label: exampleUser.username,
shape: 'circle',
src: exampleUser.avatar_url,
}),
);
expect(wrapper.findComponent(GlAvatar).attributes()).toMatchObject({
entityname: exampleUser.username,
shape: 'circle',
src: exampleUser.avatar_url,
});
});
 
describe.each`
Loading
Loading
@@ -273,20 +343,46 @@ describe('~/content_editor/components/suggestions_dropdown', () => {
it('displays emoji', () => {
const testEmojis = [
{
c: 'people',
e: '😄',
d: 'smiling face with open mouth and smiling eyes',
u: '6.0',
name: 'smile',
emoji: {
c: 'people',
e: '😄',
d: 'smiling face with open mouth and smiling eyes',
u: '6.0',
name: 'smile',
},
fieldValue: 'smile',
},
{
emoji: {
c: 'people',
e: '😸',
d: 'grinning cat face with smiling eyes',
u: '6.0',
name: 'smile_cat',
},
fieldValue: 'smile_cat',
},
{
emoji: {
c: 'people',
e: '😃',
d: 'smiling face with open mouth',
u: '6.0',
name: 'smiley',
},
fieldValue: 'smiley',
},
{
c: 'people',
e: '😸',
d: 'grinning cat face with smiling eyes',
u: '6.0',
name: 'smile_cat',
emoji: {
c: 'custom',
e: null,
d: 'party-parrot',
u: 'custom',
name: 'party-parrot',
src: 'https://cultofthepartyparrot.com/parrots/hd/parrot.gif',
},
fieldValue: 'party-parrot',
},
{ c: 'people', e: '😃', d: 'smiling face with open mouth', u: '6.0', name: 'smiley' },
];
 
buildWrapper({
Loading
Loading
@@ -298,11 +394,41 @@ describe('~/content_editor/components/suggestions_dropdown', () => {
},
});
 
testEmojis.forEach((testEmoji) => {
expect(wrapper.text()).toContain(testEmoji.e);
expect(wrapper.text()).toContain(testEmoji.d);
expect(wrapper.text()).toContain(testEmoji.name);
});
expect(wrapper.findAllComponents('gl-emoji-stub').at(0).html()).toMatchInlineSnapshot(`
<gl-emoji-stub
data-name="smile"
data-unicode-version="6.0"
title="smiling face with open mouth and smiling eyes"
>
😄
</gl-emoji-stub>
`);
expect(wrapper.findAllComponents('gl-emoji-stub').at(1).html()).toMatchInlineSnapshot(`
<gl-emoji-stub
data-name="smile_cat"
data-unicode-version="6.0"
title="grinning cat face with smiling eyes"
>
😸
</gl-emoji-stub>
`);
expect(wrapper.findAllComponents('gl-emoji-stub').at(2).html()).toMatchInlineSnapshot(`
<gl-emoji-stub
data-name="smiley"
data-unicode-version="6.0"
title="smiling face with open mouth"
>
😃
</gl-emoji-stub>
`);
expect(wrapper.findAllComponents('gl-emoji-stub').at(3).html()).toMatchInlineSnapshot(`
<gl-emoji-stub
data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif"
data-name="party-parrot"
data-unicode-version="custom"
title="party-parrot"
/>
`);
});
});
});
Loading
Loading
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DataSourceFactory filters items based on command "/assign" for reference type "user" and command 1`] = `
Array [
"florida.schoen",
"root",
"all",
"lakeesha.batz",
"laurene_blick",
"myrtis",
"patty",
"Commit451",
"flightjs",
"gitlab-instance-ade037f9",
"gitlab-org",
"gnuwget",
"h5bp",
"jashkenas",
"twitter",
]
`;
exports[`DataSourceFactory filters items based on command "/assign_reviewer" for reference type "user" and command 1`] = `
Array [
"florida.schoen",
"root",
"all",
"errol",
"evelynn_olson",
"Commit451",
"flightjs",
"gitlab-instance-ade037f9",
"gitlab-org",
"gnuwget",
"h5bp",
"jashkenas",
"twitter",
]
`;
exports[`DataSourceFactory filters items based on command "/label" for reference type "label" and command 1`] = `
Array [
"Bronce",
"Contour",
"Corolla",
"Cygsync",
"Frontier",
"Grand Am",
"Onesync",
"Phone",
"Pynefunc",
"Trinix",
"Trounswood",
"group::knowledge",
"scoped label",
"type::one",
"type::two",
]
`;
exports[`DataSourceFactory filters items based on command "/reassign" for reference type "user" and command 1`] = `
Array [
"florida.schoen",
"root",
"all",
"errol",
"evelynn_olson",
"lakeesha.batz",
"laurene_blick",
"myrtis",
"patty",
"Commit451",
"flightjs",
"gitlab-instance-ade037f9",
"gitlab-org",
"gnuwget",
"h5bp",
]
`;
exports[`DataSourceFactory filters items based on command "/reassign_reviewer" for reference type "user" and command 1`] = `
Array [
"florida.schoen",
"root",
"all",
"errol",
"evelynn_olson",
"lakeesha.batz",
"laurene_blick",
"myrtis",
"patty",
"Commit451",
"flightjs",
"gitlab-instance-ade037f9",
"gitlab-org",
"gnuwget",
"h5bp",
]
`;
exports[`DataSourceFactory filters items based on command "/relabel" for reference type "label" and command 1`] = `
Array [
"Amsche",
"Brioffe",
"Bronce",
"Bryncefunc",
"Contour",
"Corolla",
"Cygsync",
"Frontier",
"Ghost",
"Grand Am",
"Onesync",
"Phone",
"Pynefunc",
"Trinix",
"Trounswood",
]
`;
exports[`DataSourceFactory filters items based on command "/unassign" for reference type "user" and command 1`] = `
Array [
"errol",
"evelynn_olson",
]
`;
exports[`DataSourceFactory filters items based on command "/unassign_reviewer" for reference type "user" and command 1`] = `
Array [
"lakeesha.batz",
"laurene_blick",
"myrtis",
"patty",
]
`;
exports[`DataSourceFactory filters items based on command "/unlabel" for reference type "label" and command 1`] = `
Array [
"Amsche",
"Brioffe",
"Bryncefunc",
"Ghost",
]
`;
exports[`DataSourceFactory for reference type "command", searches for "re" correctly 1`] = `
Array [
"relabel",
"remove_milestone",
"remove_estimate",
"remove_time_spent",
"relate",
"remove_epic",
"reassign",
"create_merge_request",
]
`;
exports[`DataSourceFactory for reference type "epic", searches for "n" correctly 1`] = `
Array [
"Nobis quidem aspernatur reprehenderit sunt ut ipsum tempora sapiente sed iste.",
"Minus eius ut omnis quos sunt dicta ex ipsum.",
"Quae nostrum possimus rerum aliquam pariatur a eos aut id.",
"Dicta incidunt vel dignissimos sint sit esse est quibusdam quidem consequatur.",
"Doloremque a quisquam qui culpa numquam doloribus similique iure enim.",
]
`;
exports[`DataSourceFactory for reference type "issue", searches for "q" correctly 1`] = `
Array [
"Quasi id et et nihil sint autem.",
"Eaque omnis eius quas necessitatibus hic ut et corrupti.",
"Aut quisquam magnam eos distinctio incidunt perferendis fugit.",
"Dolorem quisquam cupiditate consequatur perspiciatis sequi eligendi ullam.",
"Nesciunt quia molestiae in aliquam amet et dolorem.",
"Porro tempore qui qui culpa saepe et nam quos.",
"Sed sint a est consequatur quae quasi autem debitis alias.",
"Molestiae minima maxime optio nihil quam eveniet dolor.",
"Et laboriosam aut ratione voluptatem quasi recusandae.",
"Et molestiae delectus voluptates velit vero illo aut rerum quo et.",
]
`;
exports[`DataSourceFactory for reference type "label", searches for "c" correctly 1`] = `
Array [
"Contour",
"Corolla",
"Cygsync",
"scoped label",
"Amsche",
"Bronce",
"Bryncefunc",
"Onesync",
"Pynefunc",
]
`;
exports[`DataSourceFactory for reference type "merge_request", searches for "n" correctly 1`] = `
Array [
"Blanditiis maxime voluptatem ut pariatur vel autem vero non quod libero.",
"Optio nemo qui dolorem sit ipsum qui saepe.",
"Draft: Alunny/publish lib",
"Draft: Fix event current target",
"Draft: Resolve \\"hgvbbvnnb\\"",
"Autem eaque et sed provident enim corrupti molestiae.",
"Always call registry's trigger method from withRegistration",
]
`;
exports[`DataSourceFactory for reference type "milestone", searches for "16" correctly 1`] = `
Array [
"16.7",
"16.8",
"16.9",
"16.10",
"16.11",
"16.0 (expired)",
"16.1 (expired)",
"16.2 (expired)",
"16.3 (expired)",
"16.4 (expired)",
"16.5 (expired)",
"16.6 (expired)",
]
`;
exports[`DataSourceFactory for reference type "snippet", searches for "s" correctly 1`] = `
Array [
"ss",
"test snippet",
"another test snippet",
]
`;
exports[`DataSourceFactory for reference type "user", searches for "r" correctly 1`] = `
Array [
"root",
"errol",
"lakeesha.batz",
"myrtis",
"florida.schoen",
"laurene_blick",
"all",
"twitter",
"gitlab-org",
"evelynn_olson",
]
`;
exports[`DataSourceFactory for reference type "vulnerability", searches for "cross" correctly 1`] = `
Array [
"Cross Site Scripting (Persistent)",
"Cross Site Scripting (Persistent)",
"Cross Site Scripting (Persistent)",
]
`;
export const MOCK_MEMBERS = [
{
type: 'User',
username: 'florida.schoen',
name: 'Anglea Durgan',
avatar_url:
'https://www.gravatar.com/avatar/ac82b5615d3308ecbcacedad361af8e7?s=80\u0026d=identicon',
availability: null,
},
{
type: 'User',
username: 'root',
name: 'Administrator',
avatar_url:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
availability: null,
},
{
username: 'all',
name: 'All Project and Group Members',
count: 8,
},
{
type: 'User',
username: 'errol',
name: "Linnie O'Connell",
avatar_url:
'https://www.gravatar.com/avatar/d3d9a468a9884eb217fad5ca5b2b9bd7?s=80\u0026d=identicon',
availability: null,
},
{
type: 'User',
username: 'evelynn_olson',
name: 'Dimple Dare',
avatar_url:
'https://www.gravatar.com/avatar/bc1e51ee3512c2b4442f51732d655107?s=80\u0026d=identicon',
availability: null,
},
{
type: 'User',
username: 'lakeesha.batz',
name: 'Larae Veum',
avatar_url:
'https://www.gravatar.com/avatar/e5605cb9bbb1a28640d65f25f256e541?s=80\u0026d=identicon',
availability: null,
},
{
type: 'User',
username: 'laurene_blick',
name: 'Evelina Murray',
avatar_url:
'https://www.gravatar.com/avatar/389768eef61b7b2d125c64ee01c240fb?s=80\u0026d=identicon',
availability: null,
},
{
type: 'User',
username: 'myrtis',
name: 'Fernanda Adams',
avatar_url:
'https://www.gravatar.com/avatar/719d5569bd31d4a70e350b4205fa2cb5?s=80\u0026d=identicon',
availability: null,
},
{
type: 'User',
username: 'patty',
name: 'Emily Toy',
avatar_url:
'https://www.gravatar.com/avatar/dca2077b662338808459dc11e70d6688?s=80\u0026d=identicon',
availability: null,
},
{
type: 'Group',
username: 'Commit451',
name: 'Commit451',
avatar_url: null,
count: 5,
mentionsDisabled: null,
},
{
type: 'Group',
username: 'flightjs',
name: 'Flightjs',
avatar_url: null,
count: 5,
mentionsDisabled: null,
},
{
type: 'Group',
username: 'gitlab-instance-ade037f9',
name: 'GitLab Instance',
avatar_url: null,
count: 1,
mentionsDisabled: null,
},
{
type: 'Group',
username: 'gitlab-org',
name: 'Gitlab Org',
avatar_url: null,
count: 5,
mentionsDisabled: null,
},
{
type: 'Group',
username: 'gnuwget',
name: 'Gnuwget',
avatar_url: null,
count: 5,
mentionsDisabled: null,
},
{
type: 'Group',
username: 'h5bp',
name: 'H5bp',
avatar_url: null,
count: 4,
mentionsDisabled: null,
},
{
type: 'Group',
username: 'jashkenas',
name: 'Jashkenas',
avatar_url: null,
count: 5,
mentionsDisabled: null,
},
{
type: 'Group',
username: 'twitter',
name: 'Twitter',
avatar_url: null,
count: 5,
mentionsDisabled: null,
},
];
export const MOCK_ASSIGNEES = MOCK_MEMBERS.filter(
({ username }) => username === 'errol' || username === 'evelynn_olson',
);
export const MOCK_REVIEWERS = MOCK_MEMBERS.filter(
({ username }) =>
username === 'lakeesha.batz' ||
username === 'laurene_blick' ||
username === 'myrtis' ||
username === 'patty',
);
export const MOCK_ISSUES = [
{
iid: 31,
title: 'rdfhdfj',
id: null,
},
{
iid: 30,
title: 'incident1',
id: null,
},
{
iid: 29,
title: 'example feature rollout',
id: null,
},
{
iid: 28,
title: 'sagasg',
id: null,
},
{
iid: 26,
title: 'Quasi id et et nihil sint autem.',
id: null,
},
{
iid: 25,
title: 'Dolorem quisquam cupiditate consequatur perspiciatis sequi eligendi ullam.',
id: null,
},
{
iid: 24,
title: 'Et molestiae delectus voluptates velit vero illo aut rerum quo et.',
id: null,
},
{
iid: 23,
title: 'Nesciunt quia molestiae in aliquam amet et dolorem.',
id: null,
},
{
iid: 22,
title: 'Sint asperiores unde vel autem delectus ullam dolor nihil et.',
id: null,
},
{
iid: 21,
title: 'Eaque omnis eius quas necessitatibus hic ut et corrupti.',
id: null,
},
{
iid: 20,
title: 'Porro tempore qui qui culpa saepe et nam quos.',
id: null,
},
{
iid: 19,
title: 'Molestiae minima maxime optio nihil quam eveniet dolor.',
id: null,
},
{
iid: 18,
title: 'Sed sint a est consequatur quae quasi autem debitis alias.',
id: null,
},
{
iid: 6,
title: 'Et laboriosam aut ratione voluptatem quasi recusandae.',
id: null,
},
{
iid: 2,
title: 'Aut quisquam magnam eos distinctio incidunt perferendis fugit.',
id: null,
},
];
export const MOCK_EPICS = [
{
iid: 6,
title: 'sgs',
reference: 'flightjs\u00266',
},
{
iid: 5,
title: 'Doloremque a quisquam qui culpa numquam doloribus similique iure enim.',
reference: 'flightjs\u00265',
},
{
iid: 4,
title: 'Minus eius ut omnis quos sunt dicta ex ipsum.',
reference: 'flightjs\u00264',
},
{
iid: 3,
title: 'Quae nostrum possimus rerum aliquam pariatur a eos aut id.',
reference: 'flightjs\u00263',
},
{
iid: 2,
title: 'Nobis quidem aspernatur reprehenderit sunt ut ipsum tempora sapiente sed iste.',
reference: 'flightjs\u00262',
},
{
iid: 1,
title: 'Dicta incidunt vel dignissimos sint sit esse est quibusdam quidem consequatur.',
reference: 'flightjs\u00261',
},
];
export const MOCK_MERGE_REQUESTS = [
{
iid: 12,
title: "Always call registry's trigger method from withRegistration",
id: null,
},
{
iid: 11,
title: 'Draft: Alunny/publish lib',
id: null,
},
{
iid: 10,
title: 'Draft: Resolve "hgvbbvnnb"',
id: null,
},
{
iid: 9,
title: 'Draft: Fix event current target',
id: null,
},
{
iid: 3,
title: 'Autem eaque et sed provident enim corrupti molestiae.',
id: null,
},
{
iid: 2,
title: 'Blanditiis maxime voluptatem ut pariatur vel autem vero non quod libero.',
id: null,
},
{
iid: 1,
title: 'Optio nemo qui dolorem sit ipsum qui saepe.',
id: null,
},
];
export const MOCK_SNIPPETS = [
{
id: 24,
title: 'ss',
},
{
id: 22,
title: 'another test snippet',
},
{
id: 21,
title: 'test snippet',
},
];
export const MOCK_LABELS = [
{
title: 'Amsche',
color: '#9964cf',
type: 'GroupLabel',
textColor: '#FFFFFF',
set: true,
},
{
title: 'Brioffe',
color: '#203e13',
type: 'GroupLabel',
textColor: '#FFFFFF',
set: true,
},
{
title: 'Bronce',
color: '#c0b7f2',
type: 'GroupLabel',
textColor: '#1F1E24',
},
{
title: 'Bryncefunc',
color: '#8baa5e',
type: 'GroupLabel',
textColor: '#FFFFFF',
set: true,
},
{
title: 'Contour',
color: '#8cf3a3',
type: 'ProjectLabel',
textColor: '#1F1E24',
},
{
title: 'Corolla',
color: '#0384f3',
type: 'ProjectLabel',
textColor: '#FFFFFF',
},
{
title: 'Cygsync',
color: '#1308c3',
type: 'GroupLabel',
textColor: '#FFFFFF',
},
{
title: 'Frontier',
color: '#85db43',
type: 'ProjectLabel',
textColor: '#1F1E24',
},
{
title: 'Ghost',
color: '#df1bc4',
type: 'ProjectLabel',
textColor: '#FFFFFF',
set: true,
},
{
title: 'Grand Am',
color: '#a1d7ee',
type: 'ProjectLabel',
textColor: '#1F1E24',
},
{
title: 'Onesync',
color: '#a73ba0',
type: 'GroupLabel',
textColor: '#FFFFFF',
},
{
title: 'Phone',
color: '#63dceb',
type: 'GroupLabel',
textColor: '#1F1E24',
},
{
title: 'Pynefunc',
color: '#974b19',
type: 'GroupLabel',
textColor: '#FFFFFF',
},
{
title: 'Trinix',
color: '#2c894f',
type: 'GroupLabel',
textColor: '#FFFFFF',
},
{
title: 'Trounswood',
color: '#ad0370',
type: 'GroupLabel',
textColor: '#FFFFFF',
},
{
title: 'group::knowledge',
color: '#8fbc8f',
type: 'ProjectLabel',
textColor: '#1F1E24',
},
{
title: 'scoped label',
color: '#6699cc',
type: 'GroupLabel',
textColor: '#FFFFFF',
},
{
title: 'type::one',
color: '#9400d3',
type: 'ProjectLabel',
textColor: '#FFFFFF',
},
{
title: 'type::two',
color: '#013220',
type: 'ProjectLabel',
textColor: '#FFFFFF',
},
];
export const MOCK_MILESTONES = [
{
iid: 65,
title: '15.0',
due_date: '2022-05-17',
id: null,
},
{
iid: 73,
title: '15.1',
due_date: '2022-06-17',
id: null,
},
{
iid: 74,
title: '15.2',
due_date: '2022-07-17',
id: null,
},
{
iid: 75,
title: '15.3',
due_date: '2022-08-17',
id: null,
},
{
iid: 76,
title: '15.4',
due_date: '2022-09-17',
id: null,
},
{
iid: 77,
title: '15.5',
due_date: '2022-10-17',
id: null,
},
{
iid: 81,
title: '15.6',
due_date: '2022-11-17',
id: null,
},
{
iid: 82,
title: '15.7',
due_date: '2022-12-17',
id: null,
},
{
iid: 83,
title: '15.8',
due_date: '2023-01-17',
id: null,
},
{
iid: 84,
title: '15.9',
due_date: '2023-02-17',
id: null,
},
{
iid: 85,
title: '15.10',
due_date: '2023-03-17',
id: null,
},
{
iid: 86,
title: '15.11',
due_date: '2023-04-17',
id: null,
},
{
iid: 80,
title: '16.0',
due_date: '2023-05-17',
id: null,
},
{
iid: 88,
title: '16.1',
due_date: '2023-06-17',
id: null,
},
{
iid: 89,
title: '16.2',
due_date: '2023-07-17',
id: null,
},
{
iid: 90,
title: '16.3',
due_date: '2023-08-17',
id: null,
},
{
iid: 91,
title: '16.4',
due_date: '2023-09-17',
id: null,
},
{
iid: 92,
title: '16.5',
due_date: '2023-10-17',
id: null,
},
{
iid: 93,
title: '16.6',
due_date: '2023-11-10',
id: null,
},
{
iid: 95,
title: '16.7',
due_date: '2023-12-15',
id: null,
},
{
iid: 94,
title: '16.8',
due_date: '2024-01-12',
id: null,
},
{
iid: 96,
title: '16.9',
due_date: '2024-02-09',
id: null,
},
{
iid: 97,
title: '16.10',
due_date: '2024-03-15',
id: null,
},
{
iid: 98,
title: '16.11',
due_date: '2024-04-12',
id: null,
},
{
iid: 87,
title: '17.0',
due_date: '2024-05-10',
id: null,
},
{
iid: 48,
title: 'Next 1-3 releases',
due_date: null,
id: null,
},
{
iid: 24,
title: 'Awaiting further demand',
due_date: null,
id: null,
},
{
iid: 14,
title: 'Backlog',
due_date: null,
id: null,
},
{
iid: 11,
title: 'Next 4-7 releases',
due_date: null,
id: null,
},
{
iid: 10,
title: 'Next 3-4 releases',
due_date: null,
id: null,
},
{
iid: 6,
title: 'Next 7-13 releases',
due_date: null,
id: null,
},
];
export const MOCK_VULNERABILITIES = [
{
id: 99499903,
title: 'Cross Site Scripting (Persistent)',
},
{
id: 99495085,
title: 'Possible SQL injection',
},
{
id: 99490610,
title: 'GitLab Runner Authentication Token',
},
{
id: 99288920,
title: 'Cross Site Scripting (Persistent)',
},
{
id: 99258720,
title: 'Cross Site Scripting (Persistent)',
},
];
export const MOCK_COMMANDS = [
{
name: 'due',
aliases: [],
description: 'Set due date',
warning: '',
icon: '',
params: ['\u003cin 2 days | this Friday | December 31st\u003e'],
},
{
name: 'duplicate',
aliases: [],
description: 'Mark this issue as a duplicate of another issue',
warning: '',
icon: '',
params: ['#issue'],
},
{
name: 'clone',
aliases: [],
description: 'Clone this issue',
warning: '',
icon: '',
params: ['path/to/project [--with_notes]'],
},
{
name: 'move',
aliases: [],
description: 'Move this issue to another project.',
warning: '',
icon: '',
params: ['path/to/project'],
},
{
name: 'create_merge_request',
aliases: [],
description: 'Create a merge request',
warning: '',
icon: '',
params: ['\u003cbranch name\u003e'],
},
{
name: 'zoom',
aliases: [],
description: 'Add Zoom meeting',
warning: '',
icon: '',
params: ['\u003cZoom URL\u003e'],
},
{
name: 'promote_to_incident',
aliases: [],
description: 'Promote issue to incident',
warning: '',
icon: '',
params: [],
},
{
name: 'close',
aliases: [],
description: 'Close this issue',
warning: '',
icon: '',
params: [],
},
{
name: 'title',
aliases: [],
description: 'Change title',
warning: '',
icon: '',
params: ['\u003cNew title\u003e'],
},
{
name: 'label',
aliases: ['labels'],
description: 'Add labels',
warning: '',
icon: '',
params: ['~label1 ~"label 2"'],
},
{
name: 'unlabel',
aliases: ['remove_label'],
description: 'Remove all or specific labels',
warning: '',
icon: '',
params: ['~label1 ~"label 2"'],
},
{
name: 'relabel',
aliases: [],
description: 'Replace all labels',
warning: '',
icon: '',
params: ['~label1 ~"label 2"'],
},
{
name: 'todo',
aliases: [],
description: 'Add a to do',
warning: '',
icon: '',
params: [],
},
{
name: 'unsubscribe',
aliases: [],
description: 'Unsubscribe',
warning: '',
icon: '',
params: [],
},
{
name: 'award',
aliases: [],
description: 'Toggle emoji award',
warning: '',
icon: '',
params: [':emoji:'],
},
{
name: 'shrug',
aliases: [],
description: 'Append the comment with ¯\\_(ツ)_/¯',
warning: '',
icon: '',
params: ['\u003cComment\u003e'],
},
{
name: 'tableflip',
aliases: [],
description: 'Append the comment with (╯°□°)╯︵ ┻━┻',
warning: '',
icon: '',
params: ['\u003cComment\u003e'],
},
{
name: 'confidential',
aliases: [],
description: 'Make issue confidential',
warning: '',
icon: '',
params: [],
},
{
name: 'assign',
aliases: [],
description: 'Assign',
warning: '',
icon: '',
params: ['@user1 @user2'],
},
{
name: 'unassign',
aliases: [],
description: 'Remove all or specific assignees',
warning: '',
icon: '',
params: ['@user1 @user2'],
},
{
name: 'milestone',
aliases: [],
description: 'Set milestone',
warning: '',
icon: '',
params: ['%"milestone"'],
},
{
name: 'remove_milestone',
aliases: [],
description: 'Remove milestone',
warning: '',
icon: '',
params: [],
},
{
name: 'copy_metadata',
aliases: [],
description: 'Copy labels and milestone from other issue or merge request in this project',
warning: '',
icon: '',
params: ['#issue | !merge_request'],
},
{
name: 'estimate',
aliases: ['estimate_time'],
description: 'Set time estimate',
warning: '',
icon: '',
params: ['\u003c1w 3d 2h 14m\u003e'],
},
{
name: 'spend',
aliases: ['spent', 'spend_time'],
description: 'Add or subtract spent time',
warning: '',
icon: '',
params: ['\u003ctime(1h30m | -1h30m)\u003e \u003cdate(YYYY-MM-DD)\u003e'],
},
{
name: 'remove_estimate',
aliases: ['remove_time_estimate'],
description: 'Remove time estimate',
warning: '',
icon: '',
params: [],
},
{
name: 'remove_time_spent',
aliases: [],
description: 'Remove spent time',
warning: '',
icon: '',
params: [],
},
{
name: 'lock',
aliases: [],
description: 'Lock the discussion',
warning: '',
icon: '',
params: [],
},
{
name: 'cc',
aliases: [],
description: 'CC',
warning: '',
icon: '',
params: ['@user'],
},
{
name: 'relate',
aliases: [],
description: 'Mark this issue as related to another issue',
warning: '',
icon: '',
params: ['\u003c#issue | group/project#issue | issue URL\u003e'],
},
{
name: 'unlink',
aliases: [],
description: 'Remove link with another issue',
warning: '',
icon: '',
params: ['\u003c#issue | group/project#issue | issue URL\u003e'],
},
{
name: 'epic',
aliases: [],
description: 'Add to epic',
warning: '',
icon: '',
params: ['\u003c\u0026epic | group\u0026epic | Epic URL\u003e'],
},
{
name: 'remove_epic',
aliases: [],
description: 'Remove from epic',
warning: '',
icon: '',
params: [],
},
{
name: 'promote',
aliases: [],
description: 'Promote issue to an epic',
warning: '',
icon: 'confidential',
params: [],
},
{
name: 'iteration',
aliases: [],
description: 'Set iteration',
warning: '',
icon: '',
params: ['*iteration:"iteration name" | *iteration:\u003cID\u003e'],
},
{
name: 'health_status',
aliases: [],
description: 'Set health status',
warning: '',
icon: '',
params: ['\u003con_track|needs_attention|at_risk\u003e'],
},
{
name: 'reassign',
aliases: [],
description: 'Change assignees',
warning: '',
icon: '',
params: ['@user1 @user2'],
},
{
name: 'weight',
aliases: [],
description: 'Set weight',
warning: '',
icon: '',
params: ['0, 1, 2, …'],
},
{
name: 'blocks',
aliases: [],
description: 'Specifies that this issue blocks other issues',
warning: '',
icon: '',
params: ['\u003c#issue | group/project#issue | issue URL\u003e'],
},
{
name: 'blocked_by',
aliases: [],
description: 'Mark this issue as blocked by other issues',
warning: '',
icon: '',
params: ['\u003c#issue | group/project#issue | issue URL\u003e'],
},
];
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import DataSourceFactory, {
defaultSorter,
customSorter,
createDataSource,
} from '~/content_editor/services/data_source_factory';
import {
MOCK_MEMBERS,
MOCK_COMMANDS,
MOCK_EPICS,
MOCK_ISSUES,
MOCK_LABELS,
MOCK_MILESTONES,
MOCK_SNIPPETS,
MOCK_VULNERABILITIES,
MOCK_MERGE_REQUESTS,
MOCK_ASSIGNEES,
MOCK_REVIEWERS,
} from './autocomplete_mock_data';
jest.mock('~/emoji');
describe('defaultSorter', () => {
it('returns items as is if query is empty', () => {
const items = [{ name: 'abc' }, { name: 'bcd' }, { name: 'cde' }];
const sorter = defaultSorter(['name']);
expect(sorter(items, '')).toEqual(items);
});
it('sorts items based on query match', () => {
const items = [{ name: 'abc' }, { name: 'bcd' }, { name: 'cde' }];
const sorter = defaultSorter(['name']);
expect(sorter(items, 'b')).toEqual([{ name: 'bcd' }, { name: 'abc' }, { name: 'cde' }]);
});
it('sorts items based on query match in multiple fields', () => {
const items = [
{ name: 'wabc', description: 'xyz' },
{ name: 'bcd', description: 'wxy' },
{ name: 'cde', description: 'vwx' },
];
const sorter = defaultSorter(['name', 'description']);
expect(sorter(items, 'w')).toEqual([
{ name: 'wabc', description: 'xyz' },
{ name: 'bcd', description: 'wxy' },
{ name: 'cde', description: 'vwx' },
]);
});
});
describe('customSorter', () => {
it('sorts items based on custom sorter function', () => {
const items = [3, 1, 2];
const sorter = customSorter((a, b) => a - b);
expect(sorter(items)).toEqual([1, 2, 3]);
});
});
describe('createDataSource', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
it('fetches data from source and filters based on query', async () => {
const data = [
{ name: 'abc', description: 'xyz' },
{ name: 'bcd', description: 'wxy' },
{ name: 'cde', description: 'vwx' },
];
mock.onGet('/source').reply(200, data);
const dataSource = createDataSource({
source: '/source',
searchFields: ['name', 'description'],
});
const results = await dataSource.search('b');
expect(results).toEqual([
{ name: 'bcd', description: 'wxy' },
{ name: 'abc', description: 'xyz' },
]);
});
it('handles source fetch errors', async () => {
mock.onGet('/source').reply(500);
const dataSource = createDataSource({
source: '/source',
searchFields: ['name', 'description'],
sorter: (items) => items,
});
const results = await dataSource.search('b');
expect(results).toEqual([]);
});
});
describe('DataSourceFactory', () => {
let mock;
let autocompleteHelper;
let dateNowOld;
beforeEach(() => {
mock = new MockAdapter(axios);
const dataSourceUrls = {
members: '/members',
issues: '/issues',
snippets: '/snippets',
labels: '/labels',
epics: '/epics',
milestones: '/milestones',
mergeRequests: '/mergeRequests',
vulnerabilities: '/vulnerabilities',
commands: '/commands',
};
mock.onGet('/members').reply(200, MOCK_MEMBERS);
mock.onGet('/issues').reply(200, MOCK_ISSUES);
mock.onGet('/snippets').reply(200, MOCK_SNIPPETS);
mock.onGet('/labels').reply(200, MOCK_LABELS);
mock.onGet('/epics').reply(200, MOCK_EPICS);
mock.onGet('/milestones').reply(200, MOCK_MILESTONES);
mock.onGet('/mergeRequests').reply(200, MOCK_MERGE_REQUESTS);
mock.onGet('/vulnerabilities').reply(200, MOCK_VULNERABILITIES);
mock.onGet('/commands').reply(200, MOCK_COMMANDS);
const sidebarMediator = {
store: {
assignees: MOCK_ASSIGNEES,
reviewers: MOCK_REVIEWERS,
},
};
autocompleteHelper = new DataSourceFactory({
dataSourceUrls,
sidebarMediator,
});
dateNowOld = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => new Date('2023-11-14').getTime());
});
afterEach(() => {
mock.restore();
jest.spyOn(Date, 'now').mockImplementation(() => dateNowOld);
});
it.each`
referenceType | query
${'user'} | ${'r'}
${'issue'} | ${'q'}
${'snippet'} | ${'s'}
${'label'} | ${'c'}
${'epic'} | ${'n'}
${'milestone'} | ${'16'}
${'merge_request'} | ${'n'}
${'vulnerability'} | ${'cross'}
${'command'} | ${'re'}
`(
'for reference type "$referenceType", searches for "$query" correctly',
async ({ referenceType, query }) => {
const dataSource = autocompleteHelper.getDataSource(referenceType);
const results = await dataSource.search(query);
expect(
results.map(({ title, name, username }) => username || name || title),
).toMatchSnapshot();
},
);
it.each`
referenceType | command
${'label'} | ${'/label'}
${'label'} | ${'/unlabel'}
${'label'} | ${'/relabel'}
${'user'} | ${'/assign'}
${'user'} | ${'/reassign'}
${'user'} | ${'/unassign'}
${'user'} | ${'/assign_reviewer'}
${'user'} | ${'/unassign_reviewer'}
${'user'} | ${'/reassign_reviewer'}
`(
'filters items based on command "$command" for reference type "$referenceType" and command',
async ({ referenceType, command }) => {
const dataSource = autocompleteHelper.getDataSource(referenceType, { command });
const results = await dataSource.search();
expect(
results.map(({ username, name, title }) => username || name || title),
).toMatchSnapshot();
},
);
});
Loading
Loading
@@ -547,7 +547,7 @@ describe('GfmAutoComplete', () => {
expect(membersBeforeSave([{ ...mockGroup, avatar_url: null }])).toEqual([
{
username: 'my-group',
avatarTag: '<div class="avatar rect-avatar center avatar-inline s26">M</div>',
avatarTag: '<div class="avatar rect-avatar avatar-inline s24 gl-mr-2">M</div>',
title: 'My Group (2)',
search: 'MyGroup my-group',
icon: '',
Loading
Loading
@@ -560,7 +560,7 @@ describe('GfmAutoComplete', () => {
{
username: 'my-group',
avatarTag:
'<img src="./group.jpg" alt="my-group" class="avatar rect-avatar avatar-inline center s26"/>',
'<img src="./group.jpg" alt="my-group" class="avatar rect-avatar avatar-inline s24 gl-mr-2"/>',
title: 'My Group (2)',
search: 'MyGroup my-group',
icon: '',
Loading
Loading
@@ -573,7 +573,7 @@ describe('GfmAutoComplete', () => {
{
username: 'my-group',
avatarTag:
'<img src="./group.jpg" alt="my-group" class="avatar rect-avatar avatar-inline center s26"/>',
'<img src="./group.jpg" alt="my-group" class="avatar rect-avatar avatar-inline s24 gl-mr-2"/>',
title: 'My Group',
search: 'MyGroup my-group',
icon:
Loading
Loading
@@ -591,7 +591,7 @@ describe('GfmAutoComplete', () => {
{
username: 'my-user',
avatarTag:
'<img src="./users.jpg" alt="my-user" class="avatar avatar-inline center s26"/>',
'<img src="./users.jpg" alt="my-user" class="avatar avatar-inline s24 gl-mr-2"/>',
title: 'My User',
search: 'MyUser my-user',
icon: '',
Loading
Loading
Loading
Loading
@@ -570,7 +570,7 @@ def assert_selected(text)
type_in_content_editor '/assign'
 
expect(find(suggestions_dropdown)).to have_text('/assign')
send_keys [:arrow_down, :enter]
send_keys :enter
 
expect(page).to have_text('/assign @')
end
Loading
Loading
@@ -579,7 +579,7 @@ def assert_selected(text)
type_in_content_editor '/label'
 
expect(find(suggestions_dropdown)).to have_text('/label')
send_keys [:arrow_down, :enter]
send_keys :enter
 
expect(page).to have_text('/label ~')
end
Loading
Loading
@@ -588,10 +588,23 @@ def assert_selected(text)
type_in_content_editor '/milestone'
 
expect(find(suggestions_dropdown)).to have_text('/milestone')
send_keys [:arrow_down, :enter]
send_keys :enter
 
expect(page).to have_text('/milestone %')
end
it 'scrolls selected item into view when navigating with keyboard' do
type_in_content_editor '/'
expect(find(suggestions_dropdown)).to have_text('label')
expect(dropdown_scroll_top).to be 0
send_keys :arrow_up
expect(dropdown_scroll_top).to be > 100
end
end
 
it 'shows suggestions for members with descriptions' do
Loading
Loading
@@ -603,7 +616,18 @@ def assert_selected(text)
 
type_in_content_editor 'bc'
 
send_keys [:arrow_down, :enter]
send_keys :enter
expect(page).not_to have_css(suggestions_dropdown)
expect(page).to have_text('@abc123')
end
it 'allows selecting element with tab key' do
type_in_content_editor '@abc'
expect(find(suggestions_dropdown)).to have_text('abc123')
send_keys :tab
 
expect(page).not_to have_css(suggestions_dropdown)
expect(page).to have_text('@abc123')
Loading
Loading
@@ -701,11 +725,11 @@ def assert_selected(text)
expect(find(suggestions_dropdown)).to have_text('😃 smiley')
expect(find(suggestions_dropdown)).to have_text('😸 smile_cat')
 
send_keys [:arrow_down, :enter]
send_keys :enter
 
expect(page).not_to have_css(suggestions_dropdown)
 
expect(page).to have_text('😃')
expect(page).to have_text('😄')
end
 
it 'doesn\'t show suggestions dropdown if there are no suggestions to show' do
Loading
Loading
@@ -718,18 +742,6 @@ def assert_selected(text)
expect(page).not_to have_css(suggestions_dropdown)
end
 
it 'scrolls selected item into view when navigating with keyboard' do
type_in_content_editor ':'
expect(find(suggestions_dropdown)).to have_text('grinning')
expect(dropdown_scroll_top).to be 0
send_keys :arrow_up
expect(dropdown_scroll_top).to be > 100
end
def dropdown_scroll_top
evaluate_script("document.querySelector('#{suggestions_dropdown}').scrollTop")
end
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment