diff options
125 files changed, 1492 insertions, 3462 deletions
diff --git a/.rubocop_todo/rspec/leaky_constant_declaration.yml b/.rubocop_todo/rspec/leaky_constant_declaration.yml index e08625eb2a7..fffc8f81789 100644 --- a/.rubocop_todo/rspec/leaky_constant_declaration.yml +++ b/.rubocop_todo/rspec/leaky_constant_declaration.yml @@ -5,6 +5,5 @@ RSpec/LeakyConstantDeclaration: - 'spec/lib/gitlab/config/entry/simplifiable_spec.rb' - 'spec/lib/gitlab/quick_actions/dsl_spec.rb' - 'spec/lib/marginalia_spec.rb' - - 'spec/mailers/notify_spec.rb' - 'spec/models/concerns/batch_destroy_dependent_associations_spec.rb' - 'spec/models/concerns/bulk_insert_safe_spec.rb' diff --git a/.rubocop_todo/style/class_and_module_children.yml b/.rubocop_todo/style/class_and_module_children.yml index c15095a7e72..2303c5a1652 100644 --- a/.rubocop_todo/style/class_and_module_children.yml +++ b/.rubocop_todo/style/class_and_module_children.yml @@ -8,11 +8,6 @@ Style/ClassAndModuleChildren: - 'app/controllers/admin/application_settings/appearances_controller.rb' - 'app/controllers/admin/application_settings_controller.rb' - 'app/controllers/admin/applications_controller.rb' - - 'app/controllers/admin/background_jobs_controller.rb' - - 'app/controllers/admin/background_migrations_controller.rb' - - 'app/controllers/admin/batched_jobs_controller.rb' - - 'app/controllers/admin/broadcast_messages_controller.rb' - - 'app/controllers/admin/ci/variables_controller.rb' - 'app/controllers/admin/clusters/integrations_controller.rb' - 'app/controllers/admin/clusters_controller.rb' - 'app/controllers/admin/cohorts_controller.rb' diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 2d9ebcf1150..8b00bbb192d 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -93488d22495586a8ab51adacc42afb3811f1291a +be4a52de3497779aa112614dbad096504cfd07d7 diff --git a/app/assets/javascripts/admin/users/components/actions/ban.vue b/app/assets/javascripts/admin/users/components/actions/ban.vue index 55938832dce..898a688c203 100644 --- a/app/assets/javascripts/admin/users/components/actions/ban.vue +++ b/app/assets/javascripts/admin/users/components/actions/ban.vue @@ -11,7 +11,9 @@ const messageHtml = ` <ul> <li>${s__("AdminUsers|The user can't log in.")}</li> <li>${s__("AdminUsers|The user can't access git repositories.")}</li> - <li>${s__('AdminUsers|Issues authored by this user are hidden from other users.')}</li> + <li>${s__( + 'AdminUsers|Issues and merge requests authored by this user are hidden from other users.', + )}</li> </ul> <p>${s__('AdminUsers|You can unban their account in the future. Their data remains intact.')}</p> <p>${sprintf( diff --git a/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue index 5cc654fa6c9..54b632968e2 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue @@ -18,7 +18,7 @@ import { processFilters, filterToQueryObject, } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; -import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue'; @@ -75,21 +75,21 @@ export default { icon: 'pencil', title: TOKEN_TITLE_AUTHOR, type: TOKEN_TYPE_AUTHOR, - token: AuthorToken, - initialAuthors: this.authorsData, + token: UserToken, + initialUsers: this.authorsData, unique: true, operators: OPERATORS_IS, - fetchAuthors: this.fetchAuthors, + fetchUsers: this.fetchAuthors, }, { icon: 'user', title: TOKEN_TITLE_ASSIGNEE, type: TOKEN_TYPE_ASSIGNEE, - token: AuthorToken, - initialAuthors: this.assigneesData, + token: UserToken, + initialUsers: this.assigneesData, unique: false, operators: OPERATORS_IS, - fetchAuthors: this.fetchAssignees, + fetchUsers: this.fetchAssignees, }, ]; }, diff --git a/app/assets/javascripts/boards/components/board_card_move_to_position.vue b/app/assets/javascripts/boards/components/board_card_move_to_position.vue index 352e891d5ea..706b453e868 100644 --- a/app/assets/javascripts/boards/components/board_card_move_to_position.vue +++ b/app/assets/javascripts/boards/components/board_card_move_to_position.vue @@ -1,14 +1,19 @@ <script> -import { GlCollapsibleListbox } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; +import { s__ } from '~/locale'; + import Tracking from '~/tracking'; -import { BOARD_CARD_MOVE_TO_POSITION_OPTIONS, MOVE_TO_START } from '../constants'; export default { - BOARD_CARD_MOVE_TO_POSITION_OPTIONS, + i18n: { + moveToStartText: s__('Boards|Move to start of list'), + moveToEndText: s__('Boards|Move to end of list'), + }, name: 'BoardCardMoveToPosition', components: { - GlCollapsibleListbox, + GlDropdown, + GlDropdownItem, }, mixins: [Tracking.mixin()], props: { @@ -91,30 +96,30 @@ export default { allItemsLoadedInList: !this.listHasNextPage, }); }, - selectMoveAction(action) { - if (action === MOVE_TO_START) { - this.moveToStart(); - } else { - this.moveToEnd(); - } - }, }, }; </script> <template> - <gl-collapsible-listbox + <gl-dropdown ref="dropdown" :key="itemIdentifier" - category="tertiary" - class="move-to-position gl-display-block gl-mb-2 gl-ml-2 gl-mt-n3 gl-mr-n3 js-no-trigger" icon="ellipsis_v" - :items="$options.BOARD_CARD_MOVE_TO_POSITION_OPTIONS" - no-caret - :tabindex="index" + :text="s__('Boards|Move card')" :text-sr-only="true" - :toggle-text="s__('Boards|Move card')" + class="move-to-position gl-display-block gl-mb-2 gl-ml-2 gl-mt-n3 gl-mr-n3" + category="tertiary" + :tabindex="index" + no-caret @keydown.esc.native="$emit('hide')" - @select="selectMoveAction" - /> + > + <div> + <gl-dropdown-item @click.stop="moveToStart"> + {{ $options.i18n.moveToStartText }} + </gl-dropdown-item> + <gl-dropdown-item @click.stop="moveToEnd"> + {{ $options.i18n.moveToEndText }} + </gl-dropdown-item> + </div> + </gl-dropdown> </template> diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue index e2055325b7a..bc68c2e0e99 100644 --- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue @@ -31,7 +31,7 @@ import { TOKEN_TYPE_RELEASE, TOKEN_TYPE_TYPE, } from '~/vue_shared/components/filtered_search_bar/constants'; -import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; @@ -60,7 +60,7 @@ export default { tokensCE() { const { issue, incident } = this.$options.i18n; const { types } = this.$options; - const { fetchAuthors, fetchLabels } = issueBoardFilters( + const { fetchUsers, fetchLabels } = issueBoardFilters( this.$apollo, this.fullPath, this.boardType, @@ -72,10 +72,10 @@ export default { title: TOKEN_TITLE_ASSIGNEE, type: TOKEN_TYPE_ASSIGNEE, operators: OPERATORS_IS_NOT, - token: AuthorToken, + token: UserToken, unique: true, - fetchAuthors, - preloadedAuthors: this.preloadedAuthors(), + fetchUsers, + preloadedUsers: this.preloadedUsers(), }, { icon: 'pencil', @@ -83,10 +83,10 @@ export default { type: TOKEN_TYPE_AUTHOR, operators: OPERATORS_IS_NOT, symbol: '@', - token: AuthorToken, + token: UserToken, unique: true, - fetchAuthors, - preloadedAuthors: this.preloadedAuthors(), + fetchUsers, + preloadedUsers: this.preloadedUsers(), }, { icon: 'labels', @@ -186,7 +186,7 @@ export default { }, methods: { ...mapActions(['fetchMilestones']), - preloadedAuthors() { + preloadedUsers() { return gon?.current_user_id ? [ { diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 696a4a93900..91b7f5004ad 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -1,5 +1,5 @@ import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql'; -import { s__, __ } from '~/locale'; +import { __ } from '~/locale'; import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql'; import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql'; import destroyBoardListMutation from './graphql/board_list_destroy.mutation.graphql'; @@ -141,16 +141,3 @@ export default { }; export const DEFAULT_BOARD_LIST_ITEMS_SIZE = 10; - -export const MOVE_TO_START = 'moveToStart'; -export const MOVE_TO_END = 'moveToEnd'; -export const BOARD_CARD_MOVE_TO_POSITION_OPTIONS = [ - { - text: s__('Boards|Move to start of list'), - value: MOVE_TO_START, - }, - { - text: s__('Boards|Move to end of list'), - value: MOVE_TO_END, - }, -]; diff --git a/app/assets/javascripts/boards/issue_board_filters.js b/app/assets/javascripts/boards/issue_board_filters.js index 699d7e12de4..4bfd92fb748 100644 --- a/app/assets/javascripts/boards/issue_board_filters.js +++ b/app/assets/javascripts/boards/issue_board_filters.js @@ -14,13 +14,13 @@ export default function issueBoardFilters(apollo, fullPath, boardType) { return isGroupBoard ? groupBoardMembers : projectBoardMembers; }; - const fetchAuthors = (authorsSearchTerm) => { + const fetchUsers = (usersSearchTerm) => { return apollo .query({ query: boardAssigneesQuery(), variables: { fullPath, - search: authorsSearchTerm, + search: usersSearchTerm, }, }) .then(({ data }) => data.workspace?.assignees.nodes.map(({ user }) => user)); @@ -42,6 +42,6 @@ export default function issueBoardFilters(apollo, fullPath, boardType) { return { fetchLabels, - fetchAuthors, + fetchUsers, }; } diff --git a/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue index f10545faea6..c96487d0d08 100644 --- a/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue +++ b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui'; +import { GlAvatar, GlCollapsibleListbox } from '@gitlab/ui'; import defaultAvatarUrl from 'images/no_avatar.png'; import { __, sprintf } from '~/locale'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -8,13 +8,19 @@ import { findVersionId } from '../../utils/design_management_utils'; export default { components: { - GlDropdown, - GlDropdownItem, - GlSprintf, + GlAvatar, + GlCollapsibleListbox, TimeAgo, }, mixins: [allVersionsMixin], computed: { + allVersionsList() { + return this.allVersions.map(({ id, ...item }, index) => ({ + value: id, + index, + ...item, + })); + }, queryVersion() { return this.$route.query.version; }, @@ -29,17 +35,11 @@ export default { // then return the latest version (index 0) return idx !== -1 ? idx : 0; }, - currentVersionId() { - if (this.queryVersion) return this.queryVersion; - - const currentVersion = this.allVersions[this.currentVersionIdx]; - return this.findVersionId(currentVersion.id); - }, dropdownText() { if (this.isLatestVersion) { return __('Showing latest version'); } - // allVersions is sorted in reverse chronological order (latest first) + // allVersions is sorted in reverse chronological order (the latest first) const currentVersionNumber = this.allVersions.length - this.currentVersionIdx; return sprintf(__('Showing version #%{versionNumber}'), { @@ -55,47 +55,49 @@ export default { query: { version: this.findVersionId(versionId) }, }); }, - versionText(versionId) { - if (this.findVersionId(versionId) === this.latestVersionId) { - return __('Version %{versionNumber} (latest)'); - } - return __('Version %{versionNumber}'); + versionText(item) { + const versionNumber = this.allVersions.length - item.index; + const message = + this.findVersionId(item.value) === this.latestVersionId + ? __('Version %{versionNumber} (latest)') + : __('Version %{versionNumber}'); + return sprintf(message, { versionNumber }); }, getAvatarUrl(version) { return version?.author?.avatarUrl || defaultAvatarUrl; }, + getAuthorName(author) { + return author?.name; + }, }, }; </script> <template> - <gl-dropdown :text="dropdownText" size="small"> - <gl-dropdown-item - v-for="(version, index) in allVersions" - :key="version.id" - is-check-item - is-check-centered - :is-checked="findVersionId(version.id) === currentVersionId" - :avatar-url="getAvatarUrl(version)" - @click="routeToVersion(version.id)" - > - <strong> - <gl-sprintf :message="versionText(version.id)"> - <template #versionNumber> - {{ allVersions.length - index }} - </template> - </gl-sprintf> - </strong> - - <div v-if="version.author" class="gl-text-gray-600 gl-mt-1"> - <div>{{ version.author.name }}</div> - <time-ago - v-if="version.createdAt" - class="text-1" - :time="version.createdAt" - tooltip-placement="bottom" - /> - </div> - </gl-dropdown-item> - </gl-dropdown> + <gl-collapsible-listbox + is-check-centered + :items="allVersionsList" + :toggle-text="dropdownText" + :selected="designsVersion" + size="small" + @select="routeToVersion" + > + <template #list-item="{ item }"> + <span class="gl-display-flex gl-align-items-center"> + <gl-avatar :alt="getAuthorName(item.author)" :size="32" :src="getAvatarUrl(item)" /> + <span class="gl-display-flex gl-flex-direction-column"> + <span class="gl-font-weight-bold">{{ versionText(item) }}</span> + <span v-if="item.author" class="gl-text-gray-600 gl-mt-1"> + <span class="gl-display-block">{{ getAuthorName(item.author) }}</span> + <time-ago + v-if="item.createdAt" + class="text-1" + :time="item.createdAt" + tooltip-placement="bottom" + /> + </span> + </span> + </span> + </template> + </gl-collapsible-listbox> </template> diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index 15f5a3518a5..46d5341ea97 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -1,21 +1,25 @@ <script> -import { GlLoadingIcon, GlModal } from '@gitlab/ui'; +import { GlLoadingIcon, GlModal, GlEmptyState } from '@gitlab/ui'; import { createAlert } from '~/flash'; import { mergeUrlParams, getParameterByName } from '~/lib/utils/url_utility'; -import { HIDDEN_CLASS } from '~/lib/utils/constants'; import { __, s__, sprintf } from '~/locale'; -import { COMMON_STR, CONTENT_LIST_CLASS } from '../constants'; +import { COMMON_STR } from '../constants'; import eventHub from '../event_hub'; import GroupsComponent from './groups.vue'; -import EmptyState from './empty_state.vue'; export default { + i18n: { + searchEmptyState: { + title: __('No results found'), + description: __('Edit your search and try again'), + }, + }, components: { GroupsComponent, GlModal, GlLoadingIcon, - EmptyState, + GlEmptyState, }, props: { action: { @@ -40,20 +44,14 @@ export default { type: Boolean, required: true, }, - renderEmptyState: { - type: Boolean, - required: false, - default: false, - }, }, data() { return { isModalVisible: false, isLoading: true, - isSearchEmpty: false, + fromSearch: false, targetGroup: null, targetParentGroup: null, - showEmptyState: false, }; }, computed: { @@ -79,6 +77,9 @@ export default { groups() { return this.store.getGroups(); }, + hasGroups() { + return this.groups && this.groups.length > 0; + }, pageInfo() { return this.store.getPaginationInfo(); }, @@ -231,47 +232,17 @@ export default { this.targetGroup.isBeingRemoved = false; }); }, - showLegacyEmptyState() { - const { containerEl } = this; - - if (!containerEl) return; - - const contentListEl = containerEl.querySelector(CONTENT_LIST_CLASS); - const emptyStateEl = containerEl.querySelector('.empty-state'); - - if (contentListEl) { - contentListEl.remove(); - } - - if (emptyStateEl) { - emptyStateEl.classList.remove(HIDDEN_CLASS); - } - }, updatePagination(headers) { this.store.setPaginationInfo(headers); }, updateGroups(groups, fromSearch) { - const hasGroups = groups && groups.length > 0; - - if (this.renderEmptyState) { - this.isSearchEmpty = fromSearch && !hasGroups; - } else { - this.isSearchEmpty = !hasGroups; - } + this.fromSearch = fromSearch; if (fromSearch) { this.store.setSearchedGroups(groups); } else { this.store.setGroups(groups); } - - if (this.action && !hasGroups && !fromSearch) { - if (this.renderEmptyState) { - this.showEmptyState = true; - } else { - this.showLegacyEmptyState(); - } - } }, }, }; @@ -285,14 +256,16 @@ export default { size="lg" class="loading-animation prepend-top-20" /> - <groups-component - v-else - :groups="groups" - :search-empty="isSearchEmpty" - :page-info="pageInfo" - :action="action" - /> - <empty-state v-if="showEmptyState" /> + <template v-else> + <groups-component v-if="hasGroups" :groups="groups" :page-info="pageInfo" :action="action" /> + <gl-empty-state + v-else-if="fromSearch" + :title="$options.i18n.searchEmptyState.title" + :description="$options.i18n.searchEmptyState.description" + data-testid="search-empty-state" + /> + <slot v-else name="empty-state"></slot> + </template> <gl-modal modal-id="leave-group-modal" :visible="isModalVisible" diff --git a/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue new file mode 100644 index 00000000000..535758750f9 --- /dev/null +++ b/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue @@ -0,0 +1,21 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; + +import { s__ } from '~/locale'; + +export default { + components: { GlEmptyState }, + i18n: { + title: s__('GroupsEmptyState|No archived projects.'), + }, + inject: ['newProjectIllustration'], +}; +</script> + +<template> + <gl-empty-state + :title="$options.i18n.title" + :svg-path="newProjectIllustration" + :svg-height="100" + /> +</template> diff --git a/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue new file mode 100644 index 00000000000..7223321bf3e --- /dev/null +++ b/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue @@ -0,0 +1,21 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; + +import { s__ } from '~/locale'; + +export default { + components: { GlEmptyState }, + i18n: { + title: s__('GroupsEmptyState|No shared projects.'), + }, + inject: ['newProjectIllustration'], +}; +</script> + +<template> + <gl-empty-state + :title="$options.i18n.title" + :svg-path="newProjectIllustration" + :svg-height="100" + /> +</template> diff --git a/app/assets/javascripts/groups/components/empty_state.vue b/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue index 4219b52737d..955cb1ca63e 100644 --- a/app/assets/javascripts/groups/components/empty_state.vue +++ b/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue @@ -83,7 +83,6 @@ export default { </div> <gl-empty-state v-else - class="gl-mt-5" :title="$options.i18n.withoutLinks.title" :svg-path="emptySubgroupIllustration" :description="$options.i18n.withoutLinks.description" diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue index 43aa0753082..5075be62214 100644 --- a/app/assets/javascripts/groups/components/groups.vue +++ b/app/assets/javascripts/groups/components/groups.vue @@ -1,5 +1,4 @@ <script> -import { GlEmptyState } from '@gitlab/ui'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; import { getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -12,7 +11,6 @@ export default { }, components: { PaginationLinks, - GlEmptyState, }, props: { groups: { @@ -23,10 +21,6 @@ export default { type: Object, required: true, }, - searchEmpty: { - type: Boolean, - required: true, - }, action: { type: String, required: false, @@ -46,18 +40,11 @@ export default { <template> <div class="groups-list-tree-container" data-qa-selector="groups_list_tree_container"> - <gl-empty-state - v-if="searchEmpty" - :title="$options.i18n.emptyStateTitle" - :description="$options.i18n.emptyStateDescription" + <group-folder :groups="groups" :action="action" /> + <pagination-links + :change="change" + :page-info="pageInfo" + class="d-flex justify-content-center gl-mt-3" /> - <template v-else> - <group-folder :groups="groups" :action="action" /> - <pagination-links - :change="change" - :page-info="pageInfo" - class="d-flex justify-content-center gl-mt-3" - /> - </template> </div> </template> diff --git a/app/assets/javascripts/groups/components/overview_tabs.vue b/app/assets/javascripts/groups/components/overview_tabs.vue index 46ab30367a0..79a2e11b0bb 100644 --- a/app/assets/javascripts/groups/components/overview_tabs.vue +++ b/app/assets/javascripts/groups/components/overview_tabs.vue @@ -13,19 +13,32 @@ import { } from '../constants'; import eventHub from '../event_hub'; import GroupsApp from './app.vue'; +import SubgroupsAndProjectsEmptyState from './empty_states/subgroups_and_projects_empty_state.vue'; +import SharedProjectsEmptyState from './empty_states/shared_projects_empty_state.vue'; +import ArchivedProjectsEmptyState from './empty_states/archived_projects_empty_state.vue'; const [SORTING_ITEM_NAME] = OVERVIEW_TABS_SORTING_ITEMS; const MIN_SEARCH_LENGTH = 3; export default { - components: { GlTabs, GlTab, GroupsApp, GlSearchBoxByType, GlSorting, GlSortingItem }, + components: { + GlTabs, + GlTab, + GroupsApp, + GlSearchBoxByType, + GlSorting, + GlSortingItem, + SubgroupsAndProjectsEmptyState, + SharedProjectsEmptyState, + ArchivedProjectsEmptyState, + }, inject: ['endpoints', 'initialSort'], data() { const tabs = [ { title: this.$options.i18n[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS], key: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, - renderEmptyState: true, + emptyStateComponent: SubgroupsAndProjectsEmptyState, lazy: this.$route.name !== ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, service: new GroupsService(this.endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]), store: new GroupsStore({ showSchemaMarkup: true }), @@ -33,7 +46,7 @@ export default { { title: this.$options.i18n[ACTIVE_TAB_SHARED], key: ACTIVE_TAB_SHARED, - renderEmptyState: false, + emptyStateComponent: SharedProjectsEmptyState, lazy: this.$route.name !== ACTIVE_TAB_SHARED, service: new GroupsService(this.endpoints[ACTIVE_TAB_SHARED]), store: new GroupsStore(), @@ -41,7 +54,7 @@ export default { { title: this.$options.i18n[ACTIVE_TAB_ARCHIVED], key: ACTIVE_TAB_ARCHIVED, - renderEmptyState: false, + emptyStateComponent: ArchivedProjectsEmptyState, lazy: this.$route.name !== ACTIVE_TAB_ARCHIVED, service: new GroupsService(this.endpoints[ACTIVE_TAB_ARCHIVED]), store: new GroupsStore(), @@ -158,18 +171,16 @@ export default { <template> <gl-tabs content-class="gl-pt-0" :value="activeTabIndex" @input="handleTabInput"> <gl-tab - v-for="{ key, title, renderEmptyState, lazy, service, store } in tabs" + v-for="{ key, title, emptyStateComponent, 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" - /> + <groups-app :action="key" :service="service" :store="store" :hide-projects="false"> + <template v-if="emptyStateComponent" #empty-state> + <component :is="emptyStateComponent" /> + </template> + </groups-app> </gl-tab> <template #tabs-end> <li class="gl-flex-grow-1 gl-align-self-center gl-w-full gl-lg-w-auto gl-py-2"> diff --git a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue index 543dca0afe1..a84187ab86b 100644 --- a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue +++ b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { mapGetters } from 'vuex'; -import { __ } from '~/locale'; +import { sprintf, __ } from '~/locale'; import { IssuableType, WorkspaceType } from '~/issues/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; @@ -40,7 +40,9 @@ export default { iconName: 'spam', visible: this.hidden, dataTestId: 'hidden', - tooltip: __('This issue is hidden because its author has been banned'), + tooltip: sprintf(__('This %{issuable} is hidden because its author has been banned'), { + issuable: this.getNoteableData.targetType.replace('_', ' '), + }), }, ]; }, diff --git a/app/assets/javascripts/issues/list/components/issues_list_app.vue b/app/assets/javascripts/issues/list/components/issues_list_app.vue index cf672737254..b4066fff3c6 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -86,8 +86,7 @@ import EmptyStateWithAnyIssues from './empty_state_with_any_issues.vue'; import EmptyStateWithoutAnyIssues from './empty_state_without_any_issues.vue'; import NewIssueDropdown from './new_issue_dropdown.vue'; -const AuthorToken = () => - import('~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'); +const UserToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'); const EmojiToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'); const LabelToken = () => @@ -290,10 +289,10 @@ export default { return convertToSearchQuery(this.filterTokens) || undefined; }, searchTokens() { - const preloadedAuthors = []; + const preloadedUsers = []; if (gon.current_user_id) { - preloadedAuthors.push({ + preloadedUsers.push({ id: convertToGraphQLId(TYPE_USER, gon.current_user_id), name: gon.current_user_fullname, username: gon.current_username, @@ -322,22 +321,22 @@ export default { type: TOKEN_TYPE_AUTHOR, title: TOKEN_TITLE_AUTHOR, icon: 'pencil', - token: AuthorToken, - defaultAuthors: [], + token: UserToken, + defaultUsers: [], operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT, - fetchAuthors: this.fetchUsers, + fetchUsers: this.fetchUsers, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-author`, - preloadedAuthors, + preloadedUsers, }, { type: TOKEN_TYPE_ASSIGNEE, title: TOKEN_TITLE_ASSIGNEE, icon: 'user', - token: AuthorToken, + token: UserToken, operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT, - fetchAuthors: this.fetchUsers, + fetchUsers: this.fetchUsers, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-assignee`, - preloadedAuthors, + preloadedUsers, }, { type: TOKEN_TYPE_MILESTONE, diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index 379c57f3945..2c8953237cf 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -1,6 +1,5 @@ export const BYTES_IN_KIB = 1024; export const DEFAULT_DEBOUNCE_AND_THROTTLE_MS = 250; -export const HIDDEN_CLASS = 'hidden'; export const THOUSAND = 1000; export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80; export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 1bbdd3625be..f00378733fc 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -1,36 +1,33 @@ import VueRouter from 'vue-router'; import { createAlert } from '~/flash'; -import { __, s__ } from '~/locale'; -import createDagApp from './pipeline_details_dag'; -import { createPipelinesDetailApp } from './pipeline_details_graph'; +import { __ } from '~/locale'; import { createPipelineHeaderApp } from './pipeline_details_header'; -import { createPipelineJobsApp } from './pipeline_details_jobs'; -import { createPipelineFailedJobsApp } from './pipeline_details_failed_jobs'; import { apolloProvider } from './pipeline_shared_client'; -import { createTestDetails } from './pipeline_test_details'; const SELECTORS = { - PIPELINE_DETAILS: '.js-pipeline-details-vue', - PIPELINE_GRAPH: '#js-pipeline-graph-vue', PIPELINE_HEADER: '#js-pipeline-header-vue', PIPELINE_TABS: '#js-pipeline-tabs', - PIPELINE_TESTS: '#js-pipeline-tests-detail', - PIPELINE_JOBS: '#js-pipeline-jobs-vue', - PIPELINE_FAILED_JOBS: '#js-pipeline-failed-jobs-vue', }; export default async function initPipelineDetailsBundle() { - const { dataset } = document.querySelector(SELECTORS.PIPELINE_DETAILS); + const { dataset: headerDataset } = document.querySelector(SELECTORS.PIPELINE_HEADER); try { - createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER, apolloProvider, dataset.graphqlResourceEtag); + createPipelineHeaderApp( + SELECTORS.PIPELINE_HEADER, + apolloProvider, + headerDataset.graphqlResourceEtag, + ); } catch { createAlert({ message: __('An error occurred while loading a section of this page.'), }); } - if (gon.features?.pipelineTabsVue) { + const tabsEl = document.querySelector(SELECTORS.PIPELINE_TABS); + + if (tabsEl) { + const { dataset } = tabsEl; const { createAppOptions } = await import('ee_else_ce/pipelines/pipeline_tabs'); const { createPipelineTabs } = await import('./pipeline_tabs'); const { routes } = await import('ee_else_ce/pipelines/routes'); @@ -49,45 +46,5 @@ export default async function initPipelineDetailsBundle() { message: __('An error occurred while loading a section of this page.'), }); } - } else { - try { - createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset); - } catch { - createAlert({ - message: __('An error occurred while loading the pipeline.'), - }); - } - - try { - createDagApp(apolloProvider); - } catch { - createAlert({ - message: __('An error occurred while loading the Needs tab.'), - }); - } - - try { - createTestDetails(SELECTORS.PIPELINE_TESTS); - } catch { - createAlert({ - message: __('An error occurred while loading the Test Reports tab.'), - }); - } - - try { - createPipelineJobsApp(SELECTORS.PIPELINE_JOBS); - } catch { - createAlert({ - message: __('An error occurred while loading the Jobs tab.'), - }); - } - - try { - createPipelineFailedJobsApp(SELECTORS.PIPELINE_FAILED_JOBS); - } catch { - createAlert({ - message: s__('Jobs|An error occurred while loading the Failed Jobs tab.'), - }); - } } } diff --git a/app/assets/javascripts/pipelines/pipeline_details_dag.js b/app/assets/javascripts/pipelines/pipeline_details_dag.js deleted file mode 100644 index b2cb0457c4d..00000000000 --- a/app/assets/javascripts/pipelines/pipeline_details_dag.js +++ /dev/null @@ -1,42 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import Dag from './components/dag/dag.vue'; - -Vue.use(VueApollo); - -const createDagApp = (apolloProvider) => { - const el = document.querySelector('#js-pipeline-dag-vue'); - - if (!el) { - return; - } - - const { - aboutDagDocPath, - dagDocPath, - emptySvgPath, - pipelineProjectPath, - pipelineIid, - } = el.dataset; - - // eslint-disable-next-line no-new - new Vue({ - el, - components: { - Dag, - }, - apolloProvider, - provide: { - aboutDagDocPath, - dagDocPath, - emptySvgPath, - pipelineProjectPath, - pipelineIid, - }, - render(createElement) { - return createElement('dag', {}); - }, - }); -}; - -export default createDagApp; diff --git a/app/assets/javascripts/pipelines/pipeline_details_failed_jobs.js b/app/assets/javascripts/pipelines/pipeline_details_failed_jobs.js deleted file mode 100644 index 7bf3b64bf47..00000000000 --- a/app/assets/javascripts/pipelines/pipeline_details_failed_jobs.js +++ /dev/null @@ -1,36 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; -import FailedJobsApp from './components/jobs/failed_jobs_app.vue'; - -Vue.use(VueApollo); - -const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), -}); - -export const createPipelineFailedJobsApp = (selector) => { - const containerEl = document.querySelector(selector); - - if (!containerEl) { - return false; - } - - const { fullPath, pipelineIid, failedJobsSummaryData } = containerEl.dataset; - - return new Vue({ - el: containerEl, - apolloProvider, - provide: { - fullPath, - pipelineIid, - }, - render(createElement) { - return createElement(FailedJobsApp, { - props: { - failedJobsSummary: JSON.parse(failedJobsSummaryData), - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/pipelines/pipeline_details_graph.js b/app/assets/javascripts/pipelines/pipeline_details_graph.js deleted file mode 100644 index 9dd5cd7b281..00000000000 --- a/app/assets/javascripts/pipelines/pipeline_details_graph.js +++ /dev/null @@ -1,35 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import PipelineGraphWrapper from './components/graph/graph_component_wrapper.vue'; -import { reportToSentry } from './utils'; - -Vue.use(VueApollo); - -const createPipelinesDetailApp = ( - selector, - apolloProvider, - { pipelineProjectPath, pipelineIid, metricsPath, graphqlResourceEtag } = {}, -) => { - // eslint-disable-next-line no-new - new Vue({ - el: selector, - components: { - PipelineGraphWrapper, - }, - apolloProvider, - provide: { - metricsPath, - pipelineProjectPath, - pipelineIid, - graphqlResourceEtag, - }, - errorCaptured(err, _vm, info) { - reportToSentry('pipeline_details_graph', `error: ${err}, info: ${info}`); - }, - render(createElement) { - return createElement(PipelineGraphWrapper); - }, - }); -}; - -export { createPipelinesDetailApp }; diff --git a/app/assets/javascripts/pipelines/pipeline_details_jobs.js b/app/assets/javascripts/pipelines/pipeline_details_jobs.js deleted file mode 100644 index a1294a484f0..00000000000 --- a/app/assets/javascripts/pipelines/pipeline_details_jobs.js +++ /dev/null @@ -1,34 +0,0 @@ -import { GlToast } from '@gitlab/ui'; -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; -import JobsApp from './components/jobs/jobs_app.vue'; - -Vue.use(VueApollo); -Vue.use(GlToast); - -const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), -}); - -export const createPipelineJobsApp = (selector) => { - const containerEl = document.querySelector(selector); - - if (!containerEl) { - return false; - } - - const { fullPath, pipelineIid } = containerEl.dataset; - - return new Vue({ - el: containerEl, - apolloProvider, - provide: { - fullPath, - pipelineIid, - }, - render(createElement) { - return createElement(JobsApp); - }, - }); -}; diff --git a/app/assets/javascripts/pipelines/pipeline_test_details.js b/app/assets/javascripts/pipelines/pipeline_test_details.js deleted file mode 100644 index fe4ca8e9529..00000000000 --- a/app/assets/javascripts/pipelines/pipeline_test_details.js +++ /dev/null @@ -1,40 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import Translate from '~/vue_shared/translate'; -import TestReports from './components/test_reports/test_reports.vue'; - -Vue.use(Vuex); -Vue.use(Translate); - -export const createTestDetails = (selector) => { - const el = document.querySelector(selector); - const { - blobPath, - emptyStateImagePath, - hasTestReport, - summaryEndpoint, - suiteEndpoint, - artifactsExpiredImagePath, - } = el?.dataset || {}; - - // eslint-disable-next-line no-new - new Vue({ - el, - components: { - TestReports, - }, - provide: { - emptyStateImagePath, - artifactsExpiredImagePath, - hasTestReport: parseBoolean(hasTestReport), - blobPath, - summaryEndpoint, - suiteEndpoint, - }, - store: new Vuex.Store(), - render(createElement) { - return createElement('test-reports'); - }, - }); -}; diff --git a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue index ba1e00a2b36..c00e75db722 100644 --- a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue +++ b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue @@ -57,7 +57,7 @@ export default { <gl-dropdown :text="selectedProject.name" :header-text="s__(`CompareRevisions|Select target project`)" - class="gl-w-full gl-font-monospace gl-sm-pr-3" + class="gl-w-full gl-font-monospace" toggle-class="gl-min-w-0" :disabled="disableRepoDropdown" > diff --git a/app/assets/javascripts/projects/compare/components/revision_card.vue b/app/assets/javascripts/projects/compare/components/revision_card.vue index d6ada24604d..162aca44f9d 100644 --- a/app/assets/javascripts/projects/compare/components/revision_card.vue +++ b/app/assets/javascripts/projects/compare/components/revision_card.vue @@ -43,7 +43,7 @@ export default { <h2 class="gl-font-size-h2"> {{ s__(`CompareRevisions|${revisionText}`) }} </h2> - <div class="gl-sm-display-flex gl-align-items-center"> + <div class="gl-sm-display-flex gl-align-items-center gl-gap-3"> <repo-dropdown class="gl-sm-w-half" :params-name="paramsName" diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue index c40bdae207c..28e65c1185f 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue @@ -30,30 +30,30 @@ export default { }, data() { return { - authors: this.config.initialAuthors || [], + users: this.config.initialUsers || [], loading: false, }; }, computed: { - defaultAuthors() { - return this.config.defaultAuthors || OPTIONS_NONE_ANY; + defaultUsers() { + return this.config.defaultUsers || OPTIONS_NONE_ANY; }, - preloadedAuthors() { - return this.config.preloadedAuthors || []; + preloadedUsers() { + return this.config.preloadedUsers || []; }, }, methods: { - getActiveAuthor(authors, data) { - return authors.find((author) => author.username.toLowerCase() === data.toLowerCase()); + getActiveUser(users, data) { + return users.find((user) => user.username.toLowerCase() === data.toLowerCase()); }, - getAvatarUrl(author) { - return author.avatarUrl || author.avatar_url; + getAvatarUrl(user) { + return user.avatarUrl || user.avatar_url; }, - fetchAuthors(searchTerm) { + fetchUsers(searchTerm) { this.loading = true; const fetchPromise = this.config.fetchPath - ? this.config.fetchAuthors(this.config.fetchPath, searchTerm) - : this.config.fetchAuthors(searchTerm); + ? this.config.fetchUsers(this.config.fetchPath, searchTerm) + : this.config.fetchUsers(searchTerm); fetchPromise .then((res) => { @@ -62,7 +62,7 @@ export default { // return response differently // TODO: rm when completed https://gitlab.com/gitlab-org/gitlab/-/issues/345756 - this.authors = Array.isArray(res) ? compact(res) : compact(res.data); + this.users = Array.isArray(res) ? compact(res) : compact(res.data); }) .catch(() => createAlert({ @@ -83,12 +83,12 @@ export default { :value="value" :active="active" :suggestions-loading="loading" - :suggestions="authors" - :get-active-token-value="getActiveAuthor" - :default-suggestions="defaultAuthors" - :preloaded-suggestions="preloadedAuthors" + :suggestions="users" + :get-active-token-value="getActiveUser" + :default-suggestions="defaultUsers" + :preloaded-suggestions="preloadedUsers" v-bind="$attrs" - @fetch-suggestions="fetchAuthors" + @fetch-suggestions="fetchUsers" v-on="$listeners" > <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> @@ -102,15 +102,15 @@ export default { </template> <template #suggestions-list="{ suggestions }"> <gl-filtered-search-suggestion - v-for="author in suggestions" - :key="author.username" - :value="author.username" + v-for="user in suggestions" + :key="user.username" + :value="user.username" > <div class="gl-display-flex"> - <gl-avatar :size="32" :src="getAvatarUrl(author)" /> + <gl-avatar :size="32" :src="getAvatarUrl(user)" /> <div> - <div>{{ author.name }}</div> - <div>@{{ author.username }}</div> + <div>{{ user.name }}</div> + <div>@{{ user.username }}</div> </div> </div> </gl-filtered-search-suggestion> diff --git a/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue b/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue index 7d20e0ce10b..015f024cffb 100644 --- a/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue +++ b/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue @@ -35,13 +35,9 @@ export default { title: '', body: null, open: false, + drawerTop: '0px', }; }, - computed: { - drawerOffsetTop() { - return `${contentTop()}px`; - }, - }, watch: { documentPath: { immediate: true, @@ -77,6 +73,9 @@ export default { cache[this.documentPath] = { title, body }; } }, + getDrawerTop() { + this.drawerTop = `${contentTop()}px`; + }, renderGLFM() { this.$nextTick(() => { $(this.$refs['content-element']).renderGFM(); @@ -86,9 +85,11 @@ export default { this.open = false; }, toggleDrawer() { + this.getDrawerTop(); this.open = !this.open; }, openDrawer() { + this.getDrawerTop(); this.open = true; }, }, @@ -98,7 +99,7 @@ export default { }; </script> <template> - <gl-drawer :header-height="drawerOffsetTop" :open="open" header-sticky @close="closeDrawer"> + <gl-drawer :header-height="drawerTop" :open="open" header-sticky @close="closeDrawer"> <template #title> <h4 data-testid="title-element" class="gl-m-0">{{ title }}</h4> </template> diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue index f2f65b5e9e8..57e3a97244e 100644 --- a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue +++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue @@ -13,7 +13,7 @@ import { TOKEN_TYPE_AUTHOR, } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; import { initialPaginationState, defaultI18n, defaultPageSize } from './constants'; import { isAny } from './utils'; @@ -115,10 +115,10 @@ export default { title: TOKEN_TITLE_AUTHOR, unique: true, symbol: '@', - token: AuthorToken, + token: UserToken, operators: OPERATORS_IS, fetchPath: this.projectPath, - fetchAuthors: Api.projectUsers.bind(Api), + fetchUsers: Api.projectUsers.bind(Api), }, { type: TOKEN_TYPE_ASSIGNEE, @@ -126,10 +126,10 @@ export default { title: TOKEN_TITLE_ASSIGNEE, unique: true, symbol: '@', - token: AuthorToken, + token: UserToken, operators: OPERATORS_IS, fetchPath: this.projectPath, - fetchAuthors: Api.projectUsers.bind(Api), + fetchUsers: Api.projectUsers.bind(Api), }, ]; }, diff --git a/app/controllers/admin/background_jobs_controller.rb b/app/controllers/admin/background_jobs_controller.rb index 4eda35d66f6..43d2c983823 100644 --- a/app/controllers/admin/background_jobs_controller.rb +++ b/app/controllers/admin/background_jobs_controller.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true -class Admin::BackgroundJobsController < Admin::ApplicationController - feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned +module Admin + class BackgroundJobsController < ApplicationController + feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned + end end diff --git a/app/controllers/admin/background_migrations_controller.rb b/app/controllers/admin/background_migrations_controller.rb index c6c9e0ced22..b904196c5ab 100644 --- a/app/controllers/admin/background_migrations_controller.rb +++ b/app/controllers/admin/background_migrations_controller.rb @@ -1,66 +1,68 @@ # frozen_string_literal: true -class Admin::BackgroundMigrationsController < Admin::ApplicationController - feature_category :database - urgency :low - - around_action :support_multiple_databases - - def index - @relations_by_tab = { - 'queued' => batched_migration_class.queued.queue_order, - 'failed' => batched_migration_class.with_status(:failed).queue_order, - 'finished' => batched_migration_class.with_status(:finished).queue_order.reverse_order - } - - @current_tab = @relations_by_tab.key?(params[:tab]) ? params[:tab] : 'queued' - @migrations = @relations_by_tab[@current_tab].page(params[:page]) - @successful_rows_counts = batched_migration_class.successful_rows_counts(@migrations.map(&:id)) - @databases = Gitlab::Database.db_config_names - end +module Admin + class BackgroundMigrationsController < ApplicationController + feature_category :database + urgency :low + + around_action :support_multiple_databases + + def index + @relations_by_tab = { + 'queued' => batched_migration_class.queued.queue_order, + 'failed' => batched_migration_class.with_status(:failed).queue_order, + 'finished' => batched_migration_class.with_status(:finished).queue_order.reverse_order + } + + @current_tab = @relations_by_tab.key?(params[:tab]) ? params[:tab] : 'queued' + @migrations = @relations_by_tab[@current_tab].page(params[:page]) + @successful_rows_counts = batched_migration_class.successful_rows_counts(@migrations.map(&:id)) + @databases = Gitlab::Database.db_config_names + end - def show - @migration = batched_migration_class.find(params[:id]) + def show + @migration = batched_migration_class.find(params[:id]) - @failed_jobs = @migration.batched_jobs.with_status(:failed).page(params[:page]) - end + @failed_jobs = @migration.batched_jobs.with_status(:failed).page(params[:page]) + end - def pause - migration = batched_migration_class.find(params[:id]) - migration.pause! + def pause + migration = batched_migration_class.find(params[:id]) + migration.pause! - redirect_back fallback_location: { action: 'index' } - end + redirect_back fallback_location: { action: 'index' } + end - def resume - migration = batched_migration_class.find(params[:id]) - migration.execute! + def resume + migration = batched_migration_class.find(params[:id]) + migration.execute! - redirect_back fallback_location: { action: 'index' } - end + redirect_back fallback_location: { action: 'index' } + end - def retry - migration = batched_migration_class.find(params[:id]) - migration.retry_failed_jobs! if migration.failed? + def retry + migration = batched_migration_class.find(params[:id]) + migration.retry_failed_jobs! if migration.failed? - redirect_back fallback_location: { action: 'index' } - end + redirect_back fallback_location: { action: 'index' } + end - private + private - def support_multiple_databases - Gitlab::Database::SharedModel.using_connection(base_model.connection) do - yield + def support_multiple_databases + Gitlab::Database::SharedModel.using_connection(base_model.connection) do + yield + end end - end - def base_model - @selected_database = params[:database] || Gitlab::Database::MAIN_DATABASE_NAME + def base_model + @selected_database = params[:database] || Gitlab::Database::MAIN_DATABASE_NAME - Gitlab::Database.database_base_models[@selected_database] - end + Gitlab::Database.database_base_models[@selected_database] + end - def batched_migration_class - @batched_migration_class ||= Gitlab::Database::BackgroundMigration::BatchedMigration + def batched_migration_class + @batched_migration_class ||= Gitlab::Database::BackgroundMigration::BatchedMigration + end end end diff --git a/app/controllers/admin/batched_jobs_controller.rb b/app/controllers/admin/batched_jobs_controller.rb index 0a00ba13dc8..10b5f68d630 100644 --- a/app/controllers/admin/batched_jobs_controller.rb +++ b/app/controllers/admin/batched_jobs_controller.rb @@ -1,28 +1,30 @@ # frozen_string_literal: true -class Admin::BatchedJobsController < Admin::ApplicationController - feature_category :database - urgency :low +module Admin + class BatchedJobsController < ApplicationController + feature_category :database + urgency :low - around_action :support_multiple_databases + around_action :support_multiple_databases - def show - @job = Gitlab::Database::BackgroundMigration::BatchedJob.find(params[:id]) + def show + @job = Gitlab::Database::BackgroundMigration::BatchedJob.find(params[:id]) - @transition_logs = @job.batched_job_transition_logs - end + @transition_logs = @job.batched_job_transition_logs + end - private + private - def support_multiple_databases - Gitlab::Database::SharedModel.using_connection(base_model.connection) do - yield + def support_multiple_databases + Gitlab::Database::SharedModel.using_connection(base_model.connection) do + yield + end end - end - def base_model - @selected_database = params[:database] || Gitlab::Database::MAIN_DATABASE_NAME + def base_model + @selected_database = params[:database] || Gitlab::Database::MAIN_DATABASE_NAME - Gitlab::Database.database_base_models[@selected_database] + Gitlab::Database.database_base_models[@selected_database] + end end end diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb index e8c913f0a1e..093c5667a24 100644 --- a/app/controllers/admin/broadcast_messages_controller.rb +++ b/app/controllers/admin/broadcast_messages_controller.rb @@ -1,104 +1,106 @@ # frozen_string_literal: true -class Admin::BroadcastMessagesController < Admin::ApplicationController - include BroadcastMessagesHelper +module Admin + class BroadcastMessagesController < ApplicationController + include BroadcastMessagesHelper - before_action :find_broadcast_message, only: [:edit, :update, :destroy] - before_action :find_broadcast_messages, only: [:index, :create] - before_action :push_features, only: [:index, :edit] + before_action :find_broadcast_message, only: [:edit, :update, :destroy] + before_action :find_broadcast_messages, only: [:index, :create] + before_action :push_features, only: [:index, :edit] - feature_category :onboarding - urgency :low + feature_category :onboarding + urgency :low - def index - @broadcast_message = BroadcastMessage.new - end - - def edit - end + def index + @broadcast_message = BroadcastMessage.new + end - def create - @broadcast_message = BroadcastMessage.new(broadcast_message_params) - success = @broadcast_message.save + def edit + end - respond_to do |format| - format.json do - if success - render json: @broadcast_message, status: :ok - else - render json: { errors: @broadcast_message.errors.full_messages }, status: :bad_request + def create + @broadcast_message = BroadcastMessage.new(broadcast_message_params) + success = @broadcast_message.save + + respond_to do |format| + format.json do + if success + render json: @broadcast_message, status: :ok + else + render json: { errors: @broadcast_message.errors.full_messages }, status: :bad_request + end end - end - format.html do - if success - redirect_to admin_broadcast_messages_path, notice: _('Broadcast Message was successfully created.') - else - render :index + format.html do + if success + redirect_to admin_broadcast_messages_path, notice: _('Broadcast Message was successfully created.') + else + render :index + end end end end - end - def update - success = @broadcast_message.update(broadcast_message_params) + def update + success = @broadcast_message.update(broadcast_message_params) - respond_to do |format| - format.json do - if success - render json: @broadcast_message, status: :ok - else - render json: { errors: @broadcast_message.errors.full_messages }, status: :bad_request + respond_to do |format| + format.json do + if success + render json: @broadcast_message, status: :ok + else + render json: { errors: @broadcast_message.errors.full_messages }, status: :bad_request + end end - end - format.html do - if success - redirect_to admin_broadcast_messages_path, notice: _('Broadcast Message was successfully updated.') - else - render :edit + format.html do + if success + redirect_to admin_broadcast_messages_path, notice: _('Broadcast Message was successfully updated.') + else + render :edit + end end end end - end - def destroy - @broadcast_message.destroy + def destroy + @broadcast_message.destroy - respond_to do |format| - format.html { redirect_back_or_default(default: { action: 'index' }) } - format.js { head :ok } + respond_to do |format| + format.html { redirect_back_or_default(default: { action: 'index' }) } + format.js { head :ok } + end end - end - def preview - @broadcast_message = BroadcastMessage.new(broadcast_message_params) - render partial: 'admin/broadcast_messages/preview' - end + def preview + @broadcast_message = BroadcastMessage.new(broadcast_message_params) + render partial: 'admin/broadcast_messages/preview' + end - protected + protected - def find_broadcast_message - @broadcast_message = BroadcastMessage.find(params[:id]) - end + def find_broadcast_message + @broadcast_message = BroadcastMessage.find(params[:id]) + end - def find_broadcast_messages - @broadcast_messages = BroadcastMessage.order(ends_at: :desc).page(params[:page]) # rubocop: disable CodeReuse/ActiveRecord - end + def find_broadcast_messages + @broadcast_messages = BroadcastMessage.order(ends_at: :desc).page(params[:page]) # rubocop: disable CodeReuse/ActiveRecord + end - def broadcast_message_params - params.require(:broadcast_message) - .permit(%i[ - theme - ends_at - message - starts_at - target_path - broadcast_type - dismissable - ], target_access_levels: []).reverse_merge!(target_access_levels: []) - end + def broadcast_message_params + params.require(:broadcast_message) + .permit(%i[ + theme + ends_at + message + starts_at + target_path + broadcast_type + dismissable + ], target_access_levels: []).reverse_merge!(target_access_levels: []) + end - def push_features - push_frontend_feature_flag(:vue_broadcast_messages, current_user) - push_frontend_feature_flag(:role_targeted_broadcast_messages, current_user) + def push_features + push_frontend_feature_flag(:vue_broadcast_messages, current_user) + push_frontend_feature_flag(:role_targeted_broadcast_messages, current_user) + end end end diff --git a/app/controllers/admin/ci/variables_controller.rb b/app/controllers/admin/ci/variables_controller.rb index cd9bf422eee..ef50d7362c4 100644 --- a/app/controllers/admin/ci/variables_controller.rb +++ b/app/controllers/admin/ci/variables_controller.rb @@ -1,50 +1,54 @@ # frozen_string_literal: true -class Admin::Ci::VariablesController < Admin::ApplicationController - feature_category :pipeline_authoring - - def show - respond_to do |format| - format.json { render_instance_variables } - end - end - - def update - service = Ci::UpdateInstanceVariablesService.new(variables_params) - - if service.execute - respond_to do |format| - format.json { render_instance_variables } +module Admin + module Ci + class VariablesController < ApplicationController + feature_category :pipeline_authoring + + def show + respond_to do |format| + format.json { render_instance_variables } + end end - else - respond_to do |format| - format.json { render_error(service.errors) } + + def update + service = ::Ci::UpdateInstanceVariablesService.new(variables_params) + + if service.execute + respond_to do |format| + format.json { render_instance_variables } + end + else + respond_to do |format| + format.json { render_error(service.errors) } + end + end end - end - end - private + private - def variables - @variables ||= Ci::InstanceVariable.all - end + def variables + @variables ||= ::Ci::InstanceVariable.all + end - def render_instance_variables - render status: :ok, - json: { - variables: Ci::InstanceVariableSerializer.new.represent(variables) - } - end + def render_instance_variables + render status: :ok, + json: { + variables: ::Ci::InstanceVariableSerializer.new.represent(variables) + } + end - def render_error(errors) - render status: :bad_request, json: errors - end + def render_error(errors) + render status: :bad_request, json: errors + end - def variables_params - params.permit(variables_attributes: Array(variable_params_attributes)) - end + def variables_params + params.permit(variables_attributes: Array(variable_params_attributes)) + end - def variable_params_attributes - %i[id variable_type key secret_value protected masked raw _destroy] + def variable_params_attributes + %i[id variable_type key secret_value protected masked raw _destroy] + end + end end end diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index d8da448a323..76b06b2ce9d 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -13,6 +13,10 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont @issuable = @merge_request ||= merge_request_includes(@project.merge_requests).find_by_iid!(params[:id]) + + return render_404 unless can?(current_user, :read_merge_request, @issuable) + + @issuable end def merge_request_includes(association) diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index ea8c556dc49..db77127cb0a 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -24,10 +24,6 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :ensure_pipeline, only: [:show, :downloadable_artifacts] before_action :reject_if_build_artifacts_size_refreshing!, only: [:destroy] - before_action do - push_frontend_feature_flag(:pipeline_tabs_vue, @project) - end - # Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596 before_action :redirect_for_legacy_scope_filter, only: [:index], if: -> { request.format.html? } diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 5fcb81949ee..c5a3293ad2f 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -248,7 +248,10 @@ class IssuableFinder end def init_collection - klass.all + return klass.all if params.user_can_see_all_issuables? + + # Only admins and auditors can see hidden issuables, for other users we filter out hidden issuables + klass.without_hidden end def default_or_simple_sort? diff --git a/app/finders/issuable_finder/params.rb b/app/finders/issuable_finder/params.rb index 32d50802537..4e17f06e1c1 100644 --- a/app/finders/issuable_finder/params.rb +++ b/app/finders/issuable_finder/params.rb @@ -195,6 +195,11 @@ class IssuableFinder project || group end + def user_can_see_all_issuables? + Ability.allowed?(current_user, :read_all_resources) + end + strong_memoize_attr :user_can_see_all_issuables?, :user_can_see_all_issuables + private def projects_public_or_visible_to_user diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index e12dce744b5..bd81f06f93b 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -49,7 +49,7 @@ class IssuesFinder < IssuableFinder # rubocop: disable CodeReuse/ActiveRecord def with_confidentiality_access_check - return model_class.all if params.user_can_see_all_issues? + return model_class.all if params.user_can_see_all_issuables? # Only admins can see hidden issues, so for non-admins, we filter out any hidden issues issues = model_class.without_hidden diff --git a/app/finders/issues_finder/params.rb b/app/finders/issues_finder/params.rb index 7f8acb79ed6..786bfbd4113 100644 --- a/app/finders/issues_finder/params.rb +++ b/app/finders/issues_finder/params.rb @@ -44,7 +44,7 @@ class IssuesFinder if parent Ability.allowed?(current_user, :read_confidential_issues, parent) else - user_can_see_all_issues? + user_can_see_all_issuables? end end end @@ -54,12 +54,6 @@ class IssuesFinder current_user.blank? end - - def user_can_see_all_issues? - strong_memoize(:user_can_see_all_issues) do - Ability.allowed?(current_user, :read_all_resources) - end - end end end diff --git a/app/graphql/resolvers/concerns/resolves_merge_requests.rb b/app/graphql/resolvers/concerns/resolves_merge_requests.rb index d56951bc821..c68e120ee24 100644 --- a/app/graphql/resolvers/concerns/resolves_merge_requests.rb +++ b/app/graphql/resolvers/concerns/resolves_merge_requests.rb @@ -34,7 +34,7 @@ module ResolvesMergeRequests end def unconditional_includes - [:target_project] + [:target_project, :author] end def preloads diff --git a/app/graphql/resolvers/paginated_tree_resolver.rb b/app/graphql/resolvers/paginated_tree_resolver.rb index c7e9e522c25..6c4e978125e 100644 --- a/app/graphql/resolvers/paginated_tree_resolver.rb +++ b/app/graphql/resolvers/paginated_tree_resolver.rb @@ -41,7 +41,10 @@ module Resolvers next_cursor = tree.cursor&.next_cursor Gitlab::Graphql::ExternallyPaginatedArray.new(cursor, next_cursor, *tree) rescue Gitlab::Git::CommandError => e - raise Gitlab::Graphql::Errors::ArgumentError, e + raise Gitlab::Graphql::Errors::BaseError.new( + e, + extensions: { code: e.code, gitaly_code: e.status, service: e.service } + ) end def self.field_options diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 2b21d8c51e6..7d99b0da890 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -275,7 +275,7 @@ module IssuablesHelper zoomMeetingUrl: ZoomMeeting.canonical_meeting_url(issuable), sentryIssueIdentifier: SentryIssue.find_by(issue: issuable)&.sentry_issue_identifier, # rubocop:disable CodeReuse/ActiveRecord iid: issuable.iid.to_s, - isHidden: issue_hidden?(issuable), + isHidden: issuable_hidden?(issuable), canCreateIncident: create_issue_type_allowed?(issuable.project, :incident) } end @@ -372,6 +372,20 @@ module IssuablesHelper end end + def issuable_hidden?(issuable) + Feature.enabled?(:ban_user_feature_flag) && issuable.hidden? + end + + def hidden_issuable_icon(issuable) + return unless issuable_hidden?(issuable) + + title = format(_('This %{issuable} is hidden because its author has been banned'), + issuable: issuable.human_class_name) + content_tag(:span, class: 'has-tooltip', title: title) do + sprite_icon('spam', css_class: 'gl-vertical-align-text-bottom') + end + end + private def sidebar_gutter_collapsed? diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 1d68dccc741..101df8cdd41 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -70,18 +70,6 @@ module IssuesHelper sprite_icon('eye-slash', css_class: 'gl-vertical-align-text-bottom') if issue.confidential? end - def issue_hidden?(issue) - Feature.enabled?(:ban_user_feature_flag) && issue.hidden? - end - - def hidden_issue_icon(issue) - return unless issue_hidden?(issue) - - content_tag(:span, class: 'has-tooltip', title: _('This issue is hidden because its author has been banned')) do - sprite_icon('spam', css_class: 'gl-vertical-align-text-bottom') - end - end - def award_user_list(awards, current_user, limit: 10) names = awards.map do |award| award.user == current_user ? 'You' : award.user.name diff --git a/app/helpers/projects/pipeline_helper.rb b/app/helpers/projects/pipeline_helper.rb index edbdb9d4adf..5c62920cd89 100644 --- a/app/helpers/projects/pipeline_helper.rb +++ b/app/helpers/projects/pipeline_helper.rb @@ -12,6 +12,7 @@ module Projects graphql_resource_etag: graphql_etag_pipeline_path(pipeline), metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: project.namespace, project_id: project, format: :json), pipeline_iid: pipeline.iid, + pipeline_path: pipeline_path(pipeline), pipeline_project_path: project.full_path, total_job_count: pipeline.total_size, summary_endpoint: summary_project_pipeline_tests_path(project, pipeline, format: :json), diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 9f0cd96a8f8..1a50ebde0a3 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -144,6 +144,14 @@ module Issuable includes(*associations) end + scope :without_hidden, -> { + if Feature.enabled?(:ban_user_feature_flag) + where.not(author_id: Users::BannedUser.all.select(:user_id)) + else + all + end + } + attr_mentionable :title, pipeline: :single_line attr_mentionable :description @@ -227,6 +235,10 @@ module Issuable issuable_severity&.severity || IssuableSeverity::DEFAULT end + def hidden? + author&.banned? + end + private def description_max_length_for_new_records_is_valid diff --git a/app/models/issue.rb b/app/models/issue.rb index b338ecfce88..f517f42d6ba 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -178,14 +178,6 @@ class Issue < ApplicationRecord scope :confidential_only, -> { where(confidential: true) } - scope :without_hidden, -> { - if Feature.enabled?(:ban_user_feature_flag) - where.not(author_id: Users::BannedUser.all.select(:user_id)) - else - all - end - } - scope :counts_by_state, -> { reorder(nil).group(:state_id).count } scope :service_desk, -> { where(author: ::User.support_bot) } @@ -658,10 +650,6 @@ class Issue < ApplicationRecord end end - def hidden? - author&.banned? - end - # Necessary until all issues are backfilled and we add a NOT NULL constraint on the DB def work_item_type super || WorkItems::Type.default_by_type(issue_type) diff --git a/app/models/project.rb b/app/models/project.rb index 91527f9f76d..0d9b6f922d9 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -218,6 +218,17 @@ class Project < ApplicationRecord has_one :fork_network_member has_one :fork_network, through: :fork_network_member has_one :forked_from_project, through: :fork_network_member + + # Projects with a very large number of notes may time out destroying them + # through the foreign key. Additionally, the deprecated attachment uploader + # for notes requires us to use dependent: :destroy to avoid orphaning uploaded + # files. + # + # https://gitlab.com/gitlab-org/gitlab/-/issues/207222 + # Order of this association is important for project deletion. + # has_many :notes` should be the first association among all `has_many` associations. + has_many :notes, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :forked_to_members, class_name: 'ForkNetworkMember', foreign_key: 'forked_from_project_id' has_many :forks, through: :forked_to_members, source: :project, inverse_of: :forked_from_project has_many :fork_network_projects, through: :fork_network, source: :projects @@ -246,13 +257,13 @@ class Project < ApplicationRecord has_one :service_desk_setting, class_name: 'ServiceDeskSetting' # Merge requests for target project should be removed with it - has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project + has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :merge_request_metrics, foreign_key: 'target_project', class_name: 'MergeRequest::Metrics', inverse_of: :target_project has_many :source_of_merge_requests, foreign_key: 'source_project_id', class_name: 'MergeRequest' - has_many :issues + has_many :issues, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :incident_management_issuable_escalation_statuses, through: :issues, inverse_of: :project, class_name: 'IncidentManagement::IssuableEscalationStatus' has_many :incident_management_timeline_event_tags, inverse_of: :project, class_name: 'IncidentManagement::TimelineEventTag' - has_many :labels, class_name: 'ProjectLabel' + has_many :labels, class_name: 'ProjectLabel', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :events has_many :milestones @@ -270,15 +281,6 @@ class Project < ApplicationRecord has_many :push_hooks_integrations, -> { push_hooks }, class_name: 'Integration' has_many :tag_push_hooks_integrations, -> { tag_push_hooks }, class_name: 'Integration' has_many :wiki_page_hooks_integrations, -> { wiki_page_hooks }, class_name: 'Integration' - - # Projects with a very large number of notes may time out destroying them - # through the foreign key. Additionally, the deprecated attachment uploader - # for notes requires us to use dependent: :destroy to avoid orphaning uploaded - # files. - # - # https://gitlab.com/gitlab-org/gitlab/-/issues/207222 - has_many :notes, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_many :snippets, class_name: 'ProjectSnippet' has_many :hooks, class_name: 'ProjectHook' has_many :protected_branches diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb index aa07bb7dc5f..779384ee3fe 100644 --- a/app/policies/issuable_policy.rb +++ b/app/policies/issuable_policy.rb @@ -16,6 +16,9 @@ class IssuablePolicy < BasePolicy condition(:is_incident) { @subject.incident? } + desc "Issuable is hidden" + condition(:hidden, scope: :subject) { @subject.hidden? } + rule { can?(:guest_access) & assignee_or_author & ~is_incident }.policy do enable :read_issue enable :update_issue diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb index 491eebe9daf..7d4e42580b8 100644 --- a/app/policies/issue_policy.rb +++ b/app/policies/issue_policy.rb @@ -21,9 +21,6 @@ class IssuePolicy < IssuablePolicy desc "Issue is confidential" condition(:confidential, scope: :subject) { @subject.confidential? } - desc "Issue is hidden" - condition(:hidden, scope: :subject) { @subject.hidden? } - desc "Issue is persisted" condition(:persisted, scope: :subject) { @subject.persisted? } diff --git a/app/policies/merge_request_policy.rb b/app/policies/merge_request_policy.rb index 32128d84d0b..8da2af506c7 100644 --- a/app/policies/merge_request_policy.rb +++ b/app/policies/merge_request_policy.rb @@ -27,6 +27,10 @@ class MergeRequestPolicy < IssuablePolicy enable :update_subscription end + rule { hidden & ~admin }.policy do + prevent :read_merge_request + end + condition(:can_merge) { @subject.can_be_merged_by?(@user) } rule { can_merge }.policy do diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index f9a2c825608..f02bbdbee60 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -65,16 +65,36 @@ module Projects return unless changing_default_branch? previous_default_branch = project.default_branch + new_default_branch = params[:default_branch] - if project.change_head(params[:default_branch]) + if project.change_head(new_default_branch) params[:previous_default_branch] = previous_default_branch + if !project.root_ref?(new_default_branch) && has_custom_head_branch? + raise ValidationError, + format( + s_("UpdateProject|Could not set the default branch. Do you have a branch named 'HEAD' in your repository? (%{linkStart}How do I fix this?%{linkEnd})"), + linkStart: ambiguous_head_documentation_link, linkEnd: '</a>' + ).html_safe + end + after_default_branch_change(previous_default_branch) else raise ValidationError, s_("UpdateProject|Could not set the default branch") end end + def ambiguous_head_documentation_link + url = Rails.application.routes.url_helpers.help_page_path('user/project/repository/branches/index.md', anchor: 'error-ambiguous-head-branch-exists') + + format('<a href="%{url}" target="_blank" rel="noopener noreferrer">', url: url) + end + + # See issue: https://gitlab.com/gitlab-org/gitlab/-/issues/381731 + def has_custom_head_branch? + project.repository.branch_names.any? { |name| name.casecmp('head') == 0 } + end + def after_default_branch_change(previous_default_branch) # overridden by EE module end diff --git a/app/views/admin/application_settings/_performance_bar.html.haml b/app/views/admin/application_settings/_performance_bar.html.haml index d4f6d84ea74..c09ba01b7ed 100644 --- a/app/views/admin/application_settings/_performance_bar.html.haml +++ b/app/views/admin/application_settings/_performance_bar.html.haml @@ -4,7 +4,7 @@ %fieldset .form-group = f.gitlab_ui_checkbox_component :performance_bar_enabled, - s_("Allow non-administrators access to the performance bar"), + _("Allow non-administrators access to the performance bar"), checkbox_options: { data: { qa_selector: 'enable_performance_bar_checkbox' } } .form-group = f.label :performance_bar_allowed_group_path, _('Allow access to members of the following group'), class: 'label-bold' diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml index 538fa59b83a..2715eae123d 100644 --- a/app/views/admin/application_settings/_repository_check.html.haml +++ b/app/views/admin/application_settings/_repository_check.html.haml @@ -19,7 +19,7 @@ %h4= _("Housekeeping") .form-group - help_text = _("Run housekeeping tasks to automatically optimize Git repositories. Disabling this option will cause performance to degenerate over time.") - - help_link = link_to s_('Learn more.'), help_page_path('administration/housekeeping.md', anchor: 'configure-push-based-maintenance'), target: '_blank', rel: 'noopener noreferrer' + - help_link = link_to _('Learn more.'), help_page_path('administration/housekeeping.md', anchor: 'configure-push-based-maintenance'), target: '_blank', rel: 'noopener noreferrer' = f.gitlab_ui_checkbox_component :housekeeping_enabled, _("Enable automatic repository housekeeping"), help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link } diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml index 12dd8816783..066d77c792b 100644 --- a/app/views/admin/application_settings/_repository_storage.html.haml +++ b/app/views/admin/application_settings/_repository_storage.html.haml @@ -20,7 +20,7 @@ - weights_link_url = help_page_path('administration/repository_storage_paths.md', anchor: 'configure-where-new-repositories-are-stored') - weights_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: weights_link_url } = html_escape(s_('Enter %{weights_link_start}weights%{weights_link_end} for storages for new repositories. Configured storages appear below.')) % { weights_link_start: weights_link_start, weights_link_end: '</a>'.html_safe } - = link_to s_('Learn more.'), help_page_path('administration/repository_storage_paths.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/repository_storage_paths.md'), target: '_blank', rel: 'noopener noreferrer' .form-check = f.fields_for :repository_storages_weighted, storage_weights do |storage_form| - Gitlab.config.repositories.storages.each_key do |storage| diff --git a/app/views/admin/application_settings/ci/_header.html.haml b/app/views/admin/application_settings/ci/_header.html.haml index 25a7827db8b..79c07f491fc 100644 --- a/app/views/admin/application_settings/ci/_header.html.haml +++ b/app/views/admin/application_settings/ci/_header.html.haml @@ -8,7 +8,7 @@ %p = _('Variables store information, like passwords and secret keys, that you can use in job scripts. All projects on the instance can use these variables.') - = link_to s_('Learn more.'), help_page_path('ci/variables/index', anchor: 'add-a-cicd-variable-to-an-instance'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'add-a-cicd-variable-to-an-instance'), target: '_blank', rel: 'noopener noreferrer' %p = _('Variables can be:') %ul diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml index e0a7ba702b3..b5981578866 100644 --- a/app/views/admin/application_settings/metrics_and_profiling.html.haml +++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml @@ -24,7 +24,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Link to your Grafana instance.') - = link_to s_('Learn more.'), help_page_path('administration/monitoring/performance/grafana_configuration.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/monitoring/performance/grafana_configuration.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'grafana' @@ -37,7 +37,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Enable access to the performance bar for non-administrators in a given group.') - = link_to s_('Learn more.'), help_page_path('administration/monitoring/performance/performance_bar.md', anchor: 'enable-the-performance-bar-for-non-administrators'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/monitoring/performance/performance_bar.md', anchor: 'enable-the-performance-bar-for-non-administrators'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'performance_bar' diff --git a/app/views/admin/application_settings/preferences.html.haml b/app/views/admin/application_settings/preferences.html.haml index bee169c8154..c3e1c6156bf 100644 --- a/app/views/admin/application_settings/preferences.html.haml +++ b/app/views/admin/application_settings/preferences.html.haml @@ -33,7 +33,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Additional text for the sign-in and Help page.') - = link_to s_('Learn more.'), help_page_path('user/admin_area/settings/help_page.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('user/admin_area/settings/help_page.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'help_page' diff --git a/app/views/admin/application_settings/repository.html.haml b/app/views/admin/application_settings/repository.html.haml index d30b509e833..50798ad476c 100644 --- a/app/views/admin/application_settings/repository.html.haml +++ b/app/views/admin/application_settings/repository.html.haml @@ -22,7 +22,7 @@ = expanded_by_default? ? 'Collapse' : 'Expand' %p = _('Configure repository mirroring.') - = link_to s_('Learn more.'), help_page_path('user/project/repository/mirror/index.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('user/project/repository/mirror/index.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render partial: 'repository_mirrors_form' @@ -34,7 +34,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Configure repository storage.') - = link_to s_('Learn more.'), help_page_path('administration/repository_storage_paths.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/repository_storage_paths.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'repository_storage' @@ -61,6 +61,6 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Serve repository static objects (for example, archives and blobs) from external storage.') - = link_to s_('Learn more.'), help_page_path('administration/static_objects_external_storage.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/static_objects_external_storage.md'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'repository_static_objects' diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml index b6a97887b5c..cf1bd2a8022 100644 --- a/app/views/admin/projects/_projects.html.haml +++ b/app/views/admin/projects/_projects.html.haml @@ -25,7 +25,7 @@ .controls.gl-flex-shrink-0.gl-ml-5 = render Pajamas::ButtonComponent.new(href: edit_project_path(project), button_options: { id: dom_id(project, :edit) }) do - = s_('Edit') + = _('Edit') = render Pajamas::ButtonComponent.new(variant: :danger, button_options: { class: 'delete-project-button', data: { delete_project_url: admin_project_path(project), project_name: project.name } }) do = s_('AdminProjects|Delete') diff --git a/app/views/ci/variables/_content.html.haml b/app/views/ci/variables/_content.html.haml index 18a38dbddfe..37043a207ff 100644 --- a/app/views/ci/variables/_content.html.haml +++ b/app/views/ci/variables/_content.html.haml @@ -1,5 +1,5 @@ = format(s_('CiVariables|Variables store information, like passwords and secret keys, that you can use in job scripts. Each %{entity} can define a maximum of %{limit} variables.'), entity: entity, limit: variable_limit).html_safe -= link_to s_('Learn more.'), help_page_path('ci/variables/index'), target: '_blank', rel: 'noopener noreferrer' += link_to _('Learn more.'), help_page_path('ci/variables/index'), target: '_blank', rel: 'noopener noreferrer' %p = _('Variables can have several attributes.') = link_to _('Learn more.'), help_page_path('ci/variables/index', anchor: 'add-a-cicd-variable-to-an-instance'), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index b848bcc2339..ede1dfdac5c 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -5,6 +5,12 @@ = todo_target_state_pill(todo) + %span.todo-target-title{ data: { qa_selector: "todo_target_title_content" }, :id => dom_id(todo) + "_describer" } + = todo_target_title(todo) + + - if !todo.for_design? && !todo.member_access_requested? + · + %span = todo_parent_path(todo) @@ -15,12 +21,6 @@ - else = _("(removed)") - - if !todo.for_design? - · - - %span.todo-target-title{ data: { qa_selector: "todo_target_title_content" }, :id => dom_id(todo) + "_describer" } - = todo_target_title(todo) - .todo-body.gl-mb-2.gl-px-2.gl-display-flex.gl-align-items-flex-start.gl-lg-align-items-center .todo-avatar.gl-display-none.gl-sm-display-inline-block = author_avatar(todo, size: 24) diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index bca1c874cc6..8763912438b 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -13,7 +13,7 @@ = _('Collapse') %p = _('Update your group name, description, avatar, and visibility.') - = link_to s_('Learn more about groups.'), help_page_path('user/group/index') + = link_to _('Learn more about groups.'), help_page_path('user/group/index') .settings-content = render 'groups/settings/general' diff --git a/app/views/groups/settings/_git_access_protocols.html.haml b/app/views/groups/settings/_git_access_protocols.html.haml index df798db79ad..01e8536c7ad 100644 --- a/app/views/groups/settings/_git_access_protocols.html.haml +++ b/app/views/groups/settings/_git_access_protocols.html.haml @@ -1,6 +1,6 @@ - if group.root? && Feature.enabled?(:group_level_git_protocol_control, group) .form-group - = f.label s_('Enabled Git access protocols'), class: 'label-bold' + = f.label _('Enabled Git access protocols'), class: 'label-bold' = f.select :enabled_git_access_protocol, options_for_select(enabled_git_access_protocol_options_for_group, group.enabled_git_access_protocol), {}, class: 'form-control', data: { qa_selector: 'enabled_git_access_protocol_dropdown' }, disabled: !::Gitlab::CurrentSettings.enabled_git_access_protocol.blank? - if !::Gitlab::CurrentSettings.enabled_git_access_protocol.blank? .form-text.text-muted diff --git a/app/views/groups/settings/ci_cd/_form.html.haml b/app/views/groups/settings/ci_cd/_form.html.haml index 89e353b94b0..d31d22c61be 100644 --- a/app/views/groups/settings/ci_cd/_form.html.haml +++ b/app/views/groups/settings/ci_cd/_form.html.haml @@ -7,5 +7,5 @@ = f.number_field :max_artifacts_size, class: 'form-control' %p.form-text.text-muted = _("The maximum file size in megabytes for individual job artifacts.") - = link_to s_('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size'), target: '_blank', rel: 'noopener noreferrer' = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/notify/_reassigned_issuable_email.html.haml b/app/views/notify/_reassigned_issuable_email.html.haml index 54e51e07c86..ead8e5d0a7e 100644 --- a/app/views/notify/_reassigned_issuable_email.html.haml +++ b/app/views/notify/_reassigned_issuable_email.html.haml @@ -1,4 +1,4 @@ -- to_names = content_tag(:strong, issuable.assignees.any? ? sanitize_name(issuable.assignee_list) : s_('Unassigned')) +- to_names = content_tag(:strong, issuable.assignees.any? ? sanitize_name(issuable.assignee_list) : _('Unassigned')) %p - if previous_assignees.any? diff --git a/app/views/notify/repository_push_email.html.haml b/app/views/notify/repository_push_email.html.haml index ee219914513..d493f9d5d98 100644 --- a/app/views/notify/repository_push_email.html.haml +++ b/app/views/notify/repository_push_email.html.haml @@ -60,7 +60,7 @@ - if diff_file.deleted_file? %strong< = diff_file.old_path - = s_('deleted') + = _('deleted') - elsif diff_file.renamed_file? %strong< = diff_file.old_path diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 1d3320e4f82..a34ed332fcf 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -6,7 +6,7 @@ - if issue.confidential? %span.has-tooltip{ title: _('Confidential') } = confidential_icon(issue) - = hidden_issue_icon(issue) + = hidden_issuable_icon(issue) = link_to issue.title, issue_path(issue), class: 'js-prefetch-document' = render_if_exists 'projects/issues/subepic_flag', issue: issue - if issue.tasks? diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index 71f8e4c32f5..d7e26da5f51 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -12,6 +12,7 @@ .issuable-main-info .merge-request-title.title %span.merge-request-title-text.js-onboarding-mr-item + = hidden_issuable_icon(merge_request) = link_to merge_request.title, merge_request_path(merge_request), class: 'js-prefetch-document' - if merge_request.tasks? %span.task-status.d-none.d-sm-inline-block diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index a73d2aa5cc4..6129e349df3 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -16,7 +16,7 @@ .detail-page-header.border-bottom-0.pt-0.pb-0.gl-display-block{ class: "gl-md-display-flex! #{'is-merge-request' if moved_mr_sidebar_enabled? && !fluid_layout}" } .detail-page-header-body .issuable-meta.gl-display-flex - #js-issuable-header-warnings + #js-issuable-header-warnings{ data: { hidden: issuable_hidden?(@merge_request).to_s } } %h1.title.page-title.gl-font-size-h-display.gl-my-0.gl-display-inline-block{ data: { qa_selector: 'title_content' } } = markdown_field(@merge_request, :title) diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml deleted file mode 100644 index e83547fd8f8..00000000000 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ /dev/null @@ -1,48 +0,0 @@ -- return if pipeline_has_errors - -.tabs-holder - %ul.pipelines-tabs.nav-links.no-top.no-bottom.mobile-separator.nav.nav-tabs - %li.js-pipeline-tab-link - = link_to project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-pipeline', action: 'pipelines', toggle: 'tab' }, class: 'pipeline-tab' do - = _('Pipeline') - %li.js-dag-tab-link - = link_to dag_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-dag', action: 'dag', toggle: 'tab' }, class: 'dag-tab' do - = _('Needs') - %li.js-builds-tab-link - = link_to builds_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do - = _('Jobs') - = gl_badge_tag @pipeline.total_size, { size: :sm }, { class: 'js-builds-counter' } - - if @pipeline.failed_builds.present? - %li.js-failures-tab-link - = link_to failures_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do - = _('Failed Jobs') - = gl_badge_tag @pipeline.failed_builds.count, { size: :sm }, { class: 'js-failures-counter' } - %li.js-tests-tab-link - = link_to test_report_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-tests', action: 'test_report', toggle: 'tab' }, class: 'test-tab' do - = s_('TestReports|Tests') - = gl_badge_tag @pipeline.test_report_summary.total[:count], { size: :sm }, { class: 'js-test-report-badge-counter' } - = render_if_exists "projects/pipelines/tabs_holder", pipeline: @pipeline, project: @project - -.tab-content - #js-tab-pipeline.tab-pane.gl-w-full - #js-pipeline-graph-vue - - #js-tab-builds.tab-pane - - if stages.present? - #js-pipeline-jobs-vue{ data: { full_path: @project.full_path, pipeline_iid: @pipeline.iid } } - - - if @pipeline.failed_builds.present? - #js-tab-failures.tab-pane - #js-pipeline-failed-jobs-vue{ data: { full_path: @project.full_path, pipeline_iid: @pipeline.iid, failed_jobs_summary_data: prepare_failed_jobs_summary_data(@pipeline.failed_builds) } } - - #js-tab-dag.tab-pane - #js-pipeline-dag-vue{ data: { pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, empty_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'), about_dag_doc_path: help_page_path('ci/directed_acyclic_graph/index.md'), dag_doc_path: help_page_path('ci/yaml/index.md', anchor: 'needs')} } - - #js-tab-tests.tab-pane - #js-pipeline-tests-detail{ data: { summary_endpoint: summary_project_pipeline_tests_path(@project, @pipeline, format: :json), - suite_endpoint: project_pipeline_test_path(@project, @pipeline, suite_name: 'suite', format: :json), - blob_path: project_blob_path(@project, @pipeline.sha), - has_test_report: @pipeline.complete_and_has_reports?(Ci::JobArtifact.of_report_type(:test)).to_s, - empty_state_image_path: image_path('illustrations/empty-state/empty-test-cases-lg.svg'), - artifacts_expired_image_path: image_path('illustrations/pipeline.svg') } } - = render_if_exists "projects/pipelines/tabs_content", pipeline: @pipeline, project: @project diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index 4531bb2d0a9..1dba3ed1b94 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -9,7 +9,7 @@ - add_page_startup_graphql_call('pipelines/get_pipeline_details', { projectPath: @project.full_path, iid: @pipeline.iid }) .js-pipeline-container{ data: { controller_action: "#{controller.action_name}" } } - #js-pipeline-header-vue.pipeline-header-container{ data: { full_path: @project.full_path, pipeline_iid: @pipeline.iid, pipeline_id: @pipeline.id, pipelines_path: project_pipelines_path(@project) } } + #js-pipeline-header-vue.pipeline-header-container{ data: { full_path: @project.full_path, graphql_resource_etag: graphql_etag_pipeline_path(@pipeline), pipeline_iid: @pipeline.iid, pipeline_id: @pipeline.id, pipelines_path: project_pipelines_path(@project) } } = render_if_exists 'projects/pipelines/cc_validation_required_alert', pipeline: @pipeline @@ -26,8 +26,5 @@ - lint_link_start = '<a href="%{url}" class="gl-text-blue-500!">'.html_safe % { url: lint_link_url } = s_('You can also test your %{gitlab_ci_yml} in %{lint_link_start}CI Lint%{lint_link_end}').html_safe % { gitlab_ci_yml: '.gitlab-ci.yml', lint_link_start: lint_link_start, lint_link_end: '</a>'.html_safe } - - if Feature.enabled?(:pipeline_tabs_vue, @project) - #js-pipeline-tabs{ data: js_pipeline_tabs_data(@project, @pipeline, @current_user) } - else - = render "projects/pipelines/with_tabs", pipeline: @pipeline, stages: @stages, pipeline_has_errors: pipeline_has_errors -.js-pipeline-details-vue{ data: { metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: @project.namespace, project_id: @project, format: :json), pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, graphql_resource_etag: graphql_etag_pipeline_path(@pipeline), pipeline_path: pipeline_path(@pipeline) } } + #js-pipeline-tabs{ data: js_pipeline_tabs_data(@project, @pipeline, @current_user) } diff --git a/app/views/shared/issue_type/_details_header.html.haml b/app/views/shared/issue_type/_details_header.html.haml index ccb501dae11..c8762f8e060 100644 --- a/app/views/shared/issue_type/_details_header.html.haml +++ b/app/views/shared/issue_type/_details_header.html.haml @@ -13,7 +13,7 @@ %span.gl-display-none.gl-sm-display-block.gl-ml-2 = _('Open') - #js-issuable-header-warnings{ data: { hidden: issue_hidden?(issuable).to_s } } + #js-issuable-header-warnings{ data: { hidden: issuable_hidden?(issuable).to_s } } = issuable_meta(issuable, @project) %a.btn.gl-button.btn-default.btn-icon.float-right.gl-display-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" } diff --git a/app/views/shared/topics/_search_form.html.haml b/app/views/shared/topics/_search_form.html.haml index 97343983b3c..2806b2865dd 100644 --- a/app/views/shared/topics/_search_form.html.haml +++ b/app/views/shared/topics/_search_form.html.haml @@ -1,6 +1,6 @@ = form_tag page_filter_path, method: :get, class: "topic-filter-form js-topic-filter-form", id: 'topic-filter-form' do |f| = search_field_tag :search, params[:search], - placeholder: s_('Filter by name'), + placeholder: _('Filter by name'), class: 'topic-filter-form-field form-control input-short', spellcheck: false, id: 'topic-filter-form-field', diff --git a/config/feature_flags/development/require_approval_on_scan_removal.yml b/config/feature_flags/development/enforce_scan_result_policies_for_preexisting_vulnerabilities.yml index fe782dbc27b..e72dc713e02 100644 --- a/config/feature_flags/development/require_approval_on_scan_removal.yml +++ b/config/feature_flags/development/enforce_scan_result_policies_for_preexisting_vulnerabilities.yml @@ -1,8 +1,8 @@ --- -name: require_approval_on_scan_removal -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/102631 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/382079 -milestone: '15.6' +name: enforce_scan_result_policies_for_preexisting_vulnerabilities +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/105248 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/384260 +milestone: '15.7' type: development group: group::security policies -default_enabled: true +default_enabled: false diff --git a/config/feature_flags/development/pipeline_tabs_vue.yml b/config/feature_flags/development/pipeline_tabs_vue.yml deleted file mode 100644 index 848166d2cc1..00000000000 --- a/config/feature_flags/development/pipeline_tabs_vue.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: pipeline_tabs_vue -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/80401 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/353118 -milestone: '14.10' -type: development -group: group::pipeline authoring -default_enabled: false diff --git a/doc/ci/examples/authenticating-with-hashicorp-vault/index.md b/doc/ci/examples/authenticating-with-hashicorp-vault/index.md index 9b80fe22c37..125ae3650c9 100644 --- a/doc/ci/examples/authenticating-with-hashicorp-vault/index.md +++ b/doc/ci/examples/authenticating-with-hashicorp-vault/index.md @@ -184,6 +184,31 @@ This example uses [bound claims](https://developer.hashicorp.com/vault/api-docs/ Combined with [protected branches](../../../user/project/protected_branches.md), you can restrict who is able to authenticate and read the secrets. +To use the same policy for a list of projects, use `namespace_id`: + +```json +"bound_claims": { + "namespace_id": ["12", "22", "37"] +} +``` + +Any of the claims [included in the JWT](#how-it-works) can be matched against a list of values +in the bound claims. For example: + +```json +"bound_claims": { + "user_login": ["alice", "bob", "mallory"] +} + +"bound_claims": { + "ref": ["main", "develop", "test"] +} + +"bound_claims": { + "project_id": ["12", "22", "37"] +} +``` + [`token_explicit_max_ttl`](https://developer.hashicorp.com/vault/api-docs/auth/jwt#token_explicit_max_ttl) specifies that the token issued by Vault, upon successful authentication, has a hard lifetime limit of 60 seconds. [`user_claim`](https://developer.hashicorp.com/vault/api-docs/auth/jwt#user_claim) specifies the name for the Identity alias created by Vault upon a successful login. diff --git a/doc/user/admin_area/moderate_users.md b/doc/user/admin_area/moderate_users.md index c0daf029b1f..117781f7222 100644 --- a/doc/user/admin_area/moderate_users.md +++ b/doc/user/admin_area/moderate_users.md @@ -223,7 +223,7 @@ On self-managed GitLab, by default this feature is available. To hide the feature, ask an administrator to [disable the feature flag](../../administration/feature_flags.md) named `ban_user_feature_flag`. On GitLab.com, this feature is available to GitLab.com administrators only. -GitLab administrators can ban and unban users. Banned users are blocked, and their issues are hidden. +GitLab administrators can ban and unban users. Banned users are blocked, and their issues and merge requests are hidden. The banned user's comments are still displayed. Hiding a banned user's comments is [tracked in this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/327356). ### Ban a user diff --git a/doc/user/group/epics/manage_epics.md b/doc/user/group/epics/manage_epics.md index 61aa5c5fe02..8e7b6fd82ad 100644 --- a/doc/user/group/epics/manage_epics.md +++ b/doc/user/group/epics/manage_epics.md @@ -494,7 +494,8 @@ The maximum number of direct child epics is 100. ### Child epics from other groups -> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/8502) in GitLab 15.6 [with a flag](../../../administration/feature_flags.md) named `child_epics_from_different_hierarchies`. Disabled by default. +> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/8502) in GitLab 15.6 [with a flag](../../../administration/feature_flags.md) named `child_epics_from_different_hierarchies`. Disabled by default. +> - Minimum required role for the group [changed](https://gitlab.com/gitlab-org/gitlab/-/issues/382503) from Reporter to Guest in GitLab 15.7. FLAG: On self-managed GitLab, by default this feature is not available. To make it available per group, ask an administrator to [enable the feature flag](../../../administration/feature_flags.md) named `child_epics_from_different_hierarchies`. @@ -504,16 +505,18 @@ You can add a child epic that belongs to a group that is different from the pare Prerequisites: -- You must have at least the Reporter role for both the child and parent epics' groups. +- You must have at least the Guest role for both the child and parent epics' groups. - Multi-level child epics must be available for both the child and parent epics' groups. To add a child epic from another group, paste the epic's URL when [adding an existing epic](#add-a-child-epic-to-an-epic). ### Add a child epic to an epic +> Minimum required role for the group [changed](https://gitlab.com/gitlab-org/gitlab/-/issues/382503) from Reporter to Guest in GitLab 15.7. + Prerequisites: -- You must have at least the Reporter role for the parent epic's group. +- You must have at least the Guest role for the parent epic's group. To add a new epic as child epic: @@ -534,7 +537,8 @@ To add an existing epic as child epic: ### Move child epics between epics -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/33039) in GitLab 13.0. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/33039) in GitLab 13.0. +> - Minimum required role for the group [changed](https://gitlab.com/gitlab-org/gitlab/-/issues/382503) from Reporter to Guest in GitLab 15.7. New child epics appear at the top of the list in the **Epics and Issues** tab. You can move child epics from one epic to another. @@ -543,7 +547,7 @@ Issues and child epics cannot be intermingled. Prerequisites: -- You must have at least the Reporter role for the parent epic's group. +- You must have at least the Guest role for the parent epic's group. To move child epics to another epic: @@ -552,14 +556,15 @@ To move child epics to another epic: ### Reorder child epics assigned to an epic -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/9367) in GitLab 12.5. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/9367) in GitLab 12.5. +> - Minimum required role for the group [changed](https://gitlab.com/gitlab-org/gitlab/-/issues/382503) from Reporter to Guest in GitLab 15.7. New child epics appear at the top of the list in the **Epics and Issues** tab. You can reorder the list of child epics. Prerequisites: -- You must have at least the Reporter role for the parent epic's group. +- You must have at least the Guest role for the parent epic's group. To reorder child epics assigned to an epic: @@ -568,9 +573,11 @@ To reorder child epics assigned to an epic: ### Remove a child epic from a parent epic +> Minimum required role for the group [changed](https://gitlab.com/gitlab-org/gitlab/-/issues/382503) from Reporter to Guest in GitLab 15.7. + Prerequisites: -- You must have at least the Reporter role for the parent epic's group. +- You must have at least the Guest role for the parent epic's group. To remove a child epic from a parent epic: diff --git a/doc/user/permissions.md b/doc/user/permissions.md index 5f56be66d54..f75c2e9dc72 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -358,6 +358,7 @@ The following table lists group permissions available for each role: | Action | Guest | Reporter | Developer | Maintainer | Owner | |-----------------------------------------------------------------------------------------|-------|----------|-----------|------------|-------| +| Add/remove [child epics](group/epics/manage_epics.md#multi-level-child-epics) | ✓ (8) | ✓ | ✓ | ✓ | ✓ | | Add an issue to an [epic](group/epics/index.md) | ✓ (7) | ✓ (7) | ✓ (7) | ✓ (7) | ✓ (7) | | Browse group | ✓ | ✓ | ✓ | ✓ | ✓ | | Pull a container image using the dependency proxy | ✓ | ✓ | ✓ | ✓ | ✓ | @@ -427,6 +428,7 @@ The following table lists group permissions available for each role: 5. In addition, if your group is public or internal, all users who can see the group can also see group wiki pages. 6. Users can only view events based on their individual actions. 7. You must have permission to [view the epic](group/epics/manage_epics.md#who-can-view-an-epic) and edit the issue. +8. You must have permission to [view](group/epics/manage_epics.md#who-can-view-an-epic) the parent and child epics. <!-- markdownlint-enable MD029 --> diff --git a/doc/user/project/repository/branches/index.md b/doc/user/project/repository/branches/index.md index 645144522e4..a86e32b4721 100644 --- a/doc/user/project/repository/branches/index.md +++ b/doc/user/project/repository/branches/index.md @@ -119,14 +119,26 @@ The Swap revisions feature allows you to swap the Source and Target revisions. W ![After swap revisions](img/swap_revisions_after_v13_12.png) -<!-- ## Troubleshooting +## Troubleshooting -Include any troubleshooting steps that you can foresee. If you know beforehand what issues -one might have when setting this up, or when something is changed, or on upgrading, it's -important to describe those, too. Think of things that may go wrong and include them here. -This is important to minimize requests for support, and to avoid doc comments with -questions that you know someone might ask. +### Error: ambiguous `HEAD` branch exists -Each scenario can be a third-level heading, for example `### Getting error message X`. -If you have none to add when creating a doc, leave this section in place -but commented out to help encourage others to add to it in the future. --> +In versions of Git earlier than 2.16.0, you could create a branch named `HEAD`. +This branch named `HEAD` collides with the internal reference (also named `HEAD`) +Git uses to describe the active (checked out) branch. This naming collision can +prevent you from updating the default branch of your repository: + +```plaintext +Error: Could not set the default branch. Do you have a branch named 'HEAD' in your repository? +``` + +To fix this problem: + +1. On the top bar, select **Main menu > Projects** and find your project. +1. On the left sidebar, select **Repository > Branches**. +1. Search for a branch named `HEAD`. +1. Make sure the branch has no uncommitted changes. +1. Select **Delete branch**, then **Yes, delete branch**. + +Git versions [2.16.0 and later](https://github.com/git/git/commit/a625b092cc59940521789fe8a3ff69c8d6b14eb2), +prevent you from creating a branch with this name. diff --git a/lib/gitlab/git/base_error.rb b/lib/gitlab/git/base_error.rb index a7eaa82b347..0b0fdef54cc 100644 --- a/lib/gitlab/git/base_error.rb +++ b/lib/gitlab/git/base_error.rb @@ -1,20 +1,50 @@ # frozen_string_literal: true +require 'grpc' module Gitlab module Git class BaseError < StandardError DEBUG_ERROR_STRING_REGEX = /(.*?) debug_error_string:.*$/m.freeze + GRPC_CODES = { + '0' => 'ok', + '1' => 'cancelled', + '2' => 'unknown', + '3' => 'invalid_argument', + '4' => 'deadline_exceeded', + '5' => 'not_found', + '6' => 'already_exists', + '7' => 'permission_denied', + '8' => 'resource_exhausted', + '9' => 'failed_precondition', + '10' => 'aborted', + '11' => 'out_of_range', + '12' => 'unimplemented', + '13' => 'internal', + '14' => 'unavailable', + '15' => 'data_loss', + '16' => 'unauthenticated' + }.freeze + + attr_reader :status, :code, :service def initialize(msg = nil) - if msg - raw_message = msg.to_s - match = DEBUG_ERROR_STRING_REGEX.match(raw_message) - raw_message = match[1] if match + super && return if msg.nil? + + set_grpc_error_code(msg) if msg.is_a?(::GRPC::BadStatus) + + super(build_raw_message(msg)) + end + + def build_raw_message(message) + raw_message = message.to_s + match = DEBUG_ERROR_STRING_REGEX.match(raw_message) + match ? match[1] : raw_message + end - super(raw_message) - else - super - end + def set_grpc_error_code(grpc_error) + @status = grpc_error.code + @code = GRPC_CODES[@status.to_s] + @service = 'git' end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index f0b142047aa..27b97be0fd3 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2128,9 +2128,6 @@ msgstr "" msgid "Activate Service Desk" msgstr "" -msgid "Activated on" -msgstr "" - msgid "Active" msgstr "" @@ -3301,7 +3298,7 @@ msgstr "" msgid "AdminUsers|Is using seat" msgstr "" -msgid "AdminUsers|Issues authored by this user are hidden from other users." +msgid "AdminUsers|Issues and merge requests authored by this user are hidden from other users." msgstr "" msgid "AdminUsers|It's you!" @@ -4417,15 +4414,6 @@ msgstr "" msgid "An error occurred while loading projects." msgstr "" -msgid "An error occurred while loading the Jobs tab." -msgstr "" - -msgid "An error occurred while loading the Needs tab." -msgstr "" - -msgid "An error occurred while loading the Test Reports tab." -msgstr "" - msgid "An error occurred while loading the blob controls." msgstr "" @@ -4459,9 +4447,6 @@ msgstr "" msgid "An error occurred while loading the notification settings. Please try again." msgstr "" -msgid "An error occurred while loading the pipeline." -msgstr "" - msgid "An error occurred while loading the pipelines jobs." msgstr "" @@ -16560,9 +16545,6 @@ msgstr "" msgid "Expires %{preposition} %{expires_at}" msgstr "" -msgid "Expires on" -msgstr "" - msgid "Expires:" msgstr "" @@ -19886,9 +19868,15 @@ msgstr "" msgid "GroupsEmptyState|If you organize your projects under a group, it works like a folder." msgstr "" +msgid "GroupsEmptyState|No archived projects." +msgstr "" + msgid "GroupsEmptyState|No groups found" msgstr "" +msgid "GroupsEmptyState|No shared projects." +msgstr "" + msgid "GroupsEmptyState|No subgroups or projects." msgstr "" @@ -23732,9 +23720,6 @@ msgstr "" msgid "Jobs|All" msgstr "" -msgid "Jobs|An error occurred while loading the Failed Jobs tab." -msgstr "" - msgid "Jobs|Are you sure you want to proceed?" msgstr "" @@ -24210,9 +24195,6 @@ msgstr "" msgid "Last Seen" msgstr "" -msgid "Last Sync" -msgstr "" - msgid "Last Used" msgstr "" @@ -31561,9 +31543,15 @@ msgstr "" msgid "ProductAnalytics|Audience" msgstr "" +msgid "ProductAnalytics|Dashboards are created by editing the projects dashboard files." +msgstr "" + msgid "ProductAnalytics|New Analytics Widget Title" msgstr "" +msgid "ProductAnalytics|Product analytics dashboards" +msgstr "" + msgid "ProductAnalytics|There is no data for this type of chart currently. Please see the Setup tab if you have not configured the product analytics tool already." msgstr "" @@ -34649,9 +34637,6 @@ msgstr "" msgid "Renew subscription" msgstr "" -msgid "Renews" -msgstr "" - msgid "Reopen" msgstr "" @@ -40044,18 +40029,36 @@ msgstr "" msgid "Subscriptions" msgstr "" +msgid "Subscriptions|Activation date" +msgstr "" + msgid "Subscriptions|Chat with sales" msgstr "" msgid "Subscriptions|Close" msgstr "" +msgid "Subscriptions|End date" +msgstr "" + +msgid "Subscriptions|End date:" +msgstr "" + +msgid "Subscriptions|Last sync" +msgstr "" + +msgid "Subscriptions|None" +msgstr "" + msgid "Subscriptions|Not ready to buy yet?" msgstr "" msgid "Subscriptions|Start a free trial" msgstr "" +msgid "Subscriptions|Start date" +msgstr "" + msgid "Subscriptions|We understand. Maybe you have some questions for our sales team, or maybe you'd like to try some of the paid features first. What would you like to do?" msgstr "" @@ -41107,9 +41110,6 @@ msgstr "" msgid "TestReports|Test reports require job artifacts but all artifacts are expired. %{linkStart}Learn more%{linkEnd}" msgstr "" -msgid "TestReports|Tests" -msgstr "" - msgid "TestReports|There are no test cases to display." msgstr "" @@ -42067,6 +42067,9 @@ msgstr "" msgid "This %{issuableDisplayName} is locked. Only project members can comment." msgstr "" +msgid "This %{issuable} is hidden because its author has been banned" +msgstr "" + msgid "This %{issuable} is locked. Only %{strong_open}project members%{strong_close} can comment." msgstr "" @@ -44268,6 +44271,9 @@ msgstr "" msgid "UpdateProject|Could not set the default branch" msgstr "" +msgid "UpdateProject|Could not set the default branch. Do you have a branch named 'HEAD' in your repository? (%{linkStart}How do I fix this?%{linkEnd})" +msgstr "" + msgid "UpdateProject|New visibility level not allowed!" msgstr "" @@ -45239,9 +45245,6 @@ msgstr "" msgid "VS Code in your browser. View code and make changes from the same UI as in your local IDE." msgstr "" -msgid "Valid From" -msgstr "" - msgid "Validate" msgstr "" diff --git a/spec/features/dashboard/todos/todos_filtering_spec.rb b/spec/features/dashboard/todos/todos_filtering_spec.rb index 4af3a93b916..ea8c7e800c5 100644 --- a/spec/features/dashboard/todos/todos_filtering_spec.rb +++ b/spec/features/dashboard/todos/todos_filtering_spec.rb @@ -58,9 +58,9 @@ RSpec.describe 'Dashboard > User filters todos', :js, feature_category: :team_pl wait_for_requests - expect(page).to have_content "#{group1.name} / project_1 #{issue1.to_reference} · issue" + expect(page).to have_content "issue · #{group1.name} / project_1 #{issue1.to_reference}" expect(page).to have_content merge_request.to_reference.to_s - expect(page).not_to have_content "#{group2.name} / project_3 #{issue2.to_reference} · issue" + expect(page).not_to have_content "issue · #{group2.name} / project_3 #{issue2.to_reference}" end context 'Author filter' do diff --git a/spec/features/dashboard/todos/todos_spec.rb b/spec/features/dashboard/todos/todos_spec.rb index 4ec7859a292..bef3ac47c54 100644 --- a/spec/features/dashboard/todos/todos_spec.rb +++ b/spec/features/dashboard/todos/todos_spec.rb @@ -167,7 +167,7 @@ RSpec.describe 'Dashboard Todos', feature_category: :team_planning do it 'shows issue assigned to yourself message' do page.within('.js-todos-all') do - expect(page).to have_content("#{project.namespace.owner_name} / #{project.name} #{issue.to_reference} · Fix bug") + expect(page).to have_content("Fix bug · #{project.namespace.owner_name} / #{project.name} #{issue.to_reference}") expect(page).to have_content("You assigned to yourself.") end end @@ -181,7 +181,7 @@ RSpec.describe 'Dashboard Todos', feature_category: :team_planning do it 'shows you added a to-do item message' do page.within('.js-todos-all') do - expect(page).to have_content("#{project.namespace.owner_name} / #{project.name} #{issue.to_reference} · Fix bug") + expect(page).to have_content("Fix bug · #{project.namespace.owner_name} / #{project.name} #{issue.to_reference}") expect(page).to have_content("You added a to-do item.") end end @@ -195,7 +195,7 @@ RSpec.describe 'Dashboard Todos', feature_category: :team_planning do it 'shows you mentioned yourself message' do page.within('.js-todos-all') do - expect(page).to have_content("#{project.namespace.owner_name} / #{project.name} #{issue.to_reference} · Fix bug") + expect(page).to have_content("Fix bug · #{project.namespace.owner_name} / #{project.name} #{issue.to_reference}") expect(page).to have_content("You mentioned yourself.") end end @@ -209,7 +209,7 @@ RSpec.describe 'Dashboard Todos', feature_category: :team_planning do it 'shows you directly addressed yourself message being displayed as mentioned yourself' do page.within('.js-todos-all') do - expect(page).to have_content("#{project.namespace.owner_name} / #{project.name} #{issue.to_reference} · Fix bug") + expect(page).to have_content("Fix bug · #{project.namespace.owner_name} / #{project.name} #{issue.to_reference}") expect(page).to have_content("You mentioned yourself.") end end @@ -225,7 +225,7 @@ RSpec.describe 'Dashboard Todos', feature_category: :team_planning do it 'shows you set yourself as an approver message' do page.within('.js-todos-all') do - expect(page).to have_content("#{project.namespace.owner_name} / #{project.name} #{merge_request.to_reference} · Fixes issue") + expect(page).to have_content("Fixes issue · #{project.namespace.owner_name} / #{project.name} #{merge_request.to_reference}") expect(page).to have_content("You set yourself as an approver.") end end @@ -241,7 +241,7 @@ RSpec.describe 'Dashboard Todos', feature_category: :team_planning do it 'shows you set yourself as an reviewer message' do page.within('.js-todos-all') do - expect(page).to have_content("#{project.namespace.owner_name} / #{project.name} #{merge_request.to_reference} · Fixes issue") + expect(page).to have_content("Fixes issue · #{project.namespace.owner_name} / #{project.name} #{merge_request.to_reference}") expect(page).to have_content("You requested a review from yourself.") end end @@ -261,7 +261,7 @@ RSpec.describe 'Dashboard Todos', feature_category: :team_planning do it 'shows unmergeable message' do page.within('.js-todos-all') do - expect(page).to have_content("#{project.namespace.owner_name} / #{project.name} #{issue.to_reference} · Fix bug") + expect(page).to have_content("Fix bug · #{project.namespace.owner_name} / #{project.name} #{issue.to_reference}") expect(page).to have_content("Could not merge.") end end diff --git a/spec/features/merge_request/admin_views_hidden_merge_request_spec.rb b/spec/features/merge_request/admin_views_hidden_merge_request_spec.rb new file mode 100644 index 00000000000..99344d2cf32 --- /dev/null +++ b/spec/features/merge_request/admin_views_hidden_merge_request_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Admin views hidden merge request', feature_category: :code_review do + context 'when signed in as admin and viewing a hidden merge request', :js do + let_it_be(:admin) { create(:admin) } + let_it_be(:author) { create(:user, :banned) } + let_it_be(:project) { create(:project, :repository) } + let!(:merge_request) { create(:merge_request, source_project: project, author: author) } + + before do + sign_in(admin) + gitlab_enable_admin_mode_sign_in(admin) + visit(project_merge_request_path(project, merge_request)) + end + + it 'shows a hidden merge request icon' do + page.within('.detail-page-header-body') do + tooltip = format(_('This %{issuable} is hidden because its author has been banned'), issuable: 'merge request') + expect(page).to have_css("div[data-testid='hidden'][title='#{tooltip}']") + expect(page).to have_css('svg[data-testid="spam-icon"]') + end + end + end +end diff --git a/spec/features/merge_requests/admin_views_hidden_merge_requests_spec.rb b/spec/features/merge_requests/admin_views_hidden_merge_requests_spec.rb new file mode 100644 index 00000000000..bc5ec124861 --- /dev/null +++ b/spec/features/merge_requests/admin_views_hidden_merge_requests_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Admin views hidden merge requests', feature_category: :code_review do + context 'when signed in as admin and viewing a hidden merge request' do + let_it_be(:admin) { create(:admin) } + let_it_be(:author) { create(:user, :banned) } + let_it_be(:project) { create(:project) } + let!(:merge_request) { create(:merge_request, source_project: project, author: author) } + + before do + sign_in(admin) + gitlab_enable_admin_mode_sign_in(admin) + visit(project_merge_requests_path(project)) + end + + it 'shows a hidden merge request icon' do + page.within("#merge_request_#{merge_request.id}") do + tooltip = format(_('This %{issuable} is hidden because its author has been banned'), issuable: 'merge request') + expect(page).to have_css("span[title='#{tooltip}']") + expect(page).to have_css('svg[data-testid="spam-icon"]') + end + end + end +end diff --git a/spec/features/projects/pipelines/legacy_pipeline_spec.rb b/spec/features/projects/pipelines/legacy_pipeline_spec.rb deleted file mode 100644 index 8ae6d26e2a0..00000000000 --- a/spec/features/projects/pipelines/legacy_pipeline_spec.rb +++ /dev/null @@ -1,1315 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'Pipeline', :js, feature_category: :projects do - include RoutesHelpers - include ProjectForksHelper - include ::ExclusiveLeaseHelpers - - let_it_be(:project) { create(:project) } - - let(:user) { create(:user) } - let(:role) { :developer } - - before do - sign_in(user) - project.add_role(user, role) - stub_feature_flags(pipeline_tabs_vue: false) - end - - shared_context 'pipeline builds' do - let!(:build_passed) do - create(:ci_build, :success, - pipeline: pipeline, stage: 'build', stage_idx: 0, name: 'build') - end - - let!(:build_failed) do - create(:ci_build, :failed, - pipeline: pipeline, stage: 'test', stage_idx: 1, name: 'test') - end - - let!(:build_preparing) do - create(:ci_build, :preparing, - pipeline: pipeline, stage: 'deploy', stage_idx: 2, name: 'prepare') - end - - let!(:build_running) do - create(:ci_build, :running, - pipeline: pipeline, stage: 'deploy', stage_idx: 3, name: 'deploy') - end - - let!(:build_manual) do - create(:ci_build, :manual, - pipeline: pipeline, stage: 'deploy', stage_idx: 3, name: 'manual-build') - end - - let!(:build_scheduled) do - create(:ci_build, :scheduled, - pipeline: pipeline, stage: 'deploy', stage_idx: 3, name: 'delayed-job') - end - - let!(:build_external) do - create(:generic_commit_status, status: 'success', - pipeline: pipeline, - name: 'jenkins', - stage: 'external', - ref: 'master', - target_url: 'http://gitlab.com/status') - end - end - - describe 'GET /:project/-/pipelines/:id' do - include_context 'pipeline builds' - - let_it_be(:group) { create(:group) } - let_it_be(:project, reload: true) { create(:project, :repository, group: group) } - - let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id, user: user) } - - subject(:visit_pipeline) { visit project_pipeline_path(project, pipeline) } - - it 'shows the pipeline graph' do - visit_pipeline - - expect(page).to have_selector('.js-pipeline-graph') - expect(page).to have_content('build') - expect(page).to have_content('test') - expect(page).to have_content('deploy') - expect(page).to have_content('Retry') - expect(page).to have_content('Cancel running') - end - - it 'shows Pipeline tab pane as active' do - visit_pipeline - - expect(page).to have_css('#js-tab-pipeline.active') - end - - it 'shows link to the pipeline ref' do - visit_pipeline - - expect(page).to have_link(pipeline.ref) - end - - it 'shows the pipeline information' do - visit_pipeline - - within '.pipeline-info' do - expect(page).to have_content("#{pipeline.statuses.count} jobs " \ - "for #{pipeline.ref}") - expect(page).to have_link(pipeline.ref, - href: project_commits_path(pipeline.project, pipeline.ref)) - end - end - - describe 'related merge requests' do - context 'when there are no related merge requests' do - it 'shows a "no related merge requests" message' do - visit_pipeline - - within '.related-merge-request-info' do - expect(page).to have_content('No related merge requests found.') - end - end - end - - context 'when there is one related merge request' do - let!(:merge_request) do - create(:merge_request, - source_project: project, - source_branch: pipeline.ref) - end - - it 'shows a link to the merge request' do - visit_pipeline - - within '.related-merge-requests' do - expect(page).to have_content('1 related merge request: ') - expect(page).to have_selector('.js-truncated-mr-list') - expect(page).to have_link("#{merge_request.to_reference} #{merge_request.title}") - - expect(page).not_to have_selector('.js-full-mr-list') - expect(page).not_to have_selector('.text-expander') - end - end - end - - context 'when there are two related merge requests' do - let!(:merge_request1) do - create(:merge_request, - source_project: project, - source_branch: pipeline.ref) - end - - let!(:merge_request2) do - create(:merge_request, - source_project: project, - source_branch: pipeline.ref, - target_branch: 'fix') - end - - it 'links to the most recent related merge request' do - visit_pipeline - - within '.related-merge-requests' do - expect(page).to have_content('2 related merge requests: ') - expect(page).to have_link("#{merge_request2.to_reference} #{merge_request2.title}") - expect(page).to have_selector('.text-expander') - expect(page).to have_selector('.js-full-mr-list', visible: false) - end - end - - it 'expands to show links to all related merge requests' do - visit_pipeline - - within '.related-merge-requests' do - find('.text-expander').click - - expect(page).to have_selector('.js-full-mr-list', visible: true) - - pipeline.all_merge_requests.map do |merge_request| - expect(page).to have_link(href: project_merge_request_path(project, merge_request)) - end - end - end - end - end - - describe 'pipelines details view' do - let!(:status) { create(:user_status, user: pipeline.user, emoji: 'smirk', message: 'Authoring this object') } - - it 'pipeline header shows the user status and emoji' do - visit project_pipeline_path(project, pipeline) - - within '[data-testid="ci-header-content"]' do - expect(page).to have_selector("[data-testid='#{status.message}']") - expect(page).to have_selector("[data-name='#{status.emoji}']") - end - end - end - - describe 'pipeline graph' do - before do - visit_pipeline - end - - context 'when pipeline has running builds' do - it 'shows a running icon and a cancel action for the running build' do - page.within('#ci-badge-deploy') do - expect(page).to have_selector('.js-ci-status-icon-running') - expect(page).to have_selector('.js-icon-cancel') - expect(page).to have_content('deploy') - end - end - - it 'cancels the running build and shows retry button', :sidekiq_might_not_need_inline do - find('#ci-badge-deploy .ci-action-icon-container').click - - page.within('#ci-badge-deploy') do - expect(page).to have_css('.js-icon-retry') - end - end - end - - context 'when pipeline has preparing builds' do - it 'shows a preparing icon and a cancel action' do - page.within('#ci-badge-prepare') do - expect(page).to have_selector('.js-ci-status-icon-preparing') - expect(page).to have_selector('.js-icon-cancel') - expect(page).to have_content('prepare') - end - end - - it 'cancels the preparing build and shows retry button', :sidekiq_might_not_need_inline do - find('#ci-badge-deploy .ci-action-icon-container').click - - page.within('#ci-badge-deploy') do - expect(page).to have_css('.js-icon-retry') - end - end - end - - context 'when pipeline has successful builds' do - it 'shows the success icon and a retry action for the successful build' do - page.within('#ci-badge-build') do - expect(page).to have_selector('.js-ci-status-icon-success') - expect(page).to have_content('build') - end - - page.within('#ci-badge-build .ci-action-icon-container.js-icon-retry') do - expect(page).to have_selector('svg') - end - end - - it 'is possible to retry the success job', :sidekiq_might_not_need_inline do - find('#ci-badge-build .ci-action-icon-container').click - wait_for_requests - - expect(page).not_to have_content('Retry job') - within('.js-pipeline-header-container') do - expect(page).to have_selector('.js-ci-status-icon-running') - end - end - end - - context 'when pipeline has a delayed job' do - let(:project) { create(:project, :repository, group: group) } - - it 'shows the scheduled icon and an unschedule action for the delayed job' do - page.within('#ci-badge-delayed-job') do - expect(page).to have_selector('.js-ci-status-icon-scheduled') - expect(page).to have_content('delayed-job') - end - - page.within('#ci-badge-delayed-job .ci-action-icon-container.js-icon-time-out') do - expect(page).to have_selector('svg') - end - end - - it 'unschedules the delayed job and shows play button as a manual job', :sidekiq_might_not_need_inline do - find('#ci-badge-delayed-job .ci-action-icon-container').click - - page.within('#ci-badge-delayed-job') do - expect(page).to have_css('.js-icon-play') - end - end - end - - context 'when pipeline has failed builds' do - it 'shows the failed icon and a retry action for the failed build' do - page.within('#ci-badge-test') do - expect(page).to have_selector('.js-ci-status-icon-failed') - expect(page).to have_content('test') - end - - page.within('#ci-badge-test .ci-action-icon-container.js-icon-retry') do - expect(page).to have_selector('svg') - end - end - - it 'is possible to retry the failed build', :sidekiq_might_not_need_inline do - find('#ci-badge-test .ci-action-icon-container').click - wait_for_requests - - expect(page).not_to have_content('Retry job') - within('.js-pipeline-header-container') do - expect(page).to have_selector('.js-ci-status-icon-running') - end - end - - it 'includes the failure reason' do - page.within('#ci-badge-test') do - build_link = page.find('.js-pipeline-graph-job-link') - expect(build_link['title']).to eq('test - failed - (unknown failure)') - end - end - end - - context 'when pipeline has manual jobs' do - it 'shows the skipped icon and a play action for the manual build' do - page.within('#ci-badge-manual-build') do - expect(page).to have_selector('.js-ci-status-icon-manual') - expect(page).to have_content('manual') - end - - page.within('#ci-badge-manual-build .ci-action-icon-container.js-icon-play') do - expect(page).to have_selector('svg') - end - end - - it 'is possible to play the manual job', :sidekiq_might_not_need_inline do - find('#ci-badge-manual-build .ci-action-icon-container').click - wait_for_requests - - expect(page).not_to have_content('Play job') - within('.js-pipeline-header-container') do - expect(page).to have_selector('.js-ci-status-icon-running') - end - end - end - - context 'when pipeline has external job' do - it 'shows the success icon and the generic comit status build' do - expect(page).to have_selector('.js-ci-status-icon-success') - expect(page).to have_content('jenkins') - expect(page).to have_link('jenkins', href: 'http://gitlab.com/status') - end - end - end - - context 'when the pipeline has manual stage' do - before do - create(:ci_build, :manual, pipeline: pipeline, stage_idx: 10, stage: 'publish', name: 'CentOS') - create(:ci_build, :manual, pipeline: pipeline, stage_idx: 10, stage: 'publish', name: 'Debian') - create(:ci_build, :manual, pipeline: pipeline, stage_idx: 10, stage: 'publish', name: 'OpenSUDE') - - # force to update stages statuses - Ci::ProcessPipelineService.new(pipeline).execute - - visit_pipeline - end - - it 'displays play all button' do - expect(page).to have_selector('.js-stage-action') - end - end - - context 'page tabs' do - before do - visit_pipeline - end - - it 'shows Pipeline, Jobs, DAG and Failed Jobs tabs with link' do - expect(page).to have_link('Pipeline') - expect(page).to have_link('Jobs') - expect(page).to have_link('Needs') - expect(page).to have_link('Failed Jobs') - end - - it 'shows counter in Jobs tab' do - expect(page.find('.js-builds-counter').text).to eq(pipeline.total_size.to_s) - end - - it 'shows Pipeline tab as active' do - expect(page).to have_css('.js-pipeline-tab-link .active') - end - - context 'without permission to access builds' do - let(:project) { create(:project, :public, :repository, public_builds: false) } - let(:role) { :guest } - - it 'does not show the pipeline details page' do - expect(page).to have_content('Not Found') - end - end - end - - describe 'test tabs' do - let(:pipeline) { create(:ci_pipeline, :with_test_reports, :with_report_results, project: project) } - - before do - stub_feature_flags(pipeline_tabs_vue: false) - visit_pipeline - wait_for_requests - end - - context 'with test reports' do - it 'shows badge counter in Tests tab' do - expect(page.find('.js-test-report-badge-counter').text).to eq(pipeline.test_report_summary.total[:count].to_s) - end - - it 'calls summary.json endpoint', :js do - find('.js-tests-tab-link').click - - expect(page).to have_content('Jobs') - expect(page).to have_selector('[data-testid="tests-detail"]', visible: :all) - end - end - - context 'without test reports' do - let(:pipeline) { create(:ci_pipeline, project: project) } - - it 'shows zero' do - expect(page.find('.js-test-report-badge-counter', visible: :all).text).to eq("0") - end - end - end - - context 'retrying jobs' do - before do - visit_pipeline - end - - it { expect(page).not_to have_content('retried') } - - context 'when retrying' do - before do - find('[data-testid="retryPipeline"]').click - wait_for_requests - end - - it 'does not show a "Retry" button', :sidekiq_might_not_need_inline do - expect(page).not_to have_content('Retry') - end - - it 'shows running status in pipeline header', :sidekiq_might_not_need_inline do - within('.js-pipeline-header-container') do - expect(page).to have_selector('.js-ci-status-icon-running') - end - end - end - end - - context 'canceling jobs' do - before do - visit_pipeline - end - - it { expect(page).not_to have_selector('.ci-canceled') } - - context 'when canceling' do - before do - click_on 'Cancel running' - end - - it 'does not show a "Cancel running" button', :sidekiq_might_not_need_inline do - expect(page).not_to have_content('Cancel running') - end - end - end - - context 'when user can not delete' do - before do - visit_pipeline - end - - it { expect(page).not_to have_button('Delete') } - end - - context 'when deleting' do - before do - group.add_owner(user) - - visit_pipeline - - click_button 'Delete' - click_button 'Delete pipeline' - end - - it 'redirects to pipeline overview page', :sidekiq_inline do - expect(page).to have_content('The pipeline has been deleted') - expect(page).to have_current_path(project_pipelines_path(project), ignore_query: true) - end - end - - context 'when pipeline ref does not exist in repository anymore' do - let(:pipeline) do - create(:ci_empty_pipeline, project: project, - ref: 'non-existent', - sha: project.commit.id, - user: user) - end - - before do - visit_pipeline - end - - it 'does not render link to the pipeline ref' do - expect(page).not_to have_link(pipeline.ref) - expect(page).to have_content(pipeline.ref) - end - - it 'does not render render raw HTML to the pipeline ref' do - page.within '.pipeline-info' do - expect(page).not_to have_content('<span class="ref-name"') - end - end - end - - context 'when pipeline is detached merge request pipeline' do - let(:source_project) { project } - let(:target_project) { project } - - let(:merge_request) do - create(:merge_request, - :with_detached_merge_request_pipeline, - source_project: source_project, - target_project: target_project) - end - - let(:pipeline) do - merge_request.all_pipelines.last - end - - it 'shows the pipeline information' do - visit_pipeline - - within '.pipeline-info' do - expect(page).to have_content("#{pipeline.statuses.count} jobs " \ - "for !#{merge_request.iid} " \ - "with #{merge_request.source_branch}") - expect(page).to have_link("!#{merge_request.iid}", - href: project_merge_request_path(project, merge_request)) - expect(page).to have_link(merge_request.source_branch, - href: project_commits_path(merge_request.source_project, merge_request.source_branch)) - end - end - - context 'when source branch does not exist' do - before do - project.repository.rm_branch(user, merge_request.source_branch) - end - - it 'does not link to the source branch commit path' do - visit_pipeline - - within '.pipeline-info' do - expect(page).not_to have_link(merge_request.source_branch) - expect(page).to have_content(merge_request.source_branch) - end - end - end - - context 'when source project is a forked project' do - let(:source_project) { fork_project(project, user, repository: true) } - - before do - visit project_pipeline_path(source_project, pipeline) - end - - it 'shows the pipeline information', :sidekiq_might_not_need_inline do - within '.pipeline-info' do - expect(page).to have_content("#{pipeline.statuses.count} jobs " \ - "for !#{merge_request.iid} " \ - "with #{merge_request.source_branch}") - expect(page).to have_link("!#{merge_request.iid}", - href: project_merge_request_path(project, merge_request)) - expect(page).to have_link(merge_request.source_branch, - href: project_commits_path(merge_request.source_project, merge_request.source_branch)) - end - end - end - end - - context 'when pipeline is merge request pipeline' do - let(:project) { create(:project, :repository, group: group) } - let(:source_project) { project } - let(:target_project) { project } - - let(:merge_request) do - create(:merge_request, - :with_merge_request_pipeline, - source_project: source_project, - target_project: target_project, - merge_sha: project.commit.id) - end - - let(:pipeline) do - merge_request.all_pipelines.last - end - - before do - pipeline.update!(user: user) - end - - it 'shows the pipeline information' do - visit_pipeline - - within '.pipeline-info' do - expect(page).to have_content("#{pipeline.statuses.count} jobs " \ - "for !#{merge_request.iid} " \ - "with #{merge_request.source_branch} " \ - "into #{merge_request.target_branch}") - expect(page).to have_link("!#{merge_request.iid}", - href: project_merge_request_path(project, merge_request)) - expect(page).to have_link(merge_request.source_branch, - href: project_commits_path(merge_request.source_project, merge_request.source_branch)) - expect(page).to have_link(merge_request.target_branch, - href: project_commits_path(merge_request.target_project, merge_request.target_branch)) - end - end - - context 'when target branch does not exist' do - before do - project.repository.rm_branch(user, merge_request.target_branch) - end - - it 'does not link to the target branch commit path' do - visit_pipeline - - within '.pipeline-info' do - expect(page).not_to have_link(merge_request.target_branch) - expect(page).to have_content(merge_request.target_branch) - end - end - end - - context 'when source project is a forked project' do - let(:source_project) { fork_project(project, user, repository: true) } - - before do - visit project_pipeline_path(source_project, pipeline) - end - - it 'shows the pipeline information', :sidekiq_might_not_need_inline do - within '.pipeline-info' do - expect(page).to have_content("#{pipeline.statuses.count} jobs " \ - "for !#{merge_request.iid} " \ - "with #{merge_request.source_branch} " \ - "into #{merge_request.target_branch}") - expect(page).to have_link("!#{merge_request.iid}", - href: project_merge_request_path(project, merge_request)) - expect(page).to have_link(merge_request.source_branch, - href: project_commits_path(merge_request.source_project, merge_request.source_branch)) - expect(page).to have_link(merge_request.target_branch, - href: project_commits_path(merge_request.target_project, merge_request.target_branch)) - end - end - end - end - end - - context 'when user does not have access to read jobs' do - before do - project.update!(public_builds: false) - end - - describe 'GET /:project/-/pipelines/:id' do - include_context 'pipeline builds' - - let_it_be(:project) { create(:project, :repository) } - - let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id, user: user) } - - before do - visit project_pipeline_path(project, pipeline) - end - - it 'shows the pipeline graph' do - expect(page).to have_selector('.js-pipeline-graph') - expect(page).to have_content('build') - expect(page).to have_content('test') - expect(page).to have_content('deploy') - expect(page).to have_content('Retry') - expect(page).to have_content('Cancel running') - end - - it 'does not link to job' do - expect(page).not_to have_selector('.js-pipeline-graph-job-link') - end - end - end - - context 'when a bridge job exists' do - include_context 'pipeline builds' - - let(:project) { create(:project, :repository) } - let(:downstream) { create(:project, :repository) } - - let(:pipeline) do - create(:ci_pipeline, project: project, - ref: 'master', - sha: project.commit.id, - user: user) - end - - let!(:bridge) do - create(:ci_bridge, pipeline: pipeline, - name: 'cross-build', - user: user, - downstream: downstream) - end - - describe 'GET /:project/-/pipelines/:id' do - before do - visit project_pipeline_path(project, pipeline) - end - - it 'shows the pipeline with a bridge job' do - expect(page).to have_selector('.js-pipeline-graph') - expect(page).to have_content('cross-build') - end - - context 'when a scheduled pipeline is created by a blocked user' do - let(:project) { create(:project, :repository) } - - let(:schedule) do - create(:ci_pipeline_schedule, - project: project, - owner: project.first_owner, - description: 'blocked user schedule' - ).tap do |schedule| - schedule.update_column(:next_run_at, 1.minute.ago) - end - end - - before do - schedule.owner.block! - PipelineScheduleWorker.new.perform - end - - it 'displays the PipelineSchedule in an inactive state' do - stub_feature_flags(pipeline_schedules_vue: false) - - visit project_pipeline_schedules_path(project) - page.click_link('Inactive') - - expect(page).to have_selector('table.ci-table > tbody > tr > td', text: 'blocked user schedule') - end - - it 'does not create a new Pipeline' do - visit project_pipelines_path(project) - - expect(page).not_to have_selector('.ci-table') - expect(schedule.last_pipeline).to be_nil - end - end - end - - describe 'GET /:project/-/pipelines/:id/builds' do - before do - visit builds_project_pipeline_path(project, pipeline) - end - - it 'shows a bridge job on a list' do - expect(page).to have_content('cross-build') - expect(page).to have_content(bridge.id) - end - end - end - - context 'when build requires resource', :sidekiq_inline do - let_it_be(:project) { create(:project, :repository) } - - let(:pipeline) { create(:ci_pipeline, project: project) } - let(:resource_group) { create(:ci_resource_group, project: project) } - - let!(:test_job) do - create(:ci_build, :pending, stage: 'test', name: 'test', stage_idx: 1, pipeline: pipeline, project: project) - end - - let!(:deploy_job) do - create(:ci_build, :created, - stage: 'deploy', - name: 'deploy', - stage_idx: 2, - pipeline: pipeline, - project: project, - resource_group: resource_group) - end - - describe 'GET /:project/-/pipelines/:id' do - subject { visit project_pipeline_path(project, pipeline) } - - it 'shows deploy job as created' do - subject - - within('.js-pipeline-header-container') do - expect(page).to have_content('pending') - end - - within('.js-pipeline-graph') do - within(all('[data-testid="stage-column"]')[0]) do - expect(page).to have_content('test') - expect(page).to have_css('.ci-status-icon-pending') - end - - within(all('[data-testid="stage-column"]')[1]) do - expect(page).to have_content('deploy') - expect(page).to have_css('.ci-status-icon-created') - end - end - end - - context 'when test job succeeded' do - before do - test_job.success! - end - - it 'shows deploy job as pending' do - subject - - within('.js-pipeline-header-container') do - expect(page).to have_content('running') - end - - within('.js-pipeline-graph') do - within(all('[data-testid="stage-column"]')[0]) do - expect(page).to have_content('test') - expect(page).to have_css('.ci-status-icon-success') - end - - within(all('[data-testid="stage-column"]')[1]) do - expect(page).to have_content('deploy') - expect(page).to have_css('.ci-status-icon-pending') - end - end - end - end - - context 'when test job succeeded but there are no available resources' do - let(:another_job) { create(:ci_build, :running, project: project, resource_group: resource_group) } - - before do - resource_group.assign_resource_to(another_job) - test_job.success! - end - - it 'shows deploy job as waiting for resource' do - subject - - within('.js-pipeline-header-container') do - expect(page).to have_content('waiting') - end - - within('.js-pipeline-graph') do - within(all('[data-testid="stage-column"]')[1]) do - expect(page).to have_content('deploy') - expect(page).to have_css('.ci-status-icon-waiting-for-resource') - end - end - end - - context 'when resource is released from another job' do - before do - another_job.success! - end - - it 'shows deploy job as pending' do - subject - - within('.js-pipeline-header-container') do - expect(page).to have_content('running') - end - - within('.js-pipeline-graph') do - within(all('[data-testid="stage-column"]')[1]) do - expect(page).to have_content('deploy') - expect(page).to have_css('.ci-status-icon-pending') - end - end - end - end - - context 'when deploy job is a bridge to trigger a downstream pipeline' do - let!(:deploy_job) do - create(:ci_bridge, :created, - stage: 'deploy', - name: 'deploy', - stage_idx: 2, - pipeline: pipeline, - project: project, - resource_group: resource_group - ) - end - - it 'shows deploy job as waiting for resource' do - subject - - within('.js-pipeline-header-container') do - expect(page).to have_content('waiting') - end - - within('.js-pipeline-graph') do - within(all('[data-testid="stage-column"]')[1]) do - expect(page).to have_content('deploy') - expect(page).to have_css('.ci-status-icon-waiting-for-resource') - end - end - end - end - - context 'when deploy job is a bridge to trigger a downstream pipeline' do - let!(:deploy_job) do - create(:ci_bridge, :created, - stage: 'deploy', - name: 'deploy', - stage_idx: 2, - pipeline: pipeline, - project: project, - resource_group: resource_group - ) - end - - it 'shows deploy job as waiting for resource' do - subject - - within('.js-pipeline-header-container') do - expect(page).to have_content('waiting') - end - - within('.js-pipeline-graph') do - within(all('[data-testid="stage-column"]')[1]) do - expect(page).to have_content('deploy') - expect(page).to have_css('.ci-status-icon-waiting-for-resource') - end - end - end - end - end - end - end - - describe 'GET /:project/-/pipelines/:id/dag' do - include_context 'pipeline builds' - - let_it_be(:project) { create(:project, :repository) } - - let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) } - - before do - visit dag_project_pipeline_path(project, pipeline) - end - - it 'shows DAG tab pane as active' do - expect(page).to have_css('#js-tab-dag.active', visible: false) - end - - context 'page tabs' do - it 'shows Pipeline, Jobs and DAG tabs with link' do - expect(page).to have_link('Pipeline') - expect(page).to have_link('Jobs') - expect(page).to have_link('DAG') - end - - it 'shows counter in Jobs tab' do - expect(page.find('.js-builds-counter').text).to eq(pipeline.total_size.to_s) - end - - it 'shows DAG tab as active' do - expect(page).to have_css('li.js-dag-tab-link .active') - end - end - end - - context 'when user sees pipeline flags in a pipeline detail page' do - let_it_be(:project) { create(:project, :repository) } - - context 'when pipeline is latest' do - include_context 'pipeline builds' - - let(:pipeline) do - create(:ci_pipeline, - project: project, - ref: 'master', - sha: project.commit.id, - user: user) - end - - before do - visit project_pipeline_path(project, pipeline) - end - - it 'contains badge that indicates it is the latest build' do - page.within(all('.well-segment')[1]) do - expect(page).to have_content 'latest' - end - end - end - - context 'when pipeline has configuration errors' do - let(:pipeline) do - create(:ci_pipeline, - :invalid, - project: project, - ref: 'master', - sha: project.commit.id, - user: user) - end - - before do - visit project_pipeline_path(project, pipeline) - end - - it 'contains badge that indicates errors' do - page.within(all('.well-segment')[1]) do - expect(page).to have_content 'yaml invalid' - end - end - - it 'contains badge with tooltip which contains error' do - expect(pipeline).to have_yaml_errors - - page.within(all('.well-segment')[1]) do - expect(page).to have_selector( - %Q{span[title="#{pipeline.yaml_errors}"]}) - end - end - - it 'contains badge that indicates failure reason' do - expect(page).to have_content 'error' - end - - it 'contains badge with tooltip which contains failure reason' do - expect(pipeline.failure_reason?).to eq true - - page.within(all('.well-segment')[1]) do - expect(page).to have_selector( - %Q{span[title="#{pipeline.present.failure_reason}"]}) - end - end - - it 'contains a pipeline header with title' do - expect(page).to have_content "Pipeline ##{pipeline.id}" - end - end - - context 'when pipeline is stuck' do - include_context 'pipeline builds' - - let(:pipeline) do - create(:ci_pipeline, - project: project, - ref: 'master', - sha: project.commit.id, - user: user) - end - - before do - create(:ci_build, :pending, pipeline: pipeline) - visit project_pipeline_path(project, pipeline) - end - - it 'contains badge that indicates being stuck' do - page.within(all('.well-segment')[1]) do - expect(page).to have_content 'stuck' - end - end - end - - context 'when pipeline uses auto devops' do - include_context 'pipeline builds' - - let(:project) { create(:project, :repository, auto_devops_attributes: { enabled: true }) } - let(:pipeline) do - create(:ci_pipeline, - :auto_devops_source, - project: project, - ref: 'master', - sha: project.commit.id, - user: user) - end - - before do - visit project_pipeline_path(project, pipeline) - end - - it 'contains badge that indicates using auto devops' do - page.within(all('.well-segment')[1]) do - expect(page).to have_content 'Auto DevOps' - end - end - end - - context 'when pipeline runs in a merge request context' do - include_context 'pipeline builds' - - let(:pipeline) do - create(:ci_pipeline, - source: :merge_request_event, - project: merge_request.source_project, - ref: 'feature', - sha: merge_request.diff_head_sha, - user: user, - merge_request: merge_request) - end - - let(:merge_request) do - create(:merge_request, - source_project: project, - source_branch: 'feature', - target_project: project, - target_branch: 'master') - end - - before do - visit project_pipeline_path(project, pipeline) - end - - it 'contains badge that indicates detached merge request pipeline' do - page.within(all('.well-segment')[1]) do - expect(page).to have_content 'merge request' - end - end - end - end - - describe 'GET /:project/-/pipelines/:id/builds' do - include_context 'pipeline builds' - - let_it_be(:project) { create(:project, :repository) } - - let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) } - - before do - visit builds_project_pipeline_path(project, pipeline) - end - - it 'shows a list of jobs' do - expect(page).to have_content('Test') - expect(page).to have_content(build_passed.id) - expect(page).to have_content('Deploy') - expect(page).to have_content(build_failed.id) - expect(page).to have_content(build_running.id) - expect(page).to have_content(build_external.id) - expect(page).to have_content('Retry') - expect(page).to have_content('Cancel running') - expect(page).to have_button('Play') - end - - context 'page tabs' do - it 'shows Pipeline, Jobs and DAG tabs with link' do - expect(page).to have_link('Pipeline') - expect(page).to have_link('Jobs') - expect(page).to have_link('Needs') - end - - it 'shows counter in Jobs tab' do - expect(page.find('.js-builds-counter').text).to eq(pipeline.total_size.to_s) - end - end - - context 'retrying jobs' do - it { expect(page).not_to have_content('retried') } - - context 'when retrying' do - before do - find('[data-testid="retry"]', match: :first).click - end - - it 'does not show a "Retry" button', :sidekiq_might_not_need_inline do - expect(page).not_to have_content('Retry') - end - end - end - - context 'canceling jobs' do - it { expect(page).not_to have_selector('.ci-canceled') } - - context 'when canceling' do - before do - click_on 'Cancel running' - end - - it 'does not show a "Cancel running" button', :sidekiq_might_not_need_inline do - expect(page).not_to have_content('Cancel running') - end - end - end - - context 'playing manual job' do - before do - within '[data-testid="jobs-tab-table"]' do - click_button('Play') - - wait_for_requests - end - end - - it { expect(build_manual.reload).to be_pending } - end - - context 'when user unschedules a delayed job' do - before do - within '[data-testid="jobs-tab-table"]' do - click_button('Unschedule') - end - end - - it 'unschedules the delayed job and shows play button as a manual job' do - expect(page).to have_button('Play') - expect(page).not_to have_button('Unschedule') - end - end - end - - describe 'GET /:project/-/pipelines/:id/failures' do - let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: '1234') } - let(:pipeline_failures_page) { failures_project_pipeline_path(project, pipeline) } - let!(:failed_build) { create(:ci_build, :failed, pipeline: pipeline) } - - subject { visit pipeline_failures_page } - - context 'with failed build' do - before do - failed_build.trace.set('4 examples, 1 failure') - end - - it 'lists failed builds' do - subject - - expect(page).to have_content(failed_build.name) - expect(page).to have_content(failed_build.stage_name) - end - - it 'shows build failure logs' do - subject - - expect(page).to have_content('4 examples, 1 failure') - end - - it 'shows the failure reason' do - subject - - expect(page).to have_content('There is an unknown failure, please try again') - end - - context 'when user does not have permission to retry build' do - it 'shows retry button for failed build' do - subject - - page.within(find('#js-tab-failures', match: :first)) do - expect(page).not_to have_button('Retry') - end - end - end - - context 'when user does have permission to retry build' do - before do - create(:protected_branch, :developers_can_merge, - name: pipeline.ref, project: project) - end - - it 'shows retry button for failed build' do - subject - - page.within(find('#js-tab-failures', match: :first)) do - expect(page).to have_button('Retry') - end - end - end - end - - context 'when missing build logs' do - it 'lists failed builds' do - subject - - expect(page).to have_content(failed_build.name) - expect(page).to have_content(failed_build.stage_name) - end - - it 'does not show log' do - subject - - expect(page).to have_content('No job log') - end - end - - context 'without permission to access builds' do - let(:role) { :guest } - - before do - project.update!(public_builds: false) - end - - context 'when accessing failed jobs page' do - it 'renders a 404 page' do - requests = inspect_requests { subject } - - expect(page).to have_title('Not Found') - expect(requests.first.status_code).to eq(404) - end - end - end - - context 'without failures' do - before do - failed_build.update!(status: :success) - end - - it 'does not show the failure tab' do - subject - - expect(page).not_to have_content('Failed Jobs') - end - - it 'displays the pipeline graph' do - subject - - expect(page).to have_current_path(pipeline_path(pipeline), ignore_query: true) - expect(page).to have_selector('.js-pipeline-graph') - end - end - end -end diff --git a/spec/features/projects/pipelines/legacy_pipelines_spec.rb b/spec/features/projects/pipelines/legacy_pipelines_spec.rb deleted file mode 100644 index b6ae4144709..00000000000 --- a/spec/features/projects/pipelines/legacy_pipelines_spec.rb +++ /dev/null @@ -1,851 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'Pipelines', :js, feature_category: :projects do - include ProjectForksHelper - include Spec::Support::Helpers::ModalHelpers - - let(:project) { create(:project) } - let(:expected_detached_mr_tag) { 'merge request' } - - context 'when user is logged in' do - let(:user) { create(:user) } - - before do - sign_in(user) - - project.add_developer(user) - project.update!(auto_devops_attributes: { enabled: false }) - - stub_feature_flags(pipeline_tabs_vue: false) - end - - describe 'GET /:project/-/pipelines' do - let(:project) { create(:project, :repository) } - - let!(:pipeline) do - create( - :ci_empty_pipeline, - project: project, - ref: 'master', - status: 'running', - sha: project.commit.id - ) - end - - context 'scope' do - before do - create(:ci_empty_pipeline, status: 'pending', project: project, sha: project.commit.id, ref: 'master') - create(:ci_empty_pipeline, status: 'running', project: project, sha: project.commit.id, ref: 'master') - create(:ci_empty_pipeline, status: 'created', project: project, sha: project.commit.id, ref: 'master') - create(:ci_empty_pipeline, status: 'success', project: project, sha: project.commit.id, ref: 'master') - end - - [:all, :running, :pending, :finished, :branches].each do |scope| - context "when displaying #{scope}" do - before do - visit_project_pipelines(scope: scope) - end - - it 'contains pipeline commit short SHA' do - expect(page).to have_content(pipeline.short_sha) - end - - it 'contains branch name' do - expect(page).to have_content(pipeline.ref) - end - end - end - end - - context 'header tabs' do - before do - visit project_pipelines_path(project) - wait_for_requests - end - - it 'shows a tab for All pipelines and count' do - expect(page.find('.js-pipelines-tab-all').text).to include('All') - expect(page.find('.js-pipelines-tab-all .badge').text).to include('1') - end - - it 'shows a tab for Finished pipelines and count' do - expect(page.find('.js-pipelines-tab-finished').text).to include('Finished') - end - - it 'shows a tab for Branches' do - expect(page.find('.js-pipelines-tab-branches').text).to include('Branches') - end - - it 'shows a tab for Tags' do - expect(page.find('.js-pipelines-tab-tags').text).to include('Tags') - end - - it 'updates content when tab is clicked' do - page.find('.js-pipelines-tab-finished').click - wait_for_requests - expect(page).to have_content('There are currently no finished pipelines.') - end - end - - context 'navigation links' do - before do - visit project_pipelines_path(project) - wait_for_requests - end - - it 'renders "CI lint" link' do - expect(page).to have_link('CI lint') - end - - it 'renders "Run pipeline" link' do - expect(page).to have_link('Run pipeline') - end - end - - context 'when pipeline is cancelable' do - let!(:build) do - create(:ci_build, pipeline: pipeline, - stage: 'test') - end - - before do - build.run - visit_project_pipelines - end - - it 'indicates that pipeline can be canceled' do - expect(page).to have_selector('.js-pipelines-cancel-button') - expect(page).to have_selector('.ci-running') - end - - context 'when canceling' do - before do - find('.js-pipelines-cancel-button').click - click_button 'Stop pipeline' - wait_for_requests - end - - it 'indicated that pipelines was canceled', :sidekiq_might_not_need_inline do - expect(page).not_to have_selector('.js-pipelines-cancel-button') - expect(page).to have_selector('.ci-canceled') - end - end - end - - context 'when pipeline is retryable', :sidekiq_might_not_need_inline do - let!(:build) do - create(:ci_build, pipeline: pipeline, - stage: 'test') - end - - before do - build.drop - visit_project_pipelines - end - - it 'indicates that pipeline can be retried' do - expect(page).to have_selector('.js-pipelines-retry-button') - expect(page).to have_selector('.ci-failed') - end - - context 'when retrying' do - before do - find('.js-pipelines-retry-button').click - wait_for_requests - end - - it 'shows running pipeline that is not retryable' do - expect(page).not_to have_selector('.js-pipelines-retry-button') - expect(page).to have_selector('.ci-running') - end - end - end - - context 'when pipeline is detached merge request pipeline' do - let(:merge_request) do - create(:merge_request, - :with_detached_merge_request_pipeline, - source_project: source_project, - target_project: target_project) - end - - let!(:pipeline) { merge_request.all_pipelines.first } - let(:source_project) { project } - let(:target_project) { project } - - before do - visit project_pipelines_path(source_project) - end - - shared_examples_for 'detached merge request pipeline' do - it 'shows pipeline information without pipeline ref', :sidekiq_might_not_need_inline do - within '.pipeline-tags' do - expect(page).to have_content(expected_detached_mr_tag) - - expect(page).to have_link(merge_request.iid, - href: project_merge_request_path(project, merge_request)) - - expect(page).not_to have_link(pipeline.ref) - end - end - end - - it_behaves_like 'detached merge request pipeline' - - context 'when source project is a forked project' do - let(:source_project) { fork_project(project, user, repository: true) } - - it_behaves_like 'detached merge request pipeline' - end - end - - context 'when pipeline is merge request pipeline' do - let(:merge_request) do - create(:merge_request, - :with_merge_request_pipeline, - source_project: source_project, - target_project: target_project, - merge_sha: target_project.commit.sha) - end - - let!(:pipeline) { merge_request.all_pipelines.first } - let(:source_project) { project } - let(:target_project) { project } - - before do - visit project_pipelines_path(source_project) - end - - shared_examples_for 'Correct merge request pipeline information' do - it 'does not show detached tag for the pipeline, and shows the link of the merge request' \ - 'and does not show the ref of the pipeline', :sidekiq_might_not_need_inline do - within '.pipeline-tags' do - expect(page).not_to have_content(expected_detached_mr_tag) - - expect(page).to have_link(merge_request.iid, - href: project_merge_request_path(project, merge_request)) - - expect(page).not_to have_link(pipeline.ref) - end - end - end - - it_behaves_like 'Correct merge request pipeline information' - - context 'when source project is a forked project' do - let(:source_project) { fork_project(project, user, repository: true) } - - it_behaves_like 'Correct merge request pipeline information' - end - end - - context 'when pipeline has configuration errors' do - let(:pipeline) do - create(:ci_pipeline, :invalid, project: project) - end - - before do - visit_project_pipelines - end - - it 'contains badge that indicates errors' do - expect(page).to have_content 'yaml invalid' - end - - it 'contains badge with tooltip which contains error' do - expect(pipeline).to have_yaml_errors - expect(page).to have_selector( - %Q{span[title="#{pipeline.yaml_errors}"]}) - end - - it 'contains badge that indicates failure reason' do - expect(page).to have_content 'error' - end - - it 'contains badge with tooltip which contains failure reason' do - expect(pipeline.failure_reason?).to eq true - expect(page).to have_selector( - %Q{span[title="#{pipeline.present.failure_reason}"]}) - end - end - - context 'with manual actions' do - let!(:manual) do - create(:ci_build, :manual, - pipeline: pipeline, - name: 'manual build', - stage: 'test') - end - - before do - visit_project_pipelines - end - - it 'has a dropdown with play button' do - expect(page).to have_selector('[data-testid="pipelines-manual-actions-dropdown"] [data-testid="play-icon"]') - end - - it 'has link to the manual action' do - find('[data-testid="pipelines-manual-actions-dropdown"]').click - - expect(page).to have_button('manual build') - end - - context 'when manual action was played' do - before do - find('[data-testid="pipelines-manual-actions-dropdown"]').click - click_button('manual build') - end - - it 'enqueues manual action job' do - expect(page).to have_selector( - '[data-testid="pipelines-manual-actions-dropdown"] .gl-dropdown-toggle:disabled' - ) - end - end - end - - context 'when there is a delayed job' do - let!(:delayed_job) do - create(:ci_build, :scheduled, - pipeline: pipeline, - name: 'delayed job 1', - stage: 'test') - end - - before do - visit_project_pipelines - end - - it 'has a dropdown for actionable jobs' do - expect(page).to have_selector('[data-testid="pipelines-manual-actions-dropdown"] [data-testid="play-icon"]') - end - - it "has link to the delayed job's action" do - find('[data-testid="pipelines-manual-actions-dropdown"]').click - - time_diff = [0, delayed_job.scheduled_at - Time.zone.now].max - expect(page).to have_button('delayed job 1') - expect(page).to have_content(Time.at(time_diff).utc.strftime("%H:%M:%S")) - end - - context 'when delayed job is expired already' do - let!(:delayed_job) do - create(:ci_build, :expired_scheduled, - pipeline: pipeline, - name: 'delayed job 1', - stage: 'test') - end - - it "shows 00:00:00 as the remaining time" do - find('[data-testid="pipelines-manual-actions-dropdown"]').click - - expect(page).to have_content("00:00:00") - end - end - - context 'when user played a delayed job immediately' do - before do - find('[data-testid="pipelines-manual-actions-dropdown"]').click - accept_gl_confirm do - click_button 'delayed job 1' - end - wait_for_requests - end - - it 'enqueues the delayed job', :js do - find('[data-testid="mini-pipeline-graph-dropdown"]').click - - within('[data-testid="mini-pipeline-graph-dropdown"]') { find('.ci-status-icon-pending') } - - expect(delayed_job.reload).to be_pending - end - end - end - - context 'for generic statuses' do - context 'when preparing' do - let!(:pipeline) do - create(:ci_empty_pipeline, - status: 'preparing', project: project) - end - - let!(:status) do - create(:generic_commit_status, - :preparing, pipeline: pipeline) - end - - before do - visit_project_pipelines - end - - it 'is cancelable' do - expect(page).to have_selector('.js-pipelines-cancel-button') - end - - it 'shows the pipeline as preparing' do - expect(page).to have_selector('.ci-preparing') - end - end - - context 'when running' do - let!(:running) do - create(:generic_commit_status, - status: 'running', - pipeline: pipeline, - stage: 'test') - end - - before do - visit_project_pipelines - end - - it 'is cancelable' do - expect(page).to have_selector('.js-pipelines-cancel-button') - end - - it 'has pipeline running' do - expect(page).to have_selector('.ci-running') - end - - context 'when canceling' do - before do - find('.js-pipelines-cancel-button').click - click_button 'Stop pipeline' - end - - it 'indicates that pipeline was canceled', :sidekiq_might_not_need_inline do - expect(page).not_to have_selector('.js-pipelines-cancel-button') - expect(page).to have_selector('.ci-canceled') - end - end - end - - context 'when failed' do - let!(:status) do - create(:generic_commit_status, :pending, - pipeline: pipeline, - stage: 'test') - end - - before do - status.drop - visit_project_pipelines - end - - it 'is not retryable' do - expect(page).not_to have_selector('.js-pipelines-retry-button') - end - - it 'has failed pipeline', :sidekiq_might_not_need_inline do - expect(page).to have_selector('.ci-failed') - end - end - end - - context 'downloadable pipelines' do - context 'with artifacts' do - let!(:with_artifacts) do - build = create(:ci_build, :success, - pipeline: pipeline, - name: 'rspec tests', - stage: 'test') - - create(:ci_job_artifact, :codequality, job: build) - end - - before do - visit_project_pipelines - end - - it 'has artifacts dropdown' do - expect(page).to have_selector('[data-testid="pipeline-multi-actions-dropdown"]') - end - end - - context 'with artifacts expired' do - let!(:with_artifacts_expired) do - create(:ci_build, :expired, :success, - pipeline: pipeline, - name: 'rspec', - stage: 'test') - end - - before do - visit_project_pipelines - end - - it { expect(page).not_to have_selector('[data-testid="artifact-item"]') } - end - - context 'without artifacts' do - let!(:without_artifacts) do - create(:ci_build, :success, - pipeline: pipeline, - name: 'rspec', - stage: 'test') - end - - before do - visit_project_pipelines - end - - it { expect(page).not_to have_selector('[data-testid="artifact-item"]') } - end - - context 'with trace artifact' do - before do - create(:ci_build, :success, :trace_artifact, pipeline: pipeline) - - visit_project_pipelines - end - - it 'does not show trace artifact as artifacts' do - expect(page).not_to have_selector('[data-testid="artifact-item"]') - end - end - end - - context 'mini pipeline graph' do - let!(:build) do - create(:ci_build, :pending, pipeline: pipeline, - stage: 'build', - name: 'build') - end - - dropdown_selector = '[data-testid="mini-pipeline-graph-dropdown"]' - - before do - visit_project_pipelines - end - - it 'renders a mini pipeline graph' do - expect(page).to have_selector('[data-testid="pipeline-mini-graph"]') - expect(page).to have_selector(dropdown_selector) - end - - context 'when clicking a stage badge' do - it 'opens a dropdown' do - find(dropdown_selector).click - - expect(page).to have_link build.name - end - - it 'is possible to cancel pending build' do - find(dropdown_selector).click - find('.js-ci-action').click - wait_for_requests - - expect(build.reload).to be_canceled - end - end - - context 'for a failed pipeline' do - let!(:build) do - create(:ci_build, :failed, pipeline: pipeline, - stage: 'build', - name: 'build') - end - - it 'displays the failure reason' do - find(dropdown_selector).click - - within('.js-builds-dropdown-list') do - build_element = page.find('.mini-pipeline-graph-dropdown-item') - expect(build_element['title']).to eq('build - failed - (unknown failure)') - end - end - end - end - - context 'with pagination' do - before do - allow(Ci::Pipeline).to receive(:default_per_page).and_return(1) - create(:ci_empty_pipeline, project: project) - end - - it 'renders pagination' do - visit project_pipelines_path(project) - wait_for_requests - - expect(page).to have_selector('.gl-pagination') - end - - it 'renders second page of pipelines' do - visit project_pipelines_path(project, page: '2') - wait_for_requests - - expect(page).to have_selector('.gl-pagination .page-link', count: 4) - end - - it 'shows updated content' do - visit project_pipelines_path(project) - wait_for_requests - page.find('.page-link.next-page-item').click - - expect(page).to have_selector('.gl-pagination .page-link', count: 4) - end - end - - context 'with pipeline key selection' do - before do - visit project_pipelines_path(project) - wait_for_requests - end - - it 'changes the Pipeline ID column for Pipeline IID' do - page.find('[data-testid="pipeline-key-dropdown"]').click - - within '.gl-dropdown-contents' do - dropdown_options = page.find_all '.gl-dropdown-item' - - dropdown_options[1].click - end - - expect(page.find('[data-testid="pipeline-th"]')).to have_content 'Pipeline' - expect(page.find('[data-testid="pipeline-url-link"]')).to have_content "##{pipeline.iid}" - end - end - end - - describe 'GET /:project/-/pipelines/show' do - let(:project) { create(:project, :repository) } - - let(:pipeline) do - create(:ci_empty_pipeline, - project: project, - sha: project.commit.id, - user: user) - end - - before do - create_build('build', 0, 'build', :success) - create_build('test', 1, 'rspec 0:2', :pending) - create_build('test', 1, 'rspec 1:2', :running) - create_build('test', 1, 'spinach 0:2', :created) - create_build('test', 1, 'spinach 1:2', :created) - create_build('test', 1, 'audit', :created) - create_build('deploy', 2, 'production', :created) - - create( - :generic_commit_status, - pipeline: pipeline, - stage: 'external', - name: 'jenkins', - stage_idx: 3, - ref: 'master' - ) - - visit project_pipeline_path(project, pipeline) - wait_for_requests - end - - it 'shows a graph with grouped stages' do - expect(page).to have_css('.js-pipeline-graph') - - # header - expect(page).to have_text("##{pipeline.id}") - expect(page).to have_selector(%Q(img[src="#{pipeline.user.avatar_url}"])) - expect(page).to have_link(pipeline.user.name, href: user_path(pipeline.user)) - - # stages - expect(page).to have_text('build') - expect(page).to have_text('test') - expect(page).to have_text('deploy') - expect(page).to have_text('external') - - # builds - expect(page).to have_text('rspec') - expect(page).to have_text('spinach') - expect(page).to have_text('rspec') - expect(page).to have_text('production') - expect(page).to have_text('jenkins') - end - - def create_build(stage, stage_idx, name, status) - create(:ci_build, pipeline: pipeline, stage: stage, stage_idx: stage_idx, name: name, status: status) - end - end - - describe 'POST /:project/-/pipelines' do - let(:project) { create(:project, :repository) } - - before do - visit new_project_pipeline_path(project) - end - - context 'for valid commit', :js do - before do - click_button project.default_branch - wait_for_requests - - find('p', text: 'master').click - wait_for_requests - end - - context 'with gitlab-ci.yml', :js do - before do - stub_ci_pipeline_to_return_yaml_file - end - - it 'creates a new pipeline' do - expect do - click_on 'Run pipeline' - wait_for_requests - end - .to change { Ci::Pipeline.count }.by(1) - - expect(Ci::Pipeline.last).to be_web - end - - context 'when variables are specified' do - it 'creates a new pipeline with variables' do - page.within(find("[data-testid='ci-variable-row']")) do - find("[data-testid='pipeline-form-ci-variable-key']").set('key_name') - find("[data-testid='pipeline-form-ci-variable-value']").set('value') - end - - expect do - click_on 'Run pipeline' - wait_for_requests - end - .to change { Ci::Pipeline.count }.by(1) - - expect(Ci::Pipeline.last.variables.map { |var| var.slice(:key, :secret_value) }) - .to eq [{ key: "key_name", secret_value: "value" }.with_indifferent_access] - end - end - end - - context 'without gitlab-ci.yml' do - before do - click_on 'Run pipeline' - wait_for_requests - end - - it { expect(page).to have_content('Missing CI config file') } - - it 'creates a pipeline after first request failed and a valid gitlab-ci.yml file' \ - 'is available when trying again' do - stub_ci_pipeline_to_return_yaml_file - - expect do - click_on 'Run pipeline' - wait_for_requests - end - .to change { Ci::Pipeline.count }.by(1) - end - end - end - end - - describe 'Reset runner caches' do - let(:project) { create(:project, :repository) } - - before do - create(:ci_empty_pipeline, status: 'success', project: project, sha: project.commit.id, ref: 'master') - project.add_maintainer(user) - visit project_pipelines_path(project) - end - - it 'has a clear caches button' do - expect(page).to have_button 'Clear runner caches' - end - - describe 'user clicks the button' do - context 'when project already has jobs_cache_index' do - before do - project.update!(jobs_cache_index: 1) - end - - it 'increments jobs_cache_index' do - click_button 'Clear runner caches' - wait_for_requests - expect(page.find('[data-testid="alert-info"]')).to have_content 'Project cache successfully reset.' - end - end - - context 'when project does not have jobs_cache_index' do - it 'sets jobs_cache_index to 1' do - click_button 'Clear runner caches' - wait_for_requests - expect(page.find('[data-testid="alert-info"]')).to have_content 'Project cache successfully reset.' - end - end - end - end - - describe 'Run Pipelines' do - let(:project) { create(:project, :repository) } - - before do - visit new_project_pipeline_path(project) - end - - describe 'new pipeline page' do - it 'has field to add a new pipeline' do - expect(page).to have_selector('[data-testid="ref-select"]') - expect(find('[data-testid="ref-select"]')).to have_content project.default_branch - expect(page).to have_content('Run for') - end - end - - describe 'find pipelines' do - it 'shows filtered pipelines', :js do - click_button project.default_branch - - page.within '[data-testid="ref-select"]' do - find('[data-testid="search-refs"]').native.send_keys('fix') - - page.within '.gl-dropdown-contents' do - expect(page).to have_content('fix') - end - end - end - end - end - - describe 'Empty State' do - let(:project) { create(:project, :repository) } - - before do - visit project_pipelines_path(project) - end - - it 'renders empty state' do - expect(page).to have_content 'Try test template' - end - end - end - - context 'when user is not logged in' do - before do - project.update!(auto_devops_attributes: { enabled: false }) - visit project_pipelines_path(project) - end - - context 'when project is public' do - let(:project) { create(:project, :public, :repository) } - - context 'without pipelines' do - it { expect(page).to have_content 'This project is not currently set up to run pipelines.' } - end - end - - context 'when project is private' do - let(:project) { create(:project, :private, :repository) } - - it 'redirects the user to sign_in and displays the flash alert' do - expect(page).to have_content 'You need to sign in' - expect(page).to have_current_path("/users/sign_in") - end - end - end - - def visit_project_pipelines(**query) - visit project_pipelines_path(project, query) - wait_for_requests - end -end diff --git a/spec/frontend/boards/components/board_card_move_to_position_spec.js b/spec/frontend/boards/components/board_card_move_to_position_spec.js index 46e0724fc4a..8dee3c77787 100644 --- a/spec/frontend/boards/components/board_card_move_to_position_spec.js +++ b/spec/frontend/boards/components/board_card_move_to_position_spec.js @@ -1,18 +1,17 @@ import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; -import { GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue'; import { mockList, mockIssue2, mockIssue, mockIssue3, mockIssue4 } from 'jest/boards/mock_data'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import { BOARD_CARD_MOVE_TO_POSITION_OPTIONS } from '~/boards/constants'; Vue.use(Vuex); const dropdownOptions = [ - BOARD_CARD_MOVE_TO_POSITION_OPTIONS[0].text, - BOARD_CARD_MOVE_TO_POSITION_OPTIONS[1].text, + BoardCardMoveToPosition.i18n.moveToStartText, + BoardCardMoveToPosition.i18n.moveToEndText, ]; describe('Board Card Move to position', () => { @@ -54,7 +53,8 @@ describe('Board Card Move to position', () => { ...propsData, }, stubs: { - GlCollapsibleListbox, + GlDropdown, + GlDropdownItem, }, }); }; @@ -68,8 +68,8 @@ describe('Board Card Move to position', () => { wrapper.destroy(); }); - const findMoveToPositionDropdown = () => wrapper.findComponent(GlCollapsibleListbox); - const findDropdownItems = () => findMoveToPositionDropdown().findAllComponents(GlListboxItem); + const findMoveToPositionDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownItems = () => findMoveToPositionDropdown().findAllComponents(GlDropdownItem); const findDropdownItemAtIndex = (index) => findDropdownItems().at(index); describe('Dropdown', () => { @@ -80,6 +80,7 @@ describe('Board Card Move to position', () => { }); it('is opened on the click of vertical ellipsis and has 2 dropdown items when number of list items < 10', () => { + findMoveToPositionDropdown().vm.$emit('click'); expect(findDropdownItems()).toHaveLength(dropdownOptions.length); }); }); @@ -96,17 +97,18 @@ describe('Board Card Move to position', () => { }); it.each` - dropdownIndex | dropdownLabel | trackLabel | positionInList - ${0} | ${BOARD_CARD_MOVE_TO_POSITION_OPTIONS[0].text} | ${'move_to_start'} | ${0} - ${1} | ${BOARD_CARD_MOVE_TO_POSITION_OPTIONS[1].text} | ${'move_to_end'} | ${-1} + dropdownIndex | dropdownLabel | trackLabel | positionInList + ${0} | ${BoardCardMoveToPosition.i18n.moveToStartText} | ${'move_to_start'} | ${0} + ${1} | ${BoardCardMoveToPosition.i18n.moveToEndText} | ${'move_to_end'} | ${-1} `( 'on click of dropdown index $dropdownIndex with label $dropdownLabel should call moveItem action with tracking label $trackLabel', async ({ dropdownIndex, dropdownLabel, trackLabel, positionInList }) => { - await findMoveToPositionDropdown().vm.$emit( - 'select', - BOARD_CARD_MOVE_TO_POSITION_OPTIONS[dropdownIndex].value, - ); + await findMoveToPositionDropdown().vm.$emit('click'); + expect(findDropdownItemAtIndex(dropdownIndex).text()).toBe(dropdownLabel); + await findDropdownItemAtIndex(dropdownIndex).vm.$emit('click', { + stopPropagation: () => {}, + }); await nextTick(); diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js index db94b392694..e80c66f7fb8 100644 --- a/spec/frontend/boards/components/board_filtered_search_spec.js +++ b/spec/frontend/boards/components/board_filtered_search_spec.js @@ -17,7 +17,7 @@ import { TOKEN_TYPE_WEIGHT, } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import { createStore } from '~/boards/stores'; @@ -49,9 +49,9 @@ describe('BoardFilteredSearch', () => { { value: '!=', description: 'is not' }, ], symbol: '@', - token: AuthorToken, + token: UserToken, unique: true, - fetchAuthors: () => new Promise(() => {}), + fetchUsers: () => new Promise(() => {}), }, ]; diff --git a/spec/frontend/boards/components/issue_board_filtered_search_spec.js b/spec/frontend/boards/components/issue_board_filtered_search_spec.js index e4a6a2b8b76..513561307cd 100644 --- a/spec/frontend/boards/components/issue_board_filtered_search_spec.js +++ b/spec/frontend/boards/components/issue_board_filtered_search_spec.js @@ -23,14 +23,14 @@ describe('IssueBoardFilter', () => { }); }; - let fetchAuthorsSpy; + let fetchUsersSpy; let fetchLabelsSpy; beforeEach(() => { - fetchAuthorsSpy = jest.fn(); + fetchUsersSpy = jest.fn(); fetchLabelsSpy = jest.fn(); issueBoardFilters.mockReturnValue({ - fetchAuthors: fetchAuthorsSpy, + fetchUsers: fetchUsersSpy, fetchLabels: fetchLabelsSpy, }); }); @@ -59,7 +59,7 @@ describe('IssueBoardFilter', () => { const tokens = mockTokens( fetchLabelsSpy, - fetchAuthorsSpy, + fetchUsersSpy, wrapper.vm.fetchMilestones, isSignedIn, ); diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index 30b2320097c..df41eb05eae 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -21,7 +21,7 @@ import { TOKEN_TYPE_RELEASE, TOKEN_TYPE_TYPE, } from '~/vue_shared/components/filtered_search_bar/constants'; -import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; @@ -758,16 +758,16 @@ export const mockConfidentialToken = { ], }; -export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones, isSignedIn) => [ +export const mockTokens = (fetchLabels, fetchUsers, fetchMilestones, isSignedIn) => [ { icon: 'user', title: TOKEN_TITLE_ASSIGNEE, type: TOKEN_TYPE_ASSIGNEE, operators: OPERATORS_IS_NOT, - token: AuthorToken, + token: UserToken, unique: true, - fetchAuthors, - preloadedAuthors: [], + fetchUsers, + preloadedUsers: [], }, { icon: 'pencil', @@ -775,10 +775,10 @@ export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones, isSignedI type: TOKEN_TYPE_AUTHOR, operators: OPERATORS_IS_NOT, symbol: '@', - token: AuthorToken, + token: UserToken, unique: true, - fetchAuthors, - preloadedAuthors: [], + fetchUsers, + preloadedUsers: [], }, { icon: 'labels', diff --git a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap index 2b706d21f51..1acbf14db88 100644 --- a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap +++ b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap @@ -1,163 +1,229 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Design management design version dropdown component renders design version dropdown button 1`] = ` -<gl-dropdown-stub +<gl-base-dropdown-stub + ariahaspopup="listbox" category="primary" - clearalltext="Clear all" - clearalltextclass="gl-px-5" - headertext="" - hideheaderborder="true" - highlighteditemstitle="Selected" - highlighteditemstitleclass="gl-px-5" + icon="" issueiid="" projectpath="" size="small" - text="Showing latest version" + toggleid="dropdown-toggle-btn-2" + toggletext="Showing latest version" variant="default" > - <gl-dropdown-item-stub - avatarurl="" - iconcolor="" - iconname="" - iconrightarialabel="" - iconrightname="" - ischeckcentered="true" - ischecked="true" - ischeckitem="true" - secondarytext="" + <!----> + + <!----> + + <ul + aria-labelledby="dropdown-toggle-btn-2" + class="gl-dropdown-contents gl-list-style-none gl-pl-0 gl-mb-0" + id="listbox" + role="listbox" + tabindex="-1" > - <strong> - Version - 2 - (latest) - </strong> - - <div - class="gl-text-gray-600 gl-mt-1" + <gl-listbox-item-stub + ischeckcentered="true" > - <div> - Adminstrator - </div> - - <time-ago-stub - class="text-1" - cssclass="" - time="2021-08-09T06:05:00Z" - tooltipplacement="bottom" - /> - </div> - </gl-dropdown-item-stub> - <gl-dropdown-item-stub - avatarurl="" - iconcolor="" - iconname="" - iconrightarialabel="" - iconrightname="" - ischeckcentered="true" - ischeckitem="true" - secondarytext="" - > - <strong> - Version - 1 - - </strong> - - <div - class="gl-text-gray-600 gl-mt-1" + <span + class="gl-display-flex gl-align-items-center" + > + <div + class="gl-avatar gl-avatar-identicon gl-avatar-circle gl-avatar-s32 gl-avatar-identicon-bg1" + > + + + + </div> + + <span + class="gl-display-flex gl-flex-direction-column" + > + <span + class="gl-font-weight-bold" + > + Version 2 (latest) + </span> + + <span + class="gl-text-gray-600 gl-mt-1" + > + <span + class="gl-display-block" + > + Adminstrator + </span> + + <time-ago-stub + class="text-1" + cssclass="" + time="2021-08-09T06:05:00Z" + tooltipplacement="bottom" + /> + </span> + </span> + </span> + </gl-listbox-item-stub> + <gl-listbox-item-stub + ischeckcentered="true" > - <div> - Adminstrator - </div> - - <time-ago-stub - class="text-1" - cssclass="" - time="2021-08-09T06:05:00Z" - tooltipplacement="bottom" - /> - </div> - </gl-dropdown-item-stub> -</gl-dropdown-stub> + <span + class="gl-display-flex gl-align-items-center" + > + <div + class="gl-avatar gl-avatar-identicon gl-avatar-circle gl-avatar-s32 gl-avatar-identicon-bg1" + > + + + + </div> + + <span + class="gl-display-flex gl-flex-direction-column" + > + <span + class="gl-font-weight-bold" + > + Version 1 + </span> + + <span + class="gl-text-gray-600 gl-mt-1" + > + <span + class="gl-display-block" + > + Adminstrator + </span> + + <time-ago-stub + class="text-1" + cssclass="" + time="2021-08-09T06:05:00Z" + tooltipplacement="bottom" + /> + </span> + </span> + </span> + </gl-listbox-item-stub> + </ul> + + <!----> + +</gl-base-dropdown-stub> `; exports[`Design management design version dropdown component renders design version list 1`] = ` -<gl-dropdown-stub +<gl-base-dropdown-stub + ariahaspopup="listbox" category="primary" - clearalltext="Clear all" - clearalltextclass="gl-px-5" - headertext="" - hideheaderborder="true" - highlighteditemstitle="Selected" - highlighteditemstitleclass="gl-px-5" + icon="" issueiid="" projectpath="" size="small" - text="Showing latest version" + toggleid="dropdown-toggle-btn-4" + toggletext="Showing latest version" variant="default" > - <gl-dropdown-item-stub - avatarurl="" - iconcolor="" - iconname="" - iconrightarialabel="" - iconrightname="" - ischeckcentered="true" - ischecked="true" - ischeckitem="true" - secondarytext="" + <!----> + + <!----> + + <ul + aria-labelledby="dropdown-toggle-btn-4" + class="gl-dropdown-contents gl-list-style-none gl-pl-0 gl-mb-0" + id="listbox" + role="listbox" + tabindex="-1" > - <strong> - Version - 2 - (latest) - </strong> - - <div - class="gl-text-gray-600 gl-mt-1" + <gl-listbox-item-stub + ischeckcentered="true" > - <div> - Adminstrator - </div> - - <time-ago-stub - class="text-1" - cssclass="" - time="2021-08-09T06:05:00Z" - tooltipplacement="bottom" - /> - </div> - </gl-dropdown-item-stub> - <gl-dropdown-item-stub - avatarurl="" - iconcolor="" - iconname="" - iconrightarialabel="" - iconrightname="" - ischeckcentered="true" - ischeckitem="true" - secondarytext="" - > - <strong> - Version - 1 - - </strong> - - <div - class="gl-text-gray-600 gl-mt-1" + <span + class="gl-display-flex gl-align-items-center" + > + <div + class="gl-avatar gl-avatar-identicon gl-avatar-circle gl-avatar-s32 gl-avatar-identicon-bg1" + > + + + + </div> + + <span + class="gl-display-flex gl-flex-direction-column" + > + <span + class="gl-font-weight-bold" + > + Version 2 (latest) + </span> + + <span + class="gl-text-gray-600 gl-mt-1" + > + <span + class="gl-display-block" + > + Adminstrator + </span> + + <time-ago-stub + class="text-1" + cssclass="" + time="2021-08-09T06:05:00Z" + tooltipplacement="bottom" + /> + </span> + </span> + </span> + </gl-listbox-item-stub> + <gl-listbox-item-stub + ischeckcentered="true" > - <div> - Adminstrator - </div> - - <time-ago-stub - class="text-1" - cssclass="" - time="2021-08-09T06:05:00Z" - tooltipplacement="bottom" - /> - </div> - </gl-dropdown-item-stub> -</gl-dropdown-stub> + <span + class="gl-display-flex gl-align-items-center" + > + <div + class="gl-avatar gl-avatar-identicon gl-avatar-circle gl-avatar-s32 gl-avatar-identicon-bg1" + > + + + + </div> + + <span + class="gl-display-flex gl-flex-direction-column" + > + <span + class="gl-font-weight-bold" + > + Version 1 + </span> + + <span + class="gl-text-gray-600 gl-mt-1" + > + <span + class="gl-display-block" + > + Adminstrator + </span> + + <time-ago-stub + class="text-1" + cssclass="" + time="2021-08-09T06:05:00Z" + tooltipplacement="bottom" + /> + </span> + </span> + </span> + </gl-listbox-item-stub> + </ul> + + <!----> + +</gl-base-dropdown-stub> `; diff --git a/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js index 7c26ab9739b..1e9f286a0ec 100644 --- a/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js +++ b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui'; +import { GlAvatar, GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import DesignVersionDropdown from '~/design_management/components/upload/design_version_dropdown.vue'; @@ -32,7 +32,7 @@ describe('Design management design version dropdown component', () => { mocks: { $route, }, - stubs: { GlSprintf }, + stubs: { GlAvatar, GlCollapsibleListbox }, }); // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details @@ -46,7 +46,9 @@ describe('Design management design version dropdown component', () => { wrapper.destroy(); }); - const findVersionLink = (index) => wrapper.findAllComponents(GlDropdownItem).at(index); + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + const findAllListboxItems = () => wrapper.findAllComponents(GlListboxItem); + const findVersionLink = (index) => wrapper.findAllComponents(GlListboxItem).at(index); it('renders design version dropdown button', async () => { createComponent(); @@ -76,35 +78,36 @@ describe('Design management design version dropdown component', () => { createComponent(); await nextTick(); - expect(wrapper.findComponent(GlDropdown).attributes('text')).toBe('Showing latest version'); + + expect(findListbox().props('toggleText')).toBe('Showing latest version'); }); it('displays latest version text when only 1 version is present', async () => { createComponent({ maxVersions: 1 }); await nextTick(); - expect(wrapper.findComponent(GlDropdown).attributes('text')).toBe('Showing latest version'); + expect(findListbox().props('toggleText')).toBe('Showing latest version'); }); it('displays version text when the current version is not the latest', async () => { createComponent({ $route: designRouteFactory(PREVIOUS_VERSION_ID) }); await nextTick(); - expect(wrapper.findComponent(GlDropdown).attributes('text')).toBe(`Showing version #1`); + expect(findListbox().props('toggleText')).toBe(`Showing version #1`); }); it('displays latest version text when the current version is the latest', async () => { createComponent({ $route: designRouteFactory(LATEST_VERSION_ID) }); await nextTick(); - expect(wrapper.findComponent(GlDropdown).attributes('text')).toBe('Showing latest version'); + expect(findListbox().props('toggleText')).toBe('Showing latest version'); }); it('should have the same length as apollo query', async () => { createComponent(); await nextTick(); - expect(wrapper.findAllComponents(GlDropdownItem)).toHaveLength(wrapper.vm.allVersions.length); + expect(findAllListboxItems()).toHaveLength(wrapper.vm.allVersions.length); }); it('should render TimeAgo', async () => { diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js index 091ec17d58e..140609161d4 100644 --- a/spec/frontend/groups/components/app_spec.js +++ b/spec/frontend/groups/components/app_spec.js @@ -1,7 +1,7 @@ import { GlModal, GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; import appComponent from '~/groups/components/app.vue'; @@ -10,8 +10,6 @@ import groupItemComponent from '~/groups/components/group_item.vue'; import eventHub from '~/groups/event_hub'; import GroupsService from '~/groups/service/groups_service'; import GroupsStore from '~/groups/store/groups_store'; -import EmptyState from '~/groups/components/empty_state.vue'; -import GroupsComponent from '~/groups/components/groups.vue'; import axios from '~/lib/utils/axios_utils'; import * as urlUtilities from '~/lib/utils/url_utility'; import setWindowLocation from 'helpers/set_window_location_helper'; @@ -43,7 +41,7 @@ describe('AppComponent', () => { const createShallowComponent = ({ propsData = {} } = {}) => { store.state.pageInfo = mockPageInfo; - wrapper = shallowMount(appComponent, { + wrapper = shallowMountExtended(appComponent, { propsData: { store, service, @@ -51,6 +49,9 @@ describe('AppComponent', () => { containerId: 'js-groups-tree', ...propsData, }, + scopedSlots: { + 'empty-state': '<div data-testid="empty-state" />', + }, mocks: { $toast, }, @@ -68,6 +69,7 @@ describe('AppComponent', () => { mock.onGet('/dashboard/groups.json').reply(200, mockGroups); Vue.component('GroupFolder', groupFolderComponent); Vue.component('GroupItem', groupItemComponent); + setWindowLocation('?filter=foobar'); document.body.innerHTML = ` <div id="js-groups-tree"> @@ -149,13 +151,13 @@ describe('AppComponent', () => { expect(vm.fetchGroups).toHaveBeenCalledWith({ page: null, - filterGroupsBy: null, + filterGroupsBy: 'foobar', sortBy: null, updatePagination: true, archived: null, }); return fetchPromise.then(() => { - expect(vm.updateGroups).toHaveBeenCalled(); + expect(vm.updateGroups).toHaveBeenCalledWith(mockSearchedGroups, true); }); }); }); @@ -375,32 +377,16 @@ describe('AppComponent', () => { expect(vm.store.setSearchedGroups).toHaveBeenCalledWith(mockGroups); }); - it('should set `isSearchEmpty` prop based on groups count and `filter` query param', () => { - setWindowLocation('?filter=foobar'); - createShallowComponent(); - - vm.updateGroups(mockGroups); - - expect(vm.isSearchEmpty).toBe(false); - - vm.updateGroups([]); - - expect(vm.isSearchEmpty).toBe(true); - }); - describe.each` - action | groups | fromSearch | shouldRenderEmptyState | searchEmpty - ${'subgroups_and_projects'} | ${[]} | ${false} | ${true} | ${false} - ${''} | ${[]} | ${false} | ${false} | ${false} - ${'subgroups_and_projects'} | ${mockGroups} | ${false} | ${false} | ${false} - ${'subgroups_and_projects'} | ${[]} | ${true} | ${false} | ${true} + groups | fromSearch | shouldRenderEmptyState | shouldRenderSearchEmptyState + ${[]} | ${false} | ${true} | ${false} + ${mockGroups} | ${false} | ${false} | ${false} + ${[]} | ${true} | ${false} | ${true} `( - 'when `action` is $action, `groups` is $groups, and `fromSearch` is $fromSearch', - ({ action, groups, fromSearch, shouldRenderEmptyState, searchEmpty }) => { + 'when `groups` is $groups, and `fromSearch` is $fromSearch', + ({ groups, fromSearch, shouldRenderEmptyState, shouldRenderSearchEmptyState }) => { it(`${shouldRenderEmptyState ? 'renders' : 'does not render'} empty state`, async () => { - createShallowComponent({ - propsData: { action, renderEmptyState: true }, - }); + createShallowComponent(); await waitForPromises(); @@ -408,28 +394,14 @@ describe('AppComponent', () => { await nextTick(); - expect(wrapper.findComponent(EmptyState).exists()).toBe(shouldRenderEmptyState); - expect(wrapper.findComponent(GroupsComponent).props('searchEmpty')).toBe(searchEmpty); + expect(wrapper.findByTestId('empty-state').exists()).toBe(shouldRenderEmptyState); + expect(wrapper.findByTestId('search-empty-state').exists()).toBe( + shouldRenderSearchEmptyState, + ); }); }, ); }); - - describe('when `action` is subgroups_and_projects, `groups` is [], `fromSearch` is `false`, and `renderEmptyState` is `false`', () => { - it('renders legacy empty state', async () => { - createShallowComponent({ - propsData: { action: 'subgroups_and_projects' }, - }); - - vm.updateGroups([], false); - - await nextTick(); - - expect( - document.querySelector('[data-testid="legacy-empty-state"]').classList.contains('hidden'), - ).toBe(false); - }); - }); }); describe('created', () => { diff --git a/spec/frontend/groups/components/empty_states/archived_projects_empty_state_spec.js b/spec/frontend/groups/components/empty_states/archived_projects_empty_state_spec.js new file mode 100644 index 00000000000..be61ffa92b4 --- /dev/null +++ b/spec/frontend/groups/components/empty_states/archived_projects_empty_state_spec.js @@ -0,0 +1,27 @@ +import { GlEmptyState } from '@gitlab/ui'; + +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import ArchivedProjectsEmptyState from '~/groups/components/empty_states/archived_projects_empty_state.vue'; + +let wrapper; + +const defaultProvide = { + newProjectIllustration: '/assets/illustrations/project-create-new-sm.svg', +}; + +const createComponent = () => { + wrapper = mountExtended(ArchivedProjectsEmptyState, { + provide: defaultProvide, + }); +}; + +describe('ArchivedProjectsEmptyState', () => { + it('renders empty state', () => { + createComponent(); + + expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({ + title: ArchivedProjectsEmptyState.i18n.title, + svgPath: defaultProvide.newProjectIllustration, + }); + }); +}); diff --git a/spec/frontend/groups/components/empty_states/shared_projects_empty_state_spec.js b/spec/frontend/groups/components/empty_states/shared_projects_empty_state_spec.js new file mode 100644 index 00000000000..c4ace1be1f3 --- /dev/null +++ b/spec/frontend/groups/components/empty_states/shared_projects_empty_state_spec.js @@ -0,0 +1,27 @@ +import { GlEmptyState } from '@gitlab/ui'; + +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import SharedProjectsEmptyState from '~/groups/components/empty_states/shared_projects_empty_state.vue'; + +let wrapper; + +const defaultProvide = { + newProjectIllustration: '/assets/illustrations/project-create-new-sm.svg', +}; + +const createComponent = () => { + wrapper = mountExtended(SharedProjectsEmptyState, { + provide: defaultProvide, + }); +}; + +describe('SharedProjectsEmptyState', () => { + it('renders empty state', () => { + createComponent(); + + expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({ + title: SharedProjectsEmptyState.i18n.title, + svgPath: defaultProvide.newProjectIllustration, + }); + }); +}); diff --git a/spec/frontend/groups/components/empty_state_spec.js b/spec/frontend/groups/components/empty_states/subgroups_and_projects_empty_state_spec.js index fbeaa32b1ec..75edc602fbf 100644 --- a/spec/frontend/groups/components/empty_state_spec.js +++ b/spec/frontend/groups/components/empty_states/subgroups_and_projects_empty_state_spec.js @@ -1,7 +1,7 @@ import { GlEmptyState } from '@gitlab/ui'; import { mountExtended } from 'jest/__helpers__/vue_test_utils_helper'; -import EmptyState from '~/groups/components/empty_state.vue'; +import SubgroupsAndProjectsEmptyState from '~/groups/components/empty_states/subgroups_and_projects_empty_state.vue'; let wrapper; @@ -16,7 +16,7 @@ const defaultProvide = { }; const createComponent = ({ provide = {} } = {}) => { - wrapper = mountExtended(EmptyState, { + wrapper = mountExtended(SubgroupsAndProjectsEmptyState, { provide: { ...defaultProvide, ...provide, @@ -30,18 +30,18 @@ afterEach(() => { const findNewSubgroupLink = () => wrapper.findByRole('link', { - name: new RegExp(EmptyState.i18n.withLinks.subgroup.title), + name: new RegExp(SubgroupsAndProjectsEmptyState.i18n.withLinks.subgroup.title), }); const findNewProjectLink = () => wrapper.findByRole('link', { - name: new RegExp(EmptyState.i18n.withLinks.project.title), + name: new RegExp(SubgroupsAndProjectsEmptyState.i18n.withLinks.project.title), }); const findNewSubgroupIllustration = () => - wrapper.findByRole('img', { name: EmptyState.i18n.withLinks.subgroup.title }); + wrapper.findByRole('img', { name: SubgroupsAndProjectsEmptyState.i18n.withLinks.subgroup.title }); const findNewProjectIllustration = () => - wrapper.findByRole('img', { name: EmptyState.i18n.withLinks.project.title }); + wrapper.findByRole('img', { name: SubgroupsAndProjectsEmptyState.i18n.withLinks.project.title }); -describe('EmptyState', () => { +describe('SubgroupsAndProjectsEmptyState', () => { describe('when user has permission to create a subgroup', () => { it('renders `Create new subgroup` link', () => { createComponent(); @@ -69,8 +69,8 @@ describe('EmptyState', () => { createComponent({ provide: { canCreateSubgroups: false, canCreateProjects: false } }); expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({ - title: EmptyState.i18n.withoutLinks.title, - description: EmptyState.i18n.withoutLinks.description, + title: SubgroupsAndProjectsEmptyState.i18n.withoutLinks.title, + description: SubgroupsAndProjectsEmptyState.i18n.withoutLinks.description, svgPath: defaultProvide.emptySubgroupIllustration, }); }); diff --git a/spec/frontend/groups/components/groups_spec.js b/spec/frontend/groups/components/groups_spec.js index 0cbb6cc8309..cae29a8f15a 100644 --- a/spec/frontend/groups/components/groups_spec.js +++ b/spec/frontend/groups/components/groups_spec.js @@ -16,7 +16,6 @@ describe('GroupsComponent', () => { const defaultPropsData = { groups: mockGroups, pageInfo: mockPageInfo, - searchEmpty: false, }; const createComponent = ({ propsData } = {}) => { @@ -69,14 +68,5 @@ describe('GroupsComponent', () => { expect(findPaginationLinks().exists()).toBe(true); expect(wrapper.findComponent(GlEmptyState).exists()).toBe(false); }); - - it('should render empty search message when `searchEmpty` is `true`', () => { - createComponent({ propsData: { searchEmpty: true } }); - - expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({ - title: GroupsComponent.i18n.emptyStateTitle, - description: GroupsComponent.i18n.emptyStateDescription, - }); - }); }); }); diff --git a/spec/frontend/groups/components/overview_tabs_spec.js b/spec/frontend/groups/components/overview_tabs_spec.js index b9c392243de..d1ae2c4be17 100644 --- a/spec/frontend/groups/components/overview_tabs_spec.js +++ b/spec/frontend/groups/components/overview_tabs_spec.js @@ -5,6 +5,9 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; import OverviewTabs from '~/groups/components/overview_tabs.vue'; import GroupsApp from '~/groups/components/app.vue'; import GroupFolderComponent from '~/groups/components/group_folder.vue'; +import SubgroupsAndProjectsEmptyState from '~/groups/components/empty_states/subgroups_and_projects_empty_state.vue'; +import SharedProjectsEmptyState from '~/groups/components/empty_states/shared_projects_empty_state.vue'; +import ArchivedProjectsEmptyState from '~/groups/components/empty_states/archived_projects_empty_state.vue'; import GroupsStore from '~/groups/store/groups_store'; import GroupsService from '~/groups/service/groups_service'; import { createRouter } from '~/groups/init_overview_tabs'; @@ -16,6 +19,7 @@ import { OVERVIEW_TABS_SORTING_ITEMS, } from '~/groups/constants'; import axios from '~/lib/utils/axios_utils'; +import waitForPromises from 'helpers/wait_for_promises'; Vue.component('GroupFolder', GroupFolderComponent); const router = createRouter(); @@ -68,6 +72,7 @@ describe('OverviewTabs', () => { beforeEach(() => { axiosMock = new AxiosMockAdapter(axios); + axiosMock.onGet({ data: [] }); }); afterEach(() => { @@ -75,7 +80,7 @@ describe('OverviewTabs', () => { axiosMock.restore(); }); - it('renders `Subgroups and projects` tab with `GroupsApp` component', async () => { + it('renders `Subgroups and projects` tab with `GroupsApp` component with correct empty state', async () => { await createComponent(); const tabPanel = findTabPanels().at(0); @@ -89,11 +94,14 @@ describe('OverviewTabs', () => { store: new GroupsStore({ showSchemaMarkup: true }), service: new GroupsService(defaultProvide.endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]), hideProjects: false, - renderEmptyState: true, }); + + await waitForPromises(); + + expect(wrapper.findComponent(SubgroupsAndProjectsEmptyState).exists()).toBe(true); }); - it('renders `Shared projects` tab and renders `GroupsApp` component after clicking tab', async () => { + it('renders `Shared projects` tab and renders `GroupsApp` component with correct empty state after clicking tab', async () => { await createComponent(); const tabPanel = findTabPanels().at(1); @@ -110,13 +118,16 @@ describe('OverviewTabs', () => { store: new GroupsStore(), service: new GroupsService(defaultProvide.endpoints[ACTIVE_TAB_SHARED]), hideProjects: false, - renderEmptyState: false, }); expect(tabPanel.vm.$attrs.lazy).toBe(false); + + await waitForPromises(); + + expect(wrapper.findComponent(SharedProjectsEmptyState).exists()).toBe(true); }); - it('renders `Archived projects` tab and renders `GroupsApp` component after clicking tab', async () => { + it('renders `Archived projects` tab and renders `GroupsApp` component with correct empty state after clicking tab', async () => { await createComponent(); const tabPanel = findTabPanels().at(2); @@ -133,10 +144,13 @@ describe('OverviewTabs', () => { store: new GroupsStore(), service: new GroupsService(defaultProvide.endpoints[ACTIVE_TAB_ARCHIVED]), hideProjects: false, - renderEmptyState: false, }); expect(tabPanel.vm.$attrs.lazy).toBe(false); + + await waitForPromises(); + + expect(wrapper.findComponent(ArchivedProjectsEmptyState).exists()).toBe(true); }); it('sets `lazy` prop to `false` for initially active tab and `true` for all other tabs', async () => { diff --git a/spec/frontend/issuable/components/issuable_header_warnings_spec.js b/spec/frontend/issuable/components/issuable_header_warnings_spec.js index e3a36dc8820..98a81478cb6 100644 --- a/spec/frontend/issuable/components/issuable_header_warnings_spec.js +++ b/spec/frontend/issuable/components/issuable_header_warnings_spec.js @@ -57,6 +57,7 @@ describe('IssuableHeaderWarnings', () => { beforeEach(() => { store.getters.getNoteableData.confidential = confidentialStatus; store.getters.getNoteableData.discussion_locked = lockStatus; + store.getters.getNoteableData.targetType = issuableType; createComponent({ store, provide: { hidden: hiddenStatus } }); }); @@ -84,7 +85,7 @@ describe('IssuableHeaderWarnings', () => { if (hiddenStatus) { expect(hiddenIcon.attributes('title')).toBe( - 'This issue is hidden because its author has been banned', + `This ${issuableType} is hidden because its author has been banned`, ); expect(getBinding(hiddenIcon.element, 'gl-tooltip')).not.toBeUndefined(); } diff --git a/spec/frontend/issues/list/components/issues_list_app_spec.js b/spec/frontend/issues/list/components/issues_list_app_spec.js index d404225333a..4c5d8ce3cd1 100644 --- a/spec/frontend/issues/list/components/issues_list_app_spec.js +++ b/spec/frontend/issues/list/components/issues_list_app_spec.js @@ -535,8 +535,8 @@ describe('CE IssuesListApp component', () => { it('does not render My-Reaction or Confidential tokens', () => { expect(findIssuableList().props('searchTokens')).not.toMatchObject([ - { type: TOKEN_TYPE_AUTHOR, preloadedAuthors: [mockCurrentUser] }, - { type: TOKEN_TYPE_ASSIGNEE, preloadedAuthors: [mockCurrentUser] }, + { type: TOKEN_TYPE_AUTHOR, preloadedUsers: [mockCurrentUser] }, + { type: TOKEN_TYPE_ASSIGNEE, preloadedUsers: [mockCurrentUser] }, { type: TOKEN_TYPE_MY_REACTION }, { type: TOKEN_TYPE_CONFIDENTIAL }, ]); @@ -584,13 +584,13 @@ describe('CE IssuesListApp component', () => { }); it('renders all tokens alphabetically', () => { - const preloadedAuthors = [ + const preloadedUsers = [ { ...mockCurrentUser, id: convertToGraphQLId('User', mockCurrentUser.id) }, ]; expect(findIssuableList().props('searchTokens')).toMatchObject([ - { type: TOKEN_TYPE_ASSIGNEE, preloadedAuthors }, - { type: TOKEN_TYPE_AUTHOR, preloadedAuthors }, + { type: TOKEN_TYPE_ASSIGNEE, preloadedUsers }, + { type: TOKEN_TYPE_AUTHOR, preloadedUsers }, { type: TOKEN_TYPE_CONFIDENTIAL }, { type: TOKEN_TYPE_CONTACT }, { type: TOKEN_TYPE_LABEL }, diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js index dd4d4a0b542..b2f4c780f51 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js @@ -22,7 +22,7 @@ import { TOKEN_TYPE_RELEASE, TOKEN_TYPE_SOURCE_BRANCH, } from '~/vue_shared/components/filtered_search_bar/constants'; -import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue'; import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; @@ -31,7 +31,7 @@ import ReleaseToken from '~/vue_shared/components/filtered_search_bar/tokens/rel import CrmContactToken from '~/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue'; import CrmOrganizationToken from '~/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue'; -export const mockAuthor1 = { +export const mockUser1 = { id: 1, name: 'Administrator', username: 'root', @@ -40,7 +40,7 @@ export const mockAuthor1 = { web_url: 'http://0.0.0.0:3000/root', }; -export const mockAuthor2 = { +export const mockUser2 = { id: 2, name: 'Claudio Beer', username: 'ericka_terry', @@ -49,7 +49,7 @@ export const mockAuthor2 = { web_url: 'http://0.0.0.0:3000/ericka_terry', }; -export const mockAuthor3 = { +export const mockUser3 = { id: 6, name: 'Shizue Hartmann', username: 'junita.weimann', @@ -58,7 +58,7 @@ export const mockAuthor3 = { web_url: 'http://0.0.0.0:3000/junita.weimann', }; -export const mockAuthors = [mockAuthor1, mockAuthor2, mockAuthor3]; +export const mockUsers = [mockUser1, mockUser2, mockUser3]; export const mockBranches = [{ name: 'Main' }, { name: 'v1.x' }, { name: 'my-Branch' }]; @@ -232,10 +232,10 @@ export const mockAuthorToken = { title: TOKEN_TITLE_AUTHOR, unique: false, symbol: '@', - token: AuthorToken, + token: UserToken, operators: OPERATORS_IS, fetchPath: 'gitlab-org/gitlab-test', - fetchAuthors: Api.projectUsers.bind(Api), + fetchUsers: Api.projectUsers.bind(Api), }; export const mockLabelToken = { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js index 69a0dc6469c..32cb74d5f80 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js @@ -12,10 +12,10 @@ import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { OPTIONS_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; -import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; -import { mockAuthorToken, mockAuthors } from '../mock_data'; +import { mockAuthorToken, mockUsers } from '../mock_data'; jest.mock('~/flash'); const defaultStubs = { @@ -28,7 +28,7 @@ const defaultStubs = { }, }; -const mockPreloadedAuthors = [ +const mockPreloadedUsers = [ { id: 13, name: 'Administrator', @@ -46,7 +46,7 @@ function createComponent(options = {}) { data = {}, listeners = {}, } = options; - return mount(AuthorToken, { + return mount(UserToken, { propsData: { config, value, @@ -66,7 +66,7 @@ function createComponent(options = {}) { }); } -describe('AuthorToken', () => { +describe('UserToken', () => { const originalGon = window.gon; const currentUserLength = 1; let mock; @@ -85,40 +85,40 @@ describe('AuthorToken', () => { }); describe('methods', () => { - describe('fetchAuthors', () => { + describe('fetchUsers', () => { beforeEach(() => { wrapper = createComponent(); }); - it('calls `config.fetchAuthors` with provided searchTerm param', () => { - jest.spyOn(wrapper.vm.config, 'fetchAuthors'); + it('calls `config.fetchUsers` with provided searchTerm param', () => { + jest.spyOn(wrapper.vm.config, 'fetchUsers'); - getBaseToken().vm.$emit('fetch-suggestions', mockAuthors[0].username); + getBaseToken().vm.$emit('fetch-suggestions', mockUsers[0].username); - expect(wrapper.vm.config.fetchAuthors).toHaveBeenCalledWith( + expect(wrapper.vm.config.fetchUsers).toHaveBeenCalledWith( mockAuthorToken.fetchPath, - mockAuthors[0].username, + mockUsers[0].username, ); }); - it('sets response to `authors` when request is succesful', () => { - jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockResolvedValue(mockAuthors); + it('sets response to `users` when request is successful', () => { + jest.spyOn(wrapper.vm.config, 'fetchUsers').mockResolvedValue(mockUsers); getBaseToken().vm.$emit('fetch-suggestions', 'root'); return waitForPromises().then(() => { - expect(getBaseToken().props('suggestions')).toEqual(mockAuthors); + expect(getBaseToken().props('suggestions')).toEqual(mockUsers); }); }); // TODO: rm when completed https://gitlab.com/gitlab-org/gitlab/-/issues/345756 describe('when there are null users presents', () => { - const mockAuthorsWithNullUser = mockAuthors.concat([null]); + const mockUsersWithNullUser = mockUsers.concat([null]); beforeEach(() => { jest - .spyOn(wrapper.vm.config, 'fetchAuthors') - .mockResolvedValue({ data: mockAuthorsWithNullUser }); + .spyOn(wrapper.vm.config, 'fetchUsers') + .mockResolvedValue({ data: mockUsersWithNullUser }); getBaseToken().vm.$emit('fetch-suggestions', 'root'); }); @@ -126,7 +126,7 @@ describe('AuthorToken', () => { describe('when res.data is present', () => { it('filters the successful response when null values are present', () => { return waitForPromises().then(() => { - expect(getBaseToken().props('suggestions')).toEqual(mockAuthors); + expect(getBaseToken().props('suggestions')).toEqual(mockUsers); }); }); }); @@ -134,14 +134,14 @@ describe('AuthorToken', () => { describe('when response is an array', () => { it('filters the successful response when null values are present', () => { return waitForPromises().then(() => { - expect(getBaseToken().props('suggestions')).toEqual(mockAuthors); + expect(getBaseToken().props('suggestions')).toEqual(mockUsers); }); }); }); }); it('calls `createAlert` with flash error message when request fails', () => { - jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); + jest.spyOn(wrapper.vm.config, 'fetchUsers').mockRejectedValue({}); getBaseToken().vm.$emit('fetch-suggestions', 'root'); @@ -153,7 +153,7 @@ describe('AuthorToken', () => { }); it('sets `loading` to false when request completes', async () => { - jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); + jest.spyOn(wrapper.vm.config, 'fetchUsers').mockRejectedValue({}); getBaseToken().vm.$emit('fetch-suggestions', 'root'); @@ -174,23 +174,23 @@ describe('AuthorToken', () => { it('renders base-token component', () => { wrapper = createComponent({ - value: { data: mockAuthors[0].username }, - data: { authors: mockAuthors }, + value: { data: mockUsers[0].username }, + data: { users: mockUsers }, }); const baseTokenEl = getBaseToken(); expect(baseTokenEl.exists()).toBe(true); expect(baseTokenEl.props()).toMatchObject({ - suggestions: mockAuthors, - getActiveTokenValue: wrapper.vm.getActiveAuthor, + suggestions: mockUsers, + getActiveTokenValue: wrapper.vm.getActiveUser, }); }); it('renders token item when value is selected', async () => { wrapper = createComponent({ - value: { data: mockAuthors[0].username }, - data: { authors: mockAuthors }, + value: { data: mockUsers[0].username }, + data: { users: mockUsers }, stubs: { Portal: true }, }); @@ -201,20 +201,20 @@ describe('AuthorToken', () => { const tokenValue = tokenSegments.at(2); - expect(tokenValue.findComponent(GlAvatar).props('src')).toBe(mockAuthors[0].avatar_url); - expect(tokenValue.text()).toBe(mockAuthors[0].name); // "Administrator" + expect(tokenValue.findComponent(GlAvatar).props('src')).toBe(mockUsers[0].avatar_url); + expect(tokenValue.text()).toBe(mockUsers[0].name); // "Administrator" }); - it('renders token value with correct avatarUrl from author object', async () => { + it('renders token value with correct avatarUrl from user object', async () => { const getAvatarEl = () => wrapper.findAllComponents(GlFilteredSearchTokenSegment).at(2).findComponent(GlAvatar); wrapper = createComponent({ - value: { data: mockAuthors[0].username }, + value: { data: mockUsers[0].username }, data: { - authors: [ + users: [ { - ...mockAuthors[0], + ...mockUsers[0], }, ], }, @@ -223,15 +223,15 @@ describe('AuthorToken', () => { await nextTick(); - expect(getAvatarEl().props('src')).toBe(mockAuthors[0].avatar_url); + expect(getAvatarEl().props('src')).toBe(mockUsers[0].avatar_url); // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details // eslint-disable-next-line no-restricted-syntax wrapper.setData({ - authors: [ + users: [ { - ...mockAuthors[0], - avatarUrl: mockAuthors[0].avatar_url, + ...mockUsers[0], + avatarUrl: mockUsers[0].avatar_url, avatar_url: undefined, }, ], @@ -239,14 +239,14 @@ describe('AuthorToken', () => { await nextTick(); - expect(getAvatarEl().props('src')).toBe(mockAuthors[0].avatar_url); + expect(getAvatarEl().props('src')).toBe(mockUsers[0].avatar_url); }); - it('renders provided defaultAuthors as suggestions', async () => { - const defaultAuthors = OPTIONS_NONE_ANY; + it('renders provided defaultUsers as suggestions', async () => { + const defaultUsers = OPTIONS_NONE_ANY; wrapper = createComponent({ active: true, - config: { ...mockAuthorToken, defaultAuthors, preloadedAuthors: mockPreloadedAuthors }, + config: { ...mockAuthorToken, defaultUsers, preloadedUsers: mockPreloadedUsers }, stubs: { Portal: true }, }); @@ -254,16 +254,16 @@ describe('AuthorToken', () => { const suggestions = wrapper.findAllComponents(GlFilteredSearchSuggestion); - expect(suggestions).toHaveLength(defaultAuthors.length + currentUserLength); - defaultAuthors.forEach((label, index) => { + expect(suggestions).toHaveLength(defaultUsers.length + currentUserLength); + defaultUsers.forEach((label, index) => { expect(suggestions.at(index).text()).toBe(label.text); }); }); - it('does not render divider when no defaultAuthors', async () => { + it('does not render divider when no defaultUsers', async () => { wrapper = createComponent({ active: true, - config: { ...mockAuthorToken, defaultAuthors: [] }, + config: { ...mockAuthorToken, defaultUsers: [] }, stubs: { Portal: true }, }); const tokenSegments = wrapper.findAllComponents(GlFilteredSearchTokenSegment); @@ -277,7 +277,7 @@ describe('AuthorToken', () => { it('renders `OPTIONS_NONE_ANY` as default suggestions', async () => { wrapper = createComponent({ active: true, - config: { ...mockAuthorToken, preloadedAuthors: mockPreloadedAuthors }, + config: { ...mockAuthorToken, preloadedUsers: mockPreloadedUsers }, stubs: { Portal: true }, }); @@ -308,8 +308,8 @@ describe('AuthorToken', () => { active: true, config: { ...mockAuthorToken, - preloadedAuthors: mockPreloadedAuthors, - defaultAuthors: [], + preloadedUsers: mockPreloadedUsers, + defaultUsers: [], }, stubs: { Portal: true }, }); diff --git a/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js b/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js index 8edcb905096..2b311b75f85 100644 --- a/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js +++ b/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js @@ -1,5 +1,5 @@ import { GlDrawer, GlAlert, GlSkeletonLoader } from '@gitlab/ui'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import MarkdownDrawer, { cache } from '~/vue_shared/components/markdown_drawer/markdown_drawer.vue'; import { getRenderedMarkdown } from '~/vue_shared/components/markdown_drawer/utils/fetch'; @@ -82,7 +82,10 @@ describe('MarkdownDrawer', () => { contentTop.mockClear(); }); - it(`computes offsetTop ${hasNavbar ? 'with' : 'without'} .navbar-gitlab`, () => { + it(`computes offsetTop ${hasNavbar ? 'with' : 'without'} .navbar-gitlab`, async () => { + wrapper.vm.getDrawerTop(); + await Vue.nextTick(); + expect(findDrawer().attributes('headerheight')).toBe(`${navbarHeight}px`); }); }); @@ -95,11 +98,11 @@ describe('MarkdownDrawer', () => { renderGLFMSpy = jest.spyOn(MarkdownDrawer.methods, 'renderGLFM'); fetchMarkdownSpy = jest.spyOn(MarkdownDrawer.methods, 'fetchMarkdown'); global.document.querySelector = jest.fn(() => ({ - getBoundingClientRect: jest.fn(() => ({ bottom: 100 })), dataset: { page: 'test', }, })); + contentTop.mockReturnValue(100); createComponent(); await nextTick(); }); @@ -118,12 +121,28 @@ describe('MarkdownDrawer', () => { expect(fetchMarkdownSpy).toHaveBeenCalledTimes(2); }); - it('for open triggers renderGLFM', async () => { + it('triggers renderGLFM in openDrawer', async () => { wrapper.vm.fetchMarkdown(); wrapper.vm.openDrawer(); await nextTick(); expect(renderGLFMSpy).toHaveBeenCalled(); }); + + it('triggers height calculation in openDrawer', async () => { + expect(findDrawer().attributes('headerheight')).toBe(`${0}px`); + wrapper.vm.fetchMarkdown(); + wrapper.vm.openDrawer(); + await nextTick(); + expect(findDrawer().attributes('headerheight')).toBe(`${100}px`); + }); + + it('triggers height calculation in toggleDrawer', async () => { + expect(findDrawer().attributes('headerheight')).toBe(`${0}px`); + wrapper.vm.fetchMarkdown(); + wrapper.vm.toggleDrawer(); + await nextTick(); + expect(findDrawer().attributes('headerheight')).toBe(`${100}px`); + }); }); describe('Markdown fetching', () => { diff --git a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js index c0cc54d8e2e..86a63db0d9e 100644 --- a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js +++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js @@ -10,7 +10,7 @@ import { TOKEN_TYPE_AUTHOR, } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; import PageWrapper from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue'; import mockItems from './mocks/items.json'; import mockFilters from './mocks/items_filters.json'; @@ -298,10 +298,10 @@ describe('AlertManagementEmptyState', () => { title: TOKEN_TITLE_AUTHOR, unique: true, symbol: '@', - token: AuthorToken, + token: UserToken, operators: OPERATORS_IS, fetchPath: '/link', - fetchAuthors: expect.any(Function), + fetchUsers: expect.any(Function), }, { type: TOKEN_TYPE_ASSIGNEE, @@ -309,10 +309,10 @@ describe('AlertManagementEmptyState', () => { title: TOKEN_TITLE_ASSIGNEE, unique: true, symbol: '@', - token: AuthorToken, + token: UserToken, operators: OPERATORS_IS, fetchPath: '/link', - fetchAuthors: expect.any(Function), + fetchUsers: expect.any(Function), }, ]); expect(Filters().props('recentSearchesStorageKey')).toBe('items'); diff --git a/spec/graphql/resolvers/paginated_tree_resolver_spec.rb b/spec/graphql/resolvers/paginated_tree_resolver_spec.rb index 4b05e9076d7..9a04b716001 100644 --- a/spec/graphql/resolvers/paginated_tree_resolver_spec.rb +++ b/spec/graphql/resolvers/paginated_tree_resolver_spec.rb @@ -66,9 +66,8 @@ RSpec.describe Resolvers::PaginatedTreeResolver do let(:args) { super().merge(after: 'invalid') } it 'generates an error' do - expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError) do - subject - end + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::BaseError) { subject } + expect(subject.extensions.keys).to match_array([:code, :gitaly_code, :service]) end end @@ -92,6 +91,22 @@ RSpec.describe Resolvers::PaginatedTreeResolver do expect(collected_entries).to match_array(expected_entries) end end + + describe 'Custom error handling' do + before do + grpc_err = GRPC::Unavailable.new + allow(repository).to receive(:tree).and_raise(Gitlab::Git::CommandError, grpc_err) + end + + context 'when gitaly is not available' do + let(:request) { get :index, format: :html, params: { namespace_id: project.namespace, project_id: project } } + + it 'generates an unavailable error' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::BaseError) { subject } + expect(subject.extensions).to eq(code: 'unavailable', gitaly_code: 14, service: 'git') + end + end + end end def resolve_repository(args, opts = {}) diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index 15b57a4c9eb..2a61b38337b 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -629,4 +629,66 @@ RSpec.describe IssuablesHelper do expect(helper.sidebar_milestone_tooltip_label(milestone)).to eq('<img onerror=alert(1)><br/>Milestone') end end + + describe '#issuable_hidden?' do + let_it_be(:issuable) { build(:issue) } + + context 'when issuable is hidden' do + let_it_be(:banned_user) { build(:user, :banned) } + let_it_be(:hidden_issuable) { build(:issue, author: banned_user) } + + context 'when `ban_user_feature_flag` feature flag is enabled' do + it 'returns `true`' do + expect(helper.issuable_hidden?(hidden_issuable)).to eq(true) + end + end + + context 'when `ban_user_feature_flag` feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it 'returns `false`' do + expect(helper.issuable_hidden?(hidden_issuable)).to eq(false) + end + end + end + + context 'when issuable is not hidden' do + it 'returns `false`' do + expect(helper.issuable_hidden?(issuable)).to eq(false) + end + end + end + + describe '#hidden_issuable_icon' do + let_it_be(:banned_user) { build(:user, :banned) } + let_it_be(:hidden_issuable) { build(:issue, author: banned_user) } + let_it_be(:issuable) { build(:issue) } + let_it_be(:mock_svg) { '<svg></svg>'.html_safe } + + before do + allow(helper).to receive(:sprite_icon).and_return(mock_svg) + end + + context 'when issuable is hidden' do + it 'returns icon with tooltip' do + expect(helper.hidden_issuable_icon(hidden_issuable)).to eq("<span class=\"has-tooltip\" title=\"This issue is hidden because its author has been banned\">#{mock_svg}</span>") + end + + context 'when issuable is a merge request' do + let_it_be(:hidden_issuable) { build(:merge_request, author: banned_user) } + + it 'returns icon with tooltip' do + expect(helper.hidden_issuable_icon(hidden_issuable)).to eq("<span class=\"has-tooltip\" title=\"This merge request is hidden because its author has been banned\">#{mock_svg}</span>") + end + end + end + + context 'when issuable is not hidden' do + it 'returns `nil`' do + expect(helper.hidden_issuable_icon(issuable)).to be_nil + end + end + end end diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb index ed363268cdf..39e50070169 100644 --- a/spec/helpers/issues_helper_spec.rb +++ b/spec/helpers/issues_helper_spec.rb @@ -508,55 +508,4 @@ RSpec.describe IssuesHelper do end end end - - describe '#issue_hidden?' do - context 'when issue is hidden' do - let_it_be(:banned_user) { build(:user, :banned) } - let_it_be(:hidden_issue) { build(:issue, author: banned_user) } - - context 'when `ban_user_feature_flag` feature flag is enabled' do - it 'returns `true`' do - expect(helper.issue_hidden?(hidden_issue)).to eq(true) - end - end - - context 'when `ban_user_feature_flag` feature flag is disabled' do - before do - stub_feature_flags(ban_user_feature_flag: false) - end - - it 'returns `false`' do - expect(helper.issue_hidden?(hidden_issue)).to eq(false) - end - end - end - - context 'when issue is not hidden' do - it 'returns `false`' do - expect(helper.issue_hidden?(issue)).to eq(false) - end - end - end - - describe '#hidden_issue_icon' do - let_it_be(:banned_user) { build(:user, :banned) } - let_it_be(:hidden_issue) { build(:issue, author: banned_user) } - let_it_be(:mock_svg) { '<svg></svg>'.html_safe } - - before do - allow(helper).to receive(:sprite_icon).and_return(mock_svg) - end - - context 'when issue is hidden' do - it 'returns icon with tooltip' do - expect(helper.hidden_issue_icon(hidden_issue)).to eq("<span class=\"has-tooltip\" title=\"This issue is hidden because its author has been banned\">#{mock_svg}</span>") - end - end - - context 'when issue is not hidden' do - it 'returns `nil`' do - expect(helper.hidden_issue_icon(issue)).to be_nil - end - end - end end diff --git a/spec/helpers/projects/pipeline_helper_spec.rb b/spec/helpers/projects/pipeline_helper_spec.rb index 0d3466d6ed2..35045aaef2a 100644 --- a/spec/helpers/projects/pipeline_helper_spec.rb +++ b/spec/helpers/projects/pipeline_helper_spec.rb @@ -25,6 +25,7 @@ RSpec.describe Projects::PipelineHelper do graphql_resource_etag: graphql_etag_pipeline_path(pipeline), metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: project.namespace, project_id: project, format: :json), pipeline_iid: pipeline.iid, + pipeline_path: pipeline_path(pipeline), pipeline_project_path: project.full_path, total_job_count: pipeline.total_size, summary_endpoint: summary_project_pipeline_tests_path(project, pipeline, format: :json), diff --git a/spec/lib/gitlab/git/base_error_spec.rb b/spec/lib/gitlab/git/base_error_spec.rb index 851cfa16512..d4db7cf2430 100644 --- a/spec/lib/gitlab/git/base_error_spec.rb +++ b/spec/lib/gitlab/git/base_error_spec.rb @@ -20,4 +20,15 @@ RSpec.describe Gitlab::Git::BaseError do with_them do it { is_expected.to eq(result) } end + + describe "When initialized with GRPC errors" do + let(:grpc_error) { GRPC::DeadlineExceeded.new } + let(:git_error) { described_class.new grpc_error } + + it "has status and code fields" do + expect(git_error.service).to eq('git') + expect(git_error.status).to eq(4) + expect(git_error.code).to eq('deadline_exceeded') + end + end end diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index bae92b78a24..684fe9bb9cf 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -649,16 +649,20 @@ RSpec.describe Notify do end context 'the model has no namespace' do - class TopLevelThing - include Referable - include Noteable + before do + stub_const('TopLevelThing', Class.new) - def to_reference(*_args) - 'tlt-ref' - end + TopLevelThing.class_eval do + include Referable + include Noteable - def id - 'tlt-id' + def to_reference(*_args) + 'tlt-ref' + end + + def id + 'tlt-id' + end end end @@ -672,8 +676,10 @@ RSpec.describe Notify do end context 'the model has a namespace' do - module Namespaced - class Thing + before do + stub_const('Namespaced::Thing', Class.new) + + Namespaced::Thing.class_eval do include Referable include Noteable diff --git a/spec/models/concerns/batch_destroy_dependent_associations_spec.rb b/spec/models/concerns/batch_destroy_dependent_associations_spec.rb index 358000ee174..e8d84fe9630 100644 --- a/spec/models/concerns/batch_destroy_dependent_associations_spec.rb +++ b/spec/models/concerns/batch_destroy_dependent_associations_spec.rb @@ -27,6 +27,7 @@ RSpec.describe BatchDestroyDependentAssociations do let_it_be(:build) { create(:ci_build, project: project) } let_it_be(:notification_setting) { create(:notification_setting, project: project) } let_it_be(:note) { create(:note, project: project) } + let_it_be(:merge_request) { create(:merge_request, source_project: project) } it 'destroys multiple notes' do create(:note, project: project) @@ -51,9 +52,10 @@ RSpec.describe BatchDestroyDependentAssociations do end it 'excludes associations' do - project.destroy_dependent_associations_in_batches(exclude: [:notes]) + project.destroy_dependent_associations_in_batches(exclude: [:merge_requests]) - expect(Note.count).to eq(1) + expect(MergeRequest.count).to eq(1) + expect(Note.count).to eq(0) expect(Ci::Build.count).to eq(1) expect(User.count).to be > 0 expect(NotificationSetting.count).to eq(User.count) diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index e553e34ab51..88d44e48064 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -337,6 +337,53 @@ RSpec.describe Issuable do it { expect(MergeRequest.to_ability_name).to eq("merge_request") } end + describe '.without_hidden' do + let_it_be(:banned_user) { create(:user, :banned) } + + where(issuable_type: [:issue, :merge_request]) + + with_them do + let!(:public_issuable) { create(issuable_type, :closed) } + let!(:hidden_issuable) { create(issuable_type, :closed, author: banned_user) } + + subject { issuable_type.to_s.classify.constantize.without_hidden } + + it 'only returns public issuables' do + expect(subject).to contain_exactly(public_issuable) + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it 'returns public and hidden issuables' do + expect(subject).to contain_exactly(public_issuable, hidden_issuable) + end + end + end + end + + describe '#hidden?' do + let_it_be(:author) { create(:user) } + + where(issuable_type: [:issue, :merge_request]) + + with_them do + let(:issuable) { build_stubbed(issuable_type, author: author) } + + subject { issuable.hidden? } + + it { is_expected.to eq(false) } + + context 'when the author is banned' do + let_it_be(:author) { create(:user, :banned) } + + it { is_expected.to eq(true) } + end + end + end + describe "#sort_by_attribute" do let(:project) { create(:project) } diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 82ee062aef0..c8904519629 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -1432,26 +1432,6 @@ RSpec.describe Issue do end end - describe '.without_hidden' do - let_it_be(:banned_user) { create(:user, :banned) } - let_it_be(:public_issue) { create(:issue, project: reusable_project) } - let_it_be(:hidden_issue) { create(:issue, project: reusable_project, author: banned_user) } - - it 'only returns without_hidden issues' do - expect(described_class.without_hidden).to eq([public_issue]) - end - - context 'when feature flag is disabled' do - before do - stub_feature_flags(ban_user_feature_flag: false) - end - - it 'returns public and hidden issues' do - expect(described_class.without_hidden).to contain_exactly(public_issue, hidden_issue) - end - end - end - describe '.by_project_id_and_iid' do let_it_be(:issue_a) { create(:issue, project: reusable_project) } let_it_be(:issue_b) { create(:issue, iid: issue_a.iid) } diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 04edb755b58..f33001b9c5b 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -368,6 +368,33 @@ RSpec.describe Project, factory_default: :keep do subject.ci_pipelines end end + + context 'order of the `has_many :notes` association' do + let(:associations_having_dependent_destroy) do + described_class.reflect_on_all_associations(:has_many).select do |assoc| + assoc.options[:dependent] == :destroy + end + end + + let(:associations_having_dependent_destroy_with_issuable_included) do + associations_having_dependent_destroy.select do |association| + association.klass.include?(Issuable) + end + end + + it 'has `has_many :notes` as the first association among all the other associations that'\ + 'includes the `Issuable` module' do + names_of_associations_having_dependent_destroy = associations_having_dependent_destroy.map(&:name) + index_of_has_many_notes_association = names_of_associations_having_dependent_destroy.find_index(:notes) + + associations_having_dependent_destroy_with_issuable_included.each do |issuable_included_association| + index_of_issuable_included_association = + names_of_associations_having_dependent_destroy.find_index(issuable_included_association.name) + + expect(index_of_has_many_notes_association).to be < index_of_issuable_included_association + end + end + end end describe 'modules' do diff --git a/spec/policies/merge_request_policy_spec.rb b/spec/policies/merge_request_policy_spec.rb index 741a0db3009..6c19590fcce 100644 --- a/spec/policies/merge_request_policy_spec.rb +++ b/spec/policies/merge_request_policy_spec.rb @@ -461,4 +461,20 @@ RSpec.describe MergeRequestPolicy do end end end + + context 'when the author of the merge request is banned' do + let_it_be(:user) { create(:user) } + let_it_be(:admin) { create(:user, :admin) } + let_it_be(:author) { create(:user, :banned) } + let_it_be(:project) { create(:project, :public) } + let_it_be(:hidden_merge_request) { create(:merge_request, source_project: project, author: author) } + + it 'does not allow non-admin user to read the merge_request' do + expect(permissions(user, hidden_merge_request)).not_to be_allowed(:read_merge_request) + end + + it 'allows admin to read the merge_request', :enable_admin_mode do + expect(permissions(admin, hidden_merge_request)).to be_allowed(:read_merge_request) + end + end end diff --git a/spec/requests/projects/merge_requests_controller_spec.rb b/spec/requests/projects/merge_requests_controller_spec.rb index f5f8b5c2d83..4d0e47b7f65 100644 --- a/spec/requests/projects/merge_requests_controller_spec.rb +++ b/spec/requests/projects/merge_requests_controller_spec.rb @@ -8,14 +8,30 @@ RSpec.describe Projects::MergeRequestsController, feature_category: :source_code let_it_be(:user) { merge_request.author } describe 'GET #show' do - before do - login_as(user) + context 'when logged in' do + before do + login_as(user) + end + + it_behaves_like "observability csp policy", described_class do + let(:tested_path) do + project_merge_request_path(project, merge_request) + end + end end - it_behaves_like "observability csp policy", described_class do - let(:tested_path) do - project_merge_request_path(project, merge_request) + context 'when the author of the merge request is banned' do + let_it_be(:user) { create(:user, :banned) } + let_it_be(:project) { create(:project, :public) } + let_it_be(:merge_request) { create(:merge_request, source_project: project, author: user) } + + subject { response } + + before do + get project_merge_request_path(project, merge_request) end + + it { is_expected.to have_gitlab_http_status(:not_found) } end end diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb index 49896d7b6b5..ff2de45661f 100644 --- a/spec/services/projects/destroy_service_spec.rb +++ b/spec/services/projects/destroy_service_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' RSpec.describe Projects::DestroyService, :aggregate_failures, :event_store_publisher do include ProjectForksHelper + include BatchDestroyDependentAssociationsHelper let_it_be(:user) { create(:user) } @@ -548,6 +549,30 @@ RSpec.describe Projects::DestroyService, :aggregate_failures, :event_store_publi end end + context 'associations destoyed in batches' do + let!(:merge_request) { create(:merge_request, source_project: project) } + let!(:issue) { create(:issue, project: project) } + let!(:label) { create(:label, project: project) } + + it 'destroys the associations marked as `dependent: :destroy`, in batches' do + query_recorder = ActiveRecord::QueryRecorder.new do + destroy_project(project, user, {}) + end + + expect(project.merge_requests).to be_empty + expect(project.issues).to be_empty + expect(project.labels).to be_empty + + expected_queries = [ + delete_in_batches_regexps(:merge_requests, :target_project_id, project, [merge_request]), + delete_in_batches_regexps(:issues, :project_id, project, [issue]), + delete_in_batches_regexps(:labels, :project_id, project, [label]) + ].flatten + + expect(query_recorder.log).to include(*expected_queries) + end + end + def destroy_project(project, user, params = {}) described_class.new(project, user, params).public_send(async ? :async_execute : :execute) end diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb index 7d8951bf111..5e65956c03d 100644 --- a/spec/services/projects/update_service_spec.rb +++ b/spec/services/projects/update_service_spec.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true - require 'spec_helper' RSpec.describe Projects::UpdateService do @@ -303,6 +302,25 @@ RSpec.describe Projects::UpdateService do expect(project.default_branch).to eq 'master' expect(project.previous_default_branch).to be_nil end + + context 'when repository has an ambiguous branch named "HEAD"' do + before do + allow(project.repository.raw).to receive(:write_ref).and_return(false) + allow(project.repository).to receive(:branch_names) { %w[fix master main HEAD] } + end + + it 'returns an error to the user' do + result = update_project(project, admin, default_branch: 'fix') + + expect(result).to include(status: :error) + expect(result[:message]).to include("Could not set the default branch. Do you have a branch named 'HEAD' in your repository?") + + project.reload + + expect(project.default_branch).to eq 'master' + expect(project.previous_default_branch).to be_nil + end + end end context 'when we update project but not enabling a wiki' do diff --git a/spec/services/users/migrate_records_to_ghost_user_service_spec.rb b/spec/services/users/migrate_records_to_ghost_user_service_spec.rb index d7b137d5fc8..827d6f652a4 100644 --- a/spec/services/users/migrate_records_to_ghost_user_service_spec.rb +++ b/spec/services/users/migrate_records_to_ghost_user_service_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe Users::MigrateRecordsToGhostUserService do + include BatchDestroyDependentAssociationsHelper + let!(:user) { create(:user) } let(:service) { described_class.new(user, admin, execution_tracker) } let(:execution_tracker) { instance_double(::Gitlab::Utils::ExecutionTracker, over_limit?: false) } @@ -156,12 +158,6 @@ RSpec.describe Users::MigrateRecordsToGhostUserService do def nullify_in_batches_regexp(table, column, user, batch_size: 100) %r{^UPDATE "#{table}" SET "#{column}" = NULL WHERE "#{table}"."id" IN \(SELECT "#{table}"."id" FROM "#{table}" WHERE "#{table}"."#{column}" = #{user.id} LIMIT #{batch_size}\)} end - - def delete_in_batches_regexps(table, column, user, items, batch_size: 1000) - select_query = %r{^SELECT "#{table}".* FROM "#{table}" WHERE "#{table}"."#{column}" = #{user.id}.*ORDER BY "#{table}"."id" ASC LIMIT #{batch_size}} - - [select_query] + items.map { |item| %r{^DELETE FROM "#{table}" WHERE "#{table}"."id" = #{item.id}} } - end # rubocop:enable Layout/LineLength it 'nullifies related associations in batches' do diff --git a/spec/support/helpers/batch_destroy_dependent_associations_helper.rb b/spec/support/helpers/batch_destroy_dependent_associations_helper.rb new file mode 100644 index 00000000000..22170de053b --- /dev/null +++ b/spec/support/helpers/batch_destroy_dependent_associations_helper.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module BatchDestroyDependentAssociationsHelper + private + + def delete_in_batches_regexps(table, column, resource, items, batch_size: 1000) + # rubocop:disable Layout/LineLength + select_query = %r{^SELECT "#{table}".* FROM "#{table}" WHERE.* "#{table}"."#{column}" = #{resource.id}.*ORDER BY "#{table}"."id" ASC LIMIT #{batch_size}} + # rubocop:enable Layout/LineLength + + [select_query] + items.map { |item| %r{^DELETE FROM "#{table}" WHERE "#{table}"."id" = #{item.id}} } + end +end diff --git a/spec/views/projects/pipelines/show.html.haml_spec.rb b/spec/views/projects/pipelines/show.html.haml_spec.rb index 7e300fb1e6e..4b99f74e9c6 100644 --- a/spec/views/projects/pipelines/show.html.haml_spec.rb +++ b/spec/views/projects/pipelines/show.html.haml_spec.rb @@ -13,7 +13,6 @@ RSpec.describe 'projects/pipelines/show' do before do assign(:project, project) assign(:pipeline, presented_pipeline) - stub_feature_flags(pipeline_tabs_vue: false) end context 'when pipeline has errors' do @@ -31,7 +30,7 @@ RSpec.describe 'projects/pipelines/show' do it 'does not render the pipeline tabs' do render - expect(rendered).not_to have_css('ul.pipelines-tabs') + expect(rendered).not_to have_selector('#js-pipeline-tabs') end end @@ -45,7 +44,7 @@ RSpec.describe 'projects/pipelines/show' do it 'renders the pipeline tabs' do render - expect(rendered).to have_css('ul.pipelines-tabs') + expect(rendered).to have_selector('#js-pipeline-tabs') end end end |