Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-12-07 21:07:33 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-12-07 21:07:33 +0300
commit1bdc6c89c32a7380a81598629b9ad05ba9a2a94f (patch)
tree778f1dc16130b3138ab3b641e664038648046a40 /app
parent9a940dabf04df126e7978c0ab4b8770b86dcaaa8 (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/suggestions_dropdown.vue124
-rw-r--r--app/assets/javascripts/content_editor/extensions/emoji.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/suggestions.js171
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js9
-rw-r--r--app/assets/javascripts/content_editor/services/data_source_factory.js213
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js4
-rw-r--r--app/assets/javascripts/groups/init_overview_tabs.js2
-rw-r--r--app/assets/javascripts/groups/service/archived_projects_service.js2
-rw-r--r--app/assets/stylesheets/vendors/atwho.scss41
-rw-r--r--app/models/batched_git_ref_updates/deletion.rb2
-rw-r--r--app/models/ci/catalog/components_project.rb17
-rw-r--r--app/models/concerns/ignorable_columns.rb19
-rw-r--r--app/models/loose_foreign_keys/deleted_record.rb6
-rw-r--r--app/services/bulk_imports/batched_relation_export_service.rb14
-rw-r--r--app/services/ci/components/fetch_service.rb2
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,