diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-10-04 12:11:20 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-10-04 12:11:20 +0300 |
commit | 266aad4e70f3c642583ab60894b27b2622095cd8 (patch) | |
tree | ca0959c1c5bf9a11d0ce2ae6f736504b4a48ebbb | |
parent | 87e82d6f2cc282a2c70535b4a7fb44b5a6dc8bf0 (diff) |
Add latest changes from gitlab-org/gitlab@master
35 files changed, 405 insertions, 314 deletions
diff --git a/app/assets/javascripts/admin/users/components/actions/delete.vue b/app/assets/javascripts/admin/users/components/actions/delete.vue index a0f4a4bf382..e6dde5898e7 100644 --- a/app/assets/javascripts/admin/users/components/actions/delete.vue +++ b/app/assets/javascripts/admin/users/components/actions/delete.vue @@ -14,7 +14,7 @@ export default { type: Object, required: true, }, - oncallSchedules: { + userDeletionObstacles: { type: Array, required: false, default: () => [], @@ -29,7 +29,7 @@ export default { :username="username" :paths="paths" :delete-path="paths.delete" - :oncall-schedules="oncallSchedules" + :user-deletion-obstacles="userDeletionObstacles" > <slot></slot> </shared-delete-action> diff --git a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue index 02fd3efafa1..bd920a91516 100644 --- a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue +++ b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue @@ -14,7 +14,7 @@ export default { type: Object, required: true, }, - oncallSchedules: { + userDeletionObstacles: { type: Array, required: false, default: () => [], @@ -29,7 +29,7 @@ export default { :username="username" :paths="paths" :delete-path="paths.deleteWithContributions" - :oncall-schedules="oncallSchedules" + :user-deletion-obstacles="userDeletionObstacles" > <slot></slot> </shared-delete-action> diff --git a/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue b/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue index a1589c9d46d..c9f29b55dbf 100644 --- a/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue +++ b/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue @@ -22,7 +22,7 @@ export default { type: String, required: true, }, - oncallSchedules: { + userDeletionObstacles: { type: Array, required: true, }, @@ -34,7 +34,7 @@ export default { 'data-delete-user-url': this.deletePath, 'data-gl-modal-action': this.modalType, 'data-username': this.username, - 'data-oncall-schedules': JSON.stringify(this.oncallSchedules), + 'data-user-deletion-obstacles': JSON.stringify(this.userDeletionObstacles), }; }, }, diff --git a/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue index aaf2ff313a0..ed90343777d 100644 --- a/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue +++ b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue @@ -2,7 +2,7 @@ import { GlModal, GlButton, GlFormInput, GlSprintf } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { s__, sprintf } from '~/locale'; -import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; +import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; export default { components: { @@ -10,7 +10,7 @@ export default { GlButton, GlFormInput, GlSprintf, - OncallSchedulesList, + UserDeletionObstaclesList, }, props: { title: { @@ -45,7 +45,7 @@ export default { type: String, required: true, }, - oncallSchedules: { + userDeletionObstacles: { type: String, required: false, default: '[]', @@ -66,9 +66,9 @@ export default { canSubmit() { return this.enteredUsername === this.username; }, - schedules() { + obstacles() { try { - return JSON.parse(this.oncallSchedules); + return JSON.parse(this.userDeletionObstacles); } catch (e) { Sentry.captureException(e); } @@ -112,7 +112,11 @@ export default { </gl-sprintf> </p> - <oncall-schedules-list v-if="schedules.length" :schedules="schedules" :user-name="username" /> + <user-deletion-obstacles-list + v-if="obstacles.length" + :obstacles="obstacles" + :user-name="username" + /> <p> <gl-sprintf :message="s__('AdminUsers|To confirm, type %{username}')"> diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue index c076e0bedf0..4f4e2947341 100644 --- a/app/assets/javascripts/admin/users/components/user_actions.vue +++ b/app/assets/javascripts/admin/users/components/user_actions.vue @@ -9,6 +9,7 @@ import { } from '@gitlab/ui'; import { convertArrayToCamelCase } from '~/lib/utils/common_utils'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; import { I18N_USER_ACTIONS } from '../constants'; import { generateUserPaths } from '../utils'; import Actions from './actions'; @@ -72,6 +73,9 @@ export default { href: this.userPaths.edit, }; }, + obstaclesForUserDeletion() { + return parseUserDeletionObstacles(this.user); + }, }, methods: { isLdapAction(action) { @@ -141,7 +145,7 @@ export default { :key="action" :paths="userPaths" :username="user.name" - :oncall-schedules="user.oncallSchedules" + :user-deletion-obstacles="obstaclesForUserDeletion" :data-testid="`delete-${action}`" > {{ $options.i18n[action] }} diff --git a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue index 665e8ee69f7..69137ce615b 100644 --- a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue @@ -42,7 +42,7 @@ export default { required: false, default: false, }, - oncallSchedules: { + userDeletionObstacles: { type: Object, required: false, default: () => ({}), @@ -61,7 +61,7 @@ export default { memberPath: this.memberPath.replace(':id', this.memberId), memberType: this.memberType, message: this.message, - oncallSchedules: this.oncallSchedules, + userDeletionObstacles: this.userDeletionObstacles, }; }, }, diff --git a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue index 0c20f935d50..44d658c90a0 100644 --- a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue @@ -1,5 +1,6 @@ <script> import { s__, sprintf } from '~/locale'; +import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; import ActionButtonGroup from './action_button_group.vue'; import LeaveButton from './leave_button.vue'; import RemoveMemberButton from './remove_member_button.vue'; @@ -49,9 +50,11 @@ export default { }, ); }, - oncallScheduleUserData() { - const { user: { name, oncallSchedules: schedules } = {} } = this.member; - return { name, schedules }; + userDeletionObstaclesUserData() { + return { + name: this.member.user?.name, + obstacles: parseUserDeletionObstacles(this.member.user), + }; }, }, }; @@ -65,7 +68,7 @@ export default { v-else :member-id="member.id" :member-type="member.type" - :oncall-schedules="oncallScheduleUserData" + :user-deletion-obstacles="userDeletionObstaclesUserData" :message="message" :title="s__('Member|Remove member')" /> diff --git a/app/assets/javascripts/members/components/modals/leave_modal.vue b/app/assets/javascripts/members/components/modals/leave_modal.vue index 44178981136..e39669e17dd 100644 --- a/app/assets/javascripts/members/components/modals/leave_modal.vue +++ b/app/assets/javascripts/members/components/modals/leave_modal.vue @@ -3,7 +3,8 @@ import { GlModal, GlForm, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import { mapState } from 'vuex'; import csrf from '~/lib/utils/csrf'; import { __, s__, sprintf } from '~/locale'; -import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; +import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; +import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; import { LEAVE_MODAL_ID } from '../../constants'; export default { @@ -20,7 +21,7 @@ export default { csrf, modalId: LEAVE_MODAL_ID, modalContent: s__('Members|Are you sure you want to leave "%{source}"?'), - components: { GlModal, GlForm, GlSprintf, OncallSchedulesList }, + components: { GlModal, GlForm, GlSprintf, UserDeletionObstaclesList }, directives: { GlTooltip: GlTooltipDirective, }, @@ -43,11 +44,11 @@ export default { modalTitle() { return sprintf(s__('Members|Leave "%{source}"'), { source: this.member.source.fullName }); }, - schedules() { - return this.member.user?.oncallSchedules; + obstacles() { + return parseUserDeletionObstacles(this.member.user); }, - isPartOfOnCallSchedules() { - return this.schedules?.length; + hasObstaclesToUserDeletion() { + return this.obstacles?.length; }, }, methods: { @@ -74,9 +75,9 @@ export default { </gl-sprintf> </p> - <oncall-schedules-list - v-if="isPartOfOnCallSchedules" - :schedules="schedules" + <user-deletion-obstacles-list + v-if="hasObstaclesToUserDeletion" + :obstacles="obstacles" :is-current-user="true" /> diff --git a/app/assets/javascripts/members/components/modals/remove_member_modal.vue b/app/assets/javascripts/members/components/modals/remove_member_modal.vue index 00b6ebf9a73..b82fb0030ff 100644 --- a/app/assets/javascripts/members/components/modals/remove_member_modal.vue +++ b/app/assets/javascripts/members/components/modals/remove_member_modal.vue @@ -3,7 +3,7 @@ import { GlFormCheckbox, GlModal } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; import csrf from '~/lib/utils/csrf'; import { s__, __ } from '~/locale'; -import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; +import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; export default { actionCancel: { @@ -13,7 +13,7 @@ export default { components: { GlFormCheckbox, GlModal, - OncallSchedulesList, + UserDeletionObstaclesList, }, inject: ['namespace'], computed: { @@ -33,8 +33,8 @@ export default { message(state) { return state[this.namespace].removeMemberModalData.message; }, - oncallSchedules(state) { - return state[this.namespace].removeMemberModalData.oncallSchedules ?? {}; + userDeletionObstacles(state) { + return state[this.namespace].removeMemberModalData.userDeletionObstacles ?? {}; }, removeMemberModalVisible(state) { return state[this.namespace].removeMemberModalVisible; @@ -60,11 +60,11 @@ export default { }, }; }, - showUnassignIssuablesCheckbox() { + hasWorkspaceAccess() { return !this.isAccessRequest && !this.isInvite; }, - isPartOfOncallSchedules() { - return !this.isAccessRequest && this.oncallSchedules.schedules?.length; + hasObstaclesToUserDeletion() { + return this.hasWorkspaceAccess && this.userDeletionObstacles.obstacles?.length; }, }, methods: { @@ -95,10 +95,10 @@ export default { <form ref="form" :action="memberPath" method="post"> <p>{{ message }}</p> - <oncall-schedules-list - v-if="isPartOfOncallSchedules" - :schedules="oncallSchedules.schedules" - :user-name="oncallSchedules.name" + <user-deletion-obstacles-list + v-if="hasObstaclesToUserDeletion" + :obstacles="userDeletionObstacles.obstacles" + :user-name="userDeletionObstacles.name" /> <input ref="method" type="hidden" name="_method" value="delete" /> @@ -106,7 +106,7 @@ export default { <gl-form-checkbox v-if="isGroupMember" name="remove_sub_memberships"> {{ __('Also remove direct user membership from subgroups and projects') }} </gl-form-checkbox> - <gl-form-checkbox v-if="showUnassignIssuablesCheckbox" name="unassign_issuables"> + <gl-form-checkbox v-if="hasWorkspaceAccess" name="unassign_issuables"> {{ __('Also unassign this user from related issues and merge requests') }} </gl-form-checkbox> </form> diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js new file mode 100644 index 00000000000..9700117a3da --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js @@ -0,0 +1,34 @@ +import ProjectListItem from './project_list_item.vue'; + +export default { + component: ProjectListItem, + title: 'vue_shared/components/project_selector/project_list_item', +}; + +const Template = (args, { argTypes }) => ({ + components: { ProjectListItem }, + props: Object.keys(argTypes), + template: '<project-list-item v-bind="$props" />', +}); + +export const Default = Template.bind({}); +Default.args = { + project: { + id: '1', + name: 'MyProject', + name_with_namespace: 'path / to / MyProject', + }, + selected: false, +}; + +export const SelectedProject = Template.bind({}); +SelectedProject.args = { + ...Default.args, + selected: true, +}; + +export const MatchedProject = Template.bind({}); +MatchedProject.args = { + ...Default.args, + matcher: 'proj', +}; diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue index 36d3696ec36..0bd57c84018 100644 --- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlIcon } from '@gitlab/ui'; +import { GlButton, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { isString } from 'lodash'; import highlight from '~/lib/utils/highlight'; import { truncateNamespace } from '~/lib/utils/text_utility'; @@ -8,6 +8,7 @@ import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/def export default { name: 'ProjectListItem', components: { GlIcon, ProjectAvatar, GlButton }, + directives: { SafeHtml }, props: { project: { type: Object, @@ -58,9 +59,9 @@ export default { <span v-if="truncatedNamespace" class="text-secondary">/ </span> </div> <div + v-safe-html="highlightedProjectName" :title="project.name" class="js-project-name text-truncate" - v-html="highlightedProjectName /* eslint-disable-line vue/no-v-html */" ></div> </div> </gl-button> diff --git a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/constants.js b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/constants.js new file mode 100644 index 00000000000..256db2ea1ce --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/constants.js @@ -0,0 +1,5 @@ +// Types of obstacles to user deletion +export const OBSTACLE_TYPES = Object.freeze({ + oncallSchedules: 'ONCALL_SCHEDULE', + escalationPolicies: 'ESCALATION_POLICY', +}); diff --git a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js new file mode 100644 index 00000000000..d2030c14029 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js @@ -0,0 +1,37 @@ +/* eslint-disable @gitlab/require-i18n-strings */ + +import { OBSTACLE_TYPES } from './constants'; +import UserDeletionObstaclesList from './user_deletion_obstacles_list.vue'; + +export default { + component: UserDeletionObstaclesList, + title: 'vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list', +}; + +const Template = (args, { argTypes }) => ({ + components: { UserDeletionObstaclesList }, + props: Object.keys(argTypes), + template: '<user-deletion-obstacles-list v-bind="$props" v-on="$props" />', +}); + +export const Default = Template.bind({}); +Default.args = { + obstacles: [ + { + type: OBSTACLE_TYPES.oncallSchedules, + name: 'APAC', + url: 'https://domain.com/group/main-application/oncall_schedules', + projectName: 'main-application', + projectUrl: 'https://domain.com/group/main-application', + }, + { + type: OBSTACLE_TYPES.escalationPolicies, + name: 'Engineering On-call', + url: 'https://domain.com/group/microservice-backend/escalation_policies', + projectName: 'Microservice Backend', + projectUrl: 'https://domain.com/group/microservice-backend', + }, + ], + userName: 'Thomspon Smith', + isCurrentUser: false, +}; diff --git a/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue index e37a663ace3..1eea660d527 100644 --- a/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue +++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue @@ -1,6 +1,16 @@ <script> import { GlSprintf, GlLink } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; +import { OBSTACLE_TYPES } from './constants'; + +const OBSTACLE_TEXT = { + [OBSTACLE_TYPES.oncallSchedules]: s__( + 'OnCallSchedules|On-call schedule %{obstacle} in Project %{project}', + ), + [OBSTACLE_TYPES.escalationPolicies]: s__( + 'EscalationPolicies|Escalation policy %{obstacle} in Project %{project}', + ), +}; export default { components: { @@ -8,7 +18,7 @@ export default { GlLink, }, props: { - schedules: { + obstacles: { type: Array, required: true, }, @@ -45,6 +55,15 @@ export default { ); }, }, + methods: { + textForObstacle(obstacle) { + return OBSTACLE_TEXT[obstacle.type]; + }, + urlForObstacle(obstacle) { + // Fallback to scheduleUrl for backwards compatibility + return obstacle.url || obstacle.scheduleUrl; + }, + }, }; </script> @@ -52,17 +71,15 @@ export default { <div> <p data-testid="title">{{ title }}</p> - <ul data-testid="schedules-list"> - <li v-for="(schedule, index) in schedules" :key="`${schedule.name}-${index}`"> - <gl-sprintf - :message="s__('OnCallSchedules|On-call schedule %{schedule} in Project %{project}')" - > - <template #schedule> - <gl-link :href="schedule.scheduleUrl" target="_blank">{{ schedule.name }}</gl-link> + <ul data-testid="obstacles-list"> + <li v-for="(obstacle, index) in obstacles" :key="`${obstacle.name}-${index}`"> + <gl-sprintf :message="textForObstacle(obstacle)"> + <template #obstacle> + <gl-link :href="urlForObstacle(obstacle)" target="_blank">{{ obstacle.name }}</gl-link> </template> <template #project> - <gl-link :href="schedule.projectUrl" target="_blank">{{ - schedule.projectName + <gl-link :href="obstacle.projectUrl" target="_blank">{{ + obstacle.projectName }}</gl-link> </template> </gl-sprintf> diff --git a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/utils.js b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/utils.js new file mode 100644 index 00000000000..502302a1ef2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/utils.js @@ -0,0 +1,19 @@ +import { OBSTACLE_TYPES } from './constants'; + +const addTypeToObstacles = (obstacles, type) => { + if (!obstacles) return []; + + return obstacles?.map((obstacle) => ({ type, ...obstacle })); +}; + +// For use with user objects formatted via internal REST API. +// If the removal/deletion of a user could cause critical +// problems, return a single array containing all affected +// associations including their type. +export const parseUserDeletionObstacles = (user) => { + if (!user) return []; + + return Object.keys(OBSTACLE_TYPES).flatMap((type) => { + return addTypeToObstacles(user[type], OBSTACLE_TYPES[type]); + }); +}; diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index dfbdee8e1c8..8f3b5b3b7cc 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -1,5 +1,4 @@ @import './pages/branches'; -@import './pages/ci_projects'; @import './pages/clusters'; @import './pages/commits'; @import './pages/deploy_keys'; diff --git a/app/assets/stylesheets/components/design_management/design_version_dropdown.scss b/app/assets/stylesheets/components/design_management/design_version_dropdown.scss deleted file mode 100644 index f79d672e238..00000000000 --- a/app/assets/stylesheets/components/design_management/design_version_dropdown.scss +++ /dev/null @@ -1,3 +0,0 @@ -.design-version-dropdown > button { - background: inherit; -} diff --git a/app/assets/stylesheets/components/project_list_item.scss b/app/assets/stylesheets/components/project_list_item.scss deleted file mode 100644 index 8e7c2c4398c..00000000000 --- a/app/assets/stylesheets/components/project_list_item.scss +++ /dev/null @@ -1,24 +0,0 @@ -.project-list-item { - &:not(:disabled):not(.disabled) { - &:focus, - &:active, - &:focus:active { - outline: none; - box-shadow: none; - } - } -} - -// When housed inside a modal, the edge of each item -// should extend to the edge of the modal. -.modal-body { - .project-list-item { - border-radius: 0; - margin-left: -$gl-padding; - margin-right: -$gl-padding; - - .project-namespace-name-container { - overflow: hidden; - } - } -} diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 804cc205279..06a8694eb3d 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -9,7 +9,6 @@ @import 'framework/animations'; @import 'framework/vue_transitions'; -@import 'framework/banner'; @import 'framework/blocks'; @import 'framework/buttons'; @import 'framework/badges'; diff --git a/app/assets/stylesheets/framework/banner.scss b/app/assets/stylesheets/framework/banner.scss deleted file mode 100644 index 71bbab2065d..00000000000 --- a/app/assets/stylesheets/framework/banner.scss +++ /dev/null @@ -1,40 +0,0 @@ -.banner-callout { - display: flex; - position: relative; - align-items: start; - - .banner-close { - position: absolute; - top: 10px; - right: 10px; - opacity: 1; - - .dismiss-icon { - color: $gl-text-color; - font-size: $gl-font-size; - } - } - - .banner-graphic { - margin: 0 $gl-padding $gl-padding 0; - } - - &.banner-non-empty-state { - border-bottom: 1px solid $border-color; - } - - @include media-breakpoint-down(xs) { - justify-content: center; - flex-direction: column; - align-items: center; - - .banner-title, - .banner-buttons { - text-align: center; - } - - .banner-graphic { - margin-left: $gl-padding; - } - } -} diff --git a/app/assets/stylesheets/pages/ci_projects.scss b/app/assets/stylesheets/pages/ci_projects.scss deleted file mode 100644 index fbe1f3081a0..00000000000 --- a/app/assets/stylesheets/pages/ci_projects.scss +++ /dev/null @@ -1,54 +0,0 @@ -.ci-body { - .project-title { - margin: 0; - color: $common-gray-dark; - font-size: 20px; - line-height: 1.5; - } - - .builds, - .projects-table { - .light { - border-color: $border-color; - } - - th, - td { - padding: 10px $gl-padding; - } - - td { - color: $gl-text-color; - vertical-align: middle !important; - - a { - font-weight: $gl-font-weight-normal; - text-decoration: none; - } - } - } - - .commit-info { - .attr-name { - margin-right: 5px; - } - - pre.commit-message { - background: none; - padding: 0; - border: 0; - margin: 20px 0; - border-radius: 0; - } - } - - .loading { - font-size: 20px; - } - - .ci-charts { - fieldset { - margin-bottom: 16px; - } - } -} diff --git a/doc/ci/pipelines/multi_project_pipelines.md b/doc/ci/pipelines/multi_project_pipelines.md index d31ddcf736e..8390e85d57b 100644 --- a/doc/ci/pipelines/multi_project_pipelines.md +++ b/doc/ci/pipelines/multi_project_pipelines.md @@ -88,7 +88,7 @@ The keywords available for use in trigger jobs are: - [`only` and `except`](../yaml/index.md#only--except) - [`when`](../yaml/index.md#when) (only with a value of `on_success`, `on_failure`, or `always`) - [`extends`](../yaml/index.md#extends) -- [`needs`](../yaml/index.md#needs) +- [`needs`](../yaml/index.md#needs), but not [cross project artifact downloads with `needs`](../yaml/index.md#cross-project-artifact-downloads-with-needs) #### Specify a downstream pipeline branch diff --git a/doc/ci/yaml/index.md b/doc/ci/yaml/index.md index ffc64c4ef70..39a1d9b035e 100644 --- a/doc/ci/yaml/index.md +++ b/doc/ci/yaml/index.md @@ -1703,6 +1703,8 @@ same group or namespace, you can omit them from the `project:` keyword. For exam The user running the pipeline must have at least `reporter` access to the group or project, or the group/project must have public visibility. +You cannot use cross project artifact downloads in the same job as [`trigger`](#trigger). + ##### Artifact downloads between pipelines in the same project Use `needs` to download artifacts from different pipelines in the current project. diff --git a/locale/gitlab.pot b/locale/gitlab.pot index af7be268904..1189431d298 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -13558,6 +13558,9 @@ msgstr "" msgid "EscalationPolicies|Escalation policies" msgstr "" +msgid "EscalationPolicies|Escalation policy %{obstacle} in Project %{project}" +msgstr "" + msgid "EscalationPolicies|Escalation rules" msgstr "" @@ -23570,7 +23573,7 @@ msgstr "" msgid "OnCallSchedules|For this rotation, on-call will be:" msgstr "" -msgid "OnCallSchedules|On-call schedule %{schedule} in Project %{project}" +msgid "OnCallSchedules|On-call schedule %{obstacle} in Project %{project}" msgstr "" msgid "OnCallSchedules|On-call schedules" diff --git a/spec/frontend/admin/users/components/actions/actions_spec.js b/spec/frontend/admin/users/components/actions/actions_spec.js index fd05b08a3fb..67dcf5c6149 100644 --- a/spec/frontend/admin/users/components/actions/actions_spec.js +++ b/spec/frontend/admin/users/components/actions/actions_spec.js @@ -5,6 +5,7 @@ import { nextTick } from 'vue'; import Actions from '~/admin/users/components/actions'; import SharedDeleteAction from '~/admin/users/components/actions/shared/shared_delete_action.vue'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants'; import { CONFIRMATION_ACTIONS, DELETE_ACTIONS } from '../../constants'; import { paths } from '../../mock_data'; @@ -46,7 +47,10 @@ describe('Action components', () => { }); describe('DELETE_ACTION_COMPONENTS', () => { - const oncallSchedules = [{ name: 'schedule1' }, { name: 'schedule2' }]; + const userDeletionObstacles = [ + { name: 'schedule1', type: OBSTACLE_TYPES.oncallSchedules }, + { name: 'policy1', type: OBSTACLE_TYPES.escalationPolicies }, + ]; it.each(DELETE_ACTIONS.map((action) => [action, paths[action]]))( 'renders a dropdown item for "%s"', @@ -56,7 +60,7 @@ describe('Action components', () => { props: { username: 'John Doe', paths, - oncallSchedules, + userDeletionObstacles, }, stubs: { SharedDeleteAction }, }); @@ -69,8 +73,8 @@ describe('Action components', () => { expect(sharedAction.attributes('data-delete-user-url')).toBe(expectedPath); expect(sharedAction.attributes('data-gl-modal-action')).toBe(kebabCase(action)); expect(sharedAction.attributes('data-username')).toBe('John Doe'); - expect(sharedAction.attributes('data-oncall-schedules')).toBe( - JSON.stringify(oncallSchedules), + expect(sharedAction.attributes('data-user-deletion-obstacles')).toBe( + JSON.stringify(userDeletionObstacles), ); expect(findDropdownItem().exists()).toBe(true); }, diff --git a/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap index 5e367891337..472158a9b10 100644 --- a/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap +++ b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap @@ -8,8 +8,8 @@ exports[`User Operation confirmation modal renders modal with form included 1`] /> </p> - <oncall-schedules-list-stub - schedules="schedule1,schedule2" + <user-deletion-obstacles-list-stub + obstacles="schedule1,policy1" username="username" /> diff --git a/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js index fee74764645..82307c9e3b3 100644 --- a/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js +++ b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js @@ -1,7 +1,7 @@ import { GlButton, GlFormInput } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import DeleteUserModal from '~/admin/users/components/modals/delete_user_modal.vue'; -import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; +import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; import ModalStub from './stubs/modal_stub'; const TEST_DELETE_USER_URL = 'delete-url'; @@ -25,7 +25,7 @@ describe('User Operation confirmation modal', () => { const getUsername = () => findUsernameInput().attributes('value'); const getMethodParam = () => new FormData(findForm().element).get('_method'); const getFormAction = () => findForm().attributes('action'); - const findOnCallSchedulesList = () => wrapper.findComponent(OncallSchedulesList); + const findUserDeletionObstaclesList = () => wrapper.findComponent(UserDeletionObstaclesList); const setUsername = (username) => { findUsernameInput().vm.$emit('input', username); @@ -33,7 +33,7 @@ describe('User Operation confirmation modal', () => { const username = 'username'; const badUsername = 'bad_username'; - const oncallSchedules = '["schedule1", "schedule2"]'; + const userDeletionObstacles = '["schedule1", "policy1"]'; const createComponent = (props = {}) => { wrapper = shallowMount(DeleteUserModal, { @@ -46,7 +46,7 @@ describe('User Operation confirmation modal', () => { deleteUserUrl: TEST_DELETE_USER_URL, blockUserUrl: TEST_BLOCK_USER_URL, csrfToken: TEST_CSRF, - oncallSchedules, + userDeletionObstacles, ...props, }, stubs: { @@ -150,18 +150,18 @@ describe('User Operation confirmation modal', () => { }); }); - describe('Related oncall-schedules list', () => { - it('does NOT render the list when user has no related schedules', () => { - createComponent({ oncallSchedules: '[]' }); - expect(findOnCallSchedulesList().exists()).toBe(false); + describe('Related user-deletion-obstacles list', () => { + it('does NOT render the list when user has no related obstacles', () => { + createComponent({ userDeletionObstacles: '[]' }); + expect(findUserDeletionObstaclesList().exists()).toBe(false); }); - it('renders the list when user has related schedules', () => { + it('renders the list when user has related obstalces', () => { createComponent(); - const schedules = findOnCallSchedulesList(); - expect(schedules.exists()).toBe(true); - expect(schedules.props('schedules')).toEqual(JSON.parse(oncallSchedules)); + const obstacles = findUserDeletionObstaclesList(); + expect(obstacles.exists()).toBe(true); + expect(obstacles.props('obstacles')).toEqual(JSON.parse(userDeletionObstacles)); }); }); }); diff --git a/spec/frontend/members/components/action_buttons/remove_member_button_spec.js b/spec/frontend/members/components/action_buttons/remove_member_button_spec.js index d8453d453e7..7eb0ea37fe6 100644 --- a/spec/frontend/members/components/action_buttons/remove_member_button_spec.js +++ b/spec/frontend/members/components/action_buttons/remove_member_button_spec.js @@ -45,7 +45,7 @@ describe('RemoveMemberButton', () => { title: 'Remove member', isAccessRequest: true, isInvite: true, - oncallSchedules: { name: 'user', schedules: [] }, + userDeletionObstacles: { name: 'user', obstacles: [] }, ...propsData, }, directives: { diff --git a/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js index 0aa3780f030..10e451376c8 100644 --- a/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js +++ b/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js @@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import LeaveButton from '~/members/components/action_buttons/leave_button.vue'; import RemoveMemberButton from '~/members/components/action_buttons/remove_member_button.vue'; import UserActionButtons from '~/members/components/action_buttons/user_action_buttons.vue'; +import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; import { member, orphanedMember } from '../../mock_data'; describe('UserActionButtons', () => { @@ -45,9 +46,9 @@ describe('UserActionButtons', () => { isAccessRequest: false, isInvite: false, icon: 'remove', - oncallSchedules: { + userDeletionObstacles: { name: member.user.name, - schedules: member.user.oncallSchedules, + obstacles: parseUserDeletionObstacles(member.user), }, }); }); diff --git a/spec/frontend/members/components/modals/leave_modal_spec.js b/spec/frontend/members/components/modals/leave_modal_spec.js index 1dc913e5c78..f755f08dbf2 100644 --- a/spec/frontend/members/components/modals/leave_modal_spec.js +++ b/spec/frontend/members/components/modals/leave_modal_spec.js @@ -6,7 +6,8 @@ import { nextTick } from 'vue'; import Vuex from 'vuex'; import LeaveModal from '~/members/components/modals/leave_modal.vue'; import { LEAVE_MODAL_ID, MEMBER_TYPES } from '~/members/constants'; -import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; +import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; +import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; import { member } from '../../mock_data'; jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); @@ -51,7 +52,7 @@ describe('LeaveModal', () => { const findModal = () => wrapper.findComponent(GlModal); const findForm = () => findModal().findComponent(GlForm); - const findOncallSchedulesList = () => findModal().findComponent(OncallSchedulesList); + const findUserDeletionObstaclesList = () => findModal().findComponent(UserDeletionObstaclesList); const getByText = (text, options) => createWrapper(within(findModal().element).getByText(text, options)); @@ -89,25 +90,27 @@ describe('LeaveModal', () => { ); }); - describe('On-call schedules list', () => { - it("displays oncall schedules list when member's user is part of on-call schedules ", () => { - const schedulesList = findOncallSchedulesList(); - expect(schedulesList.exists()).toBe(true); - expect(schedulesList.props()).toMatchObject({ + describe('User deletion obstacles list', () => { + it("displays obstacles list when member's user is part of on-call management", () => { + const obstaclesList = findUserDeletionObstaclesList(); + expect(obstaclesList.exists()).toBe(true); + expect(obstaclesList.props()).toMatchObject({ isCurrentUser: true, - schedules: member.user.oncallSchedules, + obstacles: parseUserDeletionObstacles(member.user), }); }); - it("does NOT display oncall schedules list when member's user is NOT a part of on-call schedules ", async () => { + it("does NOT display obstacles list when member's user is NOT a part of on-call management", async () => { wrapper.destroy(); - const memberWithoutOncallSchedules = cloneDeep(member); - delete memberWithoutOncallSchedules.user.oncallSchedules; - createComponent({ member: memberWithoutOncallSchedules }); + const memberWithoutOncall = cloneDeep(member); + delete memberWithoutOncall.user.oncallSchedules; + delete memberWithoutOncall.user.escalationPolicies; + + createComponent({ member: memberWithoutOncall }); await nextTick(); - expect(findOncallSchedulesList().exists()).toBe(false); + expect(findUserDeletionObstaclesList().exists()).toBe(false); }); }); diff --git a/spec/frontend/members/components/modals/remove_member_modal_spec.js b/spec/frontend/members/components/modals/remove_member_modal_spec.js index 1dc41582c12..1d39c4b3175 100644 --- a/spec/frontend/members/components/modals/remove_member_modal_spec.js +++ b/spec/frontend/members/components/modals/remove_member_modal_spec.js @@ -4,15 +4,19 @@ import Vue from 'vue'; import Vuex from 'vuex'; import RemoveMemberModal from '~/members/components/modals/remove_member_modal.vue'; import { MEMBER_TYPES } from '~/members/constants'; -import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; +import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants'; +import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; Vue.use(Vuex); describe('RemoveMemberModal', () => { const memberPath = '/gitlab-org/gitlab-test/-/project_members/90'; - const mockSchedules = { + const mockObstacles = { name: 'User1', - schedules: [{ id: 1, name: 'Schedule 1' }], + obstacles: [ + { name: 'Schedule 1', type: OBSTACLE_TYPES.oncallSchedules }, + { name: 'Policy 1', type: OBSTACLE_TYPES.escalationPolicies }, + ], }; let wrapper; @@ -44,18 +48,18 @@ describe('RemoveMemberModal', () => { const findForm = () => wrapper.find({ ref: 'form' }); const findGlModal = () => wrapper.findComponent(GlModal); - const findOnCallSchedulesList = () => wrapper.findComponent(OncallSchedulesList); + const findUserDeletionObstaclesList = () => wrapper.findComponent(UserDeletionObstaclesList); afterEach(() => { wrapper.destroy(); }); describe.each` - state | memberType | isAccessRequest | isInvite | actionText | removeSubMembershipsCheckboxExpected | unassignIssuablesCheckboxExpected | message | onCallSchedules - ${'removing a group member'} | ${'GroupMember'} | ${false} | ${false} | ${'Remove member'} | ${true} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${{}} - ${'removing a project member'} | ${'ProjectMember'} | ${false} | ${false} | ${'Remove member'} | ${false} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${mockSchedules} - ${'denying an access request'} | ${'ProjectMember'} | ${true} | ${false} | ${'Deny access request'} | ${false} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} | ${{}} - ${'revoking invite'} | ${'ProjectMember'} | ${false} | ${true} | ${'Revoke invite'} | ${false} | ${false} | ${'Are you sure you want to revoke the invitation for foo@bar.com to join the Gitlab Org / Gitlab Test project?'} | ${mockSchedules} + state | memberType | isAccessRequest | isInvite | actionText | removeSubMembershipsCheckboxExpected | unassignIssuablesCheckboxExpected | message | userDeletionObstacles | isPartOfOncall + ${'removing a group member'} | ${'GroupMember'} | ${false} | ${false} | ${'Remove member'} | ${true} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${{}} | ${false} + ${'removing a project member'} | ${'ProjectMember'} | ${false} | ${false} | ${'Remove member'} | ${false} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${mockObstacles} | ${true} + ${'denying an access request'} | ${'ProjectMember'} | ${true} | ${false} | ${'Deny access request'} | ${false} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} | ${{}} | ${false} + ${'revoking invite'} | ${'ProjectMember'} | ${false} | ${true} | ${'Revoke invite'} | ${false} | ${false} | ${'Are you sure you want to revoke the invitation for foo@bar.com to join the Gitlab Org / Gitlab Test project?'} | ${mockObstacles} | ${false} `( 'when $state', ({ @@ -66,7 +70,8 @@ describe('RemoveMemberModal', () => { message, removeSubMembershipsCheckboxExpected, unassignIssuablesCheckboxExpected, - onCallSchedules, + userDeletionObstacles, + isPartOfOncall, }) => { beforeEach(() => { createComponent({ @@ -75,12 +80,10 @@ describe('RemoveMemberModal', () => { message, memberPath, memberType, - onCallSchedules, + userDeletionObstacles, }); }); - const isPartOfOncallSchedules = Boolean(isAccessRequest && onCallSchedules.schedules?.length); - it(`has the title ${actionText}`, () => { expect(findGlModal().attributes('title')).toBe(actionText); }); @@ -109,8 +112,8 @@ describe('RemoveMemberModal', () => { ); }); - it(`shows ${isPartOfOncallSchedules ? 'all' : 'no'} related on-call schedules`, () => { - expect(findOnCallSchedulesList().exists()).toBe(isPartOfOncallSchedules); + it(`shows ${isPartOfOncall ? 'all' : 'no'} related on-call schedules or policies`, () => { + expect(findUserDeletionObstaclesList().exists()).toBe(isPartOfOncall); }); it('submits the form when the modal is submitted', () => { diff --git a/spec/frontend/members/mock_data.js b/spec/frontend/members/mock_data.js index eb9f905fea2..3afbe57a1aa 100644 --- a/spec/frontend/members/mock_data.js +++ b/spec/frontend/members/mock_data.js @@ -23,6 +23,7 @@ export const member = { blocked: false, twoFactorEnabled: false, oncallSchedules: [{ name: 'schedule 1' }], + escalationPolicies: [{ name: 'policy 1' }], }, id: 238, createdAt: '2020-07-17T16:22:46.923Z', @@ -63,7 +64,7 @@ export const modalData = { memberPath: '/groups/foo-bar/-/group_members/1', memberType: 'GroupMember', message: 'Are you sure you want to remove John Smith?', - oncallSchedules: { name: 'user', schedules: [] }, + userDeletionObstacles: { name: 'user', obstacles: [] }, }; const { user, ...memberNoUser } = member; diff --git a/spec/frontend/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list_spec.js b/spec/frontend/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list_spec.js new file mode 100644 index 00000000000..a92f058f311 --- /dev/null +++ b/spec/frontend/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list_spec.js @@ -0,0 +1,116 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants'; +import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; + +const mockSchedules = [ + { + type: OBSTACLE_TYPES.oncallSchedules, + name: 'Schedule 1', + url: 'http://gitlab.com/gitlab-org/gitlab-shell/-/oncall_schedules', + projectName: 'Shell', + projectUrl: 'http://gitlab.com/gitlab-org/gitlab-shell/', + }, + { + type: OBSTACLE_TYPES.oncallSchedules, + name: 'Schedule 2', + url: 'http://gitlab.com/gitlab-org/gitlab-ui/-/oncall_schedules', + projectName: 'UI', + projectUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/', + }, +]; +const mockPolicies = [ + { + type: OBSTACLE_TYPES.escalationPolicies, + name: 'Policy 1', + url: 'http://gitlab.com/gitlab-org/gitlab-ui/-/escalation-policies', + projectName: 'UI', + projectUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/', + }, +]; +const mockObstacles = mockSchedules.concat(mockPolicies); + +const userName = "O'User"; + +describe('User deletion obstacles list', () => { + let wrapper; + + function createComponent(props) { + wrapper = extendedWrapper( + shallowMount(UserDeletionObstaclesList, { + propsData: { + obstacles: mockObstacles, + userName, + ...props, + }, + stubs: { + GlSprintf, + }, + }), + ); + } + + afterEach(() => { + wrapper.destroy(); + }); + + const findLinks = () => wrapper.findAllComponents(GlLink); + const findTitle = () => wrapper.findByTestId('title'); + const findFooter = () => wrapper.findByTestId('footer'); + const findObstacles = () => wrapper.findByTestId('obstacles-list'); + + describe.each` + isCurrentUser | titleText | footerText + ${true} | ${'You are currently a part of:'} | ${'Removing yourself may put your on-call team at risk of missing a notification.'} + ${false} | ${`User ${userName} is currently part of:`} | ${'Removing this user may put their on-call team at risk of missing a notification.'} + `('when current user', ({ isCurrentUser, titleText, footerText }) => { + it(`${isCurrentUser ? 'is' : 'is not'} a part of on-call management`, async () => { + createComponent({ + isCurrentUser, + }); + + expect(findTitle().text()).toBe(titleText); + expect(findFooter().text()).toBe(footerText); + }); + }); + + describe.each(mockObstacles)( + 'renders all obstacles', + ({ type, name, url, projectName, projectUrl }) => { + it(`includes the project name and link for ${name}`, () => { + createComponent({ obstacles: [{ type, name, url, projectName, projectUrl }] }); + const msg = findObstacles().text(); + + expect(msg).toContain(`in Project ${projectName}`); + expect(findLinks().at(1).attributes('href')).toBe(projectUrl); + }); + }, + ); + + describe.each(mockSchedules)( + 'renders on-call schedules', + ({ type, name, url, projectName, projectUrl }) => { + it(`includes the schedule name and link for ${name}`, () => { + createComponent({ obstacles: [{ type, name, url, projectName, projectUrl }] }); + const msg = findObstacles().text(); + + expect(msg).toContain(`On-call schedule ${name}`); + expect(findLinks().at(0).attributes('href')).toBe(url); + }); + }, + ); + + describe.each(mockPolicies)( + 'renders escalation policies', + ({ type, name, url, projectName, projectUrl }) => { + it(`includes the policy name and link for ${name}`, () => { + createComponent({ obstacles: [{ type, name, url, projectName, projectUrl }] }); + const msg = findObstacles().text(); + + expect(msg).toContain(`Escalation policy ${name}`); + expect(findLinks().at(0).attributes('href')).toBe(url); + }); + }, + ); +}); diff --git a/spec/frontend/vue_shared/components/user_deletion_obstacles/utils_spec.js b/spec/frontend/vue_shared/components/user_deletion_obstacles/utils_spec.js new file mode 100644 index 00000000000..99f739098f7 --- /dev/null +++ b/spec/frontend/vue_shared/components/user_deletion_obstacles/utils_spec.js @@ -0,0 +1,43 @@ +import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants'; +import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; + +describe('parseUserDeletionObstacles', () => { + const mockObstacles = [{ name: 'Obstacle' }]; + const expectedSchedule = { name: 'Obstacle', type: OBSTACLE_TYPES.oncallSchedules }; + const expectedPolicy = { name: 'Obstacle', type: OBSTACLE_TYPES.escalationPolicies }; + + it('is undefined when user is not available', () => { + expect(parseUserDeletionObstacles()).toHaveLength(0); + }); + + it('is empty when obstacles are not available for user', () => { + expect(parseUserDeletionObstacles({})).toHaveLength(0); + }); + + it('is empty when user has no obstacles to deletion', () => { + const input = { oncallSchedules: [], escalationPolicies: [] }; + + expect(parseUserDeletionObstacles(input)).toHaveLength(0); + }); + + it('returns obstacles with type when user is part of on-call schedules', () => { + const input = { oncallSchedules: mockObstacles, escalationPolicies: [] }; + const expectedOutput = [expectedSchedule]; + + expect(parseUserDeletionObstacles(input)).toEqual(expectedOutput); + }); + + it('returns obstacles with type when user is part of escalation policies', () => { + const input = { oncallSchedules: [], escalationPolicies: mockObstacles }; + const expectedOutput = [expectedPolicy]; + + expect(parseUserDeletionObstacles(input)).toEqual(expectedOutput); + }); + + it('returns obstacles with type when user have every obstacle type', () => { + const input = { oncallSchedules: mockObstacles, escalationPolicies: mockObstacles }; + const expectedOutput = [expectedSchedule, expectedPolicy]; + + expect(parseUserDeletionObstacles(input)).toEqual(expectedOutput); + }); +}); diff --git a/spec/frontend/vue_shared/oncall_schedules_list_spec.js b/spec/frontend/vue_shared/oncall_schedules_list_spec.js deleted file mode 100644 index f83a5187b8b..00000000000 --- a/spec/frontend/vue_shared/oncall_schedules_list_spec.js +++ /dev/null @@ -1,87 +0,0 @@ -import { GlLink, GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; - -const mockSchedules = [ - { - name: 'Schedule 1', - scheduleUrl: 'http://gitlab.com/gitlab-org/gitlab-shell/-/oncall_schedules', - projectName: 'Shell', - projectUrl: 'http://gitlab.com/gitlab-org/gitlab-shell/', - }, - { - name: 'Schedule 2', - scheduleUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/-/oncall_schedules', - projectName: 'UI', - projectUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/', - }, -]; - -const userName = "O'User"; - -describe('On-call schedules list', () => { - let wrapper; - - function createComponent(props) { - wrapper = extendedWrapper( - shallowMount(OncallSchedulesList, { - propsData: { - schedules: mockSchedules, - userName, - ...props, - }, - stubs: { - GlSprintf, - }, - }), - ); - } - - afterEach(() => { - wrapper.destroy(); - }); - - const findLinks = () => wrapper.findAllComponents(GlLink); - const findTitle = () => wrapper.findByTestId('title'); - const findFooter = () => wrapper.findByTestId('footer'); - const findSchedules = () => wrapper.findByTestId('schedules-list'); - - describe.each` - isCurrentUser | titleText | footerText - ${true} | ${'You are currently a part of:'} | ${'Removing yourself may put your on-call team at risk of missing a notification.'} - ${false} | ${`User ${userName} is currently part of:`} | ${'Removing this user may put their on-call team at risk of missing a notification.'} - `('when current user ', ({ isCurrentUser, titleText, footerText }) => { - it(`${isCurrentUser ? 'is' : 'is not'} a part of on-call schedule`, async () => { - createComponent({ - isCurrentUser, - }); - - expect(findTitle().text()).toBe(titleText); - expect(findFooter().text()).toBe(footerText); - }); - }); - - describe.each(mockSchedules)( - 'renders each on-call schedule data', - ({ name, scheduleUrl, projectName, projectUrl }) => { - beforeEach(() => { - createComponent({ schedules: [{ name, scheduleUrl, projectName, projectUrl }] }); - }); - - it(`renders schedule ${name}'s name and link`, () => { - const msg = findSchedules().text(); - - expect(msg).toContain(`On-call schedule ${name}`); - expect(findLinks().at(0).attributes('href')).toBe(scheduleUrl); - }); - - it(`renders project ${projectName}'s name and link`, () => { - const msg = findSchedules().text(); - - expect(msg).toContain(`in Project ${projectName}`); - expect(findLinks().at(1).attributes('href')).toBe(projectUrl); - }); - }, - ); -}); |