diff options
49 files changed, 2139 insertions, 543 deletions
diff --git a/.rubocop_todo/cop/ignored_columns.yml b/.rubocop_todo/cop/ignored_columns.yml index a791fcd0d6d..d15bcb497d9 100644 --- a/.rubocop_todo/cop/ignored_columns.yml +++ b/.rubocop_todo/cop/ignored_columns.yml @@ -1,7 +1,6 @@ --- Cop/IgnoredColumns: Exclude: - - 'app/models/loose_foreign_keys/deleted_record.rb' - 'ee/lib/ee/gitlab/background_migration/create_vulnerability_links.rb' - 'ee/lib/ee/gitlab/background_migration/migrate_vulnerabilities_feedback_to_vulnerabilities_state_transition.rb' - 'spec/lib/gitlab/database/partitioning/sliding_list_strategy_spec.rb' diff --git a/.rubocop_todo/gitlab/namespaced_class.yml b/.rubocop_todo/gitlab/namespaced_class.yml index 19f31b94a6a..f1f86122067 100644 --- a/.rubocop_todo/gitlab/namespaced_class.yml +++ b/.rubocop_todo/gitlab/namespaced_class.yml @@ -1028,7 +1028,6 @@ Gitlab/NamespacedClass: - 'ee/app/workers/elastic_namespace_rollout_worker.rb' - 'ee/app/workers/elastic_remove_expired_namespace_subscriptions_from_index_cron_worker.rb' - 'ee/app/workers/elastic_wiki_indexer_worker.rb' - - 'ee/app/workers/geo_repository_destroy_worker.rb' - 'ee/app/workers/group_saml_group_sync_worker.rb' - 'ee/app/workers/historical_data_worker.rb' - 'ee/app/workers/import_software_licenses_worker.rb' diff --git a/.rubocop_todo/gitlab/strong_memoize_attr.yml b/.rubocop_todo/gitlab/strong_memoize_attr.yml index c40f51cf04e..a1aacb908e6 100644 --- a/.rubocop_todo/gitlab/strong_memoize_attr.yml +++ b/.rubocop_todo/gitlab/strong_memoize_attr.yml @@ -375,7 +375,6 @@ Gitlab/StrongMemoizeAttr: - 'ee/app/services/vulnerability_feedback/create_service.rb' - 'ee/app/services/vulnerability_feedback/destroy_service.rb' - 'ee/app/workers/auth/saml_group_sync_worker.rb' - - 'ee/app/workers/geo/repository_cleanup_worker.rb' - 'ee/app/workers/geo/scheduler/scheduler_worker.rb' - 'ee/app/workers/status_page/publish_worker.rb' - 'ee/lib/api/analytics/project_deployment_frequency.rb' diff --git a/.rubocop_todo/layout/line_length.yml b/.rubocop_todo/layout/line_length.yml index a68124c649b..471aed8912f 100644 --- a/.rubocop_todo/layout/line_length.yml +++ b/.rubocop_todo/layout/line_length.yml @@ -1083,16 +1083,10 @@ Layout/LineLength: - 'ee/app/workers/elastic_commit_indexer_worker.rb' - 'ee/app/workers/elastic_delete_project_worker.rb' - 'ee/app/workers/elastic_namespace_rollout_worker.rb' - - 'ee/app/workers/geo/batch/project_registry_scheduler_worker.rb' - - 'ee/app/workers/geo/batch/project_registry_worker.rb' - 'ee/app/workers/geo/destroy_worker.rb' - - 'ee/app/workers/geo/repositories_clean_up_worker.rb' - - 'ee/app/workers/geo/repository_shard_sync_worker.rb' - - 'ee/app/workers/geo/repository_verification/secondary/shard_worker.rb' - 'ee/app/workers/geo/scheduler/scheduler_worker.rb' - 'ee/app/workers/geo/secondary/registry_consistency_worker.rb' - 'ee/app/workers/geo/verification_worker.rb' - - 'ee/app/workers/geo_repository_destroy_worker.rb' - 'ee/app/workers/repository_update_mirror_worker.rb' - 'ee/app/workers/security/orchestration_policy_rule_schedule_namespace_worker.rb' - 'ee/app/workers/security/orchestration_policy_rule_schedule_worker.rb' diff --git a/.rubocop_todo/lint/redundant_cop_disable_directive.yml b/.rubocop_todo/lint/redundant_cop_disable_directive.yml index 453954e9bd1..1f452c24c15 100644 --- a/.rubocop_todo/lint/redundant_cop_disable_directive.yml +++ b/.rubocop_todo/lint/redundant_cop_disable_directive.yml @@ -114,8 +114,6 @@ Lint/RedundantCopDisableDirective: - 'ee/app/services/ee/search_service.rb' - 'ee/app/services/security/token_revocation_service.rb' - 'ee/app/workers/ee/issuable_export_csv_worker.rb' - - 'ee/app/workers/geo/repository_shard_sync_worker.rb' - - 'ee/app/workers/geo/repository_verification/secondary/shard_worker.rb' - 'ee/app/workers/scan_security_report_secrets_worker.rb' - 'ee/app/workers/security/orchestration_policy_rule_schedule_worker.rb' - 'ee/db/geo/migrate/20210504143244_add_verification_to_merge_request_diff_registry.rb' diff --git a/.rubocop_todo/rails/pluck.yml b/.rubocop_todo/rails/pluck.yml index 6ed8c935f82..7a25f45bfc4 100644 --- a/.rubocop_todo/rails/pluck.yml +++ b/.rubocop_todo/rails/pluck.yml @@ -21,8 +21,6 @@ Rails/Pluck: - 'ee/app/models/boards/epic_list.rb' - 'ee/app/models/concerns/geo/verification_state.rb' - 'ee/app/services/concerns/incident_management/oncall_rotations/shared_rotation_logic.rb' - - 'ee/app/workers/geo/repository_shard_sync_worker.rb' - - 'ee/app/workers/geo/repository_verification/secondary/shard_worker.rb' - 'ee/app/workers/geo/scheduler/scheduler_worker.rb' - 'ee/lib/banzai/filter/references/iteration_reference_filter.rb' - 'ee/lib/ee/gitlab/auth/ldap/person.rb' diff --git a/.rubocop_todo/rspec/feature_category.yml b/.rubocop_todo/rspec/feature_category.yml index 20f9cd77113..c956d05f6f0 100644 --- a/.rubocop_todo/rspec/feature_category.yml +++ b/.rubocop_todo/rspec/feature_category.yml @@ -1173,7 +1173,6 @@ RSpec/FeatureCategory: - 'ee/spec/policies/note_policy_spec.rb' - 'ee/spec/policies/path_lock_policy_spec.rb' - 'ee/spec/policies/project_snippet_policy_spec.rb' - - 'ee/spec/policies/protected_branch_policy_spec.rb' - 'ee/spec/policies/requirements_management/requirement_policy_spec.rb' - 'ee/spec/policies/saml_provider_policy_spec.rb' - 'ee/spec/policies/security/finding_policy_spec.rb' @@ -4827,8 +4826,6 @@ RSpec/FeatureCategory: - 'spec/policies/project_member_policy_spec.rb' - 'spec/policies/project_snippet_policy_spec.rb' - 'spec/policies/project_statistics_policy_spec.rb' - - 'spec/policies/protected_branch_access_policy_spec.rb' - - 'spec/policies/protected_branch_policy_spec.rb' - 'spec/policies/release_policy_spec.rb' - 'spec/policies/system_hook_policy_spec.rb' - 'spec/policies/terraform/state_policy_spec.rb' diff --git a/.rubocop_todo/sidekiq_load_balancing/worker_data_consistency.yml b/.rubocop_todo/sidekiq_load_balancing/worker_data_consistency.yml index 0c8affcd964..45c060ebe03 100644 --- a/.rubocop_todo/sidekiq_load_balancing/worker_data_consistency.yml +++ b/.rubocop_todo/sidekiq_load_balancing/worker_data_consistency.yml @@ -344,8 +344,6 @@ SidekiqLoadBalancing/WorkerDataConsistency: - 'ee/app/workers/epics/new_epic_issue_worker.rb' - 'ee/app/workers/epics/update_cached_metadata_worker.rb' - 'ee/app/workers/epics/update_epics_dates_worker.rb' - - 'ee/app/workers/geo/batch/project_registry_scheduler_worker.rb' - - 'ee/app/workers/geo/batch/project_registry_worker.rb' - 'ee/app/workers/geo/batch_event_create_worker.rb' - 'ee/app/workers/geo/container_repository_sync_worker.rb' - 'ee/app/workers/geo/create_repository_updated_event_worker.rb' @@ -353,17 +351,9 @@ SidekiqLoadBalancing/WorkerDataConsistency: - 'ee/app/workers/geo/event_worker.rb' - 'ee/app/workers/geo/file_removal_worker.rb' - 'ee/app/workers/geo/hashed_storage_attachments_migration_worker.rb' - - 'ee/app/workers/geo/hashed_storage_migration_worker.rb' - 'ee/app/workers/geo/metrics_update_worker.rb' - - 'ee/app/workers/geo/project_sync_worker.rb' - 'ee/app/workers/geo/prune_event_log_worker.rb' - - 'ee/app/workers/geo/rename_repository_worker.rb' - - 'ee/app/workers/geo/repositories_clean_up_worker.rb' - - 'ee/app/workers/geo/repository_cleanup_worker.rb' - - 'ee/app/workers/geo/repository_verification/primary/single_worker.rb' - - 'ee/app/workers/geo/repository_verification/secondary/single_worker.rb' - 'ee/app/workers/geo/reverification_batch_worker.rb' - - 'ee/app/workers/geo/scheduler/per_shard_scheduler_worker.rb' - 'ee/app/workers/geo/scheduler/scheduler_worker.rb' - 'ee/app/workers/geo/secondary/registry_consistency_worker.rb' - 'ee/app/workers/geo/secondary_usage_data_cron_worker.rb' @@ -374,7 +364,6 @@ SidekiqLoadBalancing/WorkerDataConsistency: - 'ee/app/workers/geo/verification_state_backfill_worker.rb' - 'ee/app/workers/geo/verification_timeout_worker.rb' - 'ee/app/workers/geo/verification_worker.rb' - - 'ee/app/workers/geo_repository_destroy_worker.rb' - 'ee/app/workers/gitlab_subscriptions/schedule_refresh_seats_worker.rb' - 'ee/app/workers/gitlab_subscriptions/trials/apply_trial_worker.rb' - 'ee/app/workers/group_saml_group_sync_worker.rb' diff --git a/.rubocop_todo/style/inline_disable_annotation.yml b/.rubocop_todo/style/inline_disable_annotation.yml index 55a3320cedd..f3d93810758 100644 --- a/.rubocop_todo/style/inline_disable_annotation.yml +++ b/.rubocop_todo/style/inline_disable_annotation.yml @@ -1696,33 +1696,15 @@ Style/InlineDisableAnnotation: - 'ee/app/workers/emails/abandoned_trial_emails_cron_worker.rb' - 'ee/app/workers/epics/new_epic_issue_worker.rb' - 'ee/app/workers/epics/update_epics_dates_worker.rb' - - 'ee/app/workers/geo/batch/project_registry_scheduler_worker.rb' - - 'ee/app/workers/geo/batch/project_registry_worker.rb' - 'ee/app/workers/geo/bulk_mark_pending_batch_worker.rb' - 'ee/app/workers/geo/bulk_mark_verification_pending_batch_worker.rb' - 'ee/app/workers/geo/container_repository_sync_worker.rb' - 'ee/app/workers/geo/event_worker.rb' - 'ee/app/workers/geo/file_removal_worker.rb' - 'ee/app/workers/geo/hashed_storage_attachments_migration_worker.rb' - - 'ee/app/workers/geo/hashed_storage_migration_worker.rb' - 'ee/app/workers/geo/metrics_update_worker.rb' - 'ee/app/workers/geo/prune_event_log_worker.rb' - - 'ee/app/workers/geo/rename_repository_worker.rb' - - 'ee/app/workers/geo/repositories_clean_up_worker.rb' - - 'ee/app/workers/geo/repository_cleanup_worker.rb' - - 'ee/app/workers/geo/repository_shard_sync_worker.rb' - - 'ee/app/workers/geo/repository_sync_worker.rb' - - 'ee/app/workers/geo/repository_verification/primary/batch_worker.rb' - - 'ee/app/workers/geo/repository_verification/primary/shard_worker.rb' - - 'ee/app/workers/geo/repository_verification/primary/single_worker.rb' - - 'ee/app/workers/geo/repository_verification/secondary/scheduler_worker.rb' - - 'ee/app/workers/geo/repository_verification/secondary/shard_worker.rb' - - 'ee/app/workers/geo/repository_verification/secondary/single_worker.rb' - - 'ee/app/workers/geo/scheduler/per_shard_scheduler_worker.rb' - - 'ee/app/workers/geo/scheduler/primary/per_shard_scheduler_worker.rb' - - 'ee/app/workers/geo/scheduler/primary/scheduler_worker.rb' - 'ee/app/workers/geo/scheduler/scheduler_worker.rb' - - 'ee/app/workers/geo/scheduler/secondary/per_shard_scheduler_worker.rb' - 'ee/app/workers/geo/scheduler/secondary/scheduler_worker.rb' - 'ee/app/workers/geo/secondary/registry_consistency_worker.rb' - 'ee/app/workers/geo/secondary_usage_data_cron_worker.rb' @@ -1730,7 +1712,6 @@ Style/InlineDisableAnnotation: - 'ee/app/workers/geo/sync_timeout_cron_worker.rb' - 'ee/app/workers/geo/verification_cron_worker.rb' - 'ee/app/workers/geo/verification_state_backfill_worker.rb' - - 'ee/app/workers/geo_repository_destroy_worker.rb' - 'ee/app/workers/gitlab_subscriptions/add_on_purchases/schedule_bulk_refresh_user_assignments_worker.rb' - 'ee/app/workers/gitlab_subscriptions/notify_seats_exceeded_batch_worker.rb' - 'ee/app/workers/gitlab_subscriptions/refresh_seats_worker.rb' diff --git a/.rubocop_todo/style/symbol_proc.yml b/.rubocop_todo/style/symbol_proc.yml index be1319c10bb..1808bb7fbb6 100644 --- a/.rubocop_todo/style/symbol_proc.yml +++ b/.rubocop_todo/style/symbol_proc.yml @@ -80,7 +80,6 @@ Style/SymbolProc: - 'ee/app/services/security/scanned_resources_counting_service.rb' - 'ee/app/services/timebox_report_service.rb' - 'ee/app/services/vulnerabilities/historical_statistics/deletion_service.rb' - - 'ee/app/workers/geo/batch/project_registry_worker.rb' - 'ee/app/workers/geo/sync_timeout_cron_worker.rb' - 'ee/app/workers/geo/verification_cron_worker.rb' - 'ee/lib/api/entities/pending_member.rb' 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, diff --git a/db/migrate/20231205201701_remove_geo_primary_deprecated_workers_job_instances.rb b/db/migrate/20231205201701_remove_geo_primary_deprecated_workers_job_instances.rb new file mode 100644 index 00000000000..a0eab0f9fcb --- /dev/null +++ b/db/migrate/20231205201701_remove_geo_primary_deprecated_workers_job_instances.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class RemoveGeoPrimaryDeprecatedWorkersJobInstances < Gitlab::Database::Migration[2.2] + DEPRECATED_JOB_CLASSES = %w[ + Geo::RepositoryVerification::Primary::BatchWorker + Geo::RepositoryVerification::Primary::ShardWorker + Geo::RepositoryVerification::Primary::SingleWorker + Geo::RepositoryVerification::Secondary::SingleWorker + Geo::Scheduler::Primary::PerShardSchedulerWorker + Geo::Scheduler::Primary::SchedulerWorker + ] + + disable_ddl_transaction! + + milestone '16.7' + + def up + sidekiq_remove_jobs(job_klasses: DEPRECATED_JOB_CLASSES) + end + + def down + # This migration removes any instances of deprecated workers and cannot be undone. + end +end diff --git a/db/schema_migrations/20231205201701 b/db/schema_migrations/20231205201701 new file mode 100644 index 00000000000..5288dfd03f3 --- /dev/null +++ b/db/schema_migrations/20231205201701 @@ -0,0 +1 @@ +f0f89526db19621991482cc7fce3d95d8762d020d06ecd3bf96c587cca71d015
\ No newline at end of file diff --git a/doc/ci/components/index.md b/doc/ci/components/index.md index 6c63cf2109a..6f1365561dc 100644 --- a/doc/ci/components/index.md +++ b/doc/ci/components/index.md @@ -92,65 +92,6 @@ In this example: - The `all-scans` component configuration is defined in a single file. - The `secret-detection` component configuration contains multiple files in a directory. -#### Component configurations saved in any directory (deprecated) - -WARNING: -Saving components through the following directory structure is [deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/415855) and should be avoided. - -Components configurations can be saved through the following directory structure, containing: - -- `template.yml`: The component configuration, one file per component. If there is - only one component, this file can be in the root of the project. If there are multiple - components, each file must be in a separate subdirectory. -- `README.md`: A documentation file explaining the details of all the components in the repository. - -For example, if the project is on GitLab.com, named `my-project`, and in a personal -namespace named `my-namespace`: - -- Containing a single component and a simple pipeline to test the component, then - the file structure might be: - - ```plaintext - ├── template.yml - ├── README.md - └── .gitlab-ci.yml - ``` - - This component is referenced with the path `gitlab.com/my-namespace/my-project@<version>`. - -- Containing one default component and multiple sub-components, then the file structure - might be: - - ```plaintext - ├── template.yml - ├── README.md - ├── .gitlab-ci.yml - ├── unit/ - │ └── template.yml - └── integration/ - └── template.yml - ``` - - These components are identified by these paths: - - - `gitlab.com/my-namespace/my-project` - - `gitlab.com/my-namespace/my-project/unit` - - `gitlab.com/my-namespace/my-project/integration` - -It is possible to have a components repository with no default component, by having -no `template.yml` in the root directory. - -**Additional notes:** - -Nesting of components is not possible. For example: - -```plaintext -├── unit/ -│ └── template.yml -│ └── another_folder/ -│ └── nested_template.yml -``` - ## Use a component You can use a component in a CI/CD configuration with the `include: component` keyword. diff --git a/doc/development/runner_fleet_dashboard.md b/doc/development/runner_fleet_dashboard.md index 474952b9de6..70499e5a087 100644 --- a/doc/development/runner_fleet_dashboard.md +++ b/doc/development/runner_fleet_dashboard.md @@ -6,11 +6,11 @@ info: >- this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments --- -# Runner Fleet Dashboard **(ULTIMATE BETA)** +# Runner Fleet Dashboard **(ULTIMATE EXPERIMENT)** > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/424495) in GitLab 16.6 behind several [feature flags](#enable-feature-flags). -This feature is in [BETA](../policy/experiment-beta-support.md). +This feature is an [Experiment](../policy/experiment-beta-support.md). To join the list of users testing this feature, contact us in [epic 11180](https://gitlab.com/groups/gitlab-org/-/epics/11180). @@ -48,7 +48,7 @@ for some customers to try this feature. To test the Runner Fleet Dashboard as part of the early adopters program, you must: -- Run GitLab 16.6 or above. +- Run GitLab 16.7 or above. - Have an [Ultimate license](https://about.gitlab.com/pricing/). - Be able to run ClickHouse database. We recommend using [ClickHouse Cloud](https://clickhouse.cloud/). diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index 816c9458201..bbc0203344c 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -1,6 +1,8 @@ import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; import { EditorContent, Editor } from '@tiptap/vue-2'; import { nextTick } from 'vue'; +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ContentEditor from '~/content_editor/components/content_editor.vue'; import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue'; @@ -16,11 +18,10 @@ import waitForPromises from 'helpers/wait_for_promises'; import { KEYDOWN_EVENT } from '~/content_editor/constants'; import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue'; -jest.mock('~/emoji'); - describe('ContentEditor', () => { let wrapper; let renderMarkdown; + let mock; const uploadsPath = '/uploads'; const findEditorElement = () => wrapper.findByTestId('content-editor'); @@ -32,6 +33,7 @@ describe('ContentEditor', () => { wrapper = shallowMountExtended(ContentEditor, { propsData: { renderMarkdown, + markdownDocsPath: '/docs/markdown', uploadsPath, markdown, autofocus, @@ -49,9 +51,17 @@ describe('ContentEditor', () => { }; beforeEach(() => { + mock = new MockAdapter(axios); + // ignore /-/emojis requests + mock.onGet().reply(200, []); + renderMarkdown = jest.fn(); }); + afterEach(() => { + mock.restore(); + }); + it('triggers initialized event', () => { createWrapper(); diff --git a/spec/frontend/content_editor/components/suggestions_dropdown_spec.js b/spec/frontend/content_editor/components/suggestions_dropdown_spec.js index ee3ad59bf9a..b17a1b5fc11 100644 --- a/spec/frontend/content_editor/components/suggestions_dropdown_spec.js +++ b/spec/frontend/content_editor/components/suggestions_dropdown_spec.js @@ -1,5 +1,6 @@ -import { GlAvatarLabeled, GlLoadingIcon } from '@gitlab/ui'; +import { GlAvatar, GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import SuggestionsDropdown from '~/content_editor/components/suggestions_dropdown.vue'; @@ -14,11 +15,17 @@ describe('~/content_editor/components/suggestions_dropdown', () => { command: jest.fn(), ...propsData, }, + stubs: ['gl-emoji'], }), ); }; - const exampleUser = { username: 'root', avatar_url: 'root_avatar.png', type: 'User' }; + const exampleUser = { + username: 'root', + avatar_url: 'root_avatar.png', + type: 'User', + name: 'Administrator', + }; const exampleIssue = { iid: 123, title: 'Test Issue' }; const exampleMergeRequest = { iid: 224, title: 'Test MR' }; const exampleMilestone1 = { iid: 21, title: '13' }; @@ -61,11 +68,14 @@ describe('~/content_editor/components/suggestions_dropdown', () => { title: 'Project creation QueryRecorder logs', }; const exampleEmoji = { - c: 'people', - e: '😃', - d: 'smiling face with open mouth', - u: '6.0', - name: 'smiley', + emoji: { + c: 'people', + e: '😃', + d: 'smiling face with open mouth', + u: '6.0', + name: 'smiley', + }, + fieldValue: 'smiley', }; const insertedEmojiProps = { @@ -95,6 +105,68 @@ describe('~/content_editor/components/suggestions_dropdown', () => { expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(loading); }); + it('selects first item if query is not empty and items are available', async () => { + buildWrapper({ + propsData: { + char: '@', + nodeType: 'reference', + nodeProps: { + referenceType: 'member', + }, + items: [exampleUser], + query: 'ro', + }, + }); + + await nextTick(); + + expect( + wrapper.findByTestId('content-editor-suggestions-dropdown').find('li').classes(), + ).toContain('focused'); + }); + + describe('when query is defined', () => { + it.each` + nodeType | referenceType | reference | query | expectedHTML + ${'reference'} | ${'user'} | ${exampleUser} | ${'r'} | ${'<strong class="gl-text-body!">r</strong>oot'} + ${'reference'} | ${'user'} | ${exampleUser} | ${'r'} | ${'Administ<strong class="gl-text-body!">r</strong>ator'} + ${'reference'} | ${'issue'} | ${exampleIssue} | ${'test'} | ${'<strong class="gl-text-body!">Test</strong> Issue'} + ${'reference'} | ${'issue'} | ${exampleIssue} | ${'12'} | ${'<strong class="gl-text-body!">12</strong>3'} + ${'reference'} | ${'merge_request'} | ${exampleMergeRequest} | ${'test'} | ${'<strong class="gl-text-body!">Test</strong> MR'} + ${'reference'} | ${'merge_request'} | ${exampleMergeRequest} | ${'22'} | ${'<strong class="gl-text-body!">22</strong>4'} + ${'reference'} | ${'epic'} | ${exampleEpic} | ${'rem'} | ${'❓ <strong class="gl-text-body!">Rem</strong>ote Development | Solution validation'} + ${'reference'} | ${'epic'} | ${exampleEpic} | ${'88'} | ${'gitlab-org&<strong class="gl-text-body!">88</strong>84'} + ${'reference'} | ${'milestone'} | ${exampleMilestone1} | ${'1'} | ${'<strong class="gl-text-body!">1</strong>3'} + ${'reference'} | ${'command'} | ${exampleCommand} | ${'due'} | ${'<strong class="gl-text-body!">due</strong>'} + ${'reference'} | ${'command'} | ${exampleCommand} | ${'due'} | ${'Set <strong class="gl-text-body!">due</strong> date'} + ${'reference'} | ${'label'} | ${exampleLabel1} | ${'c'} | ${'<strong class="gl-text-body!">C</strong>reate'} + ${'reference'} | ${'vulnerability'} | ${exampleVulnerability} | ${'network'} | ${'System procs <strong class="gl-text-body!">network</strong> activity'} + ${'reference'} | ${'vulnerability'} | ${exampleVulnerability} | ${'85'} | ${'60<strong class="gl-text-body!">85</strong>0147'} + ${'reference'} | ${'snippet'} | ${exampleSnippet} | ${'project'} | ${'<strong class="gl-text-body!">Project</strong> creation QueryRecorder logs'} + ${'reference'} | ${'snippet'} | ${exampleSnippet} | ${'242'} | ${'<strong class="gl-text-body!">242</strong>0859'} + ${'emoji'} | ${'emoji'} | ${exampleEmoji} | ${'sm'} | ${'<strong class="gl-text-body!">sm</strong>iley'} + `( + 'highlights query as bolded in $referenceType text', + ({ nodeType, referenceType, reference, query, expectedHTML }) => { + buildWrapper({ + propsData: { + char: '@', + nodeType, + nodeProps: { + referenceType, + }, + items: [reference], + query, + }, + }); + + expect(wrapper.findByTestId('content-editor-suggestions-dropdown').html()).toContain( + expectedHTML, + ); + }, + ); + }); + describe('on item select', () => { it.each` nodeType | referenceType | char | reference | insertedText | insertedProps @@ -146,7 +218,7 @@ describe('~/content_editor/components/suggestions_dropdown', () => { }); describe('rendering user references', () => { - it('displays avatar labeled component', () => { + it('displays avatar component', () => { buildWrapper({ propsData: { char: '@', @@ -157,13 +229,11 @@ describe('~/content_editor/components/suggestions_dropdown', () => { }, }); - expect(wrapper.findComponent(GlAvatarLabeled).attributes()).toEqual( - expect.objectContaining({ - label: exampleUser.username, - shape: 'circle', - src: exampleUser.avatar_url, - }), - ); + expect(wrapper.findComponent(GlAvatar).attributes()).toMatchObject({ + entityname: exampleUser.username, + shape: 'circle', + src: exampleUser.avatar_url, + }); }); describe.each` @@ -273,20 +343,46 @@ describe('~/content_editor/components/suggestions_dropdown', () => { it('displays emoji', () => { const testEmojis = [ { - c: 'people', - e: '😄', - d: 'smiling face with open mouth and smiling eyes', - u: '6.0', - name: 'smile', + emoji: { + c: 'people', + e: '😄', + d: 'smiling face with open mouth and smiling eyes', + u: '6.0', + name: 'smile', + }, + fieldValue: 'smile', + }, + { + emoji: { + c: 'people', + e: '😸', + d: 'grinning cat face with smiling eyes', + u: '6.0', + name: 'smile_cat', + }, + fieldValue: 'smile_cat', + }, + { + emoji: { + c: 'people', + e: '😃', + d: 'smiling face with open mouth', + u: '6.0', + name: 'smiley', + }, + fieldValue: 'smiley', }, { - c: 'people', - e: '😸', - d: 'grinning cat face with smiling eyes', - u: '6.0', - name: 'smile_cat', + emoji: { + c: 'custom', + e: null, + d: 'party-parrot', + u: 'custom', + name: 'party-parrot', + src: 'https://cultofthepartyparrot.com/parrots/hd/parrot.gif', + }, + fieldValue: 'party-parrot', }, - { c: 'people', e: '😃', d: 'smiling face with open mouth', u: '6.0', name: 'smiley' }, ]; buildWrapper({ @@ -298,11 +394,41 @@ describe('~/content_editor/components/suggestions_dropdown', () => { }, }); - testEmojis.forEach((testEmoji) => { - expect(wrapper.text()).toContain(testEmoji.e); - expect(wrapper.text()).toContain(testEmoji.d); - expect(wrapper.text()).toContain(testEmoji.name); - }); + expect(wrapper.findAllComponents('gl-emoji-stub').at(0).html()).toMatchInlineSnapshot(` + <gl-emoji-stub + data-name="smile" + data-unicode-version="6.0" + title="smiling face with open mouth and smiling eyes" + > + 😄 + </gl-emoji-stub> + `); + expect(wrapper.findAllComponents('gl-emoji-stub').at(1).html()).toMatchInlineSnapshot(` + <gl-emoji-stub + data-name="smile_cat" + data-unicode-version="6.0" + title="grinning cat face with smiling eyes" + > + 😸 + </gl-emoji-stub> + `); + expect(wrapper.findAllComponents('gl-emoji-stub').at(2).html()).toMatchInlineSnapshot(` + <gl-emoji-stub + data-name="smiley" + data-unicode-version="6.0" + title="smiling face with open mouth" + > + 😃 + </gl-emoji-stub> + `); + expect(wrapper.findAllComponents('gl-emoji-stub').at(3).html()).toMatchInlineSnapshot(` + <gl-emoji-stub + data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" + data-name="party-parrot" + data-unicode-version="custom" + title="party-parrot" + /> + `); }); }); }); diff --git a/spec/frontend/content_editor/services/__snapshots__/data_source_factory_spec.js.snap b/spec/frontend/content_editor/services/__snapshots__/data_source_factory_spec.js.snap new file mode 100644 index 00000000000..2d16c6b1a2f --- /dev/null +++ b/spec/frontend/content_editor/services/__snapshots__/data_source_factory_spec.js.snap @@ -0,0 +1,256 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DataSourceFactory filters items based on command "/assign" for reference type "user" and command 1`] = ` +Array [ + "florida.schoen", + "root", + "all", + "lakeesha.batz", + "laurene_blick", + "myrtis", + "patty", + "Commit451", + "flightjs", + "gitlab-instance-ade037f9", + "gitlab-org", + "gnuwget", + "h5bp", + "jashkenas", + "twitter", +] +`; + +exports[`DataSourceFactory filters items based on command "/assign_reviewer" for reference type "user" and command 1`] = ` +Array [ + "florida.schoen", + "root", + "all", + "errol", + "evelynn_olson", + "Commit451", + "flightjs", + "gitlab-instance-ade037f9", + "gitlab-org", + "gnuwget", + "h5bp", + "jashkenas", + "twitter", +] +`; + +exports[`DataSourceFactory filters items based on command "/label" for reference type "label" and command 1`] = ` +Array [ + "Bronce", + "Contour", + "Corolla", + "Cygsync", + "Frontier", + "Grand Am", + "Onesync", + "Phone", + "Pynefunc", + "Trinix", + "Trounswood", + "group::knowledge", + "scoped label", + "type::one", + "type::two", +] +`; + +exports[`DataSourceFactory filters items based on command "/reassign" for reference type "user" and command 1`] = ` +Array [ + "florida.schoen", + "root", + "all", + "errol", + "evelynn_olson", + "lakeesha.batz", + "laurene_blick", + "myrtis", + "patty", + "Commit451", + "flightjs", + "gitlab-instance-ade037f9", + "gitlab-org", + "gnuwget", + "h5bp", +] +`; + +exports[`DataSourceFactory filters items based on command "/reassign_reviewer" for reference type "user" and command 1`] = ` +Array [ + "florida.schoen", + "root", + "all", + "errol", + "evelynn_olson", + "lakeesha.batz", + "laurene_blick", + "myrtis", + "patty", + "Commit451", + "flightjs", + "gitlab-instance-ade037f9", + "gitlab-org", + "gnuwget", + "h5bp", +] +`; + +exports[`DataSourceFactory filters items based on command "/relabel" for reference type "label" and command 1`] = ` +Array [ + "Amsche", + "Brioffe", + "Bronce", + "Bryncefunc", + "Contour", + "Corolla", + "Cygsync", + "Frontier", + "Ghost", + "Grand Am", + "Onesync", + "Phone", + "Pynefunc", + "Trinix", + "Trounswood", +] +`; + +exports[`DataSourceFactory filters items based on command "/unassign" for reference type "user" and command 1`] = ` +Array [ + "errol", + "evelynn_olson", +] +`; + +exports[`DataSourceFactory filters items based on command "/unassign_reviewer" for reference type "user" and command 1`] = ` +Array [ + "lakeesha.batz", + "laurene_blick", + "myrtis", + "patty", +] +`; + +exports[`DataSourceFactory filters items based on command "/unlabel" for reference type "label" and command 1`] = ` +Array [ + "Amsche", + "Brioffe", + "Bryncefunc", + "Ghost", +] +`; + +exports[`DataSourceFactory for reference type "command", searches for "re" correctly 1`] = ` +Array [ + "relabel", + "remove_milestone", + "remove_estimate", + "remove_time_spent", + "relate", + "remove_epic", + "reassign", + "create_merge_request", +] +`; + +exports[`DataSourceFactory for reference type "epic", searches for "n" correctly 1`] = ` +Array [ + "Nobis quidem aspernatur reprehenderit sunt ut ipsum tempora sapiente sed iste.", + "Minus eius ut omnis quos sunt dicta ex ipsum.", + "Quae nostrum possimus rerum aliquam pariatur a eos aut id.", + "Dicta incidunt vel dignissimos sint sit esse est quibusdam quidem consequatur.", + "Doloremque a quisquam qui culpa numquam doloribus similique iure enim.", +] +`; + +exports[`DataSourceFactory for reference type "issue", searches for "q" correctly 1`] = ` +Array [ + "Quasi id et et nihil sint autem.", + "Eaque omnis eius quas necessitatibus hic ut et corrupti.", + "Aut quisquam magnam eos distinctio incidunt perferendis fugit.", + "Dolorem quisquam cupiditate consequatur perspiciatis sequi eligendi ullam.", + "Nesciunt quia molestiae in aliquam amet et dolorem.", + "Porro tempore qui qui culpa saepe et nam quos.", + "Sed sint a est consequatur quae quasi autem debitis alias.", + "Molestiae minima maxime optio nihil quam eveniet dolor.", + "Et laboriosam aut ratione voluptatem quasi recusandae.", + "Et molestiae delectus voluptates velit vero illo aut rerum quo et.", +] +`; + +exports[`DataSourceFactory for reference type "label", searches for "c" correctly 1`] = ` +Array [ + "Contour", + "Corolla", + "Cygsync", + "scoped label", + "Amsche", + "Bronce", + "Bryncefunc", + "Onesync", + "Pynefunc", +] +`; + +exports[`DataSourceFactory for reference type "merge_request", searches for "n" correctly 1`] = ` +Array [ + "Blanditiis maxime voluptatem ut pariatur vel autem vero non quod libero.", + "Optio nemo qui dolorem sit ipsum qui saepe.", + "Draft: Alunny/publish lib", + "Draft: Fix event current target", + "Draft: Resolve \\"hgvbbvnnb\\"", + "Autem eaque et sed provident enim corrupti molestiae.", + "Always call registry's trigger method from withRegistration", +] +`; + +exports[`DataSourceFactory for reference type "milestone", searches for "16" correctly 1`] = ` +Array [ + "16.7", + "16.8", + "16.9", + "16.10", + "16.11", + "16.0 (expired)", + "16.1 (expired)", + "16.2 (expired)", + "16.3 (expired)", + "16.4 (expired)", + "16.5 (expired)", + "16.6 (expired)", +] +`; + +exports[`DataSourceFactory for reference type "snippet", searches for "s" correctly 1`] = ` +Array [ + "ss", + "test snippet", + "another test snippet", +] +`; + +exports[`DataSourceFactory for reference type "user", searches for "r" correctly 1`] = ` +Array [ + "root", + "errol", + "lakeesha.batz", + "myrtis", + "florida.schoen", + "laurene_blick", + "all", + "twitter", + "gitlab-org", + "evelynn_olson", +] +`; + +exports[`DataSourceFactory for reference type "vulnerability", searches for "cross" correctly 1`] = ` +Array [ + "Cross Site Scripting (Persistent)", + "Cross Site Scripting (Persistent)", + "Cross Site Scripting (Persistent)", +] +`; diff --git a/spec/frontend/content_editor/services/autocomplete_mock_data.js b/spec/frontend/content_editor/services/autocomplete_mock_data.js new file mode 100644 index 00000000000..c1bf2a6ae5b --- /dev/null +++ b/spec/frontend/content_editor/services/autocomplete_mock_data.js @@ -0,0 +1,967 @@ +export const MOCK_MEMBERS = [ + { + type: 'User', + username: 'florida.schoen', + name: 'Anglea Durgan', + avatar_url: + 'https://www.gravatar.com/avatar/ac82b5615d3308ecbcacedad361af8e7?s=80\u0026d=identicon', + availability: null, + }, + { + type: 'User', + username: 'root', + name: 'Administrator', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + availability: null, + }, + { + username: 'all', + name: 'All Project and Group Members', + count: 8, + }, + { + type: 'User', + username: 'errol', + name: "Linnie O'Connell", + avatar_url: + 'https://www.gravatar.com/avatar/d3d9a468a9884eb217fad5ca5b2b9bd7?s=80\u0026d=identicon', + availability: null, + }, + { + type: 'User', + username: 'evelynn_olson', + name: 'Dimple Dare', + avatar_url: + 'https://www.gravatar.com/avatar/bc1e51ee3512c2b4442f51732d655107?s=80\u0026d=identicon', + availability: null, + }, + { + type: 'User', + username: 'lakeesha.batz', + name: 'Larae Veum', + avatar_url: + 'https://www.gravatar.com/avatar/e5605cb9bbb1a28640d65f25f256e541?s=80\u0026d=identicon', + availability: null, + }, + { + type: 'User', + username: 'laurene_blick', + name: 'Evelina Murray', + avatar_url: + 'https://www.gravatar.com/avatar/389768eef61b7b2d125c64ee01c240fb?s=80\u0026d=identicon', + availability: null, + }, + { + type: 'User', + username: 'myrtis', + name: 'Fernanda Adams', + avatar_url: + 'https://www.gravatar.com/avatar/719d5569bd31d4a70e350b4205fa2cb5?s=80\u0026d=identicon', + availability: null, + }, + { + type: 'User', + username: 'patty', + name: 'Emily Toy', + avatar_url: + 'https://www.gravatar.com/avatar/dca2077b662338808459dc11e70d6688?s=80\u0026d=identicon', + availability: null, + }, + { + type: 'Group', + username: 'Commit451', + name: 'Commit451', + avatar_url: null, + count: 5, + mentionsDisabled: null, + }, + { + type: 'Group', + username: 'flightjs', + name: 'Flightjs', + avatar_url: null, + count: 5, + mentionsDisabled: null, + }, + { + type: 'Group', + username: 'gitlab-instance-ade037f9', + name: 'GitLab Instance', + avatar_url: null, + count: 1, + mentionsDisabled: null, + }, + { + type: 'Group', + username: 'gitlab-org', + name: 'Gitlab Org', + avatar_url: null, + count: 5, + mentionsDisabled: null, + }, + { + type: 'Group', + username: 'gnuwget', + name: 'Gnuwget', + avatar_url: null, + count: 5, + mentionsDisabled: null, + }, + { + type: 'Group', + username: 'h5bp', + name: 'H5bp', + avatar_url: null, + count: 4, + mentionsDisabled: null, + }, + { + type: 'Group', + username: 'jashkenas', + name: 'Jashkenas', + avatar_url: null, + count: 5, + mentionsDisabled: null, + }, + { + type: 'Group', + username: 'twitter', + name: 'Twitter', + avatar_url: null, + count: 5, + mentionsDisabled: null, + }, +]; + +export const MOCK_ASSIGNEES = MOCK_MEMBERS.filter( + ({ username }) => username === 'errol' || username === 'evelynn_olson', +); + +export const MOCK_REVIEWERS = MOCK_MEMBERS.filter( + ({ username }) => + username === 'lakeesha.batz' || + username === 'laurene_blick' || + username === 'myrtis' || + username === 'patty', +); + +export const MOCK_ISSUES = [ + { + iid: 31, + title: 'rdfhdfj', + id: null, + }, + { + iid: 30, + title: 'incident1', + id: null, + }, + { + iid: 29, + title: 'example feature rollout', + id: null, + }, + { + iid: 28, + title: 'sagasg', + id: null, + }, + { + iid: 26, + title: 'Quasi id et et nihil sint autem.', + id: null, + }, + { + iid: 25, + title: 'Dolorem quisquam cupiditate consequatur perspiciatis sequi eligendi ullam.', + id: null, + }, + { + iid: 24, + title: 'Et molestiae delectus voluptates velit vero illo aut rerum quo et.', + id: null, + }, + { + iid: 23, + title: 'Nesciunt quia molestiae in aliquam amet et dolorem.', + id: null, + }, + { + iid: 22, + title: 'Sint asperiores unde vel autem delectus ullam dolor nihil et.', + id: null, + }, + { + iid: 21, + title: 'Eaque omnis eius quas necessitatibus hic ut et corrupti.', + id: null, + }, + { + iid: 20, + title: 'Porro tempore qui qui culpa saepe et nam quos.', + id: null, + }, + { + iid: 19, + title: 'Molestiae minima maxime optio nihil quam eveniet dolor.', + id: null, + }, + { + iid: 18, + title: 'Sed sint a est consequatur quae quasi autem debitis alias.', + id: null, + }, + { + iid: 6, + title: 'Et laboriosam aut ratione voluptatem quasi recusandae.', + id: null, + }, + { + iid: 2, + title: 'Aut quisquam magnam eos distinctio incidunt perferendis fugit.', + id: null, + }, +]; + +export const MOCK_EPICS = [ + { + iid: 6, + title: 'sgs', + reference: 'flightjs\u00266', + }, + { + iid: 5, + title: 'Doloremque a quisquam qui culpa numquam doloribus similique iure enim.', + reference: 'flightjs\u00265', + }, + { + iid: 4, + title: 'Minus eius ut omnis quos sunt dicta ex ipsum.', + reference: 'flightjs\u00264', + }, + { + iid: 3, + title: 'Quae nostrum possimus rerum aliquam pariatur a eos aut id.', + reference: 'flightjs\u00263', + }, + { + iid: 2, + title: 'Nobis quidem aspernatur reprehenderit sunt ut ipsum tempora sapiente sed iste.', + reference: 'flightjs\u00262', + }, + { + iid: 1, + title: 'Dicta incidunt vel dignissimos sint sit esse est quibusdam quidem consequatur.', + reference: 'flightjs\u00261', + }, +]; + +export const MOCK_MERGE_REQUESTS = [ + { + iid: 12, + title: "Always call registry's trigger method from withRegistration", + id: null, + }, + { + iid: 11, + title: 'Draft: Alunny/publish lib', + id: null, + }, + { + iid: 10, + title: 'Draft: Resolve "hgvbbvnnb"', + id: null, + }, + { + iid: 9, + title: 'Draft: Fix event current target', + id: null, + }, + { + iid: 3, + title: 'Autem eaque et sed provident enim corrupti molestiae.', + id: null, + }, + { + iid: 2, + title: 'Blanditiis maxime voluptatem ut pariatur vel autem vero non quod libero.', + id: null, + }, + { + iid: 1, + title: 'Optio nemo qui dolorem sit ipsum qui saepe.', + id: null, + }, +]; + +export const MOCK_SNIPPETS = [ + { + id: 24, + title: 'ss', + }, + { + id: 22, + title: 'another test snippet', + }, + { + id: 21, + title: 'test snippet', + }, +]; + +export const MOCK_LABELS = [ + { + title: 'Amsche', + color: '#9964cf', + type: 'GroupLabel', + textColor: '#FFFFFF', + set: true, + }, + { + title: 'Brioffe', + color: '#203e13', + type: 'GroupLabel', + textColor: '#FFFFFF', + set: true, + }, + { + title: 'Bronce', + color: '#c0b7f2', + type: 'GroupLabel', + textColor: '#1F1E24', + }, + { + title: 'Bryncefunc', + color: '#8baa5e', + type: 'GroupLabel', + textColor: '#FFFFFF', + set: true, + }, + { + title: 'Contour', + color: '#8cf3a3', + type: 'ProjectLabel', + textColor: '#1F1E24', + }, + { + title: 'Corolla', + color: '#0384f3', + type: 'ProjectLabel', + textColor: '#FFFFFF', + }, + { + title: 'Cygsync', + color: '#1308c3', + type: 'GroupLabel', + textColor: '#FFFFFF', + }, + { + title: 'Frontier', + color: '#85db43', + type: 'ProjectLabel', + textColor: '#1F1E24', + }, + { + title: 'Ghost', + color: '#df1bc4', + type: 'ProjectLabel', + textColor: '#FFFFFF', + set: true, + }, + { + title: 'Grand Am', + color: '#a1d7ee', + type: 'ProjectLabel', + textColor: '#1F1E24', + }, + { + title: 'Onesync', + color: '#a73ba0', + type: 'GroupLabel', + textColor: '#FFFFFF', + }, + { + title: 'Phone', + color: '#63dceb', + type: 'GroupLabel', + textColor: '#1F1E24', + }, + { + title: 'Pynefunc', + color: '#974b19', + type: 'GroupLabel', + textColor: '#FFFFFF', + }, + { + title: 'Trinix', + color: '#2c894f', + type: 'GroupLabel', + textColor: '#FFFFFF', + }, + { + title: 'Trounswood', + color: '#ad0370', + type: 'GroupLabel', + textColor: '#FFFFFF', + }, + { + title: 'group::knowledge', + color: '#8fbc8f', + type: 'ProjectLabel', + textColor: '#1F1E24', + }, + { + title: 'scoped label', + color: '#6699cc', + type: 'GroupLabel', + textColor: '#FFFFFF', + }, + { + title: 'type::one', + color: '#9400d3', + type: 'ProjectLabel', + textColor: '#FFFFFF', + }, + { + title: 'type::two', + color: '#013220', + type: 'ProjectLabel', + textColor: '#FFFFFF', + }, +]; + +export const MOCK_MILESTONES = [ + { + iid: 65, + title: '15.0', + due_date: '2022-05-17', + id: null, + }, + { + iid: 73, + title: '15.1', + due_date: '2022-06-17', + id: null, + }, + { + iid: 74, + title: '15.2', + due_date: '2022-07-17', + id: null, + }, + { + iid: 75, + title: '15.3', + due_date: '2022-08-17', + id: null, + }, + { + iid: 76, + title: '15.4', + due_date: '2022-09-17', + id: null, + }, + { + iid: 77, + title: '15.5', + due_date: '2022-10-17', + id: null, + }, + { + iid: 81, + title: '15.6', + due_date: '2022-11-17', + id: null, + }, + { + iid: 82, + title: '15.7', + due_date: '2022-12-17', + id: null, + }, + { + iid: 83, + title: '15.8', + due_date: '2023-01-17', + id: null, + }, + { + iid: 84, + title: '15.9', + due_date: '2023-02-17', + id: null, + }, + { + iid: 85, + title: '15.10', + due_date: '2023-03-17', + id: null, + }, + { + iid: 86, + title: '15.11', + due_date: '2023-04-17', + id: null, + }, + { + iid: 80, + title: '16.0', + due_date: '2023-05-17', + id: null, + }, + { + iid: 88, + title: '16.1', + due_date: '2023-06-17', + id: null, + }, + { + iid: 89, + title: '16.2', + due_date: '2023-07-17', + id: null, + }, + { + iid: 90, + title: '16.3', + due_date: '2023-08-17', + id: null, + }, + { + iid: 91, + title: '16.4', + due_date: '2023-09-17', + id: null, + }, + { + iid: 92, + title: '16.5', + due_date: '2023-10-17', + id: null, + }, + { + iid: 93, + title: '16.6', + due_date: '2023-11-10', + id: null, + }, + { + iid: 95, + title: '16.7', + due_date: '2023-12-15', + id: null, + }, + { + iid: 94, + title: '16.8', + due_date: '2024-01-12', + id: null, + }, + { + iid: 96, + title: '16.9', + due_date: '2024-02-09', + id: null, + }, + { + iid: 97, + title: '16.10', + due_date: '2024-03-15', + id: null, + }, + { + iid: 98, + title: '16.11', + due_date: '2024-04-12', + id: null, + }, + { + iid: 87, + title: '17.0', + due_date: '2024-05-10', + id: null, + }, + { + iid: 48, + title: 'Next 1-3 releases', + due_date: null, + id: null, + }, + { + iid: 24, + title: 'Awaiting further demand', + due_date: null, + id: null, + }, + { + iid: 14, + title: 'Backlog', + due_date: null, + id: null, + }, + { + iid: 11, + title: 'Next 4-7 releases', + due_date: null, + id: null, + }, + { + iid: 10, + title: 'Next 3-4 releases', + due_date: null, + id: null, + }, + { + iid: 6, + title: 'Next 7-13 releases', + due_date: null, + id: null, + }, +]; + +export const MOCK_VULNERABILITIES = [ + { + id: 99499903, + title: 'Cross Site Scripting (Persistent)', + }, + { + id: 99495085, + title: 'Possible SQL injection', + }, + { + id: 99490610, + title: 'GitLab Runner Authentication Token', + }, + { + id: 99288920, + title: 'Cross Site Scripting (Persistent)', + }, + { + id: 99258720, + title: 'Cross Site Scripting (Persistent)', + }, +]; + +export const MOCK_COMMANDS = [ + { + name: 'due', + aliases: [], + description: 'Set due date', + warning: '', + icon: '', + params: ['\u003cin 2 days | this Friday | December 31st\u003e'], + }, + { + name: 'duplicate', + aliases: [], + description: 'Mark this issue as a duplicate of another issue', + warning: '', + icon: '', + params: ['#issue'], + }, + { + name: 'clone', + aliases: [], + description: 'Clone this issue', + warning: '', + icon: '', + params: ['path/to/project [--with_notes]'], + }, + { + name: 'move', + aliases: [], + description: 'Move this issue to another project.', + warning: '', + icon: '', + params: ['path/to/project'], + }, + { + name: 'create_merge_request', + aliases: [], + description: 'Create a merge request', + warning: '', + icon: '', + params: ['\u003cbranch name\u003e'], + }, + { + name: 'zoom', + aliases: [], + description: 'Add Zoom meeting', + warning: '', + icon: '', + params: ['\u003cZoom URL\u003e'], + }, + { + name: 'promote_to_incident', + aliases: [], + description: 'Promote issue to incident', + warning: '', + icon: '', + params: [], + }, + { + name: 'close', + aliases: [], + description: 'Close this issue', + warning: '', + icon: '', + params: [], + }, + { + name: 'title', + aliases: [], + description: 'Change title', + warning: '', + icon: '', + params: ['\u003cNew title\u003e'], + }, + { + name: 'label', + aliases: ['labels'], + description: 'Add labels', + warning: '', + icon: '', + params: ['~label1 ~"label 2"'], + }, + { + name: 'unlabel', + aliases: ['remove_label'], + description: 'Remove all or specific labels', + warning: '', + icon: '', + params: ['~label1 ~"label 2"'], + }, + { + name: 'relabel', + aliases: [], + description: 'Replace all labels', + warning: '', + icon: '', + params: ['~label1 ~"label 2"'], + }, + { + name: 'todo', + aliases: [], + description: 'Add a to do', + warning: '', + icon: '', + params: [], + }, + { + name: 'unsubscribe', + aliases: [], + description: 'Unsubscribe', + warning: '', + icon: '', + params: [], + }, + { + name: 'award', + aliases: [], + description: 'Toggle emoji award', + warning: '', + icon: '', + params: [':emoji:'], + }, + { + name: 'shrug', + aliases: [], + description: 'Append the comment with ¯\\_(ツ)_/¯', + warning: '', + icon: '', + params: ['\u003cComment\u003e'], + }, + { + name: 'tableflip', + aliases: [], + description: 'Append the comment with (╯°□°)╯︵ ┻━┻', + warning: '', + icon: '', + params: ['\u003cComment\u003e'], + }, + { + name: 'confidential', + aliases: [], + description: 'Make issue confidential', + warning: '', + icon: '', + params: [], + }, + { + name: 'assign', + aliases: [], + description: 'Assign', + warning: '', + icon: '', + params: ['@user1 @user2'], + }, + { + name: 'unassign', + aliases: [], + description: 'Remove all or specific assignees', + warning: '', + icon: '', + params: ['@user1 @user2'], + }, + { + name: 'milestone', + aliases: [], + description: 'Set milestone', + warning: '', + icon: '', + params: ['%"milestone"'], + }, + { + name: 'remove_milestone', + aliases: [], + description: 'Remove milestone', + warning: '', + icon: '', + params: [], + }, + { + name: 'copy_metadata', + aliases: [], + description: 'Copy labels and milestone from other issue or merge request in this project', + warning: '', + icon: '', + params: ['#issue | !merge_request'], + }, + { + name: 'estimate', + aliases: ['estimate_time'], + description: 'Set time estimate', + warning: '', + icon: '', + params: ['\u003c1w 3d 2h 14m\u003e'], + }, + { + name: 'spend', + aliases: ['spent', 'spend_time'], + description: 'Add or subtract spent time', + warning: '', + icon: '', + params: ['\u003ctime(1h30m | -1h30m)\u003e \u003cdate(YYYY-MM-DD)\u003e'], + }, + { + name: 'remove_estimate', + aliases: ['remove_time_estimate'], + description: 'Remove time estimate', + warning: '', + icon: '', + params: [], + }, + { + name: 'remove_time_spent', + aliases: [], + description: 'Remove spent time', + warning: '', + icon: '', + params: [], + }, + { + name: 'lock', + aliases: [], + description: 'Lock the discussion', + warning: '', + icon: '', + params: [], + }, + { + name: 'cc', + aliases: [], + description: 'CC', + warning: '', + icon: '', + params: ['@user'], + }, + { + name: 'relate', + aliases: [], + description: 'Mark this issue as related to another issue', + warning: '', + icon: '', + params: ['\u003c#issue | group/project#issue | issue URL\u003e'], + }, + { + name: 'unlink', + aliases: [], + description: 'Remove link with another issue', + warning: '', + icon: '', + params: ['\u003c#issue | group/project#issue | issue URL\u003e'], + }, + { + name: 'epic', + aliases: [], + description: 'Add to epic', + warning: '', + icon: '', + params: ['\u003c\u0026epic | group\u0026epic | Epic URL\u003e'], + }, + { + name: 'remove_epic', + aliases: [], + description: 'Remove from epic', + warning: '', + icon: '', + params: [], + }, + { + name: 'promote', + aliases: [], + description: 'Promote issue to an epic', + warning: '', + icon: 'confidential', + params: [], + }, + { + name: 'iteration', + aliases: [], + description: 'Set iteration', + warning: '', + icon: '', + params: ['*iteration:"iteration name" | *iteration:\u003cID\u003e'], + }, + { + name: 'health_status', + aliases: [], + description: 'Set health status', + warning: '', + icon: '', + params: ['\u003con_track|needs_attention|at_risk\u003e'], + }, + { + name: 'reassign', + aliases: [], + description: 'Change assignees', + warning: '', + icon: '', + params: ['@user1 @user2'], + }, + { + name: 'weight', + aliases: [], + description: 'Set weight', + warning: '', + icon: '', + params: ['0, 1, 2, …'], + }, + { + name: 'blocks', + aliases: [], + description: 'Specifies that this issue blocks other issues', + warning: '', + icon: '', + params: ['\u003c#issue | group/project#issue | issue URL\u003e'], + }, + { + name: 'blocked_by', + aliases: [], + description: 'Mark this issue as blocked by other issues', + warning: '', + icon: '', + params: ['\u003c#issue | group/project#issue | issue URL\u003e'], + }, +]; diff --git a/spec/frontend/content_editor/services/data_source_factory_spec.js b/spec/frontend/content_editor/services/data_source_factory_spec.js new file mode 100644 index 00000000000..d540f11711d --- /dev/null +++ b/spec/frontend/content_editor/services/data_source_factory_spec.js @@ -0,0 +1,202 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import DataSourceFactory, { + defaultSorter, + customSorter, + createDataSource, +} from '~/content_editor/services/data_source_factory'; +import { + MOCK_MEMBERS, + MOCK_COMMANDS, + MOCK_EPICS, + MOCK_ISSUES, + MOCK_LABELS, + MOCK_MILESTONES, + MOCK_SNIPPETS, + MOCK_VULNERABILITIES, + MOCK_MERGE_REQUESTS, + MOCK_ASSIGNEES, + MOCK_REVIEWERS, +} from './autocomplete_mock_data'; + +jest.mock('~/emoji'); + +describe('defaultSorter', () => { + it('returns items as is if query is empty', () => { + const items = [{ name: 'abc' }, { name: 'bcd' }, { name: 'cde' }]; + const sorter = defaultSorter(['name']); + expect(sorter(items, '')).toEqual(items); + }); + + it('sorts items based on query match', () => { + const items = [{ name: 'abc' }, { name: 'bcd' }, { name: 'cde' }]; + const sorter = defaultSorter(['name']); + expect(sorter(items, 'b')).toEqual([{ name: 'bcd' }, { name: 'abc' }, { name: 'cde' }]); + }); + + it('sorts items based on query match in multiple fields', () => { + const items = [ + { name: 'wabc', description: 'xyz' }, + { name: 'bcd', description: 'wxy' }, + { name: 'cde', description: 'vwx' }, + ]; + const sorter = defaultSorter(['name', 'description']); + expect(sorter(items, 'w')).toEqual([ + { name: 'wabc', description: 'xyz' }, + { name: 'bcd', description: 'wxy' }, + { name: 'cde', description: 'vwx' }, + ]); + }); +}); + +describe('customSorter', () => { + it('sorts items based on custom sorter function', () => { + const items = [3, 1, 2]; + const sorter = customSorter((a, b) => a - b); + expect(sorter(items)).toEqual([1, 2, 3]); + }); +}); + +describe('createDataSource', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + it('fetches data from source and filters based on query', async () => { + const data = [ + { name: 'abc', description: 'xyz' }, + { name: 'bcd', description: 'wxy' }, + { name: 'cde', description: 'vwx' }, + ]; + mock.onGet('/source').reply(200, data); + + const dataSource = createDataSource({ + source: '/source', + searchFields: ['name', 'description'], + }); + + const results = await dataSource.search('b'); + expect(results).toEqual([ + { name: 'bcd', description: 'wxy' }, + { name: 'abc', description: 'xyz' }, + ]); + }); + + it('handles source fetch errors', async () => { + mock.onGet('/source').reply(500); + + const dataSource = createDataSource({ + source: '/source', + searchFields: ['name', 'description'], + sorter: (items) => items, + }); + + const results = await dataSource.search('b'); + expect(results).toEqual([]); + }); +}); + +describe('DataSourceFactory', () => { + let mock; + let autocompleteHelper; + let dateNowOld; + + beforeEach(() => { + mock = new MockAdapter(axios); + const dataSourceUrls = { + members: '/members', + issues: '/issues', + snippets: '/snippets', + labels: '/labels', + epics: '/epics', + milestones: '/milestones', + mergeRequests: '/mergeRequests', + vulnerabilities: '/vulnerabilities', + commands: '/commands', + }; + + mock.onGet('/members').reply(200, MOCK_MEMBERS); + mock.onGet('/issues').reply(200, MOCK_ISSUES); + mock.onGet('/snippets').reply(200, MOCK_SNIPPETS); + mock.onGet('/labels').reply(200, MOCK_LABELS); + mock.onGet('/epics').reply(200, MOCK_EPICS); + mock.onGet('/milestones').reply(200, MOCK_MILESTONES); + mock.onGet('/mergeRequests').reply(200, MOCK_MERGE_REQUESTS); + mock.onGet('/vulnerabilities').reply(200, MOCK_VULNERABILITIES); + mock.onGet('/commands').reply(200, MOCK_COMMANDS); + + const sidebarMediator = { + store: { + assignees: MOCK_ASSIGNEES, + reviewers: MOCK_REVIEWERS, + }, + }; + + autocompleteHelper = new DataSourceFactory({ + dataSourceUrls, + sidebarMediator, + }); + + dateNowOld = Date.now(); + + jest.spyOn(Date, 'now').mockImplementation(() => new Date('2023-11-14').getTime()); + }); + + afterEach(() => { + mock.restore(); + + jest.spyOn(Date, 'now').mockImplementation(() => dateNowOld); + }); + + it.each` + referenceType | query + ${'user'} | ${'r'} + ${'issue'} | ${'q'} + ${'snippet'} | ${'s'} + ${'label'} | ${'c'} + ${'epic'} | ${'n'} + ${'milestone'} | ${'16'} + ${'merge_request'} | ${'n'} + ${'vulnerability'} | ${'cross'} + ${'command'} | ${'re'} + `( + 'for reference type "$referenceType", searches for "$query" correctly', + async ({ referenceType, query }) => { + const dataSource = autocompleteHelper.getDataSource(referenceType); + const results = await dataSource.search(query); + + expect( + results.map(({ title, name, username }) => username || name || title), + ).toMatchSnapshot(); + }, + ); + + it.each` + referenceType | command + ${'label'} | ${'/label'} + ${'label'} | ${'/unlabel'} + ${'label'} | ${'/relabel'} + ${'user'} | ${'/assign'} + ${'user'} | ${'/reassign'} + ${'user'} | ${'/unassign'} + ${'user'} | ${'/assign_reviewer'} + ${'user'} | ${'/unassign_reviewer'} + ${'user'} | ${'/reassign_reviewer'} + `( + 'filters items based on command "$command" for reference type "$referenceType" and command', + async ({ referenceType, command }) => { + const dataSource = autocompleteHelper.getDataSource(referenceType, { command }); + const results = await dataSource.search(); + + expect( + results.map(({ username, name, title }) => username || name || title), + ).toMatchSnapshot(); + }, + ); +}); diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 6cea75036bc..2d7841771a1 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -547,7 +547,7 @@ describe('GfmAutoComplete', () => { expect(membersBeforeSave([{ ...mockGroup, avatar_url: null }])).toEqual([ { username: 'my-group', - avatarTag: '<div class="avatar rect-avatar center avatar-inline s26">M</div>', + avatarTag: '<div class="avatar rect-avatar avatar-inline s24 gl-mr-2">M</div>', title: 'My Group (2)', search: 'MyGroup my-group', icon: '', @@ -560,7 +560,7 @@ describe('GfmAutoComplete', () => { { username: 'my-group', avatarTag: - '<img src="./group.jpg" alt="my-group" class="avatar rect-avatar avatar-inline center s26"/>', + '<img src="./group.jpg" alt="my-group" class="avatar rect-avatar avatar-inline s24 gl-mr-2"/>', title: 'My Group (2)', search: 'MyGroup my-group', icon: '', @@ -573,7 +573,7 @@ describe('GfmAutoComplete', () => { { username: 'my-group', avatarTag: - '<img src="./group.jpg" alt="my-group" class="avatar rect-avatar avatar-inline center s26"/>', + '<img src="./group.jpg" alt="my-group" class="avatar rect-avatar avatar-inline s24 gl-mr-2"/>', title: 'My Group', search: 'MyGroup my-group', icon: @@ -591,7 +591,7 @@ describe('GfmAutoComplete', () => { { username: 'my-user', avatarTag: - '<img src="./users.jpg" alt="my-user" class="avatar avatar-inline center s26"/>', + '<img src="./users.jpg" alt="my-user" class="avatar avatar-inline s24 gl-mr-2"/>', title: 'My User', search: 'MyUser my-user', icon: '', diff --git a/spec/frontend/groups/service/archived_projects_service_spec.js b/spec/frontend/groups/service/archived_projects_service_spec.js index 6bc46e4799c..988fb5553ba 100644 --- a/spec/frontend/groups/service/archived_projects_service_spec.js +++ b/spec/frontend/groups/service/archived_projects_service_spec.js @@ -30,7 +30,7 @@ describe('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/spec/lib/gitlab/ci/components/instance_path_spec.rb b/spec/lib/gitlab/ci/components/instance_path_spec.rb index d48ad85de74..6bc6f04309d 100644 --- a/spec/lib/gitlab/ci/components/instance_path_spec.rb +++ b/spec/lib/gitlab/ci/components/instance_path_spec.rb @@ -181,88 +181,5 @@ RSpec.describe Gitlab::Ci::Components::InstancePath, feature_category: :pipeline end end end - - # All the following tests are for deprecated code and will be removed - # in https://gitlab.com/gitlab-org/gitlab/-/issues/415855 - context 'when the project does not contain a templates directory' do - let(:project_path) { project.full_path } - let(:address) { "acme.com/#{project_path}/component@#{version}" } - - let_it_be(:project) do - create( - :project, :custom_repo, - files: { - 'component/template.yml' => 'image: alpine' - } - ) - end - - before do - project.add_developer(user) - end - - it 'fetches the component content', :aggregate_failures do - result = path.fetch_content!(current_user: user) - expect(result.content).to eq('image: alpine') - expect(result.path).to eq('component/template.yml') - expect(path.host).to eq(current_host) - expect(path.project).to eq(project) - expect(path.sha).to eq(project.commit('master').id) - end - - context 'when project path is nested under a subgroup' do - let_it_be(:group) { create(:group, :nested) } - let_it_be(:project) do - create( - :project, :custom_repo, - files: { - 'component/template.yml' => 'image: alpine' - }, - group: group - ) - end - - it 'fetches the component content', :aggregate_failures do - result = path.fetch_content!(current_user: user) - expect(result.content).to eq('image: alpine') - expect(result.path).to eq('component/template.yml') - expect(path.host).to eq(current_host) - expect(path.project).to eq(project) - expect(path.sha).to eq(project.commit('master').id) - end - end - - context 'when current GitLab instance is installed on a relative URL' do - let(:address) { "acme.com/gitlab/#{project_path}/component@#{version}" } - let(:current_host) { 'acme.com/gitlab/' } - - it 'fetches the component content', :aggregate_failures do - result = path.fetch_content!(current_user: user) - expect(result.content).to eq('image: alpine') - expect(result.path).to eq('component/template.yml') - expect(path.host).to eq(current_host) - expect(path.project).to eq(project) - expect(path.sha).to eq(project.commit('master').id) - end - end - - context 'when version does not exist' do - let(:version) { 'non-existent' } - - it 'returns nil', :aggregate_failures do - expect(path.fetch_content!(current_user: user)).to be_nil - expect(path.host).to eq(current_host) - expect(path.project).to eq(project) - expect(path.sha).to be_nil - end - end - - context 'when user does not have permissions' do - it 'raises an error when fetching the content' do - expect { path.fetch_content!(current_user: build(:user)) } - .to raise_error(Gitlab::Access::AccessDeniedError) - end - end - end end end diff --git a/spec/lib/gitlab/ci/config/external/processor_spec.rb b/spec/lib/gitlab/ci/config/external/processor_spec.rb index 68cdf56f198..4684495fa26 100644 --- a/spec/lib/gitlab/ci/config/external/processor_spec.rb +++ b/spec/lib/gitlab/ci/config/external/processor_spec.rb @@ -410,7 +410,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor, feature_category: :pipel let(:other_project_files) do { - '/component-x/template.yml' => <<~YAML + '/templates/component-x/template.yml' => <<~YAML component_x_job: script: echo Component X YAML diff --git a/spec/lib/gitlab/database/obsolete_ignored_columns_spec.rb b/spec/lib/gitlab/database/obsolete_ignored_columns_spec.rb index fa7645d581c..56899924b60 100644 --- a/spec/lib/gitlab/database/obsolete_ignored_columns_spec.rb +++ b/spec/lib/gitlab/database/obsolete_ignored_columns_spec.rb @@ -53,11 +53,11 @@ RSpec.describe Gitlab::Database::ObsoleteIgnoredColumns, feature_category: :data expect(subject.execute).to eq( [ ['Testing::A', { - 'unused' => IgnorableColumns::ColumnIgnore.new(Date.parse('2019-01-01'), '12.0'), - 'also_unused' => IgnorableColumns::ColumnIgnore.new(Date.parse('2019-02-01'), '12.1') + 'unused' => IgnorableColumns::ColumnIgnore.new(Date.parse('2019-01-01'), '12.0', false), + 'also_unused' => IgnorableColumns::ColumnIgnore.new(Date.parse('2019-02-01'), '12.1', false) }], ['Testing::B', { - 'other' => IgnorableColumns::ColumnIgnore.new(Date.parse('2019-01-01'), '12.0') + 'other' => IgnorableColumns::ColumnIgnore.new(Date.parse('2019-01-01'), '12.0', false) }] ]) end diff --git a/spec/models/ci/catalog/components_project_spec.rb b/spec/models/ci/catalog/components_project_spec.rb index 79e1a113e47..5f739c244a5 100644 --- a/spec/models/ci/catalog/components_project_spec.rb +++ b/spec/models/ci/catalog/components_project_spec.rb @@ -97,6 +97,7 @@ RSpec.describe Ci::Catalog::ComponentsProject, feature_category: :pipeline_compo 'dast' | 'image: alpine_2' | 'templates/dast/template.yml' 'template' | 'image: alpine_3' | 'templates/template.yml' 'blank-yaml' | '' | 'templates/blank-yaml.yml' + 'non/exist' | nil | nil end with_them do diff --git a/spec/models/concerns/ignorable_columns_spec.rb b/spec/models/concerns/ignorable_columns_spec.rb index 339f06f9c45..44dc0bb6da6 100644 --- a/spec/models/concerns/ignorable_columns_spec.rb +++ b/spec/models/concerns/ignorable_columns_spec.rb @@ -27,6 +27,12 @@ RSpec.describe IgnorableColumns do expect { subject.ignore_columns(:name, remove_after: nil, remove_with: 12.6) }.to raise_error(ArgumentError, /Please indicate/) end + it 'allows setting remove_never: true and not setting other remove options' do + expect do + subject.ignore_columns(%i[name created_at], remove_never: true) + end.to change { subject.ignored_columns }.from([]).to(%w[name created_at]) + end + it 'requires remove_after attribute to be set' do expect { subject.ignore_columns(:name, remove_after: "not a date", remove_with: 12.6) }.to raise_error(ArgumentError, /Please indicate/) end @@ -73,9 +79,11 @@ RSpec.describe IgnorableColumns do end describe IgnorableColumns::ColumnIgnore do - subject { described_class.new(remove_after, remove_with) } + subject { described_class.new(remove_after, remove_with, remove_never) } + let(:remove_after) { nil } let(:remove_with) { double } + let(:remove_never) { false } describe '#safe_to_remove?' do context 'after remove_after date has passed' do @@ -93,6 +101,14 @@ RSpec.describe IgnorableColumns do expect(subject.safe_to_remove?).to be_falsey end end + + context 'with remove_never: true' do + let(:remove_never) { true } + + it 'is false' do + expect(subject.safe_to_remove?).to be_falsey + end + end end end end diff --git a/spec/policies/protected_branch_access_policy_spec.rb b/spec/policies/protected_branch_access_policy_spec.rb index 68a130d666a..6725cde0cb1 100644 --- a/spec/policies/protected_branch_access_policy_spec.rb +++ b/spec/policies/protected_branch_access_policy_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe ProtectedBranchAccessPolicy do +RSpec.describe ProtectedBranchAccessPolicy, feature_category: :source_code_management do let(:user) { create(:user) } let(:protected_branch_access) { create(:protected_branch_merge_access_level) } let(:project) { protected_branch_access.protected_branch.project } @@ -14,9 +14,7 @@ RSpec.describe ProtectedBranchAccessPolicy do project.add_maintainer(user) end - it 'can be read' do - is_expected.to be_allowed(:read_protected_branch) - end + it_behaves_like 'allows protected branch crud' end context 'as guests' do @@ -24,8 +22,6 @@ RSpec.describe ProtectedBranchAccessPolicy do project.add_guest(user) end - it 'can not be read' do - is_expected.to be_disallowed(:read_protected_branch) - end + it_behaves_like 'disallows protected branch crud' end end diff --git a/spec/policies/protected_branch_policy_spec.rb b/spec/policies/protected_branch_policy_spec.rb index d676de14735..d2040a0d334 100644 --- a/spec/policies/protected_branch_policy_spec.rb +++ b/spec/policies/protected_branch_policy_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe ProtectedBranchPolicy do +RSpec.describe ProtectedBranchPolicy, feature_category: :source_code_management do let(:user) { create(:user) } let(:name) { 'feature' } let(:protected_branch) { create(:protected_branch, name: name) } @@ -10,47 +10,27 @@ RSpec.describe ProtectedBranchPolicy do subject { described_class.new(user, protected_branch) } - context 'as maintainers' do + context 'as a maintainer' do before do project.add_maintainer(user) end - it 'can be read' do - is_expected.to be_allowed(:read_protected_branch) - end - - it 'can be created' do - is_expected.to be_allowed(:create_protected_branch) - end + it_behaves_like 'allows protected branch crud' + end - it 'can be updated' do - is_expected.to be_allowed(:update_protected_branch) + context 'as a developer' do + before do + project.add_developer(user) end - it 'can be destroyed' do - is_expected.to be_allowed(:destroy_protected_branch) - end + it_behaves_like 'disallows protected branch crud' end - context 'as guests' do + context 'as a guest' do before do project.add_guest(user) end - it 'can be read' do - is_expected.to be_disallowed(:read_protected_branch) - end - - it 'can be created' do - is_expected.to be_disallowed(:create_protected_branch) - end - - it 'can be updated' do - is_expected.to be_disallowed(:update_protected_branch) - end - - it 'cannot be destroyed' do - is_expected.to be_disallowed(:destroy_protected_branch) - end + it_behaves_like 'disallows protected branch crud' end end diff --git a/spec/services/ci/components/fetch_service_spec.rb b/spec/services/ci/components/fetch_service_spec.rb index 21b7df19f4a..82be795e997 100644 --- a/spec/services/ci/components/fetch_service_spec.rb +++ b/spec/services/ci/components/fetch_service_spec.rb @@ -21,14 +21,15 @@ RSpec.describe Ci::Components::FetchService, feature_category: :pipeline_composi project = create( :project, :custom_repo, files: { - 'template.yml' => content, - 'my-component/template.yml' => content, - 'my-dir/my-component/template.yml' => content + 'templates/first-component.yml' => content, + 'templates/second-component/template.yml' => content } ) project.repository.add_tag(project.creator, 'v0.1', project.repository.commit.sha) + create(:release, project: project, tag: 'v0.1', sha: project.repository.commit.sha) + project end @@ -119,32 +120,27 @@ RSpec.describe Ci::Components::FetchService, feature_category: :pipeline_composi context 'when address points to an external component' do let(:address) { "#{current_host}/#{component_path}@#{version}" } - context 'when component path is the full path to a project' do - let(:component_path) { project.full_path } - let(:component_yaml_path) { 'template.yml' } + context 'when component path points to a template file in a project' do + let(:component_path) { "#{project.full_path}/first-component" } it_behaves_like 'an external component' end - context 'when component path points to a directory in a project' do - let(:component_path) { "#{project.full_path}/my-component" } - let(:component_yaml_path) { 'my-component/template.yml' } + context 'when component path points to a template directory in a project' do + let(:component_path) { "#{project.full_path}/second-component" } it_behaves_like 'an external component' end - context 'when component path points to a nested directory in a project' do - let(:component_path) { "#{project.full_path}/my-dir/my-component" } - let(:component_yaml_path) { 'my-dir/my-component/template.yml' } + context 'when the project exists but the component does not' do + let(:component_path) { "#{project.full_path}/unknown-component" } + let(:version) { '~latest' } - it_behaves_like 'an external component' + it 'returns a content not found error' do + expect(result).to be_error + expect(result.reason).to eq(:content_not_found) + end end end end - - def stub_project_blob(ref, path, content) - allow_next_instance_of(Repository) do |instance| - allow(instance).to receive(:blob_data_at).with(ref, path).and_return(content) - end - end end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index 19e55c22df8..7dea50ba270 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -1757,7 +1757,7 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes let(:sha) do components_project.repository.create_file( user, - 'my-component/template.yml', + 'templates/my-component/template.yml', template, message: 'Add my first CI component', branch_name: 'master' @@ -1894,7 +1894,7 @@ RSpec.describe Ci::CreatePipelineService, :yaml_processor_feature_flag_corectnes let(:sha) do components_project.repository.create_file( user, - 'my-component/template.yml', + 'templates/my-component/template.yml', template, message: 'Add my first CI component', branch_name: 'master' diff --git a/spec/support/shared_examples/features/content_editor_shared_examples.rb b/spec/support/shared_examples/features/content_editor_shared_examples.rb index 6bfb60c3f34..0a71658c9e7 100644 --- a/spec/support/shared_examples/features/content_editor_shared_examples.rb +++ b/spec/support/shared_examples/features/content_editor_shared_examples.rb @@ -570,7 +570,7 @@ RSpec.shared_examples 'edits content using the content editor' do |params = { type_in_content_editor '/assign' expect(find(suggestions_dropdown)).to have_text('/assign') - send_keys [:arrow_down, :enter] + send_keys :enter expect(page).to have_text('/assign @') end @@ -579,7 +579,7 @@ RSpec.shared_examples 'edits content using the content editor' do |params = { type_in_content_editor '/label' expect(find(suggestions_dropdown)).to have_text('/label') - send_keys [:arrow_down, :enter] + send_keys :enter expect(page).to have_text('/label ~') end @@ -588,10 +588,23 @@ RSpec.shared_examples 'edits content using the content editor' do |params = { type_in_content_editor '/milestone' expect(find(suggestions_dropdown)).to have_text('/milestone') - send_keys [:arrow_down, :enter] + send_keys :enter expect(page).to have_text('/milestone %') end + + it 'scrolls selected item into view when navigating with keyboard' do + type_in_content_editor '/' + + expect(find(suggestions_dropdown)).to have_text('label') + + expect(dropdown_scroll_top).to be 0 + + send_keys :arrow_up + + expect(dropdown_scroll_top).to be > 100 + end + end it 'shows suggestions for members with descriptions' do @@ -603,7 +616,18 @@ RSpec.shared_examples 'edits content using the content editor' do |params = { type_in_content_editor 'bc' - send_keys [:arrow_down, :enter] + send_keys :enter + + expect(page).not_to have_css(suggestions_dropdown) + expect(page).to have_text('@abc123') + end + + it 'allows selecting element with tab key' do + type_in_content_editor '@abc' + + expect(find(suggestions_dropdown)).to have_text('abc123') + + send_keys :tab expect(page).not_to have_css(suggestions_dropdown) expect(page).to have_text('@abc123') @@ -701,11 +725,11 @@ RSpec.shared_examples 'edits content using the content editor' do |params = { expect(find(suggestions_dropdown)).to have_text('😃 smiley') expect(find(suggestions_dropdown)).to have_text('😸 smile_cat') - send_keys [:arrow_down, :enter] + send_keys :enter expect(page).not_to have_css(suggestions_dropdown) - expect(page).to have_text('😃') + expect(page).to have_text('😄') end it 'doesn\'t show suggestions dropdown if there are no suggestions to show' do @@ -718,18 +742,6 @@ RSpec.shared_examples 'edits content using the content editor' do |params = { expect(page).not_to have_css(suggestions_dropdown) end - it 'scrolls selected item into view when navigating with keyboard' do - type_in_content_editor ':' - - expect(find(suggestions_dropdown)).to have_text('grinning') - - expect(dropdown_scroll_top).to be 0 - - send_keys :arrow_up - - expect(dropdown_scroll_top).to be > 100 - end - def dropdown_scroll_top evaluate_script("document.querySelector('#{suggestions_dropdown}').scrollTop") end diff --git a/spec/support/shared_examples/policies/protected_branches.rb b/spec/support/shared_examples/policies/protected_branches.rb new file mode 100644 index 00000000000..39652434acb --- /dev/null +++ b/spec/support/shared_examples/policies/protected_branches.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'allows protected branch crud' do + it { is_expected.to be_allowed(:read_protected_branch) } + it { is_expected.to be_allowed(:create_protected_branch) } + it { is_expected.to be_allowed(:update_protected_branch) } + it { is_expected.to be_allowed(:destroy_protected_branch) } +end + +RSpec.shared_examples 'disallows protected branch crud' do + it { is_expected.not_to be_allowed(:read_protected_branch) } + it { is_expected.not_to be_allowed(:create_protected_branch) } + it { is_expected.not_to be_allowed(:update_protected_branch) } + it { is_expected.not_to be_allowed(:destroy_protected_branch) } +end + +RSpec.shared_examples 'disallows protected branch changes' do + it { is_expected.not_to be_allowed(:create_protected_branch) } + it { is_expected.not_to be_allowed(:update_protected_branch) } + it { is_expected.not_to be_allowed(:destroy_protected_branch) } +end diff --git a/spec/workers/every_sidekiq_worker_spec.rb b/spec/workers/every_sidekiq_worker_spec.rb index 2db8cebe58c..136fa6b4aa0 100644 --- a/spec/workers/every_sidekiq_worker_spec.rb +++ b/spec/workers/every_sidekiq_worker_spec.rb @@ -235,30 +235,19 @@ RSpec.describe 'Every Sidekiq worker', feature_category: :shared do 'ExternalServiceReactiveCachingWorker' => 3, 'FileHookWorker' => false, 'FlushCounterIncrementsWorker' => 3, - 'Geo::Batch::ProjectRegistrySchedulerWorker' => 3, - 'Geo::Batch::ProjectRegistryWorker' => 3, 'Geo::ContainerRepositorySyncWorker' => 1, 'Geo::DestroyWorker' => 3, 'Geo::EventWorker' => 3, 'Geo::FileRemovalWorker' => 3, - 'Geo::ProjectSyncWorker' => 1, - 'Geo::RenameRepositoryWorker' => 3, - 'Geo::RepositoryCleanupWorker' => 3, - 'Geo::RepositoryShardSyncWorker' => false, - 'Geo::RepositoryVerification::Primary::ShardWorker' => false, - 'Geo::RepositoryVerification::Primary::SingleWorker' => false, - 'Geo::RepositoryVerification::Secondary::SingleWorker' => false, 'Geo::ReverificationBatchWorker' => 0, 'Geo::BulkMarkPendingBatchWorker' => 0, 'Geo::BulkMarkVerificationPendingBatchWorker' => 0, - 'Geo::Scheduler::Primary::SchedulerWorker' => false, 'Geo::Scheduler::SchedulerWorker' => false, 'Geo::Scheduler::Secondary::SchedulerWorker' => false, 'Geo::VerificationBatchWorker' => 0, 'Geo::VerificationStateBackfillWorker' => false, 'Geo::VerificationTimeoutWorker' => false, 'Geo::VerificationWorker' => 3, - 'GeoRepositoryDestroyWorker' => 3, 'Gitlab::BitbucketImport::AdvanceStageWorker' => 3, 'Gitlab::BitbucketImport::Stage::FinishImportWorker' => 3, 'Gitlab::BitbucketImport::Stage::ImportIssuesWorker' => 3, |