diff options
49 files changed, 656 insertions, 410 deletions
diff --git a/.rubocop_todo/layout/line_length.yml b/.rubocop_todo/layout/line_length.yml index 7424ab7ea26..5fcc55bb0b0 100644 --- a/.rubocop_todo/layout/line_length.yml +++ b/.rubocop_todo/layout/line_length.yml @@ -3666,7 +3666,6 @@ Layout/LineLength: - 'qa/qa/ee/page/project/secure/security_dashboard.rb' - 'qa/qa/ee/page/project/secure/show.rb' - 'qa/qa/ee/resource/license.rb' - - 'qa/qa/fixtures/auto_devops_rack/config.ru' - 'qa/qa/flow/sign_up.rb' - 'qa/qa/git/repository.rb' - 'qa/qa/page/base.rb' diff --git a/.rubocop_todo/style/lambda.yml b/.rubocop_todo/style/lambda.yml index 5b898417d96..525e2c31797 100644 --- a/.rubocop_todo/style/lambda.yml +++ b/.rubocop_todo/style/lambda.yml @@ -217,7 +217,6 @@ Style/Lambda: - 'lib/gitlab/sidekiq_signals.rb' - 'lib/gitlab/utils/measuring.rb' - 'lib/gitlab/visibility_level.rb' - - 'qa/qa/fixtures/auto_devops_rack/config.ru' - 'rubocop/cop/rspec/modify_sidekiq_middleware.rb' - 'rubocop/cop/rspec/timecop_freeze.rb' - 'rubocop/cop/rspec/timecop_travel.rb' diff --git a/Gemfile.lock b/Gemfile.lock index 7b78495dea2..4ba19e79aea 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1281,7 +1281,7 @@ GEM shellany (0.0.1) shoulda-matchers (5.1.0) activesupport (>= 5.2.0) - sidekiq (6.4.0) + sidekiq (6.4.2) connection_pool (>= 2.2.2) rack (~> 2.0) redis (>= 4.2.0) diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index cd5521c599e..0bd7371d39b 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -17,11 +17,6 @@ export default { GlLoadingIcon, EmptyState, }, - inject: { - renderEmptyState: { - default: false, - }, - }, props: { action: { type: String, @@ -45,6 +40,11 @@ export default { type: Boolean, required: true, }, + renderEmptyState: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -224,6 +224,9 @@ export default { }, showLegacyEmptyState() { const { containerEl } = this; + + if (!containerEl) return; + const contentListEl = containerEl.querySelector(CONTENT_LIST_CLASS); const emptyStateEl = containerEl.querySelector('.empty-state'); diff --git a/app/assets/javascripts/groups/components/overview_tabs.vue b/app/assets/javascripts/groups/components/overview_tabs.vue new file mode 100644 index 00000000000..53efb354f5c --- /dev/null +++ b/app/assets/javascripts/groups/components/overview_tabs.vue @@ -0,0 +1,80 @@ +<script> +import { GlTabs, GlTab } from '@gitlab/ui'; +import { __ } from '~/locale'; +import GroupsStore from '../store/groups_store'; +import GroupsService from '../service/groups_service'; +import { + ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, + ACTIVE_TAB_SHARED, + ACTIVE_TAB_ARCHIVED, +} from '../constants'; +import GroupsApp from './app.vue'; + +export default { + components: { GlTabs, GlTab, GroupsApp }, + inject: ['endpoints'], + data() { + return { + tabs: [ + { + title: this.$options.i18n.subgroupsAndProjects, + key: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, + renderEmptyState: true, + lazy: false, + service: new GroupsService(this.endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]), + store: new GroupsStore({ showSchemaMarkup: true }), + }, + { + title: this.$options.i18n.sharedProjects, + key: ACTIVE_TAB_SHARED, + renderEmptyState: false, + lazy: true, + service: new GroupsService(this.endpoints[ACTIVE_TAB_SHARED]), + store: new GroupsStore(), + }, + { + title: this.$options.i18n.archivedProjects, + key: ACTIVE_TAB_ARCHIVED, + renderEmptyState: false, + lazy: true, + service: new GroupsService(this.endpoints[ACTIVE_TAB_ARCHIVED]), + store: new GroupsStore(), + }, + ], + activeTabIndex: 0, + }; + }, + methods: { + handleTabInput(tabIndex) { + this.activeTabIndex = tabIndex; + + const tab = this.tabs[tabIndex]; + tab.lazy = false; + }, + }, + i18n: { + subgroupsAndProjects: __('Subgroups and projects'), + sharedProjects: __('Shared projects'), + archivedProjects: __('Archived projects'), + }, +}; +</script> + +<template> + <gl-tabs content-class="gl-pt-0" :value="activeTabIndex" @input="handleTabInput"> + <gl-tab + v-for="{ key, title, renderEmptyState, lazy, service, store } in tabs" + :key="key" + :title="title" + :lazy="lazy" + > + <groups-app + :action="key" + :service="service" + :store="store" + :hide-projects="false" + :render-empty-state="renderEmptyState" + /> + </gl-tab> + </gl-tabs> +</template> diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js index dc2909f2621..c3bf3f28509 100644 --- a/app/assets/javascripts/groups/index.js +++ b/app/assets/javascripts/groups/index.js @@ -52,7 +52,6 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { newSubgroupIllustration, newProjectIllustration, emptySubgroupIllustration, - renderEmptyState, canCreateSubgroups, canCreateProjects, currentGroupVisibility, @@ -65,7 +64,6 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { newSubgroupIllustration, newProjectIllustration, emptySubgroupIllustration, - renderEmptyState: parseBoolean(renderEmptyState), canCreateSubgroups: parseBoolean(canCreateSubgroups), canCreateProjects: parseBoolean(canCreateProjects), currentGroupVisibility, @@ -75,6 +73,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { const { dataset } = dataEl || this.$options.el; const hideProjects = parseBoolean(dataset.hideProjects); const showSchemaMarkup = parseBoolean(dataset.showSchemaMarkup); + const renderEmptyState = parseBoolean(dataset.renderEmptyState); const service = new GroupsService(endpoint || dataset.endpoint); const store = new GroupsStore({ hideProjects, showSchemaMarkup }); @@ -83,6 +82,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { store, service, hideProjects, + renderEmptyState, loading: true, containerId, }; @@ -119,6 +119,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { store: this.store, service: this.service, hideProjects: this.hideProjects, + renderEmptyState: this.renderEmptyState, containerId: this.containerId, }, }); diff --git a/app/assets/javascripts/groups/init_overview_tabs.js b/app/assets/javascripts/groups/init_overview_tabs.js new file mode 100644 index 00000000000..5f568d10a42 --- /dev/null +++ b/app/assets/javascripts/groups/init_overview_tabs.js @@ -0,0 +1,57 @@ +import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import GroupFolder from './components/group_folder.vue'; +import GroupItem from './components/group_item.vue'; +import { + ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, + ACTIVE_TAB_SHARED, + ACTIVE_TAB_ARCHIVED, +} from './constants'; +import OverviewTabs from './components/overview_tabs.vue'; + +export const initGroupOverviewTabs = () => { + const el = document.getElementById('js-group-overview-tabs'); + + if (!el) return false; + + Vue.component('GroupFolder', GroupFolder); + Vue.component('GroupItem', GroupItem); + Vue.use(GlToast); + + const { + newSubgroupPath, + newProjectPath, + newSubgroupIllustration, + newProjectIllustration, + emptySubgroupIllustration, + canCreateSubgroups, + canCreateProjects, + currentGroupVisibility, + subgroupsAndProjectsEndpoint, + sharedProjectsEndpoint, + archivedProjectsEndpoint, + } = el.dataset; + + return new Vue({ + el, + provide: { + newSubgroupPath, + newProjectPath, + newSubgroupIllustration, + newProjectIllustration, + emptySubgroupIllustration, + canCreateSubgroups: parseBoolean(canCreateSubgroups), + canCreateProjects: parseBoolean(canCreateProjects), + currentGroupVisibility, + endpoints: { + [ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]: subgroupsAndProjectsEndpoint, + [ACTIVE_TAB_SHARED]: sharedProjectsEndpoint, + [ACTIVE_TAB_ARCHIVED]: archivedProjectsEndpoint, + }, + }, + render(createElement) { + return createElement(OverviewTabs); + }, + }); +}; diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js index cc2608b5c62..4c6685820cf 100644 --- a/app/assets/javascripts/issuable/issuable_form.js +++ b/app/assets/javascripts/issuable/issuable_form.js @@ -39,6 +39,11 @@ function format(searchTerm, isFallbackKey = false) { return formattedQuery; } +function getSearchTerm(newIssuePath) { + const { search, pathname } = document.location; + return newIssuePath === pathname ? '' : format(search); +} + function getFallbackKey() { const searchTerm = format(document.location.search, true); return ['autosave', document.location.pathname, searchTerm].join('/'); @@ -72,7 +77,8 @@ export default class IssuableForm { this.reviewersSelect = new UsersSelect(undefined, '.js-reviewer-search'); this.zenMode = new ZenMode(); - this.newIssuePath = form[0].getAttribute(DATA_ISSUES_NEW_PATH); + this.searchTerm = getSearchTerm(form[0].getAttribute(DATA_ISSUES_NEW_PATH)); + this.fallbackKey = getFallbackKey(); this.titleField = this.form.find('input[name*="[title]"]'); this.descriptionField = this.form.find('textarea[name*="[description]"]'); if (!(this.titleField.length && this.descriptionField.length)) { @@ -109,20 +115,16 @@ export default class IssuableForm { } initAutosave() { - const { search, pathname } = document.location; - const searchTerm = this.newIssuePath === pathname ? '' : format(search); - const fallbackKey = getFallbackKey(); - - this.autosave = new Autosave( + this.autosaveTitle = new Autosave( this.titleField, - [document.location.pathname, searchTerm, 'title'], - `${fallbackKey}=title`, + [document.location.pathname, this.searchTerm, 'title'], + `${this.fallbackKey}=title`, ); - return new Autosave( + this.autosaveDescription = new Autosave( this.descriptionField, - [document.location.pathname, searchTerm, 'description'], - `${fallbackKey}=description`, + [document.location.pathname, this.searchTerm, 'description'], + `${this.fallbackKey}=description`, ); } @@ -131,8 +133,8 @@ export default class IssuableForm { } resetAutosave() { - this.titleField.data('autosave').reset(); - return this.descriptionField.data('autosave').reset(); + this.autosaveTitle.reset(); + this.autosaveDescription.reset(); } initWip() { diff --git a/app/assets/javascripts/pages/groups/details/index.js b/app/assets/javascripts/pages/groups/details/index.js index 0417134f2a7..92490368b15 100644 --- a/app/assets/javascripts/pages/groups/details/index.js +++ b/app/assets/javascripts/pages/groups/details/index.js @@ -1,3 +1,5 @@ +import { initGroupOverviewTabs } from '~/groups/init_overview_tabs'; import initGroupDetails from '../shared/group_details'; initGroupDetails('details'); +initGroupOverviewTabs(); diff --git a/app/assets/javascripts/pages/groups/show/index.js b/app/assets/javascripts/pages/groups/show/index.js index e4a84dd5eec..161fca83a58 100644 --- a/app/assets/javascripts/pages/groups/show/index.js +++ b/app/assets/javascripts/pages/groups/show/index.js @@ -1,5 +1,7 @@ import leaveByUrl from '~/namespaces/leave_by_url'; +import { initGroupOverviewTabs } from '~/groups/init_overview_tabs'; import initGroupDetails from '../shared/group_details'; leaveByUrl('group'); initGroupDetails(); +initGroupOverviewTabs(); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue index aa5ab87597f..300e2a672cb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue @@ -13,6 +13,7 @@ import Poll from '~/lib/utils/poll'; import { normalizeHeaders } from '~/lib/utils/common_utils'; import { EXTENSION_ICON_CLASS, EXTENSION_ICONS } from '../../constants'; import Actions from '../action_buttons.vue'; +import StateContainer from '../state_container.vue'; import StatusIcon from './status_icon.vue'; import ChildContent from './child_content.vue'; import { createTelemetryHub } from './telemetry'; @@ -36,6 +37,7 @@ export default { ChildContent, DynamicScroller, DynamicScrollerItem, + StateContainer, }, directives: { SafeHtml: GlSafeHtmlDirective, @@ -312,18 +314,15 @@ export default { data-testid="widget-extension" data-qa-selector="mr_widget_extension" > - <div + <state-container + :mr="mr" + :status="statusIconName" + :is-loading="isLoadingSummary" :class="{ 'gl-cursor-pointer': isCollapsible }" - class="media gl-p-5" + class="gl-p-5" @mousedown="onRowMouseDown" @mouseup="onRowMouseUp" > - <status-icon - :level="1" - :name="$options.label || $options.name" - :is-loading="isLoadingSummary" - :icon-name="statusIconName" - /> <div class="media-body gl-display-flex gl-flex-direction-row! gl-align-self-center" data-testid="widget-extension-top-level" @@ -362,7 +361,7 @@ export default { /> </div> </div> - </div> + </state-container> <div v-if="!isCollapsed" class="mr-widget-grouped-section gl-relative" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue index 7f2049904fd..52c9f047b76 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue @@ -62,7 +62,9 @@ export default { <strong v-else v-safe-html="generateText(data.header)"></strong> </div> <div class="gl-display-flex"> - <status-icon v-if="data.icon" :icon-name="data.icon.name" :size="12" class="gl-pl-0" /> + <div v-if="data.icon" class="report-block-child-icon gl-display-flex"> + <status-icon :icon-name="data.icon.name" :size="12" class="gl-m-auto" /> + </div> <div class="gl-w-full"> <div class="gl-display-flex gl-flex-nowrap"> <div class="gl-flex-wrap gl-display-flex gl-w-full"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue index 2bba8d2dc82..03728e2831b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue @@ -44,7 +44,14 @@ export default { <template> <div class="mr-widget-body media"> <div v-if="isLoading" class="gl-w-full mr-conflict-loader"> - <slot name="loading"></slot> + <slot name="loading"> + <div class="gl-display-flex"> + <status-icon status="loading" /> + <div class="media-body"> + <slot></slot> + </div> + </div> + </slot> </div> <template v-else> <slot name="icon"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_section.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_section.vue index 42fd02f978b..61e3744b5dc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_section.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_section.vue @@ -11,7 +11,7 @@ export default { type: String, default: '', required: false, - validator: (value) => value === '' || Object.keys(EXTENSION_ICONS).indexOf(value) > -1, + validator: (value) => value === '' || Object.keys(EXTENSION_ICONS).includes(value), }, widgetName: { type: String, diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index 004dc22c9b8..d0b3f5bda8e 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -21,7 +21,6 @@ @import './pages/notifications'; @import './pages/pipelines'; @import './pages/profile'; -@import './pages/profiles/preferences'; @import './pages/projects'; @import './pages/prometheus'; @import './pages/registry'; diff --git a/app/assets/stylesheets/pages/profiles/preferences.scss b/app/assets/stylesheets/page_bundles/profiles/preferences.scss index c7d7aacceec..c9c78a70163 100644 --- a/app/assets/stylesheets/pages/profiles/preferences.scss +++ b/app/assets/stylesheets/page_bundles/profiles/preferences.scss @@ -1,3 +1,5 @@ +@import 'page_bundles/mixins_and_variables_and_functions'; + .application-theme { $ui-gray-bg: #303030; $ui-light-gray-bg: #f0f0f0; diff --git a/app/assets/stylesheets/page_bundles/reports.scss b/app/assets/stylesheets/page_bundles/reports.scss index d0748779f47..03c9fc7508d 100644 --- a/app/assets/stylesheets/page_bundles/reports.scss +++ b/app/assets/stylesheets/page_bundles/reports.scss @@ -16,6 +16,10 @@ line-height: 20px; } +.report-block-child-icon { + height: 20px; +} + .report-block-list { list-style: none; padding: 0 1px; diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb index e4e866a8b60..3a55fc4b951 100644 --- a/app/controllers/admin/spam_logs_controller.rb +++ b/app/controllers/admin/spam_logs_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Admin::SpamLogsController < Admin::ApplicationController - feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned + feature_category :instance_resiliency # rubocop: disable CodeReuse/ActiveRecord def index diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index bb92792de2d..f77bd6621f9 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -172,6 +172,15 @@ module GroupsHelper } end + def group_overview_tabs_app_data(group) + { + subgroups_and_projects_endpoint: group_children_path(group, format: :json), + shared_projects_endpoint: group_shared_projects_path(group, format: :json), + archived_projects_endpoint: group_children_path(group, format: :json, archived: 'only'), + current_group_visibility: group.visibility + }.merge(subgroups_and_projects_list_app_data(group)) + end + def enabled_git_access_protocol_options_for_group case ::Gitlab::CurrentSettings.enabled_git_access_protocol when nil, "" diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index d8da77dc5cc..f474f8fbd3b 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -33,33 +33,36 @@ = render_if_exists 'groups/group_activity_analytics', group: @group -.groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } } - .top-area.group-nav-container.justify-content-between - .scrolling-tabs-container.inner-page-scroll-tabs - .fade-left= sprite_icon('chevron-lg-left', size: 12) - .fade-right= sprite_icon('chevron-lg-right', size: 12) - -# `item_active` is set to `false` as the active state is set by `app/assets/javascripts/pages/groups/shared/group_details.js` - -# TODO: Replace this approach in https://gitlab.com/gitlab-org/gitlab/-/issues/23466 - = gl_tabs_nav({ class: 'nav-links scrolling-tabs gl-display-flex gl-flex-grow-1 gl-flex-nowrap gl-border-0' }) do - = gl_tab_link_to group_path, item_active: false, tab_class: 'js-subgroups_and_projects-tab', data: { target: 'div#subgroups_and_projects', action: 'subgroups_and_projects', toggle: 'tab' } do - = _("Subgroups and projects") - = gl_tab_link_to group_shared_path, item_active: false, tab_class: 'js-shared-tab', data: { target: 'div#shared', action: 'shared', toggle: 'tab' } do - = _("Shared projects") - = gl_tab_link_to group_archived_path, item_active: false, tab_class: 'js-archived-tab', data: { target: 'div#archived', action: 'archived', toggle: 'tab' } do - = _("Archived projects") +- if Feature.enabled?(:group_overview_tabs_vue, @group) + #js-group-overview-tabs{ data: group_overview_tabs_app_data(@group) } +- else + .groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } } + .top-area.group-nav-container.justify-content-between + .scrolling-tabs-container.inner-page-scroll-tabs + .fade-left= sprite_icon('chevron-lg-left', size: 12) + .fade-right= sprite_icon('chevron-lg-right', size: 12) + -# `item_active` is set to `false` as the active state is set by `app/assets/javascripts/pages/groups/shared/group_details.js` + -# TODO: Replace this approach in https://gitlab.com/gitlab-org/gitlab/-/issues/23466 + = gl_tabs_nav({ class: 'nav-links scrolling-tabs gl-display-flex gl-flex-grow-1 gl-flex-nowrap gl-border-0' }) do + = gl_tab_link_to group_path, item_active: false, tab_class: 'js-subgroups_and_projects-tab', data: { target: 'div#subgroups_and_projects', action: 'subgroups_and_projects', toggle: 'tab' } do + = _("Subgroups and projects") + = gl_tab_link_to group_shared_path, item_active: false, tab_class: 'js-shared-tab', data: { target: 'div#shared', action: 'shared', toggle: 'tab' } do + = _("Shared projects") + = gl_tab_link_to group_archived_path, item_active: false, tab_class: 'js-archived-tab', data: { target: 'div#archived', action: 'archived', toggle: 'tab' } do + = _("Archived projects") - .nav-controls.d-block.d-md-flex - .group-search - = render "shared/groups/search_form" + .nav-controls.d-block.d-md-flex + .group-search + = render "shared/groups/search_form" - = render "shared/groups/dropdown", options_hash: subgroups_sort_options_hash + = render "shared/groups/dropdown", options_hash: subgroups_sort_options_hash - .tab-content - #subgroups_and_projects.tab-pane - = render "subgroups_and_projects", group: @group + .tab-content + #subgroups_and_projects.tab-pane + = render "subgroups_and_projects", group: @group - #shared.tab-pane - = render "shared_projects", group: @group + #shared.tab-pane + = render "shared_projects", group: @group - #archived.tab-pane - = render "archived_projects", group: @group + #archived.tab-pane + = render "archived_projects", group: @group diff --git a/app/views/notify/resolved_all_discussions_email.html.haml b/app/views/notify/resolved_all_discussions_email.html.haml index bd9778ae142..78dc21caf18 100644 --- a/app/views/notify/resolved_all_discussions_email.html.haml +++ b/app/views/notify/resolved_all_discussions_email.html.haml @@ -1,2 +1,2 @@ %p - = s_('Notify|All discussions on merge request %{mr_link} were resolved by %{name}') %{mr_link: sanitize(merge_request_reference_link(@merge_request)), name: sanitize_name(@resolved_by.name)} + = s_('Notify|All discussions on merge request %{mr_link} were resolved by %{name}').html_safe % { mr_link: merge_request_reference_link(@merge_request), name: sanitize_name(@resolved_by.name) } diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index f8737a4e54a..5f306c6eb48 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -1,4 +1,5 @@ - page_title _('Preferences') +- add_page_specific_style 'page_bundles/profiles/preferences' - @content_class = "limit-container-width" unless fluid_layout - user_theme_id = Gitlab::Themes.for_user(@user).id - user_color_schema_id = Gitlab::ColorSchemes.for_user(@user).id diff --git a/config/application.rb b/config/application.rb index 8c5bb7fe110..ba768d50dd4 100644 --- a/config/application.rb +++ b/config/application.rb @@ -291,10 +291,12 @@ module Gitlab config.assets.precompile << "page_bundles/productivity_analytics.css" config.assets.precompile << "page_bundles/profile.css" config.assets.precompile << "page_bundles/profile_two_factor_auth.css" + config.assets.precompile << "page_bundles/profiles/preferences.css" config.assets.precompile << "page_bundles/project.css" config.assets.precompile << "page_bundles/projects_edit.css" config.assets.precompile << "page_bundles/reports.css" config.assets.precompile << "page_bundles/roadmap.css" + config.assets.precompile << "page_bundles/requirements.css" config.assets.precompile << "page_bundles/runner_details.css" config.assets.precompile << "page_bundles/security_dashboard.css" config.assets.precompile << "page_bundles/security_discover.css" diff --git a/config/feature_flags/development/group_overview_tabs_vue.yml b/config/feature_flags/development/group_overview_tabs_vue.yml new file mode 100644 index 00000000000..4c54ab31b53 --- /dev/null +++ b/config/feature_flags/development/group_overview_tabs_vue.yml @@ -0,0 +1,8 @@ +--- +name: group_overview_tabs_vue +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/95850 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/370872 +milestone: '15.4' +type: development +group: group::workspace +default_enabled: false diff --git a/config/initializers_before_autoloader/002_sidekiq.rb b/config/initializers_before_autoloader/002_sidekiq.rb index 9ffcf39d6fb..929bdeda996 100644 --- a/config/initializers_before_autoloader/002_sidekiq.rb +++ b/config/initializers_before_autoloader/002_sidekiq.rb @@ -9,5 +9,5 @@ require 'sidekiq/web' if Rails.env.development? - Sidekiq.default_worker_options[:backtrace] = true + Sidekiq.default_job_options[:backtrace] = true end diff --git a/doc/development/database/add_foreign_key_to_existing_column.md b/doc/development/database/add_foreign_key_to_existing_column.md index 8a8fe3c0a1e..4be3296b2bb 100644 --- a/doc/development/database/add_foreign_key_to_existing_column.md +++ b/doc/development/database/add_foreign_key_to_existing_column.md @@ -71,7 +71,7 @@ Migration file for adding `NOT VALID` foreign key: ```ruby class AddNotValidForeignKeyToEmailsUser < Gitlab::Database::Migration[2.0] def up - add_concurrent_foreign_key :emails, :users, on_delete: :cascade, validate: false + add_concurrent_foreign_key :emails, :users, column: :user_id, on_delete: :cascade, validate: false end def down diff --git a/doc/development/documentation/site_architecture/deployment_process.md b/doc/development/documentation/site_architecture/deployment_process.md index bf45066c7db..8a9c2e1e8d7 100644 --- a/doc/development/documentation/site_architecture/deployment_process.md +++ b/doc/development/documentation/site_architecture/deployment_process.md @@ -144,14 +144,14 @@ graph LR ### Manually deploy to production -GitLab Docs is deployed to production whenever the `Build docs.gitlab.com every 4 hours` scheduled pipeline runs. By -default, this pipeline runs every four hours. +GitLab Docs is deployed to production whenever the `Build docs.gitlab.com every hour` scheduled pipeline runs. By +default, this pipeline runs every hour. Maintainers can [manually](../../../ci/pipelines/schedules.md#run-manually) run this pipeline to force a deployment to production: 1. Go to the [scheduled pipelines](https://gitlab.com/gitlab-org/gitlab-docs/-/pipeline_schedules) for `gitlab-docs`. -1. Next to `Build docs.gitlab.com every 4 hours`, select **Play** (**{play}**). +1. Next to `Build docs.gitlab.com every hour`, select **Play** (**{play}**). The updated documentation is available in production after the `pages` and `pages:deploy` jobs complete in the new pipeline. diff --git a/doc/development/documentation/testing.md b/doc/development/documentation/testing.md index 428a57a11fb..59a078bdec0 100644 --- a/doc/development/documentation/testing.md +++ b/doc/development/documentation/testing.md @@ -190,7 +190,7 @@ To update the linting images: 1. In `gitlab-docs`, open a merge request to update `.gitlab-ci.yml` to use the new tooling version. ([Example MR](https://gitlab.com/gitlab-org/gitlab-docs/-/merge_requests/2571)) -1. When merged, start a `Build docs.gitlab.com every 4 hours` [scheduled pipeline](https://gitlab.com/gitlab-org/gitlab-docs/-/pipeline_schedules). +1. When merged, start a `Build docs.gitlab.com every hour` [scheduled pipeline](https://gitlab.com/gitlab-org/gitlab-docs/-/pipeline_schedules). 1. Go the pipeline you started, and manually run the relevant build-images job, for example, `image:docs-lint-markdown`. 1. In the job output, get the name of the new image. diff --git a/doc/user/project/integrations/hangouts_chat.md b/doc/user/project/integrations/hangouts_chat.md index fbfa7d914a5..6e532a6c14f 100644 --- a/doc/user/project/integrations/hangouts_chat.md +++ b/doc/user/project/integrations/hangouts_chat.md @@ -49,3 +49,16 @@ Enable the Google Chat integration in GitLab: To test the integration, make a change based on the events you selected and see the notification in your Google Chat room. + +### Enable threads in Google Chat + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/27823) in GitLab 15.4. + +To enable threaded notifications for the same GitLab object (for example, an issue or merge request): + +1. Go to [Google Chat](https://chat.google.com/). +1. In **Spaces**, select **+ > Create space**. +1. Enter the space name and (optionally) other details, and select **Use threaded replies**. +1. Select **Create**. + +You cannot enable threaded replies for existing Google Chat spaces. diff --git a/qa/qa/fixtures/auto_devops_rack/Dockerfile b/qa/qa/fixtures/auto_devops_rack/Dockerfile deleted file mode 100644 index 6ab2795dd40..00000000000 --- a/qa/qa/fixtures/auto_devops_rack/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM ruby:2.6.5-alpine -ADD ./ /app/ -WORKDIR /app -ENV RACK_ENV production -ENV PORT 5000 -EXPOSE 5000 - -RUN bundle install -CMD ["bundle","exec", "rackup", "-p", "5000"] diff --git a/qa/qa/fixtures/auto_devops_rack/Gemfile b/qa/qa/fixtures/auto_devops_rack/Gemfile deleted file mode 100644 index 2c7c77adf94..00000000000 --- a/qa/qa/fixtures/auto_devops_rack/Gemfile +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -source 'https://rubygems.org' -gem 'rack' -gem 'rake' diff --git a/qa/qa/fixtures/auto_devops_rack/Gemfile.lock b/qa/qa/fixtures/auto_devops_rack/Gemfile.lock deleted file mode 100644 index 04a85be4b2f..00000000000 --- a/qa/qa/fixtures/auto_devops_rack/Gemfile.lock +++ /dev/null @@ -1,15 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - rack (2.2.3) - rake (12.3.3) - -PLATFORMS - ruby - -DEPENDENCIES - rack - rake - -BUNDLED WITH - 1.17.3 diff --git a/qa/qa/fixtures/auto_devops_rack/Rakefile b/qa/qa/fixtures/auto_devops_rack/Rakefile deleted file mode 100644 index a6d08103d55..00000000000 --- a/qa/qa/fixtures/auto_devops_rack/Rakefile +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -require 'rake/testtask' - -task default: %w[test] - -task :test do - puts "ok" -end diff --git a/qa/qa/fixtures/auto_devops_rack/config.ru b/qa/qa/fixtures/auto_devops_rack/config.ru deleted file mode 100644 index aea28ef1893..00000000000 --- a/qa/qa/fixtures/auto_devops_rack/config.ru +++ /dev/null @@ -1,3 +0,0 @@ -# frozen_string_literal: true - -run lambda { |env| [200, { 'Content-Type' => 'text/plain' }, StringIO.new("Hello World! #{ENV['OPTIONAL_MESSAGE']}\n")] } diff --git a/qa/qa/page/project/infrastructure/kubernetes/index.rb b/qa/qa/page/project/infrastructure/kubernetes/index.rb index 34d2ad55429..4c759a049e1 100644 --- a/qa/qa/page/project/infrastructure/kubernetes/index.rb +++ b/qa/qa/page/project/infrastructure/kubernetes/index.rb @@ -10,18 +10,13 @@ module QA element :clusters_actions_button end - def connect_existing_cluster - within_element(:clusters_actions_button) { click_button(class: 'dropdown-toggle-split') } - click_link 'Connect a cluster (certificate - deprecated)' + def connect_cluster + click_element(:clusters_actions_button) end def has_cluster?(cluster) has_element?(:cluster, cluster_name: cluster.to_s) end - - def click_on_cluster(cluster) - click_on cluster.cluster_name - end end end end diff --git a/qa/qa/resource/clusters/agent.rb b/qa/qa/resource/clusters/agent.rb index b190634f357..9574289a2ed 100644 --- a/qa/qa/resource/clusters/agent.rb +++ b/qa/qa/resource/clusters/agent.rb @@ -26,25 +26,18 @@ module QA end def api_get_path - "gid://gitlab/Clusters::Agent/#{id}" + "/projects/#{project.id}/cluster_agents/#{id}" end def api_post_path - "/graphql" + "/projects/#{project.id}/cluster_agents" end def api_post_body - <<~GQL - mutation createAgent { - createClusterAgent(input: { projectPath: "#{project.full_path}", name: "#{@name}" }) { - clusterAgent { - id - name - } - errors - } + { + id: project.id, + name: name } - GQL end end end diff --git a/qa/qa/resource/clusters/agent_token.rb b/qa/qa/resource/clusters/agent_token.rb index c1cf5c2f37b..cbd2964c31d 100644 --- a/qa/qa/resource/clusters/agent_token.rb +++ b/qa/qa/resource/clusters/agent_token.rb @@ -5,7 +5,7 @@ module QA module Clusters class AgentToken < QA::Resource::Base attribute :id - attribute :secret + attribute :token attribute :agent do QA::Resource::Clusters::Agent.fabricate_via_api! end @@ -20,26 +20,19 @@ module QA end def api_get_path - "gid://gitlab/Clusters::AgentToken/#{id}" + "/projects/#{agent.project.id}/cluster_agents/#{agent.id}/tokens/#{id}" end def api_post_path - "/graphql" + "/projects/#{agent.project.id}/cluster_agents/#{agent.id}/tokens" end def api_post_body - <<~GQL - mutation createToken { - clusterAgentTokenCreate(input: { clusterAgentId: "gid://gitlab/Clusters::Agent/#{agent.id}" name: "token-#{agent.id}" }) { - secret # This is the value you need to use on the next step - token { - createdAt - id - } - errors - } + { + id: agent.project.id, + agent_id: agent.id, + name: agent.name } - GQL end end end diff --git a/qa/qa/resource/kubernetes_cluster/project_cluster.rb b/qa/qa/resource/kubernetes_cluster/project_cluster.rb deleted file mode 100644 index 0443b26064e..00000000000 --- a/qa/qa/resource/kubernetes_cluster/project_cluster.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -module QA - module Resource - module KubernetesCluster - # TODO: This resource is currently broken, since one-click apps have been removed. - # See https://gitlab.com/gitlab-org/gitlab/-/issues/333818 - class ProjectCluster < Base - attr_writer :cluster, - :install_ingress, :install_prometheus, :install_runner, :domain - - attribute :project do - Resource::Project.fabricate! - end - - attribute :ingress_ip do - @cluster.fetch_external_ip_for_ingress - end - - def fabricate! - project.visit! - - Page::Project::Menu.perform( - &:go_to_infrastructure_kubernetes) - - Page::Project::Infrastructure::Kubernetes::Index.perform( - &:connect_existing_cluster) - - Page::Project::Infrastructure::Kubernetes::AddExisting.perform do |cluster_page| - cluster_page.set_cluster_name(@cluster.cluster_name) - cluster_page.set_api_url(@cluster.api_url) - cluster_page.set_ca_certificate(@cluster.ca_certificate) - cluster_page.set_token(@cluster.token) - cluster_page.uncheck_rbac! unless @cluster.rbac - cluster_page.add_cluster! - end - - Page::Project::Infrastructure::Kubernetes::Show.perform do |show| - if @install_ingress - ingress_ip - - show.set_domain("#{@ingress_ip}.nip.io") - show.save_domain - end - end - end - end - end - end -end diff --git a/qa/qa/service/cluster_provider/gcloud.rb b/qa/qa/service/cluster_provider/gcloud.rb index 77677745f7a..14c13eecb8d 100644 --- a/qa/qa/service/cluster_provider/gcloud.rb +++ b/qa/qa/service/cluster_provider/gcloud.rb @@ -33,14 +33,32 @@ module QA delete_cluster end - def install_ingress - QA::Runtime::Logger.info "Attempting to install Ingress on cluster #{cluster_name}" - shell 'kubectl create -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-0.31.0/deploy/static/provider/cloud/deploy.yaml' - wait_for_ingress + # kas is hardcoded to staging since this test should only run in staging for now + def install_kubernetes_agent(agent_token) + install_helm + + shell <<~CMD.tr("\n", ' ') + helm repo add gitlab https://charts.gitlab.io && + helm repo update && + helm upgrade --install test gitlab/gitlab-agent + --namespace gitlab-agent + --create-namespace + --set image.tag=#{Runtime::Env.gitlab_agentk_version} + --set config.token=#{agent_token} + --set config.kasAddress=wss://kas.staging.gitlab.com + CMD end private + def install_helm + shell <<~CMD.tr("\n", ' ') + curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 && + chmod 700 get_helm.sh && + ./get_helm.sh + CMD + end + def login_if_not_already_logged_in if Runtime::Env.has_gcloud_credentials? attempt_login_with_env_vars @@ -104,18 +122,6 @@ module QA def get_region Runtime::Env.gcloud_region || @available_regions.delete(@available_regions.sample) end - - def wait_for_ingress - QA::Runtime::Logger.info 'Waiting for Ingress controller pod to be initialized' - - Support::Retrier.retry_until(max_attempts: 60, sleep_interval: 1) do - service_available?('kubectl get pods --all-namespaces -l app.kubernetes.io/component=controller | grep -o "ingress-nginx-controller.*1/1"') - end - end - - def service_available?(command) - system("#{command} > /dev/null 2>&1") - end end end end diff --git a/qa/qa/service/kubernetes_cluster.rb b/qa/qa/service/kubernetes_cluster.rb index dafce4acc33..59bfacf9195 100644 --- a/qa/qa/service/kubernetes_cluster.rb +++ b/qa/qa/service/kubernetes_cluster.rb @@ -41,8 +41,8 @@ module QA cluster_name end - def install_ingress - @provider.install_ingress + def install_kubernetes_agent(agent_token) + @provider.install_kubernetes_agent(agent_token) end def create_secret(secret, secret_name) @@ -73,16 +73,6 @@ module QA shell('kubectl apply -f -', stdin_data: network_policy) end - def fetch_external_ip_for_ingress - install_ingress - - # need to wait since the ingress-nginx service has an initial delay set of 10 seconds - sleep 12 - ingress_ip = `kubectl get svc --all-namespaces --no-headers=true -l app.kubernetes.io/name=ingress-nginx -o custom-columns=:'status.loadBalancer.ingress[0].ip' | grep -v 'none'` - QA::Runtime::Logger.debug "Has ingress address set to: #{ingress_ip}" - ingress_ip - end - private def fetch_api_url diff --git a/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb b/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb index f1a2eb71390..b839855c500 100644 --- a/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb +++ b/qa/qa/specs/features/browser_ui/7_configure/auto_devops/create_project_with_auto_devops_spec.rb @@ -1,78 +1,67 @@ # frozen_string_literal: true module QA - RSpec.describe 'Configure', - only: { subdomain: %i[staging staging-canary] }, - quarantine: { - issue: 'https://gitlab.com/gitlab-org/quality/team-tasks/-/issues/1198', - type: :waiting_on - } do - let(:project) do - Resource::Project.fabricate_via_api! do |project| - project.name = 'autodevops-project' - project.auto_devops_enabled = true + RSpec.describe 'Configure', only: { subdomain: %i[staging staging-canary] } do + describe 'Auto DevOps with a Kubernetes Agent' do + let!(:app_project) do + Resource::Project.fabricate_via_api! do |project| + project.name = 'autodevops-app-project' + project.template_name = 'express' + project.auto_devops_enabled = true + end end - end - before do - set_kube_ingress_base_domain(project) - disable_optional_jobs(project) - end + let!(:cluster) { Service::KubernetesCluster.new(provider_class: Service::ClusterProvider::Gcloud).create! } - describe 'Auto DevOps support' do - context 'when rbac is enabled' do - let(:cluster) { Service::KubernetesCluster.new.create! } + let!(:kubernetes_agent) do + Resource::Clusters::Agent.fabricate_via_api! do |agent| + agent.name = 'agent1' + agent.project = app_project + end + end - after do - cluster&.remove! - project.remove_via_api! + let!(:agent_token) do + Resource::Clusters::AgentToken.fabricate_via_api! do |token| + token.agent = kubernetes_agent end + end - it 'runs auto devops', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/348061' do - Flow::Login.sign_in - - Resource::KubernetesCluster::ProjectCluster.fabricate! do |k8s_cluster| - k8s_cluster.project = project - k8s_cluster.cluster = cluster - k8s_cluster.install_ingress = true - end - - Resource::Repository::ProjectPush.fabricate! do |push| - push.project = project - push.directory = Pathname - .new(__dir__) - .join('../../../../../fixtures/auto_devops_rack') - push.commit_message = 'Create Auto DevOps compatible rack application' - end - - Flow::Pipeline.visit_latest_pipeline - - Page::Project::Pipeline::Show.perform do |pipeline| - pipeline.click_job('build') - end - Page::Project::Job::Show.perform do |job| - expect(job).to be_successful(timeout: 600) - - job.click_element(:pipeline_path) - end - - Page::Project::Pipeline::Show.perform do |pipeline| - pipeline.click_job('test') - end - Page::Project::Job::Show.perform do |job| - expect(job).to be_successful(timeout: 600) - - job.click_element(:pipeline_path) - end - - Page::Project::Pipeline::Show.perform do |pipeline| - pipeline.click_job('production') - end - Page::Project::Job::Show.perform do |job| - expect(job).to be_successful(timeout: 1200) - - job.click_element(:pipeline_path) - end + before do + cluster.install_kubernetes_agent(agent_token.token) + upload_agent_config(app_project, kubernetes_agent.name) + + set_kube_ingress_base_domain(app_project) + set_kube_context(app_project) + disable_optional_jobs(app_project) + end + + after do + cluster&.remove! + end + + it 'runs auto devops', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/348061' do + Flow::Login.sign_in + + app_project.visit! + + Page::Project::Menu.perform(&:click_ci_cd_pipelines) + Page::Project::Pipeline::Index.perform(&:click_run_pipeline_button) + Page::Project::Pipeline::New.perform(&:click_run_pipeline_button) + + Page::Project::Pipeline::Show.perform do |pipeline| + pipeline.click_job('build') + end + Page::Project::Job::Show.perform do |job| + expect(job).to be_successful(timeout: 600) + + job.click_element(:pipeline_path) + end + + Page::Project::Pipeline::Show.perform do |pipeline| + pipeline.click_job('production') + end + Page::Project::Job::Show.perform do |job| + expect(job).to be_successful(timeout: 600) end end end @@ -88,12 +77,43 @@ module QA end end + def set_kube_context(project) + Resource::CiVariable.fabricate_via_api! do |resource| + resource.project = project + resource.key = 'KUBE_CONTEXT' + resource.value = "#{project.path_with_namespace}:#{kubernetes_agent.name}" + resource.masked = false + end + end + + def upload_agent_config(project, agent) + Support::Retrier.retry_on_exception(max_attempts: 3, sleep_interval: 2) do + Resource::Repository::Commit.fabricate_via_api! do |commit| + commit.project = project + commit.commit_message = 'Add kubernetes agent configuration' + commit.add_files( + [ + { + file_path: ".gitlab/agents/#{agent}/config.yaml", + content: <<~YAML + ci_access: + projects: + - id: #{project.path_with_namespace} + YAML + } + ] + ) + end + end + end + def disable_optional_jobs(project) %w[ - CODE_QUALITY_DISABLED LICENSE_MANAGEMENT_DISABLED - SAST_DISABLED DAST_DISABLED DEPENDENCY_SCANNING_DISABLED - CONTAINER_SCANNING_DISABLED BROWSER_PERFORMANCE_DISABLED - SECRET_DETECTION_DISABLED + TEST_DISABLED CODE_QUALITY_DISABLED LICENSE_MANAGEMENT_DISABLED + BROWSER_PERFORMANCE_DISABLED LOAD_PERFORMANCE_DISABLED + SAST_DISABLED SECRET_DETECTION_DISABLED DEPENDENCY_SCANNING_DISABLED + CONTAINER_SCANNING_DISABLED DAST_DISABLED REVIEW_DISABLED + CODE_INTELLIGENCE_DISABLED CLUSTER_IMAGE_SCANNING_DISABLED ].each do |key| Resource::CiVariable.fabricate_via_api! do |resource| resource.project = project diff --git a/qa/qa/specs/features/browser_ui/7_configure/kubernetes/kubernetes_integration_spec.rb b/qa/qa/specs/features/browser_ui/7_configure/kubernetes/kubernetes_integration_spec.rb deleted file mode 100644 index 94f9e9ec1f6..00000000000 --- a/qa/qa/specs/features/browser_ui/7_configure/kubernetes/kubernetes_integration_spec.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module QA - RSpec.describe 'Configure', except: { job: 'review-qa-*' } do - describe 'Kubernetes Cluster Integration', :orchestrated, :requires_admin, :skip_live_env do - context 'Project Clusters' do - let!(:cluster) { Service::KubernetesCluster.new(provider_class: Service::ClusterProvider::K3s).create! } - let(:project) do - Resource::Project.fabricate_via_api! do |project| - project.name = 'project-with-k8s' - project.description = 'Project with Kubernetes cluster integration' - end - end - - before do - Flow::Login.sign_in_as_admin - end - - after do - cluster.remove! - end - - it 'can create and associate a project cluster', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/348062' do - Resource::KubernetesCluster::ProjectCluster.fabricate_via_browser_ui! do |k8s_cluster| - k8s_cluster.project = project - k8s_cluster.cluster = cluster - end.project.visit! - - Page::Project::Menu.perform(&:go_to_infrastructure_kubernetes) - - Page::Project::Infrastructure::Kubernetes::Index.perform do |index| - expect(index).to have_cluster(cluster) - end - end - end - end - end -end diff --git a/spec/features/groups/show_spec.rb b/spec/features/groups/show_spec.rb index d814906a274..67310862516 100644 --- a/spec/features/groups/show_spec.rb +++ b/spec/features/groups/show_spec.rb @@ -331,6 +331,7 @@ RSpec.describe 'Group show page' do end it 'does not include structured markup in shared projects tab', :aggregate_failures, :js do + stub_feature_flags(group_overview_tabs_vue: false) other_project = create(:project, :public) other_project.project_group_links.create!(group: group) @@ -342,6 +343,7 @@ RSpec.describe 'Group show page' do end it 'does not include structured markup in archived projects tab', :aggregate_failures, :js do + stub_feature_flags(group_overview_tabs_vue: false) project.update!(archived: true) visit group_archived_path(group) diff --git a/spec/features/projects/user_sorts_projects_spec.rb b/spec/features/projects/user_sorts_projects_spec.rb index 7c970f7ee3d..b9b28398279 100644 --- a/spec/features/projects/user_sorts_projects_spec.rb +++ b/spec/features/projects/user_sorts_projects_spec.rb @@ -24,6 +24,7 @@ RSpec.describe 'User sorts projects and order persists' do end it "is set on the group_canonical_path" do + stub_feature_flags(group_overview_tabs_vue: false) visit(group_canonical_path(group)) within '[data-testid=group_sort_by_dropdown]' do @@ -32,6 +33,7 @@ RSpec.describe 'User sorts projects and order persists' do end it "is set on the details_group_path" do + stub_feature_flags(group_overview_tabs_vue: false) visit(details_group_path(group)) within '[data-testid=group_sort_by_dropdown]' do @@ -64,6 +66,7 @@ RSpec.describe 'User sorts projects and order persists' do context 'from group homepage', :js do before do + stub_feature_flags(group_overview_tabs_vue: false) sign_in(user) visit(group_canonical_path(group)) within '[data-testid=group_sort_by_dropdown]' do @@ -77,6 +80,7 @@ RSpec.describe 'User sorts projects and order persists' do context 'from group details', :js do before do + stub_feature_flags(group_overview_tabs_vue: false) sign_in(user) visit(details_group_path(group)) within '[data-testid=group_sort_by_dropdown]' do diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js index 2796a561953..a4a7530184d 100644 --- a/spec/frontend/groups/components/app_spec.js +++ b/spec/frontend/groups/components/app_spec.js @@ -40,7 +40,7 @@ describe('AppComponent', () => { const store = new GroupsStore({ hideProjects: false }); const service = new GroupsService(mockEndpoint); - const createShallowComponent = ({ propsData = {}, provide = {} } = {}) => { + const createShallowComponent = ({ propsData = {} } = {}) => { store.state.pageInfo = mockPageInfo; wrapper = shallowMount(appComponent, { propsData: { @@ -53,10 +53,6 @@ describe('AppComponent', () => { mocks: { $toast, }, - provide: { - renderEmptyState: false, - ...provide, - }, }); vm = wrapper.vm; }; @@ -402,8 +398,7 @@ describe('AppComponent', () => { ({ action, groups, fromSearch, renderEmptyState, expected }) => { it(expected ? 'renders empty state' : 'does not render empty state', async () => { createShallowComponent({ - propsData: { action }, - provide: { renderEmptyState }, + propsData: { action, renderEmptyState }, }); vm.updateGroups(groups, fromSearch); @@ -420,7 +415,6 @@ describe('AppComponent', () => { it('renders legacy empty state', async () => { createShallowComponent({ propsData: { action: 'subgroups_and_projects' }, - provide: { renderEmptyState: false }, }); vm.updateGroups([], false); diff --git a/spec/frontend/groups/components/overview_tabs_spec.js b/spec/frontend/groups/components/overview_tabs_spec.js new file mode 100644 index 00000000000..c26254acf3d --- /dev/null +++ b/spec/frontend/groups/components/overview_tabs_spec.js @@ -0,0 +1,106 @@ +import { GlTab } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import OverviewTabs from '~/groups/components/overview_tabs.vue'; +import GroupsApp from '~/groups/components/app.vue'; +import GroupsStore from '~/groups/store/groups_store'; +import GroupsService from '~/groups/service/groups_service'; +import { + ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, + ACTIVE_TAB_SHARED, + ACTIVE_TAB_ARCHIVED, +} from '~/groups/constants'; +import axios from '~/lib/utils/axios_utils'; + +describe('OverviewTabs', () => { + let wrapper; + + const endpoints = { + subgroups_and_projects: '/groups/foobar/-/children.json', + shared: '/groups/foobar/-/shared_projects.json', + archived: '/groups/foobar/-/children.json?archived=only', + }; + + const createComponent = async () => { + wrapper = mountExtended(OverviewTabs, { + provide: { + endpoints, + }, + }); + + await nextTick(); + }; + + const findTabPanels = () => wrapper.findAllComponents(GlTab); + const findTab = (name) => wrapper.findByRole('tab', { name }); + + afterEach(() => { + wrapper.destroy(); + }); + + beforeEach(async () => { + // eslint-disable-next-line no-new + new AxiosMockAdapter(axios); + + await createComponent(); + }); + + it('renders `Subgroups and projects` tab with `GroupsApp` component', async () => { + const tabPanel = findTabPanels().at(0); + + expect(tabPanel.vm.$attrs).toMatchObject({ + title: OverviewTabs.i18n.subgroupsAndProjects, + lazy: false, + }); + expect(tabPanel.findComponent(GroupsApp).props()).toMatchObject({ + action: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, + store: new GroupsStore({ showSchemaMarkup: true }), + service: new GroupsService(endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]), + hideProjects: false, + renderEmptyState: true, + }); + }); + + it('renders `Shared projects` tab and renders `GroupsApp` component after clicking tab', async () => { + const tabPanel = findTabPanels().at(1); + + expect(tabPanel.vm.$attrs).toMatchObject({ + title: OverviewTabs.i18n.sharedProjects, + lazy: true, + }); + + await findTab(OverviewTabs.i18n.sharedProjects).trigger('click'); + + expect(tabPanel.findComponent(GroupsApp).props()).toMatchObject({ + action: ACTIVE_TAB_SHARED, + store: new GroupsStore(), + service: new GroupsService(endpoints[ACTIVE_TAB_SHARED]), + hideProjects: false, + renderEmptyState: false, + }); + + expect(tabPanel.vm.$attrs.lazy).toBe(false); + }); + + it('renders `Archived projects` tab and renders `GroupsApp` component after clicking tab', async () => { + const tabPanel = findTabPanels().at(2); + + expect(tabPanel.vm.$attrs).toMatchObject({ + title: OverviewTabs.i18n.archivedProjects, + lazy: true, + }); + + await findTab(OverviewTabs.i18n.archivedProjects).trigger('click'); + + expect(tabPanel.findComponent(GroupsApp).props()).toMatchObject({ + action: ACTIVE_TAB_ARCHIVED, + store: new GroupsStore(), + service: new GroupsService(endpoints[ACTIVE_TAB_ARCHIVED]), + hideProjects: false, + renderEmptyState: false, + }); + + expect(tabPanel.vm.$attrs.lazy).toBe(false); + }); +}); diff --git a/spec/frontend/issuable/issuable_form_spec.js b/spec/frontend/issuable/issuable_form_spec.js index d844f3394d5..f37d132743a 100644 --- a/spec/frontend/issuable/issuable_form_spec.js +++ b/spec/frontend/issuable/issuable_form_spec.js @@ -1,111 +1,168 @@ import $ from 'jquery'; +import Autosave from '~/autosave'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import IssuableForm from '~/issuable/issuable_form'; import setWindowLocation from 'helpers/set_window_location_helper'; +jest.mock('~/autosave'); + +const createIssuable = (form) => { + return new IssuableForm(form); +}; + describe('IssuableForm', () => { + let $form; let instance; - const createIssuable = (form) => { - instance = new IssuableForm(form); - }; - beforeEach(() => { setHTMLFixture(` <form> <input name="[title]" /> + <textarea name="[description]"></textarea> </form> `); - createIssuable($('form')); + $form = $('form'); }); afterEach(() => { resetHTMLFixture(); + $form = null; + instance = null; }); - describe('initAutosave', () => { - it('creates autosave with the searchTerm included', () => { - setWindowLocation('https://gitlab.test/foo?bar=true'); - const autosave = instance.initAutosave(); + describe('autosave', () => { + let $title; + let $description; + + beforeEach(() => { + $title = $form.find('input[name*="[title]"]'); + $description = $form.find('textarea[name*="[description]"]'); + }); + + afterEach(() => { + $title = null; + $description = null; + }); - expect(autosave.key.includes('bar=true')).toBe(true); + describe('initAutosave', () => { + it('calls initAutosave', () => { + const initAutosave = jest.spyOn(IssuableForm.prototype, 'initAutosave'); + createIssuable($form); + + expect(initAutosave).toHaveBeenCalledTimes(1); + }); + + it('creates autosave with the searchTerm included', () => { + setWindowLocation('https://gitlab.test/foo?bar=true'); + createIssuable($form); + + expect(Autosave).toHaveBeenCalledWith( + $title, + ['/foo', 'bar=true', 'title'], + 'autosave//foo/bar=true=title', + ); + expect(Autosave).toHaveBeenCalledWith( + $description, + ['/foo', 'bar=true', 'description'], + 'autosave//foo/bar=true=description', + ); + }); + + it("creates autosave fields without the searchTerm if it's an issue new form", () => { + setWindowLocation('https://gitlab.test/issues/new?bar=true'); + $form.attr('data-new-issue-path', '/issues/new'); + createIssuable($form); + + expect(Autosave).toHaveBeenCalledWith( + $title, + ['/issues/new', '', 'title'], + 'autosave//issues/new/bar=true=title', + ); + expect(Autosave).toHaveBeenCalledWith( + $description, + ['/issues/new', '', 'description'], + 'autosave//issues/new/bar=true=description', + ); + }); }); - it("creates autosave fields without the searchTerm if it's an issue new form", () => { - setHTMLFixture(` - <form data-new-issue-path="/issues/new"> - <input name="[title]" /> - </form> - `); - createIssuable($('form')); + describe('resetAutosave', () => { + it('calls reset on title and description', () => { + instance = createIssuable($form); + + instance.resetAutosave(); - setWindowLocation('https://gitlab.test/issues/new?bar=true'); + expect(instance.autosaveTitle.reset).toHaveBeenCalledTimes(1); + expect(instance.autosaveDescription.reset).toHaveBeenCalledTimes(1); + }); - const autosave = instance.initAutosave(); + it('resets autosave when submit', () => { + const resetAutosave = jest.spyOn(IssuableForm.prototype, 'resetAutosave'); + createIssuable($form); - expect(autosave.key.includes('bar=true')).toBe(false); + $form.submit(); + + expect(resetAutosave).toHaveBeenCalledTimes(1); + }); + + it('resets autosave on elements with the .js-reset-autosave class', () => { + const resetAutosave = jest.spyOn(IssuableForm.prototype, 'resetAutosave'); + $form.append('<a class="js-reset-autosave">Cancel</a>'); + createIssuable($form); + + $form.find('.js-reset-autosave').trigger('click'); + + expect(resetAutosave).toHaveBeenCalledTimes(1); + }); }); }); - describe('resetAutosave', () => { - it('resets autosave on elements with the .js-reset-autosave class', () => { - setHTMLFixture(` - <form> - <input name="[title]" /> - <textarea name="[description]"></textarea> - <a class="js-reset-autosave">Cancel</a> - </form> - `); - const $form = $('form'); - const resetAutosave = jest.spyOn(IssuableForm.prototype, 'resetAutosave'); - createIssuable($form); - - $form.find('.js-reset-autosave').trigger('click'); - - expect(resetAutosave).toHaveBeenCalled(); + describe('wip', () => { + beforeEach(() => { + instance = createIssuable($form); }); - }); - describe('removeWip', () => { - it.each` - prefix - ${'draFT: '} - ${' [DRaft] '} - ${'drAft:'} - ${'[draFT]'} - ${'(draft) '} - ${' (DrafT)'} - ${'draft: [draft] (draft)'} - `('removes "$prefix" from the beginning of the title', ({ prefix }) => { - instance.titleField.val(`${prefix}The Issuable's Title Value`); - - instance.removeWip(); - - expect(instance.titleField.val()).toBe("The Issuable's Title Value"); + describe('removeWip', () => { + it.each` + prefix + ${'draFT: '} + ${' [DRaft] '} + ${'drAft:'} + ${'[draFT]'} + ${'(draft) '} + ${' (DrafT)'} + ${'draft: [draft] (draft)'} + `('removes "$prefix" from the beginning of the title', ({ prefix }) => { + instance.titleField.val(`${prefix}The Issuable's Title Value`); + + instance.removeWip(); + + expect(instance.titleField.val()).toBe("The Issuable's Title Value"); + }); }); - }); - describe('addWip', () => { - it("properly adds the work in progress prefix to the Issuable's title", () => { - instance.titleField.val("The Issuable's Title Value"); + describe('addWip', () => { + it("properly adds the work in progress prefix to the Issuable's title", () => { + instance.titleField.val("The Issuable's Title Value"); - instance.addWip(); + instance.addWip(); - expect(instance.titleField.val()).toBe("Draft: The Issuable's Title Value"); + expect(instance.titleField.val()).toBe("Draft: The Issuable's Title Value"); + }); }); - }); - describe('workInProgress', () => { - it.each` - title | expected - ${'draFT: something is happening'} | ${true} - ${'draft something is happening'} | ${false} - ${'something is happening to drafts'} | ${false} - ${'something is happening'} | ${false} - `('returns $expected with "$title"', ({ title, expected }) => { - instance.titleField.val(title); - - expect(instance.workInProgress()).toBe(expected); + describe('workInProgress', () => { + it.each` + title | expected + ${'draFT: something is happening'} | ${true} + ${'draft something is happening'} | ${false} + ${'something is happening to drafts'} | ${false} + ${'something is happening'} | ${false} + `('returns $expected with "$title"', ({ title, expected }) => { + instance.titleField.val(title); + + expect(instance.workInProgress()).toBe(expected); + }); }); }); }); diff --git a/spec/helpers/groups_helper_spec.rb b/spec/helpers/groups_helper_spec.rb index 2c1061d2f1b..00e620832b3 100644 --- a/spec/helpers/groups_helper_spec.rb +++ b/spec/helpers/groups_helper_spec.rb @@ -520,6 +520,29 @@ RSpec.describe GroupsHelper do end end + describe '#group_overview_tabs_app_data' do + let_it_be(:group) { create(:group) } + let_it_be(:user) { create(:user) } + + before do + allow(helper).to receive(:current_user).and_return(user) + + allow(helper).to receive(:can?).with(user, :create_subgroup, group) { true } + allow(helper).to receive(:can?).with(user, :create_projects, group) { true } + end + + it 'returns expected hash' do + expect(helper.group_overview_tabs_app_data(group)).to match( + { + subgroups_and_projects_endpoint: including("/groups/#{group.path}/-/children.json"), + shared_projects_endpoint: including("/groups/#{group.path}/-/shared_projects.json"), + archived_projects_endpoint: including("/groups/#{group.path}/-/children.json?archived=only"), + current_group_visibility: group.visibility + }.merge(helper.group_overview_tabs_app_data(group)) + ) + end + end + describe "#enabled_git_access_protocol_options_for_group" do subject { helper.enabled_git_access_protocol_options_for_group } diff --git a/spec/workers/concerns/application_worker_spec.rb b/spec/workers/concerns/application_worker_spec.rb index 707fa0c9c78..5fde54b98f0 100644 --- a/spec/workers/concerns/application_worker_spec.rb +++ b/spec/workers/concerns/application_worker_spec.rb @@ -289,7 +289,6 @@ RSpec.describe ApplicationWorker do perform_action expect(worker.jobs.count).to eq args.count - expect(worker.jobs).to all(include('enqueued_at')) end end @@ -302,7 +301,6 @@ RSpec.describe ApplicationWorker do perform_action expect(worker.jobs.count).to eq args.count - expect(worker.jobs).to all(include('enqueued_at')) end end |