diff --git a/app/assets/javascripts/api/groups_api.js b/app/assets/javascripts/api/groups_api.js index abf23b9d2ec93df17a3a86cd9f7a8ad202f16f70..a147cb62649c9df5b60626709200f31cc1ad0938 100644 --- a/app/assets/javascripts/api/groups_api.js +++ b/app/assets/javascripts/api/groups_api.js @@ -61,11 +61,11 @@ export const getGroupTransferLocations = (groupId, params = {}) => { return axios.get(url, { params: { ...defaultParams, ...params } }); }; -export const getGroupMembers = (groupId, inherited = false) => { +export const getGroupMembers = (groupId, inherited = false, params = {}) => { const path = inherited ? GROUP_ALL_MEMBERS_PATH : GROUP_MEMBERS_PATH; const url = buildApiUrl(path).replace(':id', groupId); - return axios.get(url); + return axios.get(url, { params }); }; export const createGroup = (params) => { diff --git a/app/assets/javascripts/groups/settings/api/access_dropdown_api.js b/app/assets/javascripts/groups/settings/api/access_dropdown_api.js index 5b43096325247e244a30ab6d12855378c40b626c..14aeb992f8722450640de416664905b1fae2ee7f 100644 --- a/app/assets/javascripts/groups/settings/api/access_dropdown_api.js +++ b/app/assets/javascripts/groups/settings/api/access_dropdown_api.js @@ -1,5 +1,6 @@ import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; +import { getGroupMembers } from '~/rest_api'; const GROUP_SUBGROUPS_PATH = '/-/autocomplete/group_subgroups.json'; @@ -25,3 +26,10 @@ export const getSubGroups = (options = defaultOptions) => { }, }); }; + +export const getUsers = (query) => { + return getGroupMembers(gon.current_group_id, false, { + query, + per_page: 20, + }); +}; diff --git a/app/assets/javascripts/groups/settings/components/access_dropdown.vue b/app/assets/javascripts/groups/settings/components/access_dropdown.vue index e47092b896bfa487b0b2c35f195e86185aa6af8c..56178debee541d80150cfd4495ce5e3224e0a51f 100644 --- a/app/assets/javascripts/groups/settings/components/access_dropdown.vue +++ b/app/assets/javascripts/groups/settings/components/access_dropdown.vue @@ -1,14 +1,22 @@ <script> -import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui'; +import { + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlDropdownDivider, + GlSearchBoxByType, +} from '@gitlab/ui'; import { debounce, intersectionWith, groupBy, differenceBy, intersectionBy } from 'lodash'; import { createAlert } from '~/alert'; import { __, s__, n__ } from '~/locale'; -import { getSubGroups } from '../api/access_dropdown_api'; +import { getSubGroups, getUsers } from '../api/access_dropdown_api'; import { LEVEL_TYPES } from '../constants'; export const i18n = { selectUsers: s__('ProtectedEnvironment|Select groups'), + rolesSectionHeader: s__('AccessDropdown|Roles'), groupsSectionHeader: s__('AccessDropdown|Groups'), + usersSectionHeader: s__('AccessDropdown|Users'), }; export default { @@ -17,9 +25,15 @@ export default { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, + GlDropdownDivider, GlSearchBoxByType, }, props: { + accessLevelsData: { + type: Array, + required: false, + default: () => [], + }, hasLicense: { required: false, type: Boolean, @@ -40,15 +54,29 @@ export default { required: false, default: () => [], }, + items: { + type: Array, + required: false, + default: () => [], + }, + showUsers: { + required: false, + type: Boolean, + default: false, + }, }, data() { return { loading: false, initialLoading: false, query: '', + roles: [], groups: [], + users: [], selected: { + [LEVEL_TYPES.ROLE]: [], [LEVEL_TYPES.GROUP]: [], + [LEVEL_TYPES.USER]: [], }, }; }, @@ -61,30 +89,73 @@ export default { Object.entries(this.selected).map(([key, value]) => [key, value.length]), ); + const isOnlyRoleSelected = + counts[LEVEL_TYPES.ROLE] === 1 && + [counts[LEVEL_TYPES.GROUP], counts[LEVEL_TYPES.USER]].every((count) => count === 0); + + if (isOnlyRoleSelected) { + return this.selected[LEVEL_TYPES.ROLE][0].text; + } + const labelPieces = []; + if (counts[LEVEL_TYPES.ROLE] > 0) { + labelPieces.push(n__('1 role', '%d roles', counts[LEVEL_TYPES.ROLE])); + } + if (counts[LEVEL_TYPES.GROUP] > 0) { labelPieces.push(n__('1 group', '%d groups', counts[LEVEL_TYPES.GROUP])); } + if (counts[LEVEL_TYPES.USER] > 0) { + labelPieces.push(n__('1 user', '%d users', counts[LEVEL_TYPES.USER])); + } + return labelPieces.join(', ') || this.label; }, toggleClass() { return this.toggleLabel === this.label ? 'gl-text-gray-500!' : ''; }, selection() { - return [...this.getDataForSave(LEVEL_TYPES.GROUP, 'group_id')]; + return [ + ...this.getDataForSave(LEVEL_TYPES.ROLE, 'access_level'), + ...this.getDataForSave(LEVEL_TYPES.GROUP, 'group_id'), + ...this.getDataForSave(LEVEL_TYPES.USER, 'user_id'), + ]; }, }, watch: { query: debounce(function debouncedSearch() { return this.getData(); }, 500), + items(items) { + this.setDataForSave(items); + }, }, created() { this.getData({ initial: true }); }, methods: { + setDataForSave(items) { + this.selected = items.reduce( + (selected, item) => { + if (item.group_id) { + selected[LEVEL_TYPES.GROUP].push({ id: item.group_id, ...item }); + } else if (item.user_id) { + selected[LEVEL_TYPES.USER].push({ id: item.user_id, ...item }); + } else if (item.access_level) { + const level = this.accessLevelsData.find(({ id }) => item.access_level === id); + selected[LEVEL_TYPES.ROLE].push(level); + } + return selected; + }, + { + [LEVEL_TYPES.GROUP]: [], + [LEVEL_TYPES.USER]: [], + [LEVEL_TYPES.ROLE]: [], + }, + ); + }, focusInput() { this.$refs.search.focusInput(); }, @@ -99,25 +170,44 @@ export default { includeParentSharedGroups: true, search: this.query, }), + this.showUsers ? getUsers(this.query) : Promise.resolve({ data: this.users }), ]) - .then(([groupsResponse]) => { - this.consolidateData(groupsResponse.data); + .then(([groupsResponse, usersResponse]) => { + this.consolidateData(groupsResponse.data, usersResponse.data); this.setSelected({ initial }); }) - .catch(() => createAlert({ message: __('Failed to load groups.') })) + .catch(() => createAlert({ message: __('Failed to load groups and users.') })) .finally(() => { this.initialLoading = false; this.loading = false; }); } }, - consolidateData(groupsResponse = []) { + consolidateData(groupsResponse = [], usersResponse = []) { + this.roles = this.accessLevelsData.map((role) => ({ ...role, type: LEVEL_TYPES.ROLE })); + if (this.hasLicense) { this.groups = groupsResponse.map((group) => ({ ...group, type: LEVEL_TYPES.GROUP })); + this.users = usersResponse.map(({ id, name, username, avatar_url }) => ({ + id, + name, + username, + avatar_url, + type: LEVEL_TYPES.USER, + })); } }, setSelected({ initial } = {}) { if (initial) { + const selectedRoles = intersectionWith( + this.roles, + this.preselectedItems, + (role, selected) => { + return selected.type === LEVEL_TYPES.ROLE && role.id === selected.access_level; + }, + ); + this.selected[LEVEL_TYPES.ROLE] = selectedRoles; + const selectedGroups = intersectionWith( this.groups, this.preselectedItems, @@ -126,6 +216,23 @@ export default { }, ); this.selected[LEVEL_TYPES.GROUP] = selectedGroups; + + const selectedUsers = this.preselectedItems + .filter(({ type }) => type === LEVEL_TYPES.USER) + .map(({ user_id: id, name, username, avatar_url, type }) => ({ + id, + name, + username, + avatar_url, + type, + })); + + this.selected[LEVEL_TYPES.USER] = selectedUsers; + + this.users = this.users.filter( + (user) => !this.selected[LEVEL_TYPES.USER].some((selected) => selected.id === user.id), + ); + this.users.unshift(...this.selected[LEVEL_TYPES.USER]); } }, getDataForSave(accessType, key) { @@ -144,14 +251,16 @@ export default { return [...added, ...removed, ...preserved]; }, onItemClick(item) { - this.toggleSelection(this.selected[item.type], item); + this.toggleSelection(item); this.emitUpdate(); }, - toggleSelection(arr, item) { - const itemIndex = arr.findIndex(({ id }) => id === item.id); - if (itemIndex > -1) { - arr.splice(itemIndex, 1); - } else arr.push(item); + toggleSelection(item) { + const itemSelected = this.isSelected(item); + if (itemSelected) { + this.selected[item.type] = this.selected[item.type].filter(({ id }) => id !== item.id); + return; + } + this.selected[item.type].push(item); }, isSelected(item) { return this.selected[item.type].some((selected) => selected.id === item.id); @@ -180,6 +289,23 @@ export default { <gl-search-box-by-type ref="search" v-model.trim="query" :is-loading="loading" /> </template> <div> + <template v-if="roles.length"> + <gl-dropdown-section-header>{{ + $options.i18n.rolesSectionHeader + }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="role in roles" + :key="`${role.id}${role.text}`" + data-testid="role-dropdown-item" + is-check-item + :is-checked="isSelected(role)" + @click.native.capture.stop="onItemClick(role)" + > + {{ role.text }} + </gl-dropdown-item> + <gl-dropdown-divider v-if="groups.length || users.length" /> + </template> + <template v-if="groups.length"> <gl-dropdown-section-header>{{ $options.i18n.groupsSectionHeader @@ -194,6 +320,25 @@ export default { > {{ group.name }} </gl-dropdown-item> + <gl-dropdown-divider v-if="users.length" /> + </template> + + <template v-if="users.length"> + <gl-dropdown-section-header>{{ + $options.i18n.usersSectionHeader + }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="user in users" + :key="`${user.id}${user.username}`" + data-testid="user-dropdown-item" + :avatar-url="user.avatar_url" + :secondary-text="user.username" + is-check-item + :is-checked="isSelected(user)" + @click.native.capture.stop="onItemClick(user)" + > + {{ user.name }} + </gl-dropdown-item> </template> </div> </gl-dropdown> diff --git a/app/assets/javascripts/groups/settings/constants.js b/app/assets/javascripts/groups/settings/constants.js index 023ddf29b36122fbafc3f05d0c6912c2634c5c5e..7af699c9ca9acf60000c6a3fc92aca11ac7e1e84 100644 --- a/app/assets/javascripts/groups/settings/constants.js +++ b/app/assets/javascripts/groups/settings/constants.js @@ -1,6 +1,4 @@ -export const LEVEL_TYPES = { - GROUP: 'group', -}; +export const LEVEL_TYPES = { ROLE: 'role', GROUP: 'group', USER: 'user' }; export const README_MODAL_ID = 'add_group_readme_modal'; export const GITLAB_README_PROJECT = 'gitlab-profile'; diff --git a/ee/app/assets/javascripts/api.js b/ee/app/assets/javascripts/api.js index 3cb29b74424ffb335e4d3afc1eec4ebbbd028cb8..32118246c0aebe6c54a23a4b82dd70d57a9085ea 100644 --- a/ee/app/assets/javascripts/api.js +++ b/ee/app/assets/javascripts/api.js @@ -33,10 +33,10 @@ export default { issueMetricSingleImagePath: '/api/:version/projects/:id/issues/:issue_iid/metric_images/:image_id', environmentApprovalPath: '/api/:version/projects/:id/deployments/:deployment_id/approval', - protectedEnvironmentsPath: '/api/:version/projects/:id/protected_environments/', + protectedEnvironmentsPath: '/api/:version/:entity_type/:id/protected_environments/', mrStatusCheckRetryPath: '/api/:version/projects/:id/merge_requests/:merge_request_iid/status_checks/:external_status_check_id/retry', - protectedEnvironmentPath: '/api/:version/projects/:id/protected_environments/:name', + protectedEnvironmentPath: '/api/:version/:entity_type/:id/protected_environments/:name', aiCompletionsPath: '/api/:version/ai/experimentation/openai/completions', aiEmbeddingsPath: '/api/:version/ai/experimentation/openai/embeddings', aiChatPath: '/api/:version/ai/experimentation/openai/chat/completions', @@ -272,26 +272,32 @@ export default { return this.deploymentApproval({ id, deploymentId, approve: false, representedAs, comment }); }, - protectedEnvironments(id, params = {}) { - const url = Api.buildUrl(this.protectedEnvironmentsPath).replace(':id', encodeURIComponent(id)); + protectedEnvironments(id, entityType, params = {}) { + const url = Api.buildUrl(this.protectedEnvironmentsPath) + .replace(':entity_type', encodeURIComponent(entityType)) + .replace(':id', encodeURIComponent(id)); return axios.get(url, { params }); }, - createProtectedEnvironment(id, protectedEnvironment) { - const url = Api.buildUrl(this.protectedEnvironmentsPath).replace(':id', encodeURIComponent(id)); + createProtectedEnvironment(id, entityType, protectedEnvironment) { + const url = Api.buildUrl(this.protectedEnvironmentsPath) + .replace(':entity_type', encodeURIComponent(entityType)) + .replace(':id', encodeURIComponent(id)); return axios.post(url, protectedEnvironment); }, - updateProtectedEnvironment(id, protectedEnvironment) { + updateProtectedEnvironment(id, entityType, protectedEnvironment) { const url = Api.buildUrl(this.protectedEnvironmentPath) + .replace(':entity_type', encodeURIComponent(entityType)) .replace(':id', encodeURIComponent(id)) .replace(':name', encodeURIComponent(protectedEnvironment.name)); return axios.put(url, protectedEnvironment); }, - deleteProtectedEnvironment(id, { name }) { + deleteProtectedEnvironment(id, entityType, { name }) { const url = Api.buildUrl(this.protectedEnvironmentPath) + .replace(':entity_type', encodeURIComponent(entityType)) .replace(':id', encodeURIComponent(id)) .replace(':name', encodeURIComponent(name)); diff --git a/ee/app/assets/javascripts/protected_environments/add_approvers.vue b/ee/app/assets/javascripts/protected_environments/add_approvers.vue index bd9703dade28781af5717fa9a86b4c51a0a2636a..18ed94648a3042149881bf38c4151b307182ef45 100644 --- a/ee/app/assets/javascripts/protected_environments/add_approvers.vue +++ b/ee/app/assets/javascripts/protected_environments/add_approvers.vue @@ -17,6 +17,7 @@ import Api from 'ee/api'; import { getUser } from '~/rest_api'; import { s__ } from '~/locale'; import AccessDropdown from '~/projects/settings/components/access_dropdown.vue'; +import GroupsAccessDropdown from '~/groups/settings/components/access_dropdown.vue'; import { ACCESS_LEVELS, INHERITED_GROUPS, NON_INHERITED_GROUPS } from './constants'; const mapUserToApprover = (user) => ({ @@ -66,12 +67,14 @@ export default { GlSprintf, GlToggle, AccessDropdown, + GroupsAccessDropdown, }, directives: { GlTooltip }, inject: { accessLevelsData: { default: [] }, apiLink: {}, docsLink: {}, + entityType: { default: 'projects' }, }, props: { disabled: { @@ -96,6 +99,12 @@ export default { hasSelectedApprovers() { return Boolean(this.approvers.length); }, + approverHelpText() { + return this.$options.i18n.approverHelp[this.entityType]; + }, + isProjectType() { + return this.entityType === 'projects'; + }, }, watch: { async approvers() { @@ -196,9 +205,14 @@ export default { }, i18n: { approverLabel: s__('ProtectedEnvironment|Approvers'), - approverHelp: s__( - 'ProtectedEnvironments|Set which groups, access levels, or users are required to approve. Groups and users must be members of the project.', - ), + approverHelp: { + projects: s__( + 'ProtectedEnvironments|Set which groups, access levels, or users are required to approve. Groups and users must be members of the project.', + ), + groups: s__( + 'ProtectedEnvironments|Set which groups, access levels, or users are required to approve in this environment tier.', + ), + }, approvalRulesLabel: s__('ProtectedEnvironments|Approval rules'), approvalsInvalid: s__('ProtectedEnvironments|Number of approvals must be between 1 and 5'), removeApprover: s__('ProtectedEnvironments|Remove approval rule'), @@ -221,9 +235,10 @@ export default { :label="$options.i18n.approverLabel" > <template #label-description> - {{ $options.i18n.approverHelp }} + {{ approverHelpText }} </template> <access-dropdown + v-if="isProjectType" id="create-approver-dropdown" :label="$options.i18n.accessDropdownLabel" :access-levels-data="accessLevelsData" @@ -232,6 +247,16 @@ export default { :items="approvers" @select="updateApprovers" /> + <groups-access-dropdown + v-else + id="create-approver-dropdown" + :label="$options.i18n.accessDropdownLabel" + :access-levels-data="accessLevelsData" + :disabled="disabled" + :items="approvers" + show-users + @select="updateApprovers" + /> <template #description> <gl-sprintf :message="$options.i18n.unifiedRulesHelpText"> <template #apiLink="{ content }"> diff --git a/ee/app/assets/javascripts/protected_environments/create_protected_environment.vue b/ee/app/assets/javascripts/protected_environments/create_protected_environment.vue index f32e8175949f2966fddaf7b49a35e90a74b06d34..730eb8b45a84d201fa5d867750e1acedd552f192 100644 --- a/ee/app/assets/javascripts/protected_environments/create_protected_environment.vue +++ b/ee/app/assets/javascripts/protected_environments/create_protected_environment.vue @@ -14,6 +14,7 @@ import axios from '~/lib/utils/axios_utils'; import { __, s__ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import AccessDropdown from '~/projects/settings/components/access_dropdown.vue'; +import GroupsAccessDropdown from '~/groups/settings/components/access_dropdown.vue'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import AddApprovers from './add_approvers.vue'; import { ACCESS_LEVELS } from './constants'; @@ -28,6 +29,7 @@ export default { GlFormGroup, GlCollapsibleListbox, AccessDropdown, + GroupsAccessDropdown, AddApprovers, }, mixins: [glFeatureFlagsMixin()], @@ -35,8 +37,10 @@ export default { accessLevelsData: { default: [] }, apiLink: {}, docsLink: {}, - projectId: { default: '' }, + entityId: { default: '' }, + entityType: { default: 'projects' }, searchUnprotectedEnvironmentsUrl: { default: '' }, + tiers: { default: [] }, }, data() { return { @@ -49,6 +53,7 @@ export default { environmentsLoading: false, errorMessage: '', loading: false, + environmentTier: '', }; }, computed: { @@ -59,10 +64,26 @@ export default { return this.environment || this.$options.i18n.environmentText; }, hasSelectedEnvironment() { - return Boolean(this.environment); + return Boolean(this.environment) || Boolean(this.environmentTier); + }, + isProjectType() { + return this.entityType === 'projects'; + }, + environmentTierText() { + return this.environmentTier || this.$options.i18n.environmentTierText; + }, + environmentTiers() { + return this.tiers.map((tier) => ({ text: tier, value: tier })); + }, + deployerHelpText() { + return this.$options.i18n.deployerHelp[this.entityType]; + }, + addText() { + return this.$options.i18n.addText[this.entityType]; }, }, mounted() { + if (!this.isProjectType) return; this.fetchEnvironments(); }, unmounted() { @@ -114,11 +135,12 @@ export default { this.loading = true; const protectedEnvironment = { - name: this.environment, + name: this.environment || this.environmentTier, deploy_access_levels: this.deployers, approval_rules: this.approvers, }; - Api.createProtectedEnvironment(this.projectId, protectedEnvironment) + const entityType = this.isProjectType ? 'projects' : 'groups'; + Api.createProtectedEnvironment(this.entityId, entityType, protectedEnvironment) .then(() => { this.$emit('success'); this.deployers = []; @@ -141,14 +163,24 @@ export default { }, i18n: { header: s__('ProtectedEnvironment|Protect an environment'), - addText: s__('ProtectedEnvironment|Add new protected environment'), + addText: { + projects: s__('ProtectedEnvironment|Add new protected environment'), + groups: s__('ProtectedEnvironment|Protect environment tier'), + }, environmentLabel: s__('ProtectedEnvironment|Select environment'), environmentText: s__('ProtectedEnvironment|Select an environment'), + environmentTierLabel: s__('ProtectedEnvironment|Select environment tier'), + environmentTierText: s__('ProtectedEnvironment|Select environment tier'), approvalLabel: s__('ProtectedEnvironment|Required approvals'), deployerLabel: s__('ProtectedEnvironments|Allowed to deploy'), - deployerHelp: s__( - 'ProtectedEnvironments|Set which groups, access levels, or users can deploy to this environment. Groups and users must be members of the project.', - ), + deployerHelp: { + projects: s__( + 'ProtectedEnvironments|Set which groups, access levels, or users can deploy to this environment. Groups and users must be members of the project.', + ), + groups: s__( + 'ProtectedEnvironments|Set which groups, access levels, or users can deploy in this environment tier.', + ), + }, buttonText: s__('ProtectedEnvironment|Protect'), buttonTextCancel: __('Cancel'), accessDropdownLabel: s__('ProtectedEnvironments|Select users'), @@ -162,9 +194,10 @@ export default { {{ errorMessage }} </gl-alert> - <h4 class="gl-mt-0">{{ $options.i18n.addText }}</h4> + <h4 class="gl-mt-0">{{ addText }}</h4> <gl-form-group + v-if="isProjectType" label-for="environment" data-testid="create-environment" :label="$options.i18n.environmentLabel" @@ -180,6 +213,20 @@ export default { /> </gl-form-group> + <gl-form-group + v-else + label-for="environment-tier" + data-testid="create-environment" + :label="$options.i18n.environmentTierLabel" + > + <gl-collapsible-listbox + id="create-environment" + v-model="environmentTier" + :toggle-text="environmentTierText" + :items="environmentTiers" + /> + </gl-form-group> + <gl-collapse :visible="hasSelectedEnvironment"> <gl-form-group data-testid="create-deployer-dropdown" @@ -187,9 +234,10 @@ export default { :label="$options.i18n.deployerLabel" > <template #label-description> - {{ $options.i18n.deployerHelp }} + {{ deployerHelpText }} </template> <access-dropdown + v-if="isProjectType" id="create-deployer-dropdown" :label="$options.i18n.accessDropdownLabel" :access-levels-data="accessLevelsData" @@ -199,9 +247,19 @@ export default { groups-with-project-access @select="updateDeployers" /> + <groups-access-dropdown + v-else + id="create-deployer-dropdown" + :label="$options.i18n.accessDropdownLabel" + :access-levels-data="accessLevelsData" + :disabled="disabled" + :items="deployers" + show-users + @select="updateDeployers" + /> </gl-form-group> <add-approvers - :project-id="projectId" + :project-id="entityId" :approval-rules="approvers" @change="updateApprovers" @error="errorMessage = $event" diff --git a/ee/app/assets/javascripts/protected_environments/edit_protected_environments_list.vue b/ee/app/assets/javascripts/protected_environments/edit_protected_environments_list.vue index b632ff36b2e767a6dec064482138d4d1b3e9354f..37f29d6f8c7c53f05a8a3aef4e4077dc78c6ca2b 100644 --- a/ee/app/assets/javascripts/protected_environments/edit_protected_environments_list.vue +++ b/ee/app/assets/javascripts/protected_environments/edit_protected_environments_list.vue @@ -12,6 +12,7 @@ import { import { mapState, mapActions, mapGetters } from 'vuex'; import { s__, __ } from '~/locale'; import AccessDropdown from '~/projects/settings/components/access_dropdown.vue'; +import GroupsAccessDropdown from '~/groups/settings/components/access_dropdown.vue'; import ShowMore from '~/vue_shared/components/show_more.vue'; import { ACCESS_LEVELS, DEPLOYER_RULE_KEY, APPROVER_RULE_KEY, INHERITED_GROUPS } from './constants'; import EditProtectedEnvironmentRulesCard from './edit_protected_environment_rules_card.vue'; @@ -28,6 +29,7 @@ export default { GlIcon, GlToggle, AccessDropdown, + GroupsAccessDropdown, ProtectedEnvironments, EditProtectedEnvironmentRulesCard, AddRuleModal, @@ -37,12 +39,12 @@ export default { directives: { GlTooltip, }, - inject: { accessLevelsData: { default: [] } }, + inject: { accessLevelsData: { default: [] }, entityType: { default: 'projects' } }, data() { return { isAddingRule: false, addingEnvironment: null, addingRule: '' }; }, computed: { - ...mapState(['projectId', 'loading', 'protectedEnvironments', 'editingRules']), + ...mapState(['entityId', 'loading', 'protectedEnvironments', 'editingRules']), ...mapGetters(['getUsersForRule']), isAddingDeploymentRule() { return this.addingRule === DEPLOYER_RULE_KEY; @@ -52,6 +54,9 @@ export default { ? this.$options.i18n.addDeploymentRuleModalTitle : this.$options.i18n.addApprovalRuleModalTitle; }, + isProjectType() { + return this.entityType === 'projects'; + }, }, mounted() { this.fetchProtectedEnvironments(); @@ -128,6 +133,7 @@ export default { data-testid="create-deployer-dropdown" > <access-dropdown + v-if="isProjectType" id="update-deployer-dropdown" class="gl-w-3/10" :label="$options.i18n.accessDropdownLabel" @@ -136,11 +142,20 @@ export default { :access-level="$options.ACCESS_LEVELS.DEPLOY" @hidden="setRule({ environment: addingEnvironment, newRules: $event })" /> + <groups-access-dropdown + v-else + id="update-deployer-dropdown" + class="gl-w-3/10" + :label="$options.i18n.accessDropdownLabel" + :access-levels-data="accessLevelsData" + show-users + @hidden="setRule({ environment: addingEnvironment, newRules: $event })" + /> </gl-form-group> </template> <template v-else #add-rule-form> <add-approvers - :project-id="projectId" + :project-id="entityId" @change="setRule({ environment: addingEnvironment, newRules: $event })" /> </template> diff --git a/ee/app/assets/javascripts/protected_environments/protected_environments.js b/ee/app/assets/javascripts/protected_environments/protected_environments.js index 38ded53e758acc4c9ee41b814775657f6286ba6b..e1141951d5df82e133cee602457cdb65e275df64 100644 --- a/ee/app/assets/javascripts/protected_environments/protected_environments.js +++ b/ee/app/assets/javascripts/protected_environments/protected_environments.js @@ -13,14 +13,18 @@ export const initProtectedEnvironments = () => { return null; } - const { projectId, apiLink, docsLink } = el.dataset; + // entityId is the ID of the project or group. + // entityType is either 'projects' or 'groups'. + const { entityId, apiLink, docsLink, entityType, tiers } = el.dataset; return new Vue({ el, store: createStore({ ...el.dataset, }), provide: { - projectId, + entityId, + entityType, + tiers: tiers ? JSON.parse(tiers) : [], accessLevelsData: gon?.deploy_access_levels?.roles ?? [], apiLink, docsLink, diff --git a/ee/app/assets/javascripts/protected_environments/protected_environments.vue b/ee/app/assets/javascripts/protected_environments/protected_environments.vue index 5d973bba19b82a7e177ad72d9317674697392545..2b794e0001ecc5492acec40a5cbed23cf7f4f8ef 100644 --- a/ee/app/assets/javascripts/protected_environments/protected_environments.vue +++ b/ee/app/assets/javascripts/protected_environments/protected_environments.vue @@ -18,6 +18,7 @@ export default { Pagination, CreateProtectedEnvironment, }, + inject: ['entityType'], props: { environments: { required: true, @@ -27,7 +28,10 @@ export default { i18n: { title: s__('ProtectedEnvironments|Protected environments'), newProtectedEnvironment: s__('ProtectedEnvironments|Protect an environment'), - emptyMessage: s__('ProtectedEnvironment|No environments in this project are protected.'), + emptyMessage: { + projects: s__('ProtectedEnvironment|No environments in this project are protected.'), + groups: s__('ProtectedEnvironment|No environments in this group are protected.'), + }, }, data() { return { @@ -58,6 +62,9 @@ export default { showEmptyMessage() { return this.environments.length === 0 && !this.isAddFormVisible; }, + emptyMessage() { + return this.$options.i18n.emptyMessage[this.entityType]; + }, }, methods: { ...mapActions(['setPage', 'fetchProtectedEnvironments']), @@ -166,7 +173,7 @@ export default { </gl-modal> <div v-if="showEmptyMessage" class="gl-new-card-empty gl-px-5 gl-py-4"> - {{ $options.i18n.emptyMessage }} + {{ emptyMessage }} </div> <template v-else> <div diff --git a/ee/app/assets/javascripts/protected_environments/store/edit/actions.js b/ee/app/assets/javascripts/protected_environments/store/edit/actions.js index 53bc5757a77cf0e2f0b0ff5f251170d91961988b..ce41f90cf597111d10730dc54aec045dcc18e8f8 100644 --- a/ee/app/assets/javascripts/protected_environments/store/edit/actions.js +++ b/ee/app/assets/javascripts/protected_environments/store/edit/actions.js @@ -10,7 +10,7 @@ import { import * as types from './mutation_types'; const fetchUsersForRuleForProject = ( - projectId, + entityId, { user_id: userId, group_id: groupId, @@ -27,7 +27,7 @@ const fetchUsersForRuleForProject = ( ); } - return getProjectMembers(projectId, groupInheritanceType === INHERITED_GROUPS).then(({ data }) => + return getProjectMembers(entityId, groupInheritanceType === INHERITED_GROUPS).then(({ data }) => data.filter(({ access_level: memberAccessLevel }) => memberAccessLevel >= accessLevel), ); }; @@ -39,7 +39,7 @@ export const fetchProtectedEnvironments = ({ state, commit, dispatch }) => { page: state.pageInfo?.page ?? null, }; - return Api.protectedEnvironments(state.projectId, params) + return Api.protectedEnvironments(state.entityId, state.entityType, params) .then(({ data, headers }) => { commit(types.RECEIVE_PROTECTED_ENVIRONMENTS_SUCCESS, data); dispatch('fetchAllMembers'); @@ -78,7 +78,7 @@ export const fetchAllMembersForEnvironment = ({ dispatch }, environment) => { }; export const fetchMembers = ({ state, commit }, { type, rule }) => { - return fetchUsersForRuleForProject(state.projectId, rule) + return fetchUsersForRuleForProject(state.entityId, rule) .then((users) => { commit(types.RECEIVE_MEMBER_SUCCESS, { type, rule, users }); }) @@ -135,7 +135,7 @@ export const updateRule = ({ dispatch, state, commit }, { environment, ruleKey, export const updateEnvironment = ({ state, commit, dispatch }, environment) => { commit(types.REQUEST_UPDATE_PROTECTED_ENVIRONMENT); - return Api.updateProtectedEnvironment(state.projectId, environment) + return Api.updateProtectedEnvironment(state.entityId, state.entityType, environment) .then(({ data }) => { commit(types.RECEIVE_UPDATE_PROTECTED_ENVIRONMENT_SUCCESS, data); dispatch('fetchAllMembersForEnvironment', data); @@ -157,7 +157,7 @@ export const editRule = ({ commit }, rule) => commit(types.EDIT_RULE, rule); export const unprotectEnvironment = ({ state, commit, dispatch }, environment) => { commit(types.REQUEST_UPDATE_PROTECTED_ENVIRONMENT); - return Api.deleteProtectedEnvironment(state.projectId, environment) + return Api.deleteProtectedEnvironment(state.entityId, state.entityType, environment) .then(() => { commit(types.DELETE_PROTECTED_ENVIRONMENT_SUCCESS, environment); diff --git a/ee/app/assets/javascripts/protected_environments/store/edit/state.js b/ee/app/assets/javascripts/protected_environments/store/edit/state.js index a199acde693abf065a8cf64d06d28e4fcef2f480..d662e2292e519bc1a9595486d7ba3cf97853cdc9 100644 --- a/ee/app/assets/javascripts/protected_environments/store/edit/state.js +++ b/ee/app/assets/javascripts/protected_environments/store/edit/state.js @@ -1,5 +1,6 @@ -export const state = ({ projectId }) => ({ - projectId, +export const state = ({ entityId, entityType }) => ({ + entityId, + entityType, loading: false, protectedEnvironments: [], pageInfo: {}, diff --git a/ee/app/views/projects/settings/ci_cd/_protected_environments.html.haml b/ee/app/views/projects/settings/ci_cd/_protected_environments.html.haml index e129d4655c6bfa953ded297145f58ff750cd9370..ed23b116e5c27fcfcc001678746bd517d8888f87 100644 --- a/ee/app/views/projects/settings/ci_cd/_protected_environments.html.haml +++ b/ee/app/views/projects/settings/ci_cd/_protected_environments.html.haml @@ -11,7 +11,8 @@ %p.gl-text-secondary = s_('ProtectedEnvironment|Only specified users can execute deployments in a protected environment.') .settings-content - #js-protected-environments{ data: { project_id: @project.id, + #js-protected-environments{ data: { entity_id: @project.id, + entity_type: 'projects', api_link: help_page_path('api/protected_environments'), docs_link: help_page_path('ci/environments/deployment_approvals', anchor: 'add-multiple-approval-rules') } } diff --git a/ee/spec/frontend/api_spec.js b/ee/spec/frontend/api_spec.js index 76f812298f654833de0883773129826481f07584..9a85a94376f26fe9e88416b816327e90611888a5 100644 --- a/ee/spec/frontend/api_spec.js +++ b/ee/spec/frontend/api_spec.js @@ -311,12 +311,23 @@ describe('Api', () => { }); describe('protectedEnvironments', () => { - it('fetches all protected environments', () => { + it('fetches all protected environments for projects', () => { const response = [{ name: 'staging ' }]; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/protected_environments/`; mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, response); - return Api.protectedEnvironments(1).then(({ data, config }) => { + return Api.protectedEnvironments(1, 'projects').then(({ data, config }) => { + expect(data).toEqual(response); + expect(config.url).toEqual(expectedUrl); + }); + }); + + it('fetches all protected environments for groups', () => { + const response = [{ name: 'staging ' }]; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/1/protected_environments/`; + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, response); + + return Api.protectedEnvironments(1, 'groups').then(({ data, config }) => { expect(data).toEqual(response); expect(config.url).toEqual(expectedUrl); }); @@ -324,12 +335,23 @@ describe('Api', () => { }); describe('updateProtectedEnvironment', () => { - it('puts changes to a protected environment', () => { + it('puts changes to a protected environment for projects', () => { const response = { name: 'staging' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/protected_environments/staging`; mock.onPut(expectedUrl, response).reply(HTTP_STATUS_OK, response); - return Api.updateProtectedEnvironment(1, response).then(({ data, config }) => { + return Api.updateProtectedEnvironment(1, 'projects', response).then(({ data, config }) => { + expect(data).toEqual(response); + expect(config.url).toBe(expectedUrl); + }); + }); + + it('puts changes to a protected environment for groups', () => { + const response = { name: 'staging' }; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/1/protected_environments/staging`; + mock.onPut(expectedUrl, response).reply(HTTP_STATUS_OK, response); + + return Api.updateProtectedEnvironment(1, 'groups', response).then(({ data, config }) => { expect(data).toEqual(response); expect(config.url).toBe(expectedUrl); }); @@ -337,13 +359,25 @@ describe('Api', () => { }); describe('deleteProtectedEnvironment', () => { - it('deletes a protected environment', () => { + it('deletes a protected environment for projects', () => { const environment = { name: 'staging' }; const response = {}; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/protected_environments/staging`; mock.onDelete(expectedUrl, environment).reply(HTTP_STATUS_OK, response); - return Api.deleteProtectedEnvironment(1, environment).then(({ data, config }) => { + return Api.deleteProtectedEnvironment(1, 'projects', environment).then(({ data, config }) => { + expect(data).toEqual(response); + expect(config.url).toBe(expectedUrl); + }); + }); + + it('deletes a protected environment for groups', () => { + const environment = { name: 'staging' }; + const response = {}; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/1/protected_environments/staging`; + mock.onDelete(expectedUrl, environment).reply(HTTP_STATUS_OK, response); + + return Api.deleteProtectedEnvironment(1, 'groups', environment).then(({ data, config }) => { expect(data).toEqual(response); expect(config.url).toBe(expectedUrl); }); diff --git a/ee/spec/frontend/protected_environments/add_approvers_spec.js b/ee/spec/frontend/protected_environments/add_approvers_spec.js index 278f8e93ed9e2afc8236a93626be04562a42687e..2d3459d705c1df0a5830f26c4806593525c1515a 100644 --- a/ee/spec/frontend/protected_environments/add_approvers_spec.js +++ b/ee/spec/frontend/protected_environments/add_approvers_spec.js @@ -6,46 +6,54 @@ import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'helpers/test_constants'; import axios from '~/lib/utils/axios_utils'; import AccessDropdown from '~/projects/settings/components/access_dropdown.vue'; +import GroupsAccessDropdown from '~/groups/settings/components/access_dropdown.vue'; import { ACCESS_LEVELS } from 'ee/protected_environments/constants'; import AddApprovers from 'ee/protected_environments/add_approvers.vue'; import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import { __, s__ } from '~/locale'; const PROJECT_ID = '0'; const API_LINK = `${TEST_HOST}/docs/api.md`; const DOCS_LINK = `${TEST_HOST}/docs/protected_environments.md`; +const accessLevelsData = [ + { + id: 40, + text: 'Maintainers', + before_divider: true, + }, + { + id: 30, + text: 'Developers + Maintainers', + before_divider: true, + }, +]; describe('ee/protected_environments/add_approvers.vue', () => { let wrapper; let mockAxios; - const createComponent = ({ projectId = PROJECT_ID, disabled = false } = {}) => { + const createComponent = ({ + entityId = PROJECT_ID, + disabled = false, + entityType = 'projects', + } = {}) => { wrapper = mountExtended(AddApprovers, { propsData: { - projectId, + entityId, disabled, }, provide: { - accessLevelsData: [ - { - id: 40, - text: 'Maintainers', - before_divider: true, - }, - { - id: 30, - text: 'Developers + Maintainers', - before_divider: true, - }, - ], + accessLevelsData, apiLink: API_LINK, docsLink: DOCS_LINK, + entityType, }, }); }; const findApproverDropdown = () => wrapper.findComponent(AccessDropdown); + const findGroupsApproverDropdown = () => wrapper.findComponent(GroupsAccessDropdown); + const findApproverFormGroup = () => wrapper.findByTestId('create-approver-dropdown'); const findRequiredCountForApprover = (name) => wrapper @@ -70,17 +78,44 @@ describe('ee/protected_environments/add_approvers.vue', () => { mockAxios = new MockAdapter(axios); }); - it('renders a dropdown for selecting approvers', () => { + it('renders a dropdown for selecting approvers on the project level', () => { createComponent(); const approvers = findApproverDropdown(); expect(approvers.props()).toMatchObject({ accessLevel: ACCESS_LEVELS.DEPLOY, - label: __('Select users'), + label: 'Select users', }); }); + it('renders a dropdown for selecting approvers on the group level', () => { + createComponent({ entityType: 'groups' }); + + const approvers = findGroupsApproverDropdown(); + + expect(approvers.props()).toMatchObject({ + accessLevelsData, + label: 'Select users', + }); + }); + + it('renders correct help text for the approval dropdown on the project level', () => { + createComponent(); + + expect(findApproverFormGroup().text()).toContain( + 'Set which groups, access levels, or users are required to approve. Groups and users must be members of the project.', + ); + }); + + it('renders correct help text for the approval dropdown on the group level', () => { + createComponent({ entityType: 'groups' }); + + expect(findApproverFormGroup().text()).toContain( + 'Set which groups, access levels, or users are required to approve in this environment tier.', + ); + }); + it('alerts users to the removal of unified approval rules', () => { createComponent(); const formGroup = wrapper.findComponent(GlFormGroup); @@ -110,11 +145,7 @@ describe('ee/protected_environments/add_approvers.vue', () => { await waitForPromises(); const [[event]] = wrapper.emitted('error').reverse(); - expect(event).toBe( - s__( - 'ProtectedEnvironments|An error occurred while fetching information on the selected approvers.', - ), - ); + expect(event).toBe('An error occurred while fetching information on the selected approvers.'); }); it('emits an empty error value when fetching new details', async () => { @@ -155,8 +186,8 @@ describe('ee/protected_environments/add_approvers.vue', () => { expect(button.props('icon')).toBe('remove'); expect(button.attributes()).toMatchObject({ - title: s__('ProtectedEnvironments|Remove approval rule'), - 'aria-label': s__('ProtectedEnvironments|Remove approval rule'), + title: 'Remove approval rule', + 'aria-label': 'Remove approval rule', }); }); diff --git a/ee/spec/frontend/protected_environments/create_protected_environment_spec.js b/ee/spec/frontend/protected_environments/create_protected_environment_spec.js index 5f087661155fe2db9c285bfdb97776c9d67936f2..3374a7a5dc7056d67e3604255605c826d366e044 100644 --- a/ee/spec/frontend/protected_environments/create_protected_environment_spec.js +++ b/ee/spec/frontend/protected_environments/create_protected_environment_spec.js @@ -7,6 +7,7 @@ import { TEST_HOST } from 'helpers/test_constants'; import Api from 'ee/api'; import axios from '~/lib/utils/axios_utils'; import AccessDropdown from '~/projects/settings/components/access_dropdown.vue'; +import GroupsAccessDropdown from '~/groups/settings/components/access_dropdown.vue'; import { ACCESS_LEVELS } from 'ee/protected_environments/constants'; import AddApprovers from 'ee/protected_environments/add_approvers.vue'; import CreateProtectedEnvironment from 'ee/protected_environments/create_protected_environment.vue'; @@ -18,6 +19,19 @@ const SEARCH_URL = '/search'; const PROJECT_ID = '0'; const API_LINK = `${TEST_HOST}/docs/api.md`; const DOCS_LINK = `${TEST_HOST}/docs/protected_environments.md`; +const accessLevelsData = [ + { + id: 40, + text: 'Maintainers', + before_divider: true, + }, + { + id: 30, + text: 'Developers + Maintainers', + before_divider: true, + }, +]; +const tiers = ['production', 'staging']; jest.mock('lodash'); @@ -32,6 +46,8 @@ describe('ee/protected_environments/create_protected_environment.vue', () => { wrapper.findByTestId('create-environment').findComponent(GlCollapsibleListbox); const findAccessDropdown = () => wrapper.findByTestId('create-deployer-dropdown').findComponent(AccessDropdown); + const findGroupsAccessDropdown = () => + wrapper.findByTestId('create-deployer-dropdown').findComponent(GroupsAccessDropdown); const findAddApprovers = () => wrapper.findComponent(AddApprovers); const findCancelButton = () => wrapper.findByTestId('cancel-button'); const findForm = () => wrapper.findComponent(GlForm); @@ -52,26 +68,18 @@ describe('ee/protected_environments/create_protected_environment.vue', () => { const createComponent = ({ searchUnprotectedEnvironmentsUrl = SEARCH_URL, - projectId = PROJECT_ID, + entityId = PROJECT_ID, + entityType = 'projects', } = {}) => { wrapper = mountExtended(CreateProtectedEnvironment, { provide: { apiLink: API_LINK, docsLink: DOCS_LINK, searchUnprotectedEnvironmentsUrl, - projectId, - accessLevelsData: [ - { - id: 40, - text: 'Maintainers', - before_divider: true, - }, - { - id: 30, - text: 'Developers + Maintainers', - before_divider: true, - }, - ], + entityId, + entityType, + accessLevelsData, + tiers, }, }); }; @@ -96,90 +104,148 @@ describe('ee/protected_environments/create_protected_environment.vue', () => { await findForm().trigger('submit'); }; - it('renders AccessDropdown and passes down the props', () => { - createComponent(); - const dropdown = findAccessDropdown(); + const submitGroupsForm = async (deployAccessLevels, name, requiredApprovalCount) => { + findEnvironmentsListbox().vm.$emit('select', name); + findGroupsAccessDropdown().vm.$emit('select', deployAccessLevels); + findAddApprovers().vm.$emit( + 'change', + deployAccessLevels.map((rule) => ({ ...rule, required_approvals: requiredApprovalCount })), + ); + await findForm().trigger('submit'); + }; - expect(dropdown.props()).toMatchObject({ - accessLevel: ACCESS_LEVELS.DEPLOY, - label: __('Select users'), + describe('on project level', () => { + it('renders AccessDropdown and passes down the props', () => { + createComponent(); + const dropdown = findAccessDropdown(); + + expect(dropdown.props()).toMatchObject({ + accessLevel: ACCESS_LEVELS.DEPLOY, + label: __('Select users'), + }); }); - }); - it('emits cancel event when the cancel button is clicked', () => { - createComponent(); - findCancelButton().trigger('click'); + it('emits cancel event when the cancel button is clicked', () => { + createComponent(); + findCancelButton().trigger('click'); - expect(wrapper.emitted('cancel').length).toBe(1); - }); + expect(wrapper.emitted('cancel').length).toBe(1); + }); - it('searchs the environment name', async () => { - const query = 'staging'; - mockAxios - .onGet(SEARCH_URL, { params: { query: '' } }) - .reply(HTTP_STATUS_OK, ['production', query]); - createComponent(); - jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); - await waitForPromises(); + it('searches the environment name', async () => { + const query = 'staging'; + mockAxios + .onGet(SEARCH_URL, { params: { query: '' } }) + .reply(HTTP_STATUS_OK, ['production', query]); + createComponent(); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + await waitForPromises(); - mockAxios.onGet(SEARCH_URL, { params: { query } }).reply(HTTP_STATUS_OK, [query]); + mockAxios.onGet(SEARCH_URL, { params: { query } }).reply(HTTP_STATUS_OK, [query]); - const environmentSearch = findEnvironmentsListbox(); - environmentSearch.vm.$emit('search', query); - jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); - await waitForPromises(); + const environmentSearch = findEnvironmentsListbox(); + environmentSearch.vm.$emit('search', query); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + await waitForPromises(); - expect(environmentSearch.props('items')).toEqual([{ value: query, text: query }]); - }); + expect(environmentSearch.props('items')).toEqual([{ value: query, text: query }]); + }); - it('renders a dropdown for selecting approvers', () => { - createComponent(); + it('renders a dropdown for selecting approvers', () => { + createComponent(); - const approvers = findAddApprovers(); + const approvers = findAddApprovers(); - expect(approvers.props()).toMatchObject({ - disabled: false, + expect(approvers.props()).toMatchObject({ + disabled: false, + }); }); - }); - it('should make a request when submit is clicked', async () => { - createComponent(); + it('should make a request when submit is clicked', async () => { + createComponent(); + + jest.spyOn(Api, 'createProtectedEnvironment'); + const deployAccessLevels = [{ user_id: 1 }]; + const name = 'production'; + const requiredApprovalCount = '3'; - jest.spyOn(Api, 'createProtectedEnvironment'); - const deployAccessLevels = [{ user_id: 1 }]; - const name = 'production'; - const requiredApprovalCount = '3'; + await submitForm(deployAccessLevels, name, requiredApprovalCount); - await submitForm(deployAccessLevels, name, requiredApprovalCount); + expect(Api.createProtectedEnvironment).toHaveBeenCalledWith(PROJECT_ID, 'projects', { + deploy_access_levels: deployAccessLevels, + approval_rules: [{ user_id: 1, required_approvals: requiredApprovalCount }], + name, + }); + }); - expect(Api.createProtectedEnvironment).toHaveBeenCalledWith(PROJECT_ID, { - deploy_access_levels: deployAccessLevels, - approval_rules: [{ user_id: 1, required_approvals: requiredApprovalCount }], - name, + describe('on failed protected environment', () => { + it.each([ + { + statusCode: HTTP_STATUS_BAD_REQUEST, + expectedMessage: 'Failed to protect the environment.', + }, + { + statusCode: HTTP_STATUS_BAD_REQUEST, + responseData: { message: 'Serverside message' }, + expectedMessage: 'Failed to protect the environment. Serverside message', + }, + ])( + 'should show a correct error message', + async ({ statusCode, expectedMessage, responseData }) => { + mockAxios.onPost().replyOnce(statusCode, responseData); + createComponent(); + await submitForm(); + await waitForPromises(); + + expect(findAlert().text()).toBe(expectedMessage); + }, + ); }); }); - describe('on failed protected environment', () => { - it.each([ - { - statusCode: HTTP_STATUS_BAD_REQUEST, - expectedMessage: 'Failed to protect the environment.', - }, - { - statusCode: HTTP_STATUS_BAD_REQUEST, - responseData: { message: 'Serverside message' }, - expectedMessage: 'Failed to protect the environment. Serverside message', - }, - ])( - 'should show a correct error message', - async ({ statusCode, expectedMessage, responseData }) => { - mockAxios.onPost().replyOnce(statusCode, responseData); - createComponent(); - await submitForm(); - await waitForPromises(); - - expect(findAlert().text()).toBe(expectedMessage); - }, - ); + describe('on group level', () => { + beforeEach(() => { + createComponent({ entityType: 'groups' }); + }); + + it('renders AccessDropdown and passes down the props', () => { + const dropdown = findGroupsAccessDropdown(); + + expect(dropdown.props()).toMatchObject({ + accessLevelsData, + label: __('Select users'), + }); + }); + + it('renders environment tier selector', () => { + expect(findEnvironmentsListbox().props('toggleText')).toBe('Select environment tier'); + expect(findEnvironmentsListbox().props('items')).toEqual([ + { value: 'production', text: 'production' }, + { value: 'staging', text: 'staging' }, + ]); + }); + + it('renders a dropdown for selecting approvers', () => { + const approvers = findAddApprovers(); + + expect(approvers.props()).toMatchObject({ + disabled: false, + }); + }); + + it('should make a request when submit is clicked', async () => { + jest.spyOn(Api, 'createProtectedEnvironment'); + const deployAccessLevels = [{ group_id: 1 }]; + const name = 'production'; + const requiredApprovalCount = '3'; + + await submitGroupsForm(deployAccessLevels, name, requiredApprovalCount); + + expect(Api.createProtectedEnvironment).toHaveBeenCalledWith(PROJECT_ID, 'groups', { + deploy_access_levels: deployAccessLevels, + approval_rules: [{ group_id: 1, required_approvals: requiredApprovalCount }], + name, + }); + }); }); }); diff --git a/ee/spec/frontend/protected_environments/edit_protected_environments_list_spec.js b/ee/spec/frontend/protected_environments/edit_protected_environments_list_spec.js index eebcfed8f2884d3d0913d59e00e771f210169a19..125204fce5de8db10ae268f9c36714b893c84166 100644 --- a/ee/spec/frontend/protected_environments/edit_protected_environments_list_spec.js +++ b/ee/spec/frontend/protected_environments/edit_protected_environments_list_spec.js @@ -8,8 +8,8 @@ import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'helpers/test_constants'; import axios from '~/lib/utils/axios_utils'; -import { s__ } from '~/locale'; import AccessDropdown from '~/projects/settings/components/access_dropdown.vue'; +import GroupsAccessDropdown from '~/groups/settings/components/access_dropdown.vue'; import { createStore } from 'ee/protected_environments/store/edit'; import AddRuleModal from 'ee/protected_environments/add_rule_modal.vue'; import AddApprovers from 'ee/protected_environments/add_approvers.vue'; @@ -104,8 +104,8 @@ describe('ee/protected_environments/edit_protected_environments_list.vue', () => let wrapper; let mock; - const createComponent = async () => { - store = createStore({ projectId: DEFAULT_PROJECT_ID }); + const createComponent = async ({ entityType = 'projects' } = {}) => { + store = createStore({ entityId: DEFAULT_PROJECT_ID, entityType }); wrapper = mountExtended(EditProtectedEnvironmentsList, { store, @@ -113,372 +113,530 @@ describe('ee/protected_environments/edit_protected_environments_list.vue', () => accessLevelsData: DEFAULT_ACCESS_LEVELS_DATA, apiLink: API_LINK, docsLink: DOCS_LINK, + entityType, }, }); await waitForPromises(); }; - const findDeployerDeleteButton = () => - wrapper.findByTitle(s__('ProtectedEnvironments|Delete deployer rule')); - const findApproverDeleteButton = () => - wrapper.findByTitle(s__('ProtectedEnvironments|Delete approver rule')); - const findApproverEditButton = (w = wrapper) => - w.findByRole('button', { name: s__('ProtectedEnvironments|Edit') }); + const findDeployerDeleteButton = () => wrapper.findByTitle('Delete deployer rule'); + const findApproverDeleteButton = () => wrapper.findByTitle('Delete approver rule'); + const findApproverEditButton = (w = wrapper) => w.findByRole('button', { name: 'Edit' }); const findInheritanceToggle = (w = wrapper) => w.findComponent(GlToggle); - const findApproverSaveButton = () => - wrapper.findByRole('button', { name: s__('ProtectedEnvironments|Save') }); + const findApproverSaveButton = () => wrapper.findByRole('button', { name: 'Save' }); const findApprovalsInput = () => - wrapper.findByRole('textbox', { name: s__('ProtectedEnvironments|Required approval count') }); + wrapper.findByRole('textbox', { name: 'Required approval count' }); const findProtectedEnvironments = () => wrapper.findComponent(ProtectedEnvironments); const findItemToggleButton = () => wrapper.findByTestId('protected-environment-item-toggle'); + const findAddRuleModal = () => wrapper.findComponent(AddRuleModal); + const findAddApprovers = () => wrapper.findComponent(AddApprovers); + + describe('on the project level', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + window.gon = { api_version: 'v4' }; + mock + .onGet('/api/v4/projects/8/protected_environments/') + .reply(HTTP_STATUS_OK, DEFAULT_ENVIRONMENTS); + mock + .onGet('/api/v4/groups/1/members/all') + .reply(HTTP_STATUS_OK, [{ name: 'root', avatar_url: '/avatar.png' }]); + mock + .onGet('/api/v4/users/1') + .reply(HTTP_STATUS_OK, { name: 'root', avatar_url: '/avatar.png' }); + mock.onGet('/api/v4/projects/8/members').reply(HTTP_STATUS_OK, [ + { + name: 'root', + access_level: MAINTAINER_ACCESS_LEVEL.toString(), + avatar_url: '/avatar.png', + }, + ]); + }); - beforeEach(() => { - mock = new MockAdapter(axios); - window.gon = { api_version: 'v4' }; - mock - .onGet('/api/v4/projects/8/protected_environments/') - .reply(HTTP_STATUS_OK, DEFAULT_ENVIRONMENTS); - mock - .onGet('/api/v4/groups/1/members/all') - .reply(HTTP_STATUS_OK, [{ name: 'root', avatar_url: '/avatar.png' }]); - mock - .onGet('/api/v4/users/1') - .reply(HTTP_STATUS_OK, { name: 'root', avatar_url: '/avatar.png' }); - mock.onGet('/api/v4/projects/8/members').reply(HTTP_STATUS_OK, [ - { - name: 'root', - access_level: MAINTAINER_ACCESS_LEVEL.toString(), - avatar_url: '/avatar.png', - }, - ]); - }); - - afterEach(() => { - mock.restore(); - mock.resetHistory(); - }); + afterEach(() => { + mock.restore(); + mock.resetHistory(); + }); - it('shows a header for the protected environment', async () => { - await createComponent(); + it('shows a header for the protected environment', async () => { + await createComponent(); - expect(wrapper.findByRole('button', { name: 'staging' }).exists()).toBe(true); - }); + expect(wrapper.findByRole('button', { name: 'staging' }).exists()).toBe(true); + }); - it('shows member avatars in each row', async () => { - await createComponent(); + it('shows member avatars in each row', async () => { + await createComponent(); - const avatars = wrapper.findAllComponents(GlAvatar).wrappers; + const avatars = wrapper.findAllComponents(GlAvatar).wrappers; - expect(avatars).toHaveLength(6); - avatars.forEach((avatar) => expect(avatar.props('src')).toBe('/avatar.png')); - }); + expect(avatars).toHaveLength(6); + avatars.forEach((avatar) => expect(avatar.props('src')).toBe('/avatar.png')); + }); - it('shows the description of the rule', async () => { - const [{ deploy_access_levels: deployAccessLevels, approval_rules: approvalRules }] = - DEFAULT_ENVIRONMENTS; + it('shows the description of the rule', async () => { + const [{ deploy_access_levels: deployAccessLevels, approval_rules: approvalRules }] = + DEFAULT_ENVIRONMENTS; - const ruleDescriptions = [ - ...deployAccessLevels.map((d) => d.access_level_description), - ...approvalRules.map((a) => a.access_level_description), - ]; + const ruleDescriptions = [ + ...deployAccessLevels.map((d) => d.access_level_description), + ...approvalRules.map((a) => a.access_level_description), + ]; - await createComponent(); + await createComponent(); - const descriptions = wrapper.findAllByTestId('rule-description').wrappers; + const descriptions = wrapper.findAllByTestId('rule-description').wrappers; - descriptions.forEach((description, i) => { - expect(description.text()).toBe(ruleDescriptions[i]); + descriptions.forEach((description, i) => { + expect(description.text()).toBe(ruleDescriptions[i]); + }); }); - }); - describe('add deployer rule', () => { - let environment; - let dropdown; - let modal; + describe('add deployer rule', () => { + let environment; + let dropdown; - beforeEach(async () => { - [environment] = DEFAULT_ENVIRONMENTS; + beforeEach(async () => { + [environment] = DEFAULT_ENVIRONMENTS; - await createComponent(); + await createComponent(); - wrapper - .findComponent(EditProtectedEnvironmentRulesCard) - .vm.$emit('addRule', { environment, ruleKey: DEPLOYER_RULE_KEY }); + wrapper + .findComponent(EditProtectedEnvironmentRulesCard) + .vm.$emit('addRule', { environment, ruleKey: DEPLOYER_RULE_KEY }); - await nextTick(); + await nextTick(); - dropdown = wrapper.findComponent(AccessDropdown); - modal = wrapper.findComponent(AddRuleModal); - }); + dropdown = wrapper.findComponent(AccessDropdown); + }); - it('titles the modal appropriately', () => { - expect(modal.props('title')).toBe(s__('ProtectedEnvironments|Create deployment rule')); - }); + it('titles the modal appropriately', () => { + expect(findAddRuleModal().props('title')).toBe('Create deployment rule'); + }); - it('puts the access level dropdown into the modal form', () => { - expect(dropdown.exists()).toBe(true); - }); + it('puts the access level dropdown into the modal form', () => { + expect(dropdown.exists()).toBe(true); + }); - it('sends new rules to be added', async () => { - mock.onPut().reply(HTTP_STATUS_OK); + it('sends new rules to be added', async () => { + mock.onPut().reply(HTTP_STATUS_OK); - const rule = [{ user_id: 5 }]; - dropdown.vm.$emit('hidden', rule); + const rule = [{ user_id: 5 }]; + dropdown.vm.$emit('hidden', rule); - modal.vm.$emit('saveRule'); + findAddRuleModal().vm.$emit('saveRule'); - await waitForPromises(); + await waitForPromises(); - expect(mock.history.put).toHaveLength(1); + expect(mock.history.put).toHaveLength(1); - const [{ data }] = mock.history.put; - expect(JSON.parse(data)).toMatchObject({ ...environment, deploy_access_levels: rule }); + const [{ data }] = mock.history.put; + expect(JSON.parse(data)).toMatchObject({ ...environment, deploy_access_levels: rule }); + }); }); - }); - describe('deployer delete rule', () => { - it('sends the deleted rule with _destroy set', async () => { - const [environment] = DEFAULT_ENVIRONMENTS; + describe('deployer delete rule', () => { + it('sends the deleted rule with _destroy set', async () => { + const [environment] = DEFAULT_ENVIRONMENTS; - await createComponent(); + await createComponent(); - findItemToggleButton().vm.$emit('click'); + findItemToggleButton().vm.$emit('click'); - const button = findDeployerDeleteButton(); + const button = findDeployerDeleteButton(); - mock.onPut().reply(HTTP_STATUS_OK); + mock.onPut().reply(HTTP_STATUS_OK); - const destroyedRule = { - access_level: DEVELOPER_ACCESS_LEVEL, - access_level_description: 'Deployers + Maintainers', - _destroy: true, - }; + const destroyedRule = { + access_level: DEVELOPER_ACCESS_LEVEL, + access_level_description: 'Deployers + Maintainers', + _destroy: true, + }; - button.trigger('click'); + button.trigger('click'); - await waitForPromises(); + await waitForPromises(); - expect(mock.history.put).toHaveLength(1); + expect(mock.history.put).toHaveLength(1); - const [{ data }] = mock.history.put; - expect(JSON.parse(data)).toMatchObject({ - name: environment.name, - deploy_access_levels: [destroyedRule], + const [{ data }] = mock.history.put; + expect(JSON.parse(data)).toMatchObject({ + name: environment.name, + deploy_access_levels: [destroyedRule], + }); }); - }); - it('hides the button if there is only one rule', async () => { - const [environment] = DEFAULT_ENVIRONMENTS; - const [rule] = environment.deploy_access_levels; - mock.onGet('/api/v4/projects/8/protected_environments/').reply(HTTP_STATUS_OK, [ - { - ...environment, - deploy_access_levels: [rule], - }, - ]); + it('hides the button if there is only one rule', async () => { + const [environment] = DEFAULT_ENVIRONMENTS; + const [rule] = environment.deploy_access_levels; + mock.onGet('/api/v4/projects/8/protected_environments/').reply(HTTP_STATUS_OK, [ + { + ...environment, + deploy_access_levels: [rule], + }, + ]); - await createComponent(); + await createComponent(); - findItemToggleButton().vm.$emit('click'); + findItemToggleButton().vm.$emit('click'); - const button = findDeployerDeleteButton(); + const button = findDeployerDeleteButton(); - expect(button.exists()).toBe(false); + expect(button.exists()).toBe(false); + }); }); - }); - describe('add approval rule', () => { - let environment; - let addApprover; - let modal; + describe('add approval rule', () => { + let environment; - beforeEach(async () => { - [environment] = DEFAULT_ENVIRONMENTS; + beforeEach(async () => { + [environment] = DEFAULT_ENVIRONMENTS; - await createComponent(); + await createComponent(); - wrapper - .findComponent(EditProtectedEnvironmentRulesCard) - .vm.$emit('addRule', { environment, ruleKey: APPROVER_RULE_KEY }); + wrapper + .findComponent(EditProtectedEnvironmentRulesCard) + .vm.$emit('addRule', { environment, ruleKey: APPROVER_RULE_KEY }); - await nextTick(); + await nextTick(); + }); - addApprover = wrapper.findComponent(AddApprovers); - modal = wrapper.findComponent(AddRuleModal); - }); + it('titles the modal appropriately', () => { + expect(findAddRuleModal().props('title')).toBe('Create approval rule'); + }); - it('titles the modal appropriately', () => { - expect(modal.props('title')).toBe(s__('ProtectedEnvironments|Create approval rule')); - }); + it('puts the access level dropdown into the modal form', () => { + expect(findAddApprovers().exists()).toBe(true); + }); + + it('sends new rules to be added', async () => { + mock.onPut().reply(HTTP_STATUS_OK); + + const rule = [{ user_id: 5, required_approvals: 3 }]; + findAddApprovers().vm.$emit('change', rule); + + findAddRuleModal().vm.$emit('saveRule'); + + await waitForPromises(); - it('puts the access level dropdown into the modal form', () => { - expect(addApprover.exists()).toBe(true); + expect(mock.history.put).toHaveLength(1); + + const [{ data }] = mock.history.put; + expect(JSON.parse(data)).toMatchObject({ ...environment, approval_rules: rule }); + }); }); - it('sends new rules to be added', async () => { - mock.onPut().reply(HTTP_STATUS_OK); + describe('approver delete rule', () => { + it('sends the deleted rule with _destroy set', async () => { + const [environment] = DEFAULT_ENVIRONMENTS; + + await createComponent(); + + wrapper.findComponent(GlButton).vm.$emit('click'); - const rule = [{ user_id: 5, required_approvals: 3 }]; - addApprover.vm.$emit('change', rule); + const button = findApproverDeleteButton(); - modal.vm.$emit('saveRule'); + mock.onPut().reply(HTTP_STATUS_OK); - await waitForPromises(); + const destroyedRule = { + access_level: DEVELOPER_ACCESS_LEVEL, + access_level_description: 'Deployers + Maintainers', + _destroy: true, + }; - expect(mock.history.put).toHaveLength(1); + button.trigger('click'); - const [{ data }] = mock.history.put; - expect(JSON.parse(data)).toMatchObject({ ...environment, approval_rules: rule }); + await waitForPromises(); + + expect(mock.history.put).toHaveLength(1); + + const [{ data }] = mock.history.put; + expect(JSON.parse(data)).toMatchObject({ + name: environment.name, + approval_rules: [destroyedRule], + }); + }); }); - }); - describe('approver delete rule', () => { - it('sends the deleted rule with _destroy set', async () => { - const [environment] = DEFAULT_ENVIRONMENTS; + describe('approver edit rule', () => { + let environment; - await createComponent(); + beforeEach(async () => { + [environment] = DEFAULT_ENVIRONMENTS; + await createComponent(); - wrapper.findComponent(GlButton).vm.$emit('click'); + findItemToggleButton().vm.$emit('click'); - const button = findApproverDeleteButton(); + await nextTick(); + }); - mock.onPut().reply(HTTP_STATUS_OK); + it('allows editing of an approval rule', async () => { + const [rule] = environment.approval_rules; + const value = '2'; - const destroyedRule = { - access_level: DEVELOPER_ACCESS_LEVEL, - access_level_description: 'Deployers + Maintainers', - _destroy: true, - }; + mock.onPut().reply(HTTP_STATUS_OK); + + const button = findApproverEditButton(); + + await button.trigger('click'); + + const input = findApprovalsInput(); + + expect(input.exists()).toBe(true); + + await input.setValue(value); + + findApproverSaveButton().trigger('click'); + + await waitForPromises(); + + expect(mock.history.put.length).toBe(1); + const [{ data }] = mock.history.put; + expect(JSON.parse(data)).toMatchObject({ + name: environment.name, + approval_rules: [ + { + id: rule.id, + access_level: rule.access_level, + access_level_description: rule.access_level_description, + required_approvals: value, + }, + ], + }); + }); + + it('shows a toggle for group ID rules', async () => { + const [, rule] = environment.approval_rules; + mock.onPut().reply(HTTP_STATUS_OK); - button.trigger('click'); + const row = wrapper.findByTestId(`approval_rules-${rule.id}`); + const button = findApproverEditButton(extendedWrapper(row)); - await waitForPromises(); + expect(findInheritanceToggle(row).props('value')).toBe(true); + expect(findInheritanceToggle(row).props('disabled')).toBe(true); - expect(mock.history.put).toHaveLength(1); + await button.trigger('click'); - const [{ data }] = mock.history.put; - expect(JSON.parse(data)).toMatchObject({ - name: environment.name, - approval_rules: [destroyedRule], + expect(findInheritanceToggle(row).props('value')).toBe(true); + expect(findInheritanceToggle(row).props('disabled')).toBe(false); + + await findInheritanceToggle(row).vm.$emit('change', false); + + findApproverSaveButton().trigger('click'); + + await waitForPromises(); + + expect(mock.history.put.length).toBe(1); + const [{ data }] = mock.history.put; + expect(JSON.parse(data)).toMatchObject({ + name: environment.name, + approval_rules: [ + { + id: rule.id, + group_id: rule.group_id, + access_level_description: rule.access_level_description, + group_inheritance_type: 0, + }, + ], + }); + }); + + it('hides the toggle for non-group rules', () => { + const { id } = environment.approval_rules.find(({ user_id: userId }) => userId); + const row = wrapper.findByTestId(`approval_rules-${id}`); + + expect(findInheritanceToggle(row).exists()).toBe(false); + }); + + it('hides the edit button for user rules', () => { + const { id } = environment.approval_rules.find(({ user_id: userId }) => userId); + const row = wrapper.findByTestId(`approval_rules-${id}`); + const button = findApproverEditButton(extendedWrapper(row)); + + expect(button.exists()).toBe(false); + }); + }); + + describe('unprotect environment', () => { + it('unprotects an environment when emitted', async () => { + const [environment] = DEFAULT_ENVIRONMENTS; + + mock.onDelete().reply(HTTP_STATUS_OK); + + await createComponent(); + + findProtectedEnvironments().vm.$emit('unprotect', environment); + await waitForPromises(); + + expect(mock.history.delete).toHaveLength(1); + + const [{ url }] = mock.history.delete; + expect(url).toBe(`/api/v4/projects/8/protected_environments/${environment.name}`); }); }); }); - describe('approver edit rule', () => { - let environment; + describe('on the group level', () => { + const [environment] = DEFAULT_ENVIRONMENTS; beforeEach(async () => { - [environment] = DEFAULT_ENVIRONMENTS; - await createComponent(); + mock = new MockAdapter(axios); + window.gon = { api_version: 'v4' }; + mock + .onGet('/api/v4/groups/8/protected_environments/') + .reply(HTTP_STATUS_OK, DEFAULT_ENVIRONMENTS); + mock + .onGet('/api/v4/groups/1/members/all') + .reply(HTTP_STATUS_OK, [{ name: 'root', avatar_url: '/avatar.png' }]); + mock + .onGet('/api/v4/users/1') + .reply(HTTP_STATUS_OK, { name: 'root', avatar_url: '/avatar.png' }); + mock.onGet('/api/v4/groups/8/members').reply(HTTP_STATUS_OK, [ + { + name: 'root', + access_level: MAINTAINER_ACCESS_LEVEL.toString(), + avatar_url: '/avatar.png', + }, + ]); - findItemToggleButton().vm.$emit('click'); + await createComponent({ entityType: 'groups' }); + }); - await nextTick(); + afterEach(() => { + mock.restore(); + mock.resetHistory(); }); - it('allows editing of an approval rule', async () => { - const [rule] = environment.approval_rules; - const value = '2'; + it('requests the protected environments for the group', () => { + expect(mock.history.get[0].url).toBe('/api/v4/groups/8/protected_environments/'); + }); - mock.onPut().reply(HTTP_STATUS_OK); + describe('add deployer rule', () => { + let dropdown; - const button = findApproverEditButton(); + beforeEach(async () => { + wrapper + .findComponent(EditProtectedEnvironmentRulesCard) + .vm.$emit('addRule', { environment, ruleKey: DEPLOYER_RULE_KEY }); - await button.trigger('click'); + await nextTick(); - const input = findApprovalsInput(); + dropdown = wrapper.findComponent(GroupsAccessDropdown); + }); - expect(input.exists()).toBe(true); + it('renders the group access level dropdown in the modal form', () => { + expect(dropdown.props()).toMatchObject({ + label: 'Select users', + accessLevelsData: DEFAULT_ACCESS_LEVELS_DATA, + }); + }); - await input.setValue(value); + it('sends new rules to the groups endpoint', async () => { + mock.onPut().reply(HTTP_STATUS_OK); - findApproverSaveButton().trigger('click'); + const rule = [{ user_id: 5 }]; + dropdown.vm.$emit('hidden', rule); - await waitForPromises(); + findAddRuleModal().vm.$emit('saveRule'); - expect(mock.history.put.length).toBe(1); - const [{ data }] = mock.history.put; - expect(JSON.parse(data)).toMatchObject({ - name: environment.name, - approval_rules: [ - { - id: rule.id, - access_level: rule.access_level, - access_level_description: rule.access_level_description, - required_approvals: value, - }, - ], + await waitForPromises(); + + expect(mock.history.put).toHaveLength(1); + expect(mock.history.put[0].url).toBe( + `/api/v4/groups/8/protected_environments/${environment.name}`, + ); }); }); - it('shows a toggle for group ID rules', async () => { - const [, rule] = environment.approval_rules; - mock.onPut().reply(HTTP_STATUS_OK); + describe('deployer delete rule', () => { + it('sends the deleted rule to the groups endpoint', async () => { + findItemToggleButton().vm.$emit('click'); - const row = wrapper.findByTestId(`approval_rules-${rule.id}`); - const button = findApproverEditButton(extendedWrapper(row)); + const button = findDeployerDeleteButton(); - expect(findInheritanceToggle(row).props('value')).toBe(true); - expect(findInheritanceToggle(row).props('disabled')).toBe(true); + mock.onPut().reply(HTTP_STATUS_OK); + button.trigger('click'); - await button.trigger('click'); + await waitForPromises(); - expect(findInheritanceToggle(row).props('value')).toBe(true); - expect(findInheritanceToggle(row).props('disabled')).toBe(false); + expect(mock.history.put).toHaveLength(1); + expect(mock.history.put[0].url).toBe( + `/api/v4/groups/8/protected_environments/${environment.name}`, + ); + }); + }); - await findInheritanceToggle(row).vm.$emit('change', false); + describe('add approval rule', () => { + it('sends new rules to the groups endpoint', async () => { + mock.onPut().reply(HTTP_STATUS_OK); + wrapper + .findComponent(EditProtectedEnvironmentRulesCard) + .vm.$emit('addRule', { environment, ruleKey: APPROVER_RULE_KEY }); - findApproverSaveButton().trigger('click'); + await nextTick(); - await waitForPromises(); + const rule = [{ user_id: 5, required_approvals: 3 }]; + findAddApprovers().vm.$emit('change', rule); - expect(mock.history.put.length).toBe(1); - const [{ data }] = mock.history.put; - expect(JSON.parse(data)).toMatchObject({ - name: environment.name, - approval_rules: [ - { - id: rule.id, - group_id: rule.group_id, - access_level_description: rule.access_level_description, - group_inheritance_type: 0, - }, - ], + findAddRuleModal().vm.$emit('saveRule'); + + await waitForPromises(); + + expect(mock.history.put).toHaveLength(1); + expect(mock.history.put[0].url).toBe( + `/api/v4/groups/8/protected_environments/${environment.name}`, + ); }); }); - it('hides the toggle for non-group rules', () => { - const { id } = environment.approval_rules.find(({ user_id: userId }) => userId); - const row = wrapper.findByTestId(`approval_rules-${id}`); + describe('approver delete rule', () => { + it('sends the deleted rule to the groups endpoint', async () => { + wrapper.findComponent(GlButton).vm.$emit('click'); + mock.onPut().reply(HTTP_STATUS_OK); - expect(findInheritanceToggle(row).exists()).toBe(false); - }); + findApproverDeleteButton().trigger('click'); - it('hides the edit button for user rules', () => { - const { id } = environment.approval_rules.find(({ user_id: userId }) => userId); - const row = wrapper.findByTestId(`approval_rules-${id}`); - const button = findApproverEditButton(extendedWrapper(row)); + await waitForPromises(); - expect(button.exists()).toBe(false); + expect(mock.history.put).toHaveLength(1); + expect(mock.history.put[0].url).toBe( + `/api/v4/groups/8/protected_environments/${environment.name}`, + ); + }); }); - }); - describe('unprotect environment', () => { - it('unprotects an environment when emitted', async () => { - const [environment] = DEFAULT_ENVIRONMENTS; + describe('approver edit rule', () => { + it('sends the editing request to the groups endpoint', async () => { + mock.onPut().reply(HTTP_STATUS_OK); - mock.onDelete().reply(HTTP_STATUS_OK); + findItemToggleButton().vm.$emit('click'); + await nextTick(); + await findApproverEditButton().trigger('click'); + await findApprovalsInput().setValue('2'); - await createComponent(); + findApproverSaveButton().trigger('click'); - findProtectedEnvironments().vm.$emit('unprotect', environment); - await waitForPromises(); + await waitForPromises(); - expect(mock.history.delete).toHaveLength(1); + expect(mock.history.put.length).toBe(1); + expect(mock.history.put[0].url).toBe( + `/api/v4/groups/8/protected_environments/${environment.name}`, + ); + }); + }); - const [{ url }] = mock.history.delete; - expect(url).toBe(`/api/v4/projects/8/protected_environments/${environment.name}`); + describe('unprotect environment', () => { + it('sends unprotect request to the groups endpoint', async () => { + mock.onDelete().reply(HTTP_STATUS_OK); + + findProtectedEnvironments().vm.$emit('unprotect', environment); + await waitForPromises(); + + expect(mock.history.delete).toHaveLength(1); + expect(mock.history.delete[0].url).toBe( + `/api/v4/groups/8/protected_environments/${environment.name}`, + ); + }); }); }); }); diff --git a/ee/spec/frontend/protected_environments/protected_environments_spec.js b/ee/spec/frontend/protected_environments/protected_environments_spec.js index 7a1ae6312921efa9052729d5eba9dd35360db2aa..a9be2521b0448e588bfa5834667e2810b90a62ce 100644 --- a/ee/spec/frontend/protected_environments/protected_environments_spec.js +++ b/ee/spec/frontend/protected_environments/protected_environments_spec.js @@ -65,9 +65,9 @@ describe('ee/protected_environments/protected_environments.vue', () => { const setPageMock = jest.fn(() => Promise.resolve()); const fetchProtectedEnvironmentsMock = jest.fn(() => Promise.resolve()); - const createStore = ({ pageInfo } = {}) => { + const createStore = ({ pageInfo, entityType } = {}) => { return new Vuex.Store({ - state: { ...state, pageInfo }, + state: { ...state, pageInfo, entityType }, actions: { setPage: setPageMock, fetchProtectedEnvironments: fetchProtectedEnvironmentsMock, @@ -78,9 +78,10 @@ describe('ee/protected_environments/protected_environments.vue', () => { const createComponent = ({ environments = DEFAULT_ENVIRONMENTS, pageInfo = DEFAULT_PAGE_INFO, + entityType = 'projects', } = {}) => { wrapper = mountExtended(ProtectedEnvironments, { - store: createStore({ pageInfo }), + store: createStore({ pageInfo, entityType }), propsData: { environments, }, @@ -90,6 +91,7 @@ describe('ee/protected_environments/protected_environments.vue', () => { provide: { apiLink: '', docsLink: '', + entityType, }, // Stub access dropdown since it triggers some requests that are out-of-scope here stubs: ['AccessDropdown', 'CreateProtectedEnvironment'], diff --git a/ee/spec/frontend/protected_environments/store/edit/actions_spec.js b/ee/spec/frontend/protected_environments/store/edit/actions_spec.js index 93774b8f5ea012c82b647ab8574bed5a80ca636e..6d1d00286f1c3ea08ef881262aa270f3a3def250 100644 --- a/ee/spec/frontend/protected_environments/store/edit/actions_spec.js +++ b/ee/spec/frontend/protected_environments/store/edit/actions_spec.js @@ -31,7 +31,7 @@ describe('ee/protected_environments/store/edit/actions', () => { let mock; beforeEach(() => { - mockedState = state({ projectId: '8' }); + mockedState = state({ entityId: '8', entityType: 'projects' }); mock = new MockAdapter(axios); window.gon = { api_version: 'v4' }; }); @@ -153,7 +153,7 @@ describe('ee/protected_environments/store/edit/actions', () => { type | rule | url | response ${'group with integer inheritance'} | ${{ group_id: 1, user_id: null, access_level: null, group_inheritance_type: 1 }} | ${'/api/v4/groups/1/members/all'} | ${[{ name: 'root' }]} ${'group without inheritance'} | ${{ group_id: 1, user_id: null, access_level: null, group_inheritance_type: 0 }} | ${'/api/v4/groups/1/members'} | ${[{ name: 'root' }]} - ${'user'} | ${{ group_id: null, user_id: 1, access_level: null, group_ineritance_type: null }} | ${'/api/v4/users/1'} | ${{ name: 'root' }} + ${'user'} | ${{ group_id: null, user_id: 1, access_level: null, group_inheritance_type: null }} | ${'/api/v4/users/1'} | ${{ name: 'root' }} ${'access level'} | ${{ group_id: null, user_id: null, access_level: MAINTAINER_ACCESS_LEVEL, group_inheritance_type: '0' }} | ${'/api/v4/projects/8/members'} | ${[{ name: 'root', access_level: MAINTAINER_ACCESS_LEVEL.toString() }]} `( 'successfully fetches members for a given deploy access rule of type $type', @@ -232,7 +232,7 @@ describe('ee/protected_environments/store/edit/actions', () => { it.each` type | rule | updatedRule ${'group'} | ${{ id: 1, group_id: 1, user_id: null, access_level: null, group_inheritance_type: '1' }} | ${{ id: 1, group_id: 1, group_inheritance_type: '1', _destroy: true }} - ${'user'} | ${{ id: 1, group_id: null, user_id: 1, access_level: null, group_ineritance_type: null }} | ${{ id: 1, user_id: 1, _destroy: true }} + ${'user'} | ${{ id: 1, group_id: null, user_id: 1, access_level: null, group_inheritance_type: null }} | ${{ id: 1, user_id: 1, _destroy: true }} ${'access level'} | ${{ id: 1, group_id: null, user_id: null, access_level: MAINTAINER_ACCESS_LEVEL, group_inheritance_type: '0' }} | ${{ id: 1, access_level: MAINTAINER_ACCESS_LEVEL, group_inheritance_type: '0', _destroy: true }} `('marks a rule for deletion of type $type', ({ rule, updatedRule }) => { return testAction( diff --git a/ee/spec/frontend/protected_environments/store/edit/mutations_spec.js b/ee/spec/frontend/protected_environments/store/edit/mutations_spec.js index 988890d49ebad3819bf6a3663450d582b31c4638..4f3e724ab1125654193ece3ff5085e49525b8255 100644 --- a/ee/spec/frontend/protected_environments/store/edit/mutations_spec.js +++ b/ee/spec/frontend/protected_environments/store/edit/mutations_spec.js @@ -6,7 +6,7 @@ describe('ee/protected_environments/store/edit/mutations', () => { let mockedState; beforeEach(() => { - mockedState = state({ projectId: '8' }); + mockedState = state({ entityId: '8', entityType: 'projects' }); }); describe(types.REQUEST_PROTECTED_ENVIRONMENTS, () => { diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 62765d3c93a67290d53b8dbee7b9df96225d23b1..f2b667ced1809468059a8e1faa7b600d00f9347e 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -21953,10 +21953,10 @@ msgstr "" msgid "Failed to load group activity metrics. Please try again." msgstr "" -msgid "Failed to load groups, users and deploy keys." +msgid "Failed to load groups and users." msgstr "" -msgid "Failed to load groups." +msgid "Failed to load groups, users and deploy keys." msgstr "" msgid "Failed to load labels. Please try again." @@ -42856,9 +42856,15 @@ msgstr "" msgid "ProtectedEnvironments|Select users" msgstr "" +msgid "ProtectedEnvironments|Set which groups, access levels, or users are required to approve in this environment tier." +msgstr "" + msgid "ProtectedEnvironments|Set which groups, access levels, or users are required to approve. Groups and users must be members of the project." msgstr "" +msgid "ProtectedEnvironments|Set which groups, access levels, or users can deploy in this environment tier." +msgstr "" + msgid "ProtectedEnvironments|Set which groups, access levels, or users can deploy to this environment. Groups and users must be members of the project." msgstr "" @@ -42904,6 +42910,9 @@ msgstr "" msgid "ProtectedEnvironment|Failed to protect the environment." msgstr "" +msgid "ProtectedEnvironment|No environments in this group are protected." +msgstr "" + msgid "ProtectedEnvironment|No environments in this project are protected." msgstr "" @@ -42925,6 +42934,9 @@ msgstr "" msgid "ProtectedEnvironment|Protect an environment" msgstr "" +msgid "ProtectedEnvironment|Protect environment tier" +msgstr "" + msgid "ProtectedEnvironment|Protected Environment (%{protected_environments_count})" msgstr "" @@ -42940,6 +42952,9 @@ msgstr "" msgid "ProtectedEnvironment|Select environment" msgstr "" +msgid "ProtectedEnvironment|Select environment tier" +msgstr "" + msgid "ProtectedEnvironment|Select groups" msgstr "" diff --git a/spec/frontend/groups/settings/components/access_dropdown_spec.js b/spec/frontend/groups/settings/components/access_dropdown_spec.js index 67a514ef35dbef740740bd637406c2644b841653..a1f930e0df58bb379a324ce033d63ebf91a34bb0 100644 --- a/spec/frontend/groups/settings/components/access_dropdown_spec.js +++ b/spec/frontend/groups/settings/components/access_dropdown_spec.js @@ -1,7 +1,9 @@ -import { GlDropdown, GlSearchBoxByType } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { last } from 'lodash'; import { nextTick } from 'vue'; -import { getSubGroups } from '~/groups/settings/api/access_dropdown_api'; +import waitForPromises from 'helpers/wait_for_promises'; +import { getSubGroups, getUsers } from '~/groups/settings/api/access_dropdown_api'; import AccessDropdown from '~/groups/settings/components/access_dropdown.vue'; jest.mock('~/groups/settings/api/access_dropdown_api', () => ({ @@ -12,8 +14,26 @@ jest.mock('~/groups/settings/api/access_dropdown_api', () => ({ { id: 6, name: 'group6' }, ], }), + getUsers: jest.fn().mockResolvedValue({ + data: [ + { id: 1, name: 'user1', avatar_url: 'avatar1' }, + { id: 2, name: 'user2', avatar_url: 'avatar2' }, + { id: 3, name: 'user3', avatar_url: 'avatar3' }, + ], + }), })); +const accessLevelsData = [ + { + id: 7, + text: 'role1', + }, + { + id: 8, + text: 'role2', + }, +]; + describe('Access Level Dropdown', () => { let wrapper; const createComponent = ({ ...optionalProps } = {}) => { @@ -28,6 +48,13 @@ describe('Access Level Dropdown', () => { }; const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownToggleLabel = () => findDropdown().props('text'); + const findAllDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem); + const findAllDropdownHeaders = () => findDropdown().findAllComponents(GlDropdownSectionHeader); + + const findDropdownItemWithText = (items, text) => + items.filter((item) => item.text().includes(text)).at(0); describe('data request', () => { it('should make an api call for sub-groups', () => { @@ -39,13 +66,32 @@ describe('Access Level Dropdown', () => { }); }); - it('should not make an API call sub groups when user does not have a license', () => { - createComponent({ hasLicense: false }); - expect(getSubGroups).not.toHaveBeenCalled(); + it('should make an api call for group members if `showUsers` prop is `true`', () => { + createComponent({ showUsers: true }); + expect(getUsers).toHaveBeenCalledTimes(1); }); - it('should make api calls when search query is updated', async () => { + it('should not make an api call for group members if `showUsers` prop is `false`', () => { createComponent(); + expect(getUsers).not.toHaveBeenCalled(); + }); + + describe('when user does not have a license', () => { + beforeEach(() => { + createComponent({ hasLicense: false }); + }); + + it('should not make an API call sub groups', () => { + expect(getSubGroups).not.toHaveBeenCalled(); + }); + + it('should not make an API call group members', () => { + expect(getUsers).not.toHaveBeenCalled(); + }); + }); + + it('should make api calls when search query is updated', async () => { + createComponent({ showUsers: true }); const search = 'root'; findSearchBox().vm.$emit('input', search); @@ -55,6 +101,119 @@ describe('Access Level Dropdown', () => { includeParentSharedGroups: true, search, }); + expect(getUsers).toHaveBeenCalledWith(search); + }); + }); + + describe('layout', () => { + beforeEach(async () => { + createComponent({ + accessLevelsData, + showUsers: true, + }); + await waitForPromises(); + }); + + it.each` + header | index + ${'Roles'} | ${0} + ${'Groups'} | ${1} + ${'Users'} | ${2} + `('renders header for $header at $index', ({ header, index }) => { + expect(findAllDropdownHeaders().at(index).text()).toBe(header); + }); + + it('renders dropdown item for each access level type', () => { + expect(findAllDropdownItems()).toHaveLength(8); + }); + }); + + describe('toggleLabel', () => { + it('when no items selected and custom label provided, displays it', () => { + const customLabel = 'Set the access level'; + createComponent({ label: customLabel }); + expect(findDropdownToggleLabel()).toBe(customLabel); + }); + + it('when no items selected, displays a default fallback label', () => { + createComponent(); + expect(findDropdownToggleLabel()).toBe('Select groups'); + }); + + it('displays selected items for each group level', async () => { + createComponent({ accessLevelsData, showUsers: true }); + await waitForPromises(); + + findAllDropdownItems().wrappers.forEach((item) => { + item.trigger('click'); + }); + await nextTick(); + expect(findDropdownToggleLabel()).toBe('2 roles, 3 groups, 3 users'); + }); + + it('with only role selected displays the role name', async () => { + createComponent({ accessLevelsData, showUsers: true }); + await waitForPromises(); + + await findDropdownItemWithText(findAllDropdownItems(), 'role1').trigger('click'); + expect(findDropdownToggleLabel()).toBe('role1'); + }); + + it('with only groups selected displays the number of selected groups', async () => { + createComponent(); + await waitForPromises(); + + await findDropdownItemWithText(findAllDropdownItems(), 'group4').trigger('click'); + expect(findDropdownToggleLabel()).toBe('1 group'); + }); + + it('with only users selected displays the number of selected users', async () => { + createComponent({ showUsers: true }); + await waitForPromises(); + + await findDropdownItemWithText(findAllDropdownItems(), 'user1').trigger('click'); + await findDropdownItemWithText(findAllDropdownItems(), 'user2').trigger('click'); + expect(findDropdownToggleLabel()).toBe('2 users'); + }); + + it('with users and groups selected displays the number of selected users & groups', async () => { + createComponent({ showUsers: true }); + await waitForPromises(); + + await findDropdownItemWithText(findAllDropdownItems(), 'group4').trigger('click'); + await findDropdownItemWithText(findAllDropdownItems(), 'user2').trigger('click'); + expect(findDropdownToggleLabel()).toBe('1 group, 1 user'); + }); + }); + + describe('selecting an item', () => { + it('selects the item on click and deselects on the next click', async () => { + createComponent(); + await waitForPromises(); + + const item = findAllDropdownItems().at(1); + item.trigger('click'); + await nextTick(); + expect(item.props('isChecked')).toBe(true); + item.trigger('click'); + await nextTick(); + expect(item.props('isChecked')).toBe(false); + }); + + it('emits a formatted update on selection', async () => { + createComponent({ accessLevelsData, showUsers: true }); + await waitForPromises(); + const dropdownItems = findAllDropdownItems(); + + findDropdownItemWithText(dropdownItems, 'role1').trigger('click'); + findDropdownItemWithText(dropdownItems, 'group4').trigger('click'); + findDropdownItemWithText(dropdownItems, 'user3').trigger('click'); + + expect(last(wrapper.emitted('select'))[0]).toStrictEqual([ + { access_level: 7 }, + { group_id: 4 }, + { user_id: 3 }, + ]); }); }); });