diff options
Diffstat (limited to 'app/assets/javascripts/content_editor/services')
-rw-r--r-- | app/assets/javascripts/content_editor/services/create_content_editor.js | 9 | ||||
-rw-r--r-- | app/assets/javascripts/content_editor/services/data_source_factory.js | 213 |
2 files changed, 221 insertions, 1 deletions
diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js index 51e41ceefaf..5c48c0b1d43 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -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({ @@ -86,6 +87,7 @@ export const createContentEditor = ({ drawioEnabled = false, enableAutocomplete, autocompleteDataSources = {}, + sidebarMediator = {}, codeSuggestionsConfig = {}, } = {}) => { if (!isFunction(renderMarkdown)) { @@ -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({ @@ -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); diff --git a/app/assets/javascripts/content_editor/services/data_source_factory.js b/app/assets/javascripts/content_editor/services/data_source_factory.js new file mode 100644 index 00000000000..a0f0e106f1d --- /dev/null +++ b/app/assets/javascripts/content_editor/services/data_source_factory.js @@ -0,0 +1,213 @@ +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 }), + ); +} |