diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-12-07 21:07:33 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-12-07 21:07:33 +0300 |
commit | 1bdc6c89c32a7380a81598629b9ad05ba9a2a94f (patch) | |
tree | 778f1dc16130b3138ab3b641e664038648046a40 /app | |
parent | 9a940dabf04df126e7978c0ab4b8770b86dcaaa8 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
16 files changed, 409 insertions, 221 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, diff --git a/app/assets/stylesheets/vendors/atwho.scss b/app/assets/stylesheets/vendors/atwho.scss index cf7dc79c5f5..e77e09bb85d 100644 --- a/app/assets/stylesheets/vendors/atwho.scss +++ b/app/assets/stylesheets/vendors/atwho.scss @@ -1,27 +1,24 @@ .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 { @@ -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; @@ -78,10 +73,6 @@ align-items: center; } - .center { - line-height: 14px; - } - strong { color: $gl-text-color; } diff --git a/app/models/batched_git_ref_updates/deletion.rb b/app/models/batched_git_ref_updates/deletion.rb index 61bba8aeba9..fdab19e6f78 100644 --- a/app/models/batched_git_ref_updates/deletion.rb +++ b/app/models/batched_git_ref_updates/deletion.rb @@ -15,7 +15,7 @@ module BatchedGitRefUpdates # This column must be ignored otherwise Rails will cache the default value and `bulk_insert!` will start saving # incorrect partition_id. - ignore_column :partition_id, remove_with: '3000.0', remove_after: '3000-01-01' + ignore_column :partition_id, remove_never: true belongs_to :project, inverse_of: :to_be_deleted_git_refs diff --git a/app/models/ci/catalog/components_project.rb b/app/models/ci/catalog/components_project.rb index 02593d41bc2..794cb70c126 100644 --- a/app/models/ci/catalog/components_project.rb +++ b/app/models/ci/catalog/components_project.rb @@ -45,6 +45,8 @@ module Ci end def fetch_component(component_name) + return ComponentData.new unless component_name.index('/').nil? + path = simple_template_path(component_name) content = fetch_content(path) @@ -53,11 +55,6 @@ module Ci content = fetch_content(path) end - if content.nil? - path = legacy_template_path(component_name) - content = fetch_content(path) - end - ComponentData.new(content: content, path: path) end @@ -71,9 +68,6 @@ module Ci # A simple template consists of a single file def simple_template_path(component_name) - # TODO: Extract this line and move to fetch_content once we remove legacy fetching - return unless component_name.index('/').nil? - File.join(TEMPLATES_DIR, "#{component_name}.yml") end @@ -81,15 +75,8 @@ module Ci # Given a path like "my-org/sub-group/the-project/templates/component" # returns the entry point path: "templates/component/template.yml". def complex_template_path(component_name) - # TODO: Extract this line and move to fetch_content once we remove legacy fetching - return unless component_name.index('/').nil? - File.join(TEMPLATES_DIR, component_name, TEMPLATE_FILE) end - - def legacy_template_path(component_name) - File.join(component_name, TEMPLATE_FILE).delete_prefix('/') - end end end end diff --git a/app/models/concerns/ignorable_columns.rb b/app/models/concerns/ignorable_columns.rb index 249d0b99494..fb114eed400 100644 --- a/app/models/concerns/ignorable_columns.rb +++ b/app/models/concerns/ignorable_columns.rb @@ -3,13 +3,11 @@ module IgnorableColumns extend ActiveSupport::Concern - ColumnIgnore = Struct.new(:remove_after, :remove_with) do + ColumnIgnore = Struct.new(:remove_after, :remove_with, :remove_never) do def safe_to_remove? - Date.today > remove_after - end + return false if remove_never - def to_s - "(#{remove_after}, #{remove_with})" + Date.today > remove_after end end @@ -17,14 +15,17 @@ module IgnorableColumns # Ignore database columns in a model # # Indicate the earliest date and release we can stop ignoring the column with +remove_after+ (a date string) and +remove_with+ (a release) - def ignore_columns(*columns, remove_after:, remove_with:) - raise ArgumentError, 'Please indicate when we can stop ignoring columns with remove_after (date string YYYY-MM-DD), example: ignore_columns(:name, remove_after: \'2019-12-01\', remove_with: \'12.6\')' unless Gitlab::Regex.utc_date_regex.match?(remove_after) - raise ArgumentError, 'Please indicate in which release we can stop ignoring columns with remove_with, example: ignore_columns(:name, remove_after: \'2019-12-01\', remove_with: \'12.6\')' unless remove_with + def ignore_columns(*columns, remove_after: nil, remove_with: nil, remove_never: false) + unless remove_never + raise ArgumentError, 'Please indicate when we can stop ignoring columns with remove_after (date string YYYY-MM-DD), example: ignore_columns(:name, remove_after: \'2019-12-01\', remove_with: \'12.6\')' unless remove_after && Gitlab::Regex.utc_date_regex.match?(remove_after) + raise ArgumentError, 'Please indicate in which release we can stop ignoring columns with remove_with, example: ignore_columns(:name, remove_after: \'2019-12-01\', remove_with: \'12.6\')' unless remove_with + end self.ignored_columns += columns.flatten # rubocop:disable Cop/IgnoredColumns columns.flatten.each do |column| - self.ignored_columns_details[column.to_sym] = ColumnIgnore.new(Date.parse(remove_after), remove_with) + remove_after_date = remove_after ? Date.parse(remove_after) : nil + self.ignored_columns_details[column.to_sym] = ColumnIgnore.new(remove_after_date, remove_with, remove_never) end end diff --git a/app/models/loose_foreign_keys/deleted_record.rb b/app/models/loose_foreign_keys/deleted_record.rb index 6af80686ec2..beafd9b7d4b 100644 --- a/app/models/loose_foreign_keys/deleted_record.rb +++ b/app/models/loose_foreign_keys/deleted_record.rb @@ -6,9 +6,13 @@ class LooseForeignKeys::DeletedRecord < Gitlab::Database::SharedModel PARTITION_DURATION = 1.day include PartitionedTable + include IgnorableColumns self.primary_key = :id - self.ignored_columns = %i[partition] + + # This column must be ignored otherwise Rails will cache the default value and `bulk_insert!` will start saving + # incorrect partition. + ignore_column :partition, remove_never: true partitioned_by :partition, strategy: :sliding_list, next_partition_if: -> (active_partition) do diff --git a/app/services/bulk_imports/batched_relation_export_service.rb b/app/services/bulk_imports/batched_relation_export_service.rb index 9c3b237fad7..e239a6daa4c 100644 --- a/app/services/bulk_imports/batched_relation_export_service.rb +++ b/app/services/bulk_imports/batched_relation_export_service.rb @@ -65,19 +65,27 @@ module BulkImports ) end + # rubocop:disable Cop/InBatches + # rubocop:disable CodeReuse/ActiveRecord def enqueue_batch_exports - resolved_relation.each_batch(of: BATCH_SIZE) do |batch, batch_number| + batch_number = 0 + + resolved_relation.in_batches(of: BATCH_SIZE) do |batch| + batch_number += 1 + batch_id = find_or_create_batch(batch_number).id - ids = batch.pluck(batch.model.primary_key) # rubocop:disable CodeReuse/ActiveRecord + ids = batch.pluck(batch.model.primary_key) Gitlab::Cache::Import::Caching.set_add(self.class.cache_key(export.id, batch_id), ids, timeout: CACHE_DURATION) RelationBatchExportWorker.perform_async(user.id, batch_id) end end + # rubocop:enable Cop/InBatches def find_or_create_batch(batch_number) - export.batches.find_or_create_by!(batch_number: batch_number) # rubocop:disable CodeReuse/ActiveRecord + export.batches.find_or_create_by!(batch_number: batch_number) end + # rubocop:enable CodeReuse/ActiveRecord end end diff --git a/app/services/ci/components/fetch_service.rb b/app/services/ci/components/fetch_service.rb index 4f09d47b530..f83c6e30cbb 100644 --- a/app/services/ci/components/fetch_service.rb +++ b/app/services/ci/components/fetch_service.rb @@ -24,7 +24,7 @@ module Ci component_path = component_path_class.new(address: address) result = component_path.fetch_content!(current_user: current_user) - if result + if result&.content ServiceResponse.success(payload: { content: result.content, path: result.path, |