diff options
38 files changed, 870 insertions, 159 deletions
diff --git a/.gitlab/CODEOWNERS b/.gitlab/CODEOWNERS index b0171c86bd6..989d74ff117 100644 --- a/.gitlab/CODEOWNERS +++ b/.gitlab/CODEOWNERS @@ -463,6 +463,7 @@ lib/gitlab/checks/** /doc/administration/invalidate_markdown_cache.md @msedlakjakubowski /doc/administration/issue_closing_pattern.md @aqualls /doc/administration/job_artifacts.md @marcel.amirault +/doc/administration/job_artifacts_troubleshooting.md @marcel.amirault /doc/administration/job_logs.md @fneill /doc/administration/labels.md @msedlakjakubowski /doc/administration/lfs/ @msedlakjakubowski @@ -737,6 +738,7 @@ lib/gitlab/checks/** /doc/ci/test_cases/ @msedlakjakubowski /doc/ci/testing/code_quality.md @rdickenson /doc/development/advanced_search.md @ashrafkhamis +/doc/development/ai_features.md @sselhorn /doc/development/application_limits.md @axil /doc/development/audit_event_guide/ @eread /doc/development/auto_devops.md @phillipwells @@ -826,6 +828,8 @@ lib/gitlab/checks/** /doc/development/workhorse/ @msedlakjakubowski /doc/downgrade_ee_to_ce/ @axil /doc/drawers/ @ashrafkhamis +/doc/editor_extensions/ @aqualls +/doc/editor_extensions/visual_studio_code/ @ashrafkhamis /doc/gitlab-basics/ @msedlakjakubowski /doc/install/ @axil /doc/install/postgresql_extensions.md @aqualls @@ -836,7 +840,6 @@ lib/gitlab/checks/** /doc/integration/datadog.md @eread @ashrafkhamis /doc/integration/external-issue-tracker.md @eread @ashrafkhamis /doc/integration/gitpod.md @ashrafkhamis -/doc/integration/glab/ @aqualls /doc/integration/gmail_action_buttons_for_gitlab.md @eread @ashrafkhamis /doc/integration/index.md @eread @ashrafkhamis /doc/integration/jenkins.md @eread @ashrafkhamis @@ -870,14 +873,19 @@ lib/gitlab/checks/** /doc/tutorials/configure_gitlab_runner_to_use_gke/ @fneill /doc/tutorials/container_scanning/ @rdickenson /doc/tutorials/convert_personal_namespace_to_group/ @lciutacu +/doc/tutorials/create_register_first_runner/ @fneill /doc/tutorials/dependency_scanning.md @rdickenson /doc/tutorials/fuzz_testing/ @rdickenson /doc/tutorials/install_gitlab_single_node/ @axil +/doc/tutorials/issue_triage/ @msedlakjakubowski /doc/tutorials/move_personal_project_to_group/ @lciutacu /doc/tutorials/protected_workflow/ @aqualls /doc/tutorials/scan_result_policy/ @rdickenson +/doc/tutorials/update_commit_messages/ @msedlakjakubowski +/doc/tutorials/website_project_with_analytics/ @lciutacu /doc/update/ @axil /doc/update/background_migrations.md @aqualls +/doc/user/ai_features.md @sselhorn /doc/user/analytics/ @lciutacu /doc/user/analytics/ci_cd_analytics.md @phillipwells /doc/user/application_security/ @rdickenson @@ -953,6 +961,7 @@ lib/gitlab/checks/** /doc/user/project/members/ @lciutacu /doc/user/project/merge_requests/ @aqualls /doc/user/project/merge_requests/csv_export.md @eread +/doc/user/project/merge_requests/reviews/data_usage.md @sselhorn /doc/user/project/merge_requests/status_checks.md @eread /doc/user/project/milestones/ @msedlakjakubowski /doc/user/project/organize_work_with_projects.md @lciutacu @@ -964,9 +973,9 @@ lib/gitlab/checks/** /doc/user/project/releases/release_evidence.md @eread /doc/user/project/remote_development/ @ashrafkhamis /doc/user/project/repository/ @aqualls +/doc/user/project/repository/code_suggestions.md @sselhorn /doc/user/project/repository/file_finder.md @ashrafkhamis /doc/user/project/repository/managing_large_repositories.md @axil -/doc/user/project/repository/vscode.md @ashrafkhamis /doc/user/project/repository/web_editor.md @ashrafkhamis /doc/user/project/requirements/ @msedlakjakubowski /doc/user/project/service_desk/ @msedlakjakubowski @@ -986,6 +995,7 @@ lib/gitlab/checks/** /doc/user/shortcuts.md @ashrafkhamis /doc/user/snippets.md @aqualls /doc/user/ssh.md @jglassman1 +/doc/user/storage_management_automation.md @fneill /doc/user/tasks.md @msedlakjakubowski /doc/user/todos.md @msedlakjakubowski /doc/user/usage_quotas.md @fneill diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index a7c98ef1e75..8f6706289f0 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -5bdafbc7693c7dfacc1c0932cfa8d62004c7097b +b2a3b6ba03e6f2c2ad60582733fd27f050d8aa3f diff --git a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue index eb3402f0666..fe1a907bd91 100644 --- a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue +++ b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue @@ -91,6 +91,7 @@ export default { size="small" category="tertiary" icon="dash" + class="show-on-focus-or-hover--target" :aria-label="$options.i18n.removeItem" :title="$options.i18n.removeItem" data-testid="item-remove" diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_groups.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_groups.vue new file mode 100644 index 00000000000..6f0a0a1fe79 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_groups.vue @@ -0,0 +1,40 @@ +<script> +import { s__ } from '~/locale'; +import { MAX_FREQUENT_GROUPS_COUNT } from '~/super_sidebar/constants'; +import FrequentItems from './frequent_items.vue'; + +export default { + name: 'FrequentlyVisitedGroups', + components: { + FrequentItems, + }, + inject: ['groupsPath'], + data() { + const username = gon.current_username; + + return { + storageKey: username ? `${username}/frequent-groups` : null, + }; + }, + i18n: { + groupName: s__('Navigation|Frequently visited groups'), + viewAllText: s__('Navigation|View all my groups'), + emptyStateText: s__('Navigation|Groups you visit often will appear here.'), + }, + MAX_FREQUENT_GROUPS_COUNT, +}; +</script> + +<template> + <frequent-items + :empty-state-text="$options.i18n.emptyStateText" + :group-name="$options.i18n.groupName" + :max-items="$options.MAX_FREQUENT_GROUPS_COUNT" + :storage-key="storageKey" + view-all-items-icon="group" + :view-all-items-text="$options.i18n.viewAllText" + :view-all-items-path="groupsPath" + v-bind="$attrs" + v-on="$listeners" + /> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item.vue new file mode 100644 index 00000000000..5371887ee0f --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_item.vue @@ -0,0 +1,64 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; +import { __ } from '~/locale'; + +export default { + name: 'FrequentlyVisitedItem', + components: { + GlButton, + ProjectAvatar, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + item: { + type: Object, + required: true, + }, + }, + methods: { + onRemove() { + this.$emit('remove', this.item); + }, + }, + i18n: { + remove: __('Remove'), + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center gl-gap-3"> + <project-avatar + :project-id="item.id" + :project-name="item.title" + :project-avatar-url="item.avatar" + :size="24" + aria-hidden="true" + /> + + <div class="gl-flex-grow-1 gl-truncate-end"> + {{ item.title }} + <div + v-if="item.subtitle" + data-testid="subtitle" + class="gl-font-sm gl-text-gray-500 gl-truncate-end" + > + {{ item.subtitle }} + </div> + </div> + + <gl-button + v-gl-tooltip.left + icon="dash" + category="tertiary" + :aria-label="$options.i18n.remove" + :title="$options.i18n.remove" + class="show-on-focus-or-hover--target" + @click.stop.prevent="onRemove" + @keydown.enter.stop.prevent="onRemove" + /> + </div> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue new file mode 100644 index 00000000000..382d844ceee --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_items.vue @@ -0,0 +1,133 @@ +<script> +import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlIcon } from '@gitlab/ui'; +import { truncateNamespace } from '~/lib/utils/text_utility'; +import { getItemsFromLocalStorage, removeItemFromLocalStorage } from '~/super_sidebar/utils'; +import FrequentItem from './frequent_item.vue'; + +export default { + name: 'FrequentlyVisitedItems', + components: { + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, + GlIcon, + FrequentItem, + }, + props: { + emptyStateText: { + type: String, + required: true, + }, + groupName: { + type: String, + required: true, + }, + maxItems: { + type: Number, + required: true, + }, + storageKey: { + type: String, + required: false, + default: null, + }, + viewAllItemsText: { + type: String, + required: true, + }, + viewAllItemsIcon: { + type: String, + required: true, + }, + viewAllItemsPath: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + items: getItemsFromLocalStorage({ + storageKey: this.storageKey, + maxItems: this.maxItems, + }), + }; + }, + computed: { + formattedItems() { + // Each item needs two different representations. One is for the + // GlDisclosureDropdownItem, and the other is for the FrequentItem + // renderer component inside it. + return this.items.map((item) => ({ + forDropdown: { + id: item.id, + + // The text field satsifies GlDisclosureDropdownItem's prop + // validator, and the href field ensures it renders a link. + text: item.name, + href: item.webUrl, + }, + forRenderer: { + id: item.id, + title: item.name, + subtitle: truncateNamespace(item.namespace), + avatar: item.avatarUrl, + }, + })); + }, + showEmptyState() { + return this.items.length === 0; + }, + viewAllItem() { + return { + text: this.viewAllItemsText, + href: this.viewAllItemsPath, + }; + }, + }, + created() { + if (!this.storageKey) { + this.$emit('nothing-to-render'); + } + }, + methods: { + removeItem(item) { + removeItemFromLocalStorage({ + storageKey: this.storageKey, + item, + }); + + this.items = this.items.filter((i) => i.id !== item.id); + }, + }, +}; +</script> + +<template> + <gl-disclosure-dropdown-group v-if="storageKey" v-bind="$attrs"> + <template #group-label>{{ groupName }}</template> + + <gl-disclosure-dropdown-item + v-for="item of formattedItems" + :key="item.forDropdown.id" + :item="item.forDropdown" + class="show-on-focus-or-hover--context" + > + <template #list-item + ><frequent-item :item="item.forRenderer" @remove="removeItem" + /></template> + </gl-disclosure-dropdown-item> + + <gl-disclosure-dropdown-item v-if="showEmptyState" class="gl-cursor-text"> + <span class="gl-text-gray-500 gl-font-sm gl-my-3 gl-mx-3">{{ emptyStateText }}</span> + </gl-disclosure-dropdown-item> + + <gl-disclosure-dropdown-item key="all" :item="viewAllItem"> + <template #list-item> + <span> + <gl-icon :name="viewAllItemsIcon" class="gl-w-6!" /> + {{ viewAllItemsText }} + </span> + </template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown-group> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_projects.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_projects.vue new file mode 100644 index 00000000000..35b254099c2 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/frequent_projects.vue @@ -0,0 +1,40 @@ +<script> +import { s__ } from '~/locale'; +import { MAX_FREQUENT_PROJECTS_COUNT } from '~/super_sidebar/constants'; +import FrequentItems from './frequent_items.vue'; + +export default { + name: 'FrequentlyVisitedProjects', + components: { + FrequentItems, + }, + inject: ['projectsPath'], + data() { + const username = gon.current_username; + + return { + storageKey: username ? `${username}/frequent-projects` : null, + }; + }, + i18n: { + groupName: s__('Navigation|Frequently visited projects'), + viewAllText: s__('Navigation|View all my projects'), + emptyStateText: s__('Navigation|Projects you visit often will appear here.'), + }, + MAX_FREQUENT_PROJECTS_COUNT, +}; +</script> + +<template> + <frequent-items + :empty-state-text="$options.i18n.emptyStateText" + :group-name="$options.i18n.groupName" + :max-items="$options.MAX_FREQUENT_PROJECTS_COUNT" + :storage-key="storageKey" + view-all-items-icon="project" + :view-all-items-text="$options.i18n.viewAllText" + :view-all-items-path="projectsPath" + v-bind="$attrs" + v-on="$listeners" + /> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue index 0c325f6d13f..27935d92a5c 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue @@ -1,8 +1,10 @@ <script> import DefaultPlaces from './global_search_default_places.vue'; import DefaultIssuables from './global_search_default_issuables.vue'; +import FrequentGroups from './frequent_groups.vue'; +import FrequentProjects from './frequent_projects.vue'; -const components = [DefaultPlaces, DefaultIssuables]; +const components = [DefaultPlaces, FrequentProjects, FrequentGroups, DefaultIssuables]; export default { name: 'GlobalSearchDefaultItems', diff --git a/app/assets/javascripts/super_sidebar/components/items_list.vue b/app/assets/javascripts/super_sidebar/components/items_list.vue index 764db490751..1bad13f91e8 100644 --- a/app/assets/javascripts/super_sidebar/components/items_list.vue +++ b/app/assets/javascripts/super_sidebar/components/items_list.vue @@ -19,7 +19,13 @@ export default { <template> <ul class="gl-p-0 gl-list-style-none"> - <nav-item v-for="item in items" :key="item.id" :item="item" is-subitem> + <nav-item + v-for="item in items" + :key="item.id" + :item="item" + is-subitem + class="show-on-focus-or-hover--context" + > <template #icon> <project-avatar :project-id="item.id" diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue index 25d2b8a73ed..58d27e43e61 100644 --- a/app/assets/javascripts/super_sidebar/components/nav_item.vue +++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue @@ -128,7 +128,7 @@ export default { :is="navItemLinkComponent" #default="{ isActive }" v-bind="linkProps" - class="nav-item-link gl-relative gl-display-flex gl-align-items-center gl-min-h-7 gl-gap-3 gl-mb-1 gl-py-2 gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-focus--focus" + class="nav-item-link gl-relative gl-display-flex gl-align-items-center gl-min-h-7 gl-gap-3 gl-mb-1 gl-py-2 gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-focus--focus show-on-focus-or-hover--context" :class="computedLinkClasses" data-qa-selector="nav_item_link" data-testid="nav-item-link" @@ -146,7 +146,7 @@ export default { <gl-icon v-else-if="isInPinnedSection" name="grip" - class="gl-m-auto gl-text-gray-400 draggable-icon" + class="gl-m-auto gl-text-gray-400 js-draggable-icon gl-cursor-grab show-on-focus-or-hover--target" /> </slot> </div> @@ -172,6 +172,7 @@ export default { size="small" category="tertiary" icon="thumbtack" + class="show-on-focus-or-hover--target" :aria-label="$options.i18n.pinItem" @click.prevent="$emit('pin-add', item.id)" /> @@ -182,6 +183,7 @@ export default { category="tertiary" :aria-label="$options.i18n.unpinItem" icon="thumbtack-solid" + class="show-on-focus-or-hover--target" @click.prevent="$emit('pin-remove', item.id)" /> </span> diff --git a/app/assets/javascripts/super_sidebar/components/pinned_section.vue b/app/assets/javascripts/super_sidebar/components/pinned_section.vue index a6cc70963df..1e2201fbdff 100644 --- a/app/assets/javascripts/super_sidebar/components/pinned_section.vue +++ b/app/assets/javascripts/super_sidebar/components/pinned_section.vue @@ -94,7 +94,7 @@ export default { v-model="draggableItems" class="gl-p-0 gl-m-0" data-testid="pinned-nav-items" - handle=".draggable-icon" + handle=".js-draggable-icon" tag="ul" @end="handleDrag" > diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js index 9e3d2e383e8..2b62e7a6ede 100644 --- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js +++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js @@ -81,6 +81,9 @@ export const initSuperSidebar = () => { const sidebarData = JSON.parse(sidebar); const searchData = convertObjectPropsToCamelCase(sidebarData.search); + const projectsPath = sidebarData.projects_path; + const groupsPath = sidebarData.groups_path; + const commandPaletteData = JSON.parse(commandPalette); const projectFilesPath = commandPaletteData.project_files_url; const projectBlobPath = commandPaletteData.project_blob_url; @@ -107,6 +110,8 @@ export const initSuperSidebar = () => { searchContext, projectFilesPath, projectBlobPath, + projectsPath, + groupsPath, }, store: createStore({ searchPath, diff --git a/app/assets/javascripts/super_sidebar/utils.js b/app/assets/javascripts/super_sidebar/utils.js index 5b46425d223..cbf93155fb6 100644 --- a/app/assets/javascripts/super_sidebar/utils.js +++ b/app/assets/javascripts/super_sidebar/utils.js @@ -36,17 +36,15 @@ export const getTopFrequentItems = (items, maxCount) => { return frequentItems.slice(0, maxCount); }; -const updateItemAccess = (item) => { +const updateItemAccess = (contextItem, { lastAccessedOn, frequency = 0 } = {}) => { const now = Date.now(); - const neverAccessed = !item.lastAccessedOn; - const shouldUpdate = - neverAccessed || Math.abs(now - item.lastAccessedOn) / FIFTEEN_MINUTES_IN_MS > 1; - const currentFrequency = item.frequency ?? 0; + const neverAccessed = !lastAccessedOn; + const shouldUpdate = neverAccessed || Math.abs(now - lastAccessedOn) / FIFTEEN_MINUTES_IN_MS > 1; return { - ...item, - frequency: shouldUpdate ? currentFrequency + 1 : currentFrequency, - lastAccessedOn: shouldUpdate ? now : item.lastAccessedOn, + ...contextItem, + frequency: shouldUpdate ? frequency + 1 : frequency, + lastAccessedOn: shouldUpdate ? now : lastAccessedOn, }; }; @@ -63,7 +61,7 @@ export const trackContextAccess = (username, context) => { ); if (existingItemIndex > -1) { - storedItems[existingItemIndex] = updateItemAccess(storedItems[existingItemIndex]); + storedItems[existingItemIndex] = updateItemAccess(context.item, storedItems[existingItemIndex]); } else { const newItem = updateItemAccess(context.item); if (storedItems.length === FREQUENT_ITEMS.MAX_COUNT) { diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss index 2586f544d94..8610c41b43f 100644 --- a/app/assets/stylesheets/framework/super_sidebar.scss +++ b/app/assets/stylesheets/framework/super_sidebar.scss @@ -159,33 +159,12 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; } .nav-item-link { - button, - .draggable-icon { - opacity: 0; - } - - .draggable-icon { - cursor: grab; - } - - &:hover { - button, - .draggable-icon { - opacity: 1; - } - } - &:hover, &:focus-within { .nav-item-badge { opacity: 0; } } - - &:focus button, - button:focus { - opacity: 1; - } } #trial-status-sidebar-widget:hover { @@ -315,3 +294,20 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; } } } + +.show-on-focus-or-hover--context { + .show-on-focus-or-hover--target { + opacity: 0; + } + + &:hover, + &:focus { + .show-on-focus-or-hover--target { + opacity: 1; + } + } + + .show-on-focus-or-hover--target:focus { + opacity: 1; + } +} diff --git a/app/models/project.rb b/app/models/project.rb index d3e2db2bb2a..0b0de2bf7c4 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -3203,7 +3203,7 @@ class Project < ApplicationRecord end def linked_work_items_feature_flag_enabled? - group&.linked_work_items_feature_flag_enabled? || Feature.enabled?(:linked_work_items) + group&.linked_work_items_feature_flag_enabled? || Feature.enabled?(:linked_work_items, self) end def enqueue_record_project_target_platforms diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml index 606d4e06d33..9ec4363fa9a 100644 --- a/app/views/projects/merge_requests/_widget.html.haml +++ b/app/views/projects/merge_requests/_widget.html.haml @@ -13,7 +13,7 @@ window.gl.mrWidgetData.pipeline_must_succeed_docs_path = '#{help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds.md', anchor: 'require-a-successful-pipeline-for-merge')}'; window.gl.mrWidgetData.code_coverage_check_help_page_path = '#{help_page_path('ci/testing/code_coverage.md', anchor: 'coverage-check-approval-rule')}'; window.gl.mrWidgetData.security_configuration_path = '#{project_security_configuration_path(@project)}'; - window.gl.mrWidgetData.license_compliance_docs_path = '#{help_page_path('user/compliance/license_compliance/index.md')}'; + window.gl.mrWidgetData.license_compliance_docs_path = '#{help_page_path('user/compliance/license_scanning_of_cyclonedx_files')}'; window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/approvals/rules.md', anchor: 'eligible-approvers')}'; window.gl.mrWidgetData.approvals_help_path = '#{help_page_path("user/project/merge_requests/approvals/index.md")}'; window.gl.mrWidgetData.pipelines_empty_svg_path = '#{image_path('illustrations/empty-state/empty-pipeline-md.svg')}'; diff --git a/config/feature_flags/development/pat_reuse_detection.yml b/config/feature_flags/development/pat_reuse_detection.yml deleted file mode 100644 index 8000b362296..00000000000 --- a/config/feature_flags/development/pat_reuse_detection.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: pat_reuse_detection -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/126600 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/418336 -milestone: '16.2' -type: development -group: group::authentication and authorization -default_enabled: false diff --git a/config/metrics/schema.json b/config/metrics/schema.json index 90951f1b3dc..ddd13a6104a 100644 --- a/config/metrics/schema.json +++ b/config/metrics/schema.json @@ -19,13 +19,6 @@ "key_path": { "type": "string" }, - "name": { - "type": [ - "string", - "null" - ], - "pattern": "^([a-z]+_)*[a-z]+$" - }, "description": { "type": "string" }, diff --git a/doc/administration/snippets/index.md b/doc/administration/snippets/index.md index 6f165a8b0b0..b59432b0b9e 100644 --- a/doc/administration/snippets/index.md +++ b/doc/administration/snippets/index.md @@ -5,45 +5,28 @@ group: Source Code info: "To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments" --- -# Snippets settings **(FREE SELF)** +# Snippets **(FREE SELF)** -Adjust the snippets' settings of your GitLab instance. - -## Snippets content size limit - -You can set a maximum content size limit for snippets. This limit can prevent -abuse of the feature. The default value is **52428800 Bytes** (50 MB). - -### How does it work? - -The content size limit is applied when a snippet is created or updated. - -This limit doesn't affect existing snippets until they're updated and their +You can configure a maximum size for a snippet to prevent abuse. +The default limit is 52428800 bytes (50 MB). +The limit is applied when a snippet is created or updated. +The limit does not affect existing snippets unless they are updated and their content changes. -### Snippets size limit configuration +## Configure the snippet size limit -This setting is not available through the [Admin Area settings](../settings/index.md). -To configure this setting, use either the Rails console +To configure the snippet size limit, you can use the Rails console or the [Application settings API](../../api/settings.md). -NOTE: -The value of the limit **must** be in bytes. - -#### Through the Rails console +The limit **must** be in bytes. -The steps to configure this setting through the Rails console are: +This setting is not available in the [Admin Area settings](../settings/index.md). -1. Start the Rails console: +### Use the Rails console - ```shell - # For Linux package (Omnibus) installations - sudo gitlab-rails console - - # For installations from source - sudo -u git -H bundle exec rails console -e production - ``` +To configure this setting through the Rails console: +1. [Start the Rails console](../operations/rails_console.md#starting-a-rails-console-session). 1. Update the snippets maximum file size: ```ruby @@ -56,10 +39,11 @@ To retrieve the current value, start the Rails console and run: Gitlab::CurrentSettings.snippet_size_limit ``` -#### Through the API +### Use the API -To set the snippets size limit through the Application Settings API (similar to -[updating any other setting](../../api/settings.md#change-application-settings)), use this command: +To set the limit by using the Application Settings API +(similar to [updating any other setting](../../api/settings.md#change-application-settings)), +use this command: ```shell curl --request PUT \ diff --git a/doc/development/ai_features.md b/doc/development/ai_features.md index 9074d41b46f..5b52893f397 100644 --- a/doc/development/ai_features.md +++ b/doc/development/ai_features.md @@ -1,5 +1,5 @@ --- -stage: ModelOps +stage: AI-powered group: AI Framework info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments --- diff --git a/doc/development/documentation/styleguide/index.md b/doc/development/documentation/styleguide/index.md index 08f64c68142..8db96b694e8 100644 --- a/doc/development/documentation/styleguide/index.md +++ b/doc/development/documentation/styleguide/index.md @@ -1596,8 +1596,7 @@ When names change, it is more complicated to search or grep text that has line b ### Product tier badges -Tier badges are displayed as orange text next to a topic title. These badges link to the GitLab -pricing page. +Tier badges provide information about a feature and are displayed next to the topic title. You should assign a tier badge: @@ -1606,37 +1605,58 @@ You should assign a tier badge: The H1 tier badge should be the badge that applies to the lowest tier for the features on the page. -Some pages won't have a tier badge, because no obvious tier badge applies. For example: +#### Available product tier badges -- Tutorials. -- Pages that compare features from different tiers. +Tier badges must include two components, in this order: a subscription tier and an offering. +These components are surrounded by bold and parentheses, for example `**(ULTIMATE SAAS)**`. + +Subscription tiers: + +- `FREE` - Applies to all tiers. +- `PREMIUM` - Applies to Premium and Ultimate tiers. +- `ULTIMATE` - Applies to Ultimate tier only. + +Offerings: + +- `SELF` +- `SAAS` +- `ALL` - Applies to both self-managed and SaaS. + +You can also add a third component for the feature's status: + +- `EXPERIMENT` +- `BETA` #### Add a tier badge -To add a tier badge to a topic title, add the relevant tier badge -after the title text. For example: +To add a tier badge to a topic title, add the two relevant components +after the title text. You must include the subscription tier first, and then the offering. +For example: + +```markdown +# Topic title **(FREE ALL)** +``` + +Optionally, you can add the feature status as the last part of the badge: ```markdown -# Topic title **(FREE)** +# Topic title **(FREE ALL EXPERIMENT)** ``` +##### Inline tier badges + Do not add tier badges inline with other text, except for [API attributes](../restful_api_styleguide.md). The single source of truth for a feature should be the topic where the functionality is described. -#### Available product tier badges +##### Pages that don't need a tier badge + +Some pages won't have a tier badge, because no obvious tier badge applies. For example: + +- Tutorials. +- Pages that compare features from different tiers. -| Where feature is available | Tier badge | -|:-----------------------------------------------------------------------------------------|:----------------------| -| On GitLab self-managed and GitLab SaaS, available in all tiers. | `**(FREE)**` | -| On GitLab self-managed and GitLab SaaS, available in Premium and Ultimate. | `**(PREMIUM)**` | -| On GitLab self-managed and GitLab SaaS, available in Ultimate. | `**(ULTIMATE)**` | -| On GitLab self-managed, available in all tiers. Not available on GitLab SaaS. | `**(FREE SELF)**` | -| On GitLab self-managed, available in Premium and Ultimate. Not available on GitLab SaaS. | `**(PREMIUM SELF)**` | -| On GitLab self-managed, available in Ultimate. Not available on GitLab SaaS. | `**(ULTIMATE SELF)**` | -| On GitLab SaaS, available in all tiers. Not available on self-managed. | `**(FREE SAAS)**` | -| On GitLab SaaS, available in Premium and Ultimate. Not available on self-managed. | `**(PREMIUM SAAS)**` | -| On GitLab SaaS, available in Ultimate. Not available on self-managed. | `**(ULTIMATE SAAS)**` | +##### Administrator documentation tier badges Topics that are only for instance administrators should be badged `<TIER> SELF`. Instance administrator documentation often includes sections that mention: @@ -1653,7 +1673,7 @@ instance administrator. Certain styles should be applied to specific sections. Styles for specific sections are outlined in this section. -## Help and feedback section +### Help and feedback section This section ([introduced](https://gitlab.com/gitlab-org/gitlab-docs/-/merge_requests/319) in GitLab 11.4) is displayed at the end of each document and can be omitted by adding a key into diff --git a/doc/operations/feature_flags.md b/doc/operations/feature_flags.md index 142bd9d898d..136a28a36fb 100644 --- a/doc/operations/feature_flags.md +++ b/doc/operations/feature_flags.md @@ -167,8 +167,7 @@ target users. See the [Ruby example](#ruby-application-example) below. Enables the feature for lists of users created [in the feature flags UI](#create-a-user-list), or with the [feature flag user list API](../api/feature_flag_user_lists.md). Similar to [User IDs](#user-ids), it uses the Unleash UsersIDs (`userWithId`) activation [strategy](https://docs.getunleash.io/reference/activation-strategies#userids). -It's not possible to *disable* a feature for members of a user list, but you can achieve the same -effect by enabling a feature for a user list that doesn't contain the excluded users. +You can't disable a specific feature for a user, but you can achieve similar results by enabling it for a user list. For example: @@ -221,8 +220,7 @@ To remove users from a user list: > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/300299) in GitLab 14.4. -Search your project and find any references of a feature flag in your -code so that you can clean it up when it's time to remove the feature flag. +To remove the feature flag from the code during cleanup, find any project references to it. To search for code references of a feature flag: @@ -413,8 +411,7 @@ This feature is similar to the [linked issues](../user/project/issues/related_is ## Performance factors -In general, GitLab feature flags can be used in any applications, -however, if it's a large application, it could require an additional configuration in advance. +GitLab feature flags can be used in any application. Large applications might require advance configuration. This section explains the performance factors to help your organization to identify what's needed to be done before using the feature. Read [How it works](#how-it-works) section before diving into the details. @@ -431,7 +428,7 @@ The polling rate is configurable in SDKs. Provided that all clients are requesti - Request once per minute ... 500 clients can be supported. - Request once per 15 sec ... 125 clients can be supported. -For applications looking for more scalable solution, we recommend to use [Unleash Proxy](#unleash-proxy-example). +For applications looking for more scalable solution, you should use [Unleash Proxy](#unleash-proxy-example). This proxy server sits between the server and clients. It requests to the server as a behalf of the client groups, so the number of outbound requests can be greatly reduced. @@ -452,7 +449,6 @@ Read the documentation in a SDK project for more information. Functionality-wise, there are no differences. Both SaaS and self-managed behave the same. In terms of scalability, it's up to the spec of the GitLab instance. -For example, GitLab.com runs on HA architecture so that it can handle a lot of requests concurrently, -however, a self-managed instance runs on a low spec machine can't expect the same result. +For example, GitLab.com uses HA architecture so it can handle many concurrent requests. However, self-managed instances on underpowered machines won't deliver comparable performance. See [Reference architectures](../administration/reference_architectures/index.md) for more information. diff --git a/doc/user/ai_features.md b/doc/user/ai_features.md index 4cca6bd5c66..9558e40d56f 100644 --- a/doc/user/ai_features.md +++ b/doc/user/ai_features.md @@ -1,6 +1,6 @@ --- -stage: ModelOps -group: AI Assisted +stage: AI-powered +group: AI Model Validation info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments type: index, reference --- diff --git a/doc/user/project/merge_requests/reviews/data_usage.md b/doc/user/project/merge_requests/reviews/data_usage.md index f0eb3c015b6..24e3b6a5667 100644 --- a/doc/user/project/merge_requests/reviews/data_usage.md +++ b/doc/user/project/merge_requests/reviews/data_usage.md @@ -1,6 +1,6 @@ --- -stage: ModelOps -group: Applied ML +stage: AI-powered +group: AI Model Validation info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments type: index, reference --- diff --git a/doc/user/project/repository/code_suggestions.md b/doc/user/project/repository/code_suggestions.md index f3a22fd94bd..75952f86230 100644 --- a/doc/user/project/repository/code_suggestions.md +++ b/doc/user/project/repository/code_suggestions.md @@ -1,6 +1,6 @@ --- -stage: ModelOps -group: AI Assisted +stage: AI-powered +group: AI Model Validation info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments type: index, reference --- diff --git a/doc/user/snippets.md b/doc/user/snippets.md index aace26b4bb0..fe9318a241b 100644 --- a/doc/user/snippets.md +++ b/doc/user/snippets.md @@ -247,8 +247,8 @@ GitLab forwards the spam to Akismet. than 10 files results in an error. - Revisions are not visible to the user on the GitLab UI, but [an issue exists](https://gitlab.com/gitlab-org/gitlab/-/issues/39271) for updates. -- The [maximum size for a snippet](../administration/snippets/index.md#snippets-content-size-limit) - is 50 MB, by default. +- The default [maximum size for a snippet](../administration/snippets/index.md) + is 50 MB. - Git LFS is not supported. ### Reduce snippets repository size diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb index 3568ff09cc4..a715f17ecd6 100644 --- a/lib/gitlab/auth/auth_finders.rb +++ b/lib/gitlab/auth/auth_finders.rb @@ -403,7 +403,6 @@ module Gitlab end def revoke_token_family(token) - return unless Feature.enabled?(:pat_reuse_detection) return unless access_token_rotation_request? PersonalAccessTokens::RevokeTokenFamilyService.new(token).execute diff --git a/lib/gitlab/ci/templates/Jobs/License-Scanning.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/License-Scanning.gitlab-ci.yml index b1c81e9ed5b..58846d31e2f 100644 --- a/lib/gitlab/ci/templates/Jobs/License-Scanning.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Jobs/License-Scanning.gitlab-ci.yml @@ -14,7 +14,7 @@ variables: SECURE_ANALYZERS_PREFIX: "$CI_TEMPLATE_REGISTRY_HOST/security-products" LICENSE_MANAGEMENT_SETUP_CMD: '' # If needed, specify a command to setup your environment with a custom package manager. - LICENSE_MANAGEMENT_VERSION: 4 + LICENSE_MANAGEMENT_VERSION: 'removed' license_scanning: stage: test diff --git a/lib/tasks/gitlab/tw/codeowners.rake b/lib/tasks/gitlab/tw/codeowners.rake index 68393cb31d2..cea66125fd0 100644 --- a/lib/tasks/gitlab/tw/codeowners.rake +++ b/lib/tasks/gitlab/tw/codeowners.rake @@ -21,7 +21,8 @@ namespace :tw do CODE_OWNER_RULES = [ # CodeOwnerRule.new('Activation', ''), # CodeOwnerRule.new('Acquisition', ''), - # CodeOwnerRule.new('AI Assisted', ''), + CodeOwnerRule.new('AI Framework', '@sselhorn'), + CodeOwnerRule.new('AI Model Validation', '@sselhorn'), CodeOwnerRule.new('Analytics Instrumentation', '@lciutacu'), CodeOwnerRule.new('Anti-Abuse', '@phillipwells'), CodeOwnerRule.new('Application Performance', '@jglassman1'), @@ -34,13 +35,14 @@ namespace :tw do CodeOwnerRule.new('Container Registry', '@marcel.amirault'), CodeOwnerRule.new('Contributor Experience', '@eread'), CodeOwnerRule.new('Database', '@aqualls'), - # CodeOwnerRule.new('DataOps', ''), + CodeOwnerRule.new('DataOps', '@sselhorn'), # CodeOwnerRule.new('Delivery', ''), CodeOwnerRule.new('Development', '@sselhorn'), CodeOwnerRule.new('Distribution', '@axil'), CodeOwnerRule.new('Distribution (Charts)', '@axil'), CodeOwnerRule.new('Distribution (Omnibus)', '@eread'), CodeOwnerRule.new('Documentation Guidelines', '@sselhorn'), + CodeOwnerRule.new('Duo Chat', '@sselhorn'), CodeOwnerRule.new('Dynamic Analysis', '@rdickenson'), CodeOwnerRule.new('IDE', '@ashrafkhamis'), CodeOwnerRule.new('Foundations', '@sselhorn'), @@ -53,7 +55,7 @@ namespace :tw do CodeOwnerRule.new('Import and Integrate', '@eread @ashrafkhamis'), CodeOwnerRule.new('Infrastructure', '@sselhorn'), # CodeOwnerRule.new('Knowledge', ''), - # CodeOwnerRule.new('MLOps', '') + CodeOwnerRule.new('MLOps', '@sselhorn'), # CodeOwnerRule.new('Observability', ''), CodeOwnerRule.new('Optimize', '@lciutacu'), CodeOwnerRule.new('Organization', '@lciutacu'), diff --git a/locale/gitlab.pot b/locale/gitlab.pot index ef92e099e2f..5afcc660e6a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -30892,6 +30892,12 @@ msgstr "" msgid "Navigation|Unpin item" msgstr "" +msgid "Navigation|View all my groups" +msgstr "" + +msgid "Navigation|View all my projects" +msgstr "" + msgid "Navigation|View all your groups" msgstr "" @@ -41207,6 +41213,9 @@ msgstr "" msgid "SAML|Your organization's SSO has been connected to your GitLab account" msgstr "" +msgid "SBOMs last updated" +msgstr "" + msgid "SCIM|SCIM Token" msgstr "" @@ -41982,6 +41991,9 @@ msgstr "" msgid "Security dashboard" msgstr "" +msgid "Security reports last updated" +msgstr "" + msgid "SecurityApprovals|A merge request approval is required when test coverage declines." msgstr "" diff --git a/spec/frontend/super_sidebar/components/global_search/components/frequent_groups_spec.js b/spec/frontend/super_sidebar/components/global_search/components/frequent_groups_spec.js new file mode 100644 index 00000000000..e63768a03c0 --- /dev/null +++ b/spec/frontend/super_sidebar/components/global_search/components/frequent_groups_spec.js @@ -0,0 +1,63 @@ +import { shallowMount } from '@vue/test-utils'; +import FrequentItems from '~/super_sidebar/components/global_search/components/frequent_items.vue'; +import FrequentGroups from '~/super_sidebar/components/global_search/components/frequent_groups.vue'; + +describe('FrequentlyVisitedGroups', () => { + let wrapper; + + const groupsPath = '/mock/group/path'; + + const createComponent = (options) => { + wrapper = shallowMount(FrequentGroups, { + provide: { + groupsPath, + }, + ...options, + }); + }; + + const findFrequentItems = () => wrapper.findComponent(FrequentItems); + const receivedAttrs = (wrapperInstance) => ({ + // See https://github.com/vuejs/test-utils/issues/2151. + ...wrapperInstance.vm.$attrs, + }); + + it('passes group-specific props', () => { + createComponent(); + + expect(findFrequentItems().props()).toMatchObject({ + emptyStateText: 'Groups you visit often will appear here.', + groupName: 'Frequently visited groups', + maxItems: 3, + storageKey: null, + viewAllItemsIcon: 'group', + viewAllItemsText: 'View all my groups', + viewAllItemsPath: groupsPath, + }); + }); + + it('with a user, passes a storage key string to FrequentItems', () => { + gon.current_username = 'test_user'; + createComponent(); + + expect(findFrequentItems().props('storageKey')).toBe('test_user/frequent-groups'); + }); + + it('passes attrs to FrequentItems', () => { + createComponent({ attrs: { bordered: true, class: 'test-class' } }); + + expect(findFrequentItems().classes()).toContain('test-class'); + expect(receivedAttrs(findFrequentItems())).toMatchObject({ + bordered: true, + }); + }); + + it('forwards listeners to FrequentItems', () => { + const spy = jest.fn(); + createComponent({ listeners: { 'nothing-to-render': spy } }); + + findFrequentItems().vm.$emit('nothing-to-render'); + + expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/spec/frontend/super_sidebar/components/global_search/components/frequent_item_spec.js b/spec/frontend/super_sidebar/components/global_search/components/frequent_item_spec.js new file mode 100644 index 00000000000..aae1fc543f9 --- /dev/null +++ b/spec/frontend/super_sidebar/components/global_search/components/frequent_item_spec.js @@ -0,0 +1,98 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import FrequentItem from '~/super_sidebar/components/global_search/components/frequent_item.vue'; +import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; +import { stubComponent } from 'helpers/stub_component'; + +describe('FrequentlyVisitedItem', () => { + let wrapper; + + const mockItem = { + id: 123, + title: 'mockTitle', + subtitle: 'mockSubtitle', + avatar: '/mock/avatar.png', + }; + + const createComponent = () => { + wrapper = shallowMountExtended(FrequentItem, { + propsData: { + item: mockItem, + }, + stubs: { + GlButton: stubComponent(GlButton, { + template: '<button type="button" v-on="$listeners"></button>', + }), + }, + }); + }; + + const findProjectAvatar = () => wrapper.findComponent(ProjectAvatar); + const findRemoveButton = () => wrapper.findByRole('button'); + const findSubtitle = () => wrapper.findByTestId('subtitle'); + + beforeEach(() => { + createComponent(); + }); + + it('renders the project avatar with the expected props', () => { + expect(findProjectAvatar().props()).toMatchObject({ + projectId: mockItem.id, + projectName: mockItem.title, + projectAvatarUrl: mockItem.avatar, + size: 24, + }); + }); + + it('renders the title and subtitle', () => { + expect(wrapper.text()).toContain(mockItem.title); + expect(findSubtitle().text()).toContain(mockItem.subtitle); + }); + + it('does not render the subtitle if not given', async () => { + await wrapper.setProps({ item: { ...mockItem, subtitle: null } }); + expect(findSubtitle().exists()).toBe(false); + }); + + describe('clicking the remove button', () => { + const bubbledClickSpy = jest.fn(); + const clickSpy = jest.fn(); + + beforeEach(() => { + wrapper.element.addEventListener('click', bubbledClickSpy); + const button = findRemoveButton(); + button.element.addEventListener('click', clickSpy); + button.trigger('click'); + }); + + it('emits a remove event on clicking the remove button', () => { + expect(wrapper.emitted('remove')).toEqual([[mockItem]]); + }); + + it('stops the native event from bubbling and prevents its default behavior', () => { + expect(bubbledClickSpy).not.toHaveBeenCalled(); + expect(clickSpy.mock.calls[0][0].defaultPrevented).toBe(true); + }); + }); + + describe('pressing enter on the remove button', () => { + const bubbledKeydownSpy = jest.fn(); + const keydownSpy = jest.fn(); + + beforeEach(() => { + wrapper.element.addEventListener('keydown', bubbledKeydownSpy); + const button = findRemoveButton(); + button.element.addEventListener('keydown', keydownSpy); + button.trigger('keydown.enter'); + }); + + it('emits a remove event on clicking the remove button', () => { + expect(wrapper.emitted('remove')).toEqual([[mockItem]]); + }); + + it('stops the native event from bubbling and prevents its default behavior', () => { + expect(bubbledKeydownSpy).not.toHaveBeenCalled(); + expect(keydownSpy.mock.calls[0][0].defaultPrevented).toBe(true); + }); + }); +}); diff --git a/spec/frontend/super_sidebar/components/global_search/components/frequent_items_spec.js b/spec/frontend/super_sidebar/components/global_search/components/frequent_items_spec.js new file mode 100644 index 00000000000..4700e9c7e10 --- /dev/null +++ b/spec/frontend/super_sidebar/components/global_search/components/frequent_items_spec.js @@ -0,0 +1,159 @@ +import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlIcon } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import GlobalSearchFrequentItems from '~/super_sidebar/components/global_search/components/frequent_items.vue'; +import FrequentItem from '~/super_sidebar/components/global_search/components/frequent_item.vue'; +import { getItemsFromLocalStorage, removeItemFromLocalStorage } from '~/super_sidebar/utils'; +import { cachedFrequentProjects } from 'jest/super_sidebar/mock_data'; + +jest.mock('~/super_sidebar/utils', () => { + const original = jest.requireActual('~/super_sidebar/utils'); + + return { + ...original, + getItemsFromLocalStorage: jest.fn(), + removeItemFromLocalStorage: jest.fn(), + }; +}); + +describe('FrequentlyVisitedItems', () => { + let wrapper; + const storageKey = 'mockStorageKey'; + const mockStoredItems = JSON.parse(cachedFrequentProjects); + const mockProps = { + emptyStateText: 'mock empty state text', + groupName: 'mock group name', + maxItems: 42, + storageKey, + viewAllItemsText: 'View all items', + viewAllItemsIcon: 'question-o', + viewAllItemsPath: '/mock/all_items', + }; + + const createComponent = (props) => { + wrapper = shallowMountExtended(GlobalSearchFrequentItems, { + propsData: { + ...mockProps, + ...props, + }, + stubs: { + GlDisclosureDropdownGroup, + }, + }); + }; + + const findItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem); + const findItemRenderer = (root) => root.findComponent(FrequentItem); + + const setStoredItems = (items) => { + getItemsFromLocalStorage.mockReturnValue(items); + }; + + beforeEach(() => { + setStoredItems(mockStoredItems); + }); + + describe('without a storage key', () => { + beforeEach(() => { + createComponent({ storageKey: null }); + }); + + it('does not render anything', () => { + expect(wrapper.html()).toBe(''); + }); + + it('emits a nothing-to-render event', () => { + expect(wrapper.emitted('nothing-to-render')).toEqual([[]]); + }); + }); + + describe('with a storageKey', () => { + beforeEach(() => { + createComponent(); + }); + + describe('common behavior', () => { + it('calls getItemsFromLocalStorage', () => { + expect(getItemsFromLocalStorage).toHaveBeenCalledWith({ + storageKey, + maxItems: mockProps.maxItems, + }); + }); + + it('renders the group name', () => { + expect(wrapper.text()).toContain(mockProps.groupName); + }); + + it('renders the view all items link', () => { + const lastItem = findItems().at(-1); + expect(lastItem.props('item')).toMatchObject({ + text: mockProps.viewAllItemsText, + href: mockProps.viewAllItemsPath, + }); + + const icon = lastItem.findComponent(GlIcon); + expect(icon.props('name')).toBe(mockProps.viewAllItemsIcon); + }); + }); + + describe('with stored items', () => { + it('renders the items', () => { + const items = findItems(); + + mockStoredItems.forEach((storedItem, index) => { + const dropdownItem = items.at(index); + + // Check GlDisclosureDropdownItem's item has the right structure + expect(dropdownItem.props('item')).toMatchObject({ + text: storedItem.name, + href: storedItem.webUrl, + }); + + // Check FrequentItem's item has the right structure + expect(findItemRenderer(dropdownItem).props('item')).toMatchObject({ + id: storedItem.id, + title: storedItem.name, + subtitle: expect.any(String), + avatar: storedItem.avatarUrl, + }); + }); + }); + + it('does not render the empty state text', () => { + expect(wrapper.text()).not.toContain('mock empty state text'); + }); + + describe('removing an item', () => { + let itemToRemove; + + beforeEach(() => { + const itemRenderer = findItemRenderer(findItems().at(0)); + itemToRemove = itemRenderer.props('item'); + itemRenderer.vm.$emit('remove', itemToRemove); + }); + + it('calls removeItemFromLocalStorage when an item emits a remove event', () => { + expect(removeItemFromLocalStorage).toHaveBeenCalledWith({ + storageKey, + item: itemToRemove, + }); + }); + + it('no longer renders that item', () => { + const renderedItemTexts = findItems().wrappers.map((item) => item.props('item').text); + expect(renderedItemTexts).not.toContain(itemToRemove.text); + }); + }); + }); + }); + + describe('with no stored items', () => { + beforeEach(() => { + setStoredItems([]); + createComponent(); + }); + + it('renders the empty state text', () => { + expect(wrapper.text()).toContain(mockProps.emptyStateText); + }); + }); +}); diff --git a/spec/frontend/super_sidebar/components/global_search/components/frequent_projects_spec.js b/spec/frontend/super_sidebar/components/global_search/components/frequent_projects_spec.js new file mode 100644 index 00000000000..7554c123574 --- /dev/null +++ b/spec/frontend/super_sidebar/components/global_search/components/frequent_projects_spec.js @@ -0,0 +1,63 @@ +import { shallowMount } from '@vue/test-utils'; +import FrequentItems from '~/super_sidebar/components/global_search/components/frequent_items.vue'; +import FrequentProjects from '~/super_sidebar/components/global_search/components/frequent_projects.vue'; + +describe('FrequentlyVisitedProjects', () => { + let wrapper; + + const projectsPath = '/mock/project/path'; + + const createComponent = (options) => { + wrapper = shallowMount(FrequentProjects, { + provide: { + projectsPath, + }, + ...options, + }); + }; + + const findFrequentItems = () => wrapper.findComponent(FrequentItems); + const receivedAttrs = (wrapperInstance) => ({ + // See https://github.com/vuejs/test-utils/issues/2151. + ...wrapperInstance.vm.$attrs, + }); + + it('passes project-specific props', () => { + createComponent(); + + expect(findFrequentItems().props()).toMatchObject({ + emptyStateText: 'Projects you visit often will appear here.', + groupName: 'Frequently visited projects', + maxItems: 5, + storageKey: null, + viewAllItemsIcon: 'project', + viewAllItemsText: 'View all my projects', + viewAllItemsPath: projectsPath, + }); + }); + + it('with a user, passes a storage key string to FrequentItems', () => { + gon.current_username = 'test_user'; + createComponent(); + + expect(findFrequentItems().props('storageKey')).toBe('test_user/frequent-projects'); + }); + + it('passes attrs to FrequentItems', () => { + createComponent({ attrs: { bordered: true, class: 'test-class' } }); + + expect(findFrequentItems().classes()).toContain('test-class'); + expect(receivedAttrs(findFrequentItems())).toMatchObject({ + bordered: true, + }); + }); + + it('forwards listeners to FrequentItems', () => { + const spy = jest.fn(); + createComponent({ listeners: { 'nothing-to-render': spy } }); + + findFrequentItems().vm.$emit('nothing-to-render'); + + expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/spec/frontend/super_sidebar/components/global_search/components/global_search_default_items_spec.js b/spec/frontend/super_sidebar/components/global_search/components/global_search_default_items_spec.js index 48844c565e3..d0d812c10ed 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/global_search_default_items_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/global_search_default_items_spec.js @@ -2,6 +2,8 @@ import { shallowMount } from '@vue/test-utils'; import GlobalSearchDefaultItems from '~/super_sidebar/components/global_search/components/global_search_default_items.vue'; import GlobalSearchDefaultPlaces from '~/super_sidebar/components/global_search/components/global_search_default_places.vue'; +import FrequentProjects from '~/super_sidebar/components/global_search/components/frequent_projects.vue'; +import FrequentGroups from '~/super_sidebar/components/global_search/components/frequent_groups.vue'; import GlobalSearchDefaultIssuables from '~/super_sidebar/components/global_search/components/global_search_default_issuables.vue'; describe('GlobalSearchDefaultItems', () => { @@ -12,6 +14,8 @@ describe('GlobalSearchDefaultItems', () => { }; const findPlaces = () => wrapper.findComponent(GlobalSearchDefaultPlaces); + const findProjects = () => wrapper.findComponent(FrequentProjects); + const findGroups = () => wrapper.findComponent(FrequentGroups); const findIssuables = () => wrapper.findComponent(GlobalSearchDefaultIssuables); const receivedAttrs = (wrapperInstance) => ({ // See https://github.com/vuejs/test-utils/issues/2151. @@ -25,6 +29,8 @@ describe('GlobalSearchDefaultItems', () => { describe('all child components can render', () => { it('renders the components', () => { expect(findPlaces().exists()).toBe(true); + expect(findProjects().exists()).toBe(true); + expect(findGroups().exists()).toBe(true); expect(findIssuables().exists()).toBe(true); }); @@ -34,27 +40,37 @@ describe('GlobalSearchDefaultItems', () => { expect(places.classes()).toEqual([]); }); - it('sets the expected props on second component', () => { - const issuables = findIssuables(); - expect(receivedAttrs(issuables)).toEqual({ bordered: true }); - expect(issuables.classes()).toEqual(['gl-mt-3']); + it('sets the expected props on the second component onwards', () => { + const components = [findProjects(), findGroups(), findIssuables()]; + components.forEach((component) => { + expect(receivedAttrs(component)).toEqual({ bordered: true }); + expect(component.classes()).toEqual(['gl-mt-3']); + }); }); }); - describe('when a child component emits nothing-to-render', () => { + describe('when child components emit nothing-to-render', () => { beforeEach(() => { + // Emit from two elements to guard against naive index-based splicing findPlaces().vm.$emit('nothing-to-render'); + findIssuables().vm.$emit('nothing-to-render'); }); - it('does not render the component', () => { + it('does not render the components', () => { expect(findPlaces().exists()).toBe(false); - expect(findIssuables().exists()).toBe(true); + expect(findIssuables().exists()).toBe(false); }); it('sets the expected props on first component', () => { - const issuables = findIssuables(); - expect(receivedAttrs(issuables)).toEqual({}); - expect(issuables.classes()).toEqual([]); + const projects = findProjects(); + expect(receivedAttrs(projects)).toEqual({}); + expect(projects.classes()).toEqual([]); + }); + + it('sets the expected props on the second component', () => { + const groups = findGroups(); + expect(receivedAttrs(groups)).toEqual({ bordered: true }); + expect(groups.classes()).toEqual(['gl-mt-3']); }); }); }); diff --git a/spec/frontend/super_sidebar/utils_spec.js b/spec/frontend/super_sidebar/utils_spec.js index f7a7e8db24a..536599e6c12 100644 --- a/spec/frontend/super_sidebar/utils_spec.js +++ b/spec/frontend/super_sidebar/utils_spec.js @@ -70,7 +70,7 @@ describe('Super sidebar utils spec', () => { ); }); - it('updates existing item if it was persisted to the local storage over 15 minutes ago', () => { + it('updates existing item frequency/access time if it was persisted to the local storage over 15 minutes ago', () => { window.localStorage.setItem( storageKey, JSON.stringify([ @@ -95,7 +95,7 @@ describe('Super sidebar utils spec', () => { ); }); - it('leaves item as is if it was persisted to the local storage under 15 minutes ago', () => { + it('leaves item frequency/access time as is if it was persisted to the local storage under 15 minutes ago', () => { const jsonString = JSON.stringify([ { id: 1, @@ -114,6 +114,39 @@ describe('Super sidebar utils spec', () => { expect(window.localStorage.setItem).toHaveBeenLastCalledWith(storageKey, jsonString); }); + it('always updates stored item metadata', () => { + window.localStorage.setItem( + storageKey, + JSON.stringify([ + { + id: 1, + frequency: 2, + lastAccessedOn: Date.now(), + }, + ]), + ); + + trackContextAccess(username, { + ...context, + item: { + ...context.item, + avatarUrl: '/group.png', + }, + }); + + expect(window.localStorage.setItem).toHaveBeenCalledWith( + storageKey, + JSON.stringify([ + { + id: 1, + avatarUrl: '/group.png', + frequency: 2, + lastAccessedOn: Date.now(), + }, + ]), + ); + }); + it('replaces the least popular item in the local storage once the persisted items limit has been hit', () => { // Add the maximum amount of items to the local storage, in increasing popularity const storedItems = Array.from({ length: FREQUENT_ITEMS.MAX_COUNT }).map((_, i) => ({ diff --git a/spec/lib/gitlab/auth/auth_finders_spec.rb b/spec/lib/gitlab/auth/auth_finders_spec.rb index e5e93fa5cc1..b0ec46a3a0e 100644 --- a/spec/lib/gitlab/auth/auth_finders_spec.rb +++ b/spec/lib/gitlab/auth/auth_finders_spec.rb @@ -543,20 +543,6 @@ RSpec.describe Gitlab::Auth::AuthFinders, feature_category: :system_access do expect(token_1.reload).not_to be_revoked end end - - context 'when the feature flag is disabled' do - before do - stub_feature_flags(pat_reuse_detection: false) - end - - it 'does not revoke the latest rotated token' do - expect(token_1).not_to be_revoked - - expect { find_user_from_access_token }.to raise_error(Gitlab::Auth::RevokedError) - - expect(token_1.reload).not_to be_revoked - end - end end end end diff --git a/spec/lib/gitlab/usage/metric_definition_spec.rb b/spec/lib/gitlab/usage/metric_definition_spec.rb index d67bb477350..859f3f7a8d7 100644 --- a/spec/lib/gitlab/usage/metric_definition_spec.rb +++ b/spec/lib/gitlab/usage/metric_definition_spec.rb @@ -18,7 +18,6 @@ RSpec.describe Gitlab::Usage::MetricDefinition do data_source: 'database', distribution: %w(ee ce), tier: %w(free starter premium ultimate bronze silver gold), - name: 'uuid', data_category: 'standard', removed_by_url: 'http://gdk.test' } @@ -129,7 +128,6 @@ RSpec.describe Gitlab::Usage::MetricDefinition do :distribution | nil :distribution | 'test' :tier | %w(test ee) - :name | 'count_<adjective_describing>_boards' :repair_issue_url | nil :removed_by_url | 1 |