diff options
Diffstat (limited to 'app/assets/javascripts')
9 files changed, 363 insertions, 166 deletions
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 2e9388c1e20..a48245f732d 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -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'; @@ -157,6 +158,7 @@ export default { enableAutocomplete, autocompleteDataSources, codeSuggestionsConfig, + sidebarMediator: SidebarMediator.singleton, tiptapOptions: { autofocus, editable, diff --git a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue index b34ebe85eb4..dcca76b0786 100644 --- a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue +++ b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue @@ -1,12 +1,17 @@ <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, @@ -38,6 +43,12 @@ export default { required: false, default: false, }, + + query: { + type: String, + required: false, + default: '', + }, }, data() { @@ -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': @@ -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, }); } @@ -173,7 +194,7 @@ export default { return true; } - if (event.key === 'Enter') { + if (event.key === 'Enter' || event.key === 'Tab') { this.enterHandler(); return true; } @@ -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) { @@ -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> @@ -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 @@ -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> diff --git a/app/assets/javascripts/content_editor/extensions/emoji.js b/app/assets/javascripts/content_editor/extensions/emoji.js index be6ecb6cafd..96e03dfe598 100644 --- a/app/assets/javascripts/content_editor/extensions/emoji.js +++ b/app/assets/javascripts/content_editor/extensions/emoji.js @@ -46,7 +46,7 @@ export default Node.create({ title: node.attrs.title, 'data-unicode-version': node.attrs.unicodeVersion, }, - node.attrs.moji, + node.attrs.moji || '', ]; }, diff --git a/app/assets/javascripts/content_editor/extensions/suggestions.js b/app/assets/javascripts/content_editor/extensions/suggestions.js index f7ff2fd6647..d309210404a 100644 --- a/app/assets/javascripts/content_editor/extensions/suggestions.js +++ b/app/assets/javascripts/content_editor/extensions/suggestions.js @@ -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, @@ -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: () => { @@ -76,7 +68,7 @@ function createSuggestionPlugin({ ...props, char, nodeType, - nodeProps, + nodeProps: { referenceType }, loading: true, }, editor: props.editor, @@ -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': '~', @@ -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(); - }, }); 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 }), + ); +} diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index e116f64f927..be76ce2c28b 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -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') : ''; diff --git a/app/assets/javascripts/groups/init_overview_tabs.js b/app/assets/javascripts/groups/init_overview_tabs.js index b831ae7b9d6..80dd1d36734 100644 --- a/app/assets/javascripts/groups/init_overview_tabs.js +++ b/app/assets/javascripts/groups/init_overview_tabs.js @@ -21,7 +21,7 @@ export const createRouter = () => { const router = new VueRouter({ routes, mode: 'history', - base: '/', + base: gon.relative_url_root || '/', }); return router; diff --git a/app/assets/javascripts/groups/service/archived_projects_service.js b/app/assets/javascripts/groups/service/archived_projects_service.js index b9d48cc660e..7558b8d6713 100644 --- a/app/assets/javascripts/groups/service/archived_projects_service.js +++ b/app/assets/javascripts/groups/service/archived_projects_service.js @@ -32,7 +32,7 @@ export default class ArchivedProjectsService { markdown_description: project.description_html, visibility: project.visibility, avatar_url: project.avatar_url, - relative_path: `/${project.path_with_namespace}`, + relative_path: `${gon.relative_url_root}/${project.path_with_namespace}`, edit_path: null, leave_path: null, can_edit: false, |