Commit 6582038a authored by Olena Horal-Koretska's avatar Olena Horal-Koretska Committed by Mark Florian

Styling updates of the protected environments edit access dropdown

Due to migration to GlDropdown.

Also, migrate `protected_environment_edit` to Vue. This is  done
for easier communication between the components. Besides the
migration would eventually happen anyways.

Part of https://gitlab.com/gitlab-org/gitlab/-/issues/284784.

Changelog: changed
EE: true
parent dd4410f0
...@@ -5,11 +5,10 @@ import { ...@@ -5,11 +5,10 @@ import {
GlDropdownSectionHeader, GlDropdownSectionHeader,
GlDropdownDivider, GlDropdownDivider,
GlSearchBoxByType, GlSearchBoxByType,
GlLoadingIcon,
GlAvatar, GlAvatar,
GlSprintf, GlSprintf,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { debounce } from 'lodash'; import { debounce, intersectionWith, groupBy, differenceBy, intersectionBy } from 'lodash';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { __, s__, n__ } from '~/locale'; import { __, s__, n__ } from '~/locale';
import { getUsers, getGroups, getDeployKeys } from '../api/access_dropdown_api'; import { getUsers, getGroups, getDeployKeys } from '../api/access_dropdown_api';
...@@ -32,7 +31,6 @@ export default { ...@@ -32,7 +31,6 @@ export default {
GlDropdownSectionHeader, GlDropdownSectionHeader,
GlDropdownDivider, GlDropdownDivider,
GlSearchBoxByType, GlSearchBoxByType,
GlLoadingIcon,
GlAvatar, GlAvatar,
GlSprintf, GlSprintf,
}, },
...@@ -50,10 +48,26 @@ export default { ...@@ -50,10 +48,26 @@ export default {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
label: {
type: String,
required: false,
default: i18n.selectUsers,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
preselectedItems: {
type: Array,
required: false,
default: () => [],
},
}, },
data() { data() {
return { return {
loading: false, loading: false,
initialLoading: false,
query: '', query: '',
users: [], users: [],
groups: [], groups: [],
...@@ -68,6 +82,9 @@ export default { ...@@ -68,6 +82,9 @@ export default {
}; };
}, },
computed: { computed: {
preselected() {
return groupBy(this.preselectedItems, 'type');
},
showDeployKeys() { showDeployKeys() {
return this.accessLevel === ACCESS_LEVELS.PUSH && this.deployKeys.length; return this.accessLevel === ACCESS_LEVELS.PUSH && this.deployKeys.length;
}, },
...@@ -105,10 +122,18 @@ export default { ...@@ -105,10 +122,18 @@ export default {
labelPieces.push(n__('1 group', '%d groups', counts[LEVEL_TYPES.GROUP])); labelPieces.push(n__('1 group', '%d groups', counts[LEVEL_TYPES.GROUP]));
} }
return labelPieces.join(', ') || i18n.selectUsers; return labelPieces.join(', ') || this.label;
}, },
toggleClass() { toggleClass() {
return this.toggleLabel === i18n.selectUsers ? 'gl-text-gray-500!' : ''; return this.toggleLabel === this.label ? 'gl-text-gray-500!' : '';
},
selection() {
return [
...this.getDataForSave(LEVEL_TYPES.ROLE, 'access_level'),
...this.getDataForSave(LEVEL_TYPES.GROUP, 'group_id'),
...this.getDataForSave(LEVEL_TYPES.USER, 'user_id'),
...this.getDataForSave(LEVEL_TYPES.DEPLOY_KEY, 'deploy_key_id'),
];
}, },
}, },
watch: { watch: {
...@@ -117,14 +142,14 @@ export default { ...@@ -117,14 +142,14 @@ export default {
}, 500), }, 500),
}, },
created() { created() {
this.getData(); this.getData({ initial: true });
}, },
methods: { methods: {
focusInput() { focusInput() {
this.$refs.search.focusInput(); this.$refs.search.focusInput();
}, },
getData() { getData({ initial = false } = {}) {
this.initialLoading = initial;
this.loading = true; this.loading = true;
if (this.hasLicense) { if (this.hasLicense) {
...@@ -133,20 +158,26 @@ export default { ...@@ -133,20 +158,26 @@ export default {
getUsers(this.query), getUsers(this.query),
this.groups.length ? Promise.resolve({ data: this.groups }) : getGroups(), this.groups.length ? Promise.resolve({ data: this.groups }) : getGroups(),
]) ])
.then(([deployKeysResponse, usersResponse, groupsResponse]) => .then(([deployKeysResponse, usersResponse, groupsResponse]) => {
this.consolidateData(deployKeysResponse.data, usersResponse.data, groupsResponse.data), this.consolidateData(deployKeysResponse.data, usersResponse.data, groupsResponse.data);
) this.setSelected({ initial });
})
.catch(() => .catch(() =>
createFlash({ message: __('Failed to load groups, users and deploy keys.') }), createFlash({ message: __('Failed to load groups, users and deploy keys.') }),
) )
.finally(() => { .finally(() => {
this.initialLoading = false;
this.loading = false; this.loading = false;
}); });
} else { } else {
getDeployKeys(this.query) getDeployKeys(this.query)
.then((deployKeysResponse) => this.consolidateData(deployKeysResponse.data)) .then((deployKeysResponse) => {
this.consolidateData(deployKeysResponse.data);
this.setSelected({ initial });
})
.catch(() => createFlash({ message: __('Failed to load deploy keys.') })) .catch(() => createFlash({ message: __('Failed to load deploy keys.') }))
.finally(() => { .finally(() => {
this.initialLoading = false;
this.loading = false; this.loading = false;
}); });
} }
...@@ -159,7 +190,13 @@ export default { ...@@ -159,7 +190,13 @@ export default {
if (this.hasLicense) { if (this.hasLicense) {
this.groups = groupsResponse.map((group) => ({ ...group, type: LEVEL_TYPES.GROUP })); this.groups = groupsResponse.map((group) => ({ ...group, type: LEVEL_TYPES.GROUP }));
this.users = usersResponse.map((user) => ({ ...user, type: LEVEL_TYPES.USER })); this.users = usersResponse.map(({ id, name, username, avatar_url }) => ({
id,
name,
username,
avatar_url,
type: LEVEL_TYPES.USER,
}));
} }
this.deployKeys = deployKeysResponse.map((response) => { this.deployKeys = deployKeysResponse.map((response) => {
...@@ -182,12 +219,84 @@ export default { ...@@ -182,12 +219,84 @@ export default {
}; };
}); });
}, },
setSelected({ initial } = {}) {
if (initial) {
// as all available groups && roles are always visible in the dropdown, we set local selected by looking
// for intersection in all roles/groups and initial selected (returned from BE).
// It is different for the users - not all the users will be returned on the first data load (another set
// will be returned on search, only first 20 are displayed initially).
// That is why we set ALL initial selected users (returned from BE) as local selected (not looking
// for the intersection with all users data) and later if the selected happens to be in the users list
// we filter it out from the list so that not to have duplicates
// TODO: we'll need to get back to how to handle deploy keys here but they are out of scope
// and will be checked when migrating protected branches access dropdown to the current component
// related issue - https://gitlab.com/gitlab-org/gitlab/-/issues/284784
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,
(group, selected) => {
return selected.type === LEVEL_TYPES.GROUP && group.id === selected.group_id;
},
);
this.selected[LEVEL_TYPES.GROUP] = selectedGroups;
const selectedDeployKeys = intersectionWith(
this.deployKeys,
this.preselectedItems,
(key, selected) => {
return selected.type === LEVEL_TYPES.DEPLOY_KEY && key.id === selected.deploy_key_id;
},
);
this.selected[LEVEL_TYPES.DEPLOY_KEY] = selectedDeployKeys;
const selectedUsers = this.preselectedItems
.filter(({ type }) => type === LEVEL_TYPES.USER)
.map(({ user_id, name, username, avatar_url, type }) => ({
id: user_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) {
const selected = this.selected[accessType].map(({ id }) => ({ [key]: id }));
const preselected = this.preselected[accessType];
const added = differenceBy(selected, preselected, key);
const preserved = intersectionBy(preselected, selected, key).map(({ id, [key]: keyId }) => ({
id,
[key]: keyId,
}));
const removed = differenceBy(preselected, selected, key).map(({ id, [key]: keyId }) => ({
id,
[key]: keyId,
_destroy: true,
}));
return [...added, ...removed, ...preserved];
},
onItemClick(item) { onItemClick(item) {
this.toggleSelection(this.selected[item.type], item); this.toggleSelection(this.selected[item.type], item);
this.emitUpdate(); this.emitUpdate();
}, },
toggleSelection(arr, item) { toggleSelection(arr, item) {
const itemIndex = arr.indexOf(item); const itemIndex = arr.findIndex(({ id }) => id === item.id);
if (itemIndex > -1) { if (itemIndex > -1) {
arr.splice(itemIndex, 1); arr.splice(itemIndex, 1);
} else arr.push(item); } else arr.push(item);
...@@ -196,8 +305,10 @@ export default { ...@@ -196,8 +305,10 @@ export default {
return this.selected[item.type].some((selected) => selected.id === item.id); return this.selected[item.type].some((selected) => selected.id === item.id);
}, },
emitUpdate() { emitUpdate() {
const selected = Object.values(this.selected).flat(); this.$emit('select', this.selection);
this.$emit('select', selected); },
onHide() {
this.$emit('hidden', this.selection);
}, },
}, },
}; };
...@@ -205,15 +316,16 @@ export default { ...@@ -205,15 +316,16 @@ export default {
<template> <template>
<gl-dropdown <gl-dropdown
:disabled="disabled || initialLoading"
:text="toggleLabel" :text="toggleLabel"
class="gl-display-block" class="gl-min-w-20"
:toggle-class="toggleClass" :toggle-class="toggleClass"
aria-labelledby="allowed-users-label" aria-labelledby="allowed-users-label"
@shown="focusInput" @shown="focusInput"
@hidden="onHide"
> >
<template #header> <template #header>
<gl-search-box-by-type ref="search" v-model.trim="query" /> <gl-search-box-by-type ref="search" v-model.trim="query" :is-loading="loading" />
<gl-loading-icon v-if="loading" size="sm" />
</template> </template>
<template v-if="roles.length"> <template v-if="roles.length">
<gl-dropdown-section-header>{{ <gl-dropdown-section-header>{{
...@@ -221,7 +333,8 @@ export default { ...@@ -221,7 +333,8 @@ export default {
}}</gl-dropdown-section-header> }}</gl-dropdown-section-header>
<gl-dropdown-item <gl-dropdown-item
v-for="role in roles" v-for="role in roles"
:key="role.id" :key="`${role.id}${role.text}`"
data-testid="role-dropdown-item"
is-check-item is-check-item
:is-checked="isSelected(role)" :is-checked="isSelected(role)"
@click.native.capture.stop="onItemClick(role)" @click.native.capture.stop="onItemClick(role)"
...@@ -237,7 +350,9 @@ export default { ...@@ -237,7 +350,9 @@ export default {
}}</gl-dropdown-section-header> }}</gl-dropdown-section-header>
<gl-dropdown-item <gl-dropdown-item
v-for="group in groups" v-for="group in groups"
:key="group.id" :key="`${group.id}${group.name}`"
fingerprint
data-testid="group-dropdown-item"
:avatar-url="group.avatar_url" :avatar-url="group.avatar_url"
is-check-item is-check-item
:is-checked="isSelected(group)" :is-checked="isSelected(group)"
...@@ -254,7 +369,8 @@ export default { ...@@ -254,7 +369,8 @@ export default {
}}</gl-dropdown-section-header> }}</gl-dropdown-section-header>
<gl-dropdown-item <gl-dropdown-item
v-for="user in users" v-for="user in users"
:key="user.id" :key="`${user.id}${user.username}`"
data-testid="user-dropdown-item"
:avatar-url="user.avatar_url" :avatar-url="user.avatar_url"
:secondary-text="user.username" :secondary-text="user.username"
is-check-item is-check-item
...@@ -272,7 +388,8 @@ export default { ...@@ -272,7 +388,8 @@ export default {
}}</gl-dropdown-section-header> }}</gl-dropdown-section-header>
<gl-dropdown-item <gl-dropdown-item
v-for="key in deployKeys" v-for="key in deployKeys"
:key="key.id" :key="`${key.id}${key.fingerprint}`"
data-testid="deploy_key-dropdown-item"
is-check-item is-check-item
:is-checked="isSelected(key)" :is-checked="isSelected(key)"
class="gl-text-truncate" class="gl-text-truncate"
......
import * as Sentry from '@sentry/browser';
import Vue from 'vue'; import Vue from 'vue';
import AccessDropdown from './components/access_dropdown.vue'; import AccessDropdown from './components/access_dropdown.vue';
...@@ -7,6 +8,13 @@ export const initAccessDropdown = (el, options) => { ...@@ -7,6 +8,13 @@ export const initAccessDropdown = (el, options) => {
} }
const { accessLevelsData, accessLevel } = options; const { accessLevelsData, accessLevel } = options;
const { label, disabled, preselectedItems } = el.dataset;
let preselected = [];
try {
preselected = JSON.parse(preselectedItems);
} catch (e) {
Sentry.captureException(e);
}
return new Vue({ return new Vue({
el, el,
...@@ -16,6 +24,9 @@ export const initAccessDropdown = (el, options) => { ...@@ -16,6 +24,9 @@ export const initAccessDropdown = (el, options) => {
props: { props: {
accessLevel, accessLevel,
accessLevelsData: accessLevelsData.roles, accessLevelsData: accessLevelsData.roles,
preselectedItems: preselected,
label,
disabled,
}, },
on: { on: {
select(selected) { select(selected) {
......
import Vue from 'vue'; import Vue from 'vue';
import ProtectedEnvironmentCreate from 'ee/protected_environments/protected_environment_create'; import ProtectedEnvironmentCreate from 'ee/protected_environments/protected_environment_create';
import ProtectedEnvironmentEditList from 'ee/protected_environments/protected_environment_edit_list'; import { initProtectedEnvironmentEditList } from 'ee/protected_environments/protected_environment_edit_list';
import LicenseManagement from 'ee/vue_shared/license_compliance/license_management.vue'; import LicenseManagement from 'ee/vue_shared/license_compliance/license_management.vue';
import createStore from 'ee/vue_shared/license_compliance/store/index'; import createStore from 'ee/vue_shared/license_compliance/store/index';
import showToast from '~/vue_shared/plugins/global_toast'; import showToast from '~/vue_shared/plugins/global_toast';
...@@ -32,5 +32,4 @@ toasts.forEach((toast) => showToast(toast.dataset.message)); ...@@ -32,5 +32,4 @@ toasts.forEach((toast) => showToast(toast.dataset.message));
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new ProtectedEnvironmentCreate(); new ProtectedEnvironmentCreate();
// eslint-disable-next-line no-new initProtectedEnvironmentEditList();
new ProtectedEnvironmentEditList();
...@@ -5,7 +5,7 @@ import AccessorUtilities from '~/lib/utils/accessor'; ...@@ -5,7 +5,7 @@ import AccessorUtilities from '~/lib/utils/accessor';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { initAccessDropdown } from '~/projects/settings/init_access_dropdown'; import { initAccessDropdown } from '~/projects/settings/init_access_dropdown';
import { ACCESS_LEVELS, LEVEL_TYPES } from './constants'; import { ACCESS_LEVELS } from './constants';
const PROTECTED_ENVIRONMENT_INPUT = 'input[name="protected_environment[name]"]'; const PROTECTED_ENVIRONMENT_INPUT = 'input[name="protected_environment[name]"]';
...@@ -84,29 +84,7 @@ export default class ProtectedEnvironmentCreate { ...@@ -84,29 +84,7 @@ export default class ProtectedEnvironmentCreate {
name: this.$form.find(PROTECTED_ENVIRONMENT_INPUT).val(), name: this.$form.find(PROTECTED_ENVIRONMENT_INPUT).val(),
}, },
}; };
formData.protected_environment[`${ACCESS_LEVELS.DEPLOY}_attributes`] = this.selected;
Object.keys(ACCESS_LEVELS).forEach((level) => {
const accessLevel = ACCESS_LEVELS[level];
const levelAttributes = [];
this.selected.forEach((item) => {
if (item.type === LEVEL_TYPES.USER) {
levelAttributes.push({
user_id: item.id,
});
} else if (item.type === LEVEL_TYPES.ROLE) {
levelAttributes.push({
access_level: item.id,
});
} else if (item.type === LEVEL_TYPES.GROUP) {
levelAttributes.push({
group_id: item.id,
});
}
});
formData.protected_environment[`${accessLevel}_attributes`] = levelAttributes;
});
return formData; return formData;
} }
......
import $ from 'jquery';
import { find } from 'lodash';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import AccessDropdown from '~/projects/settings/access_dropdown';
import { ACCESS_LEVELS, LEVEL_TYPES } from './constants';
export default class ProtectedEnvironmentEdit {
constructor(options) {
this.$wraps = {};
this.hasChanges = false;
this.$wrap = options.$wrap;
this.$allowedToDeployDropdown = this.$wrap.find('.js-allowed-to-deploy');
this.$wraps[ACCESS_LEVELS.DEPLOY] = this.$allowedToDeployDropdown.closest(
`.${ACCESS_LEVELS.DEPLOY}-container`,
);
this.buildDropdowns();
}
buildDropdowns() {
// Allowed to deploy dropdown
this[`${ACCESS_LEVELS.DEPLOY}_dropdown`] = new AccessDropdown({
accessLevel: ACCESS_LEVELS.deploy,
accessLevelsData: gon.deploy_access_levels,
$dropdown: this.$allowedToDeployDropdown,
onSelect: this.onSelectOption.bind(this),
onHide: this.onDropdownHide.bind(this),
});
}
onSelectOption() {
this.hasChanges = true;
}
onDropdownHide() {
if (!this.hasChanges) {
return;
}
this.hasChanges = true;
this.updatePermissions();
}
updatePermissions() {
const formData = Object.keys(ACCESS_LEVELS).reduce((acc, level) => {
const accessLevelName = ACCESS_LEVELS[level];
const inputData = this[`${accessLevelName}_dropdown`].getInputData(accessLevelName);
acc[`${accessLevelName}_attributes`] = inputData;
return acc;
}, {});
axios
.patch(this.$wrap.data('url'), {
protected_environment: formData,
})
.then(({ data }) => {
this.hasChanges = false;
Object.keys(ACCESS_LEVELS).forEach((level) => {
const accessLevelName = ACCESS_LEVELS[level];
// The data coming from server will be the new persisted *state* for each dropdown
this.setSelectedItemsToDropdown(data[accessLevelName], `${accessLevelName}_dropdown`);
});
this.$allowedToDeployDropdown.enable();
})
.catch(() => {
this.$allowedToDeployDropdown.enable();
createFlash({
message: __('Failed to update environment!'),
type: null,
parent: $('.js-protected-environments-list'),
});
});
}
setSelectedItemsToDropdown(items = [], dropdownName) {
const itemsToAdd = items.map((currentItem) => {
if (currentItem.user_id) {
// Do this only for users for now
// get the current data for selected items
const selectedItems = this[dropdownName].getSelectedItems();
const currentSelectedItem = find(selectedItems, {
user_id: currentItem.user_id,
});
return {
id: currentItem.id,
user_id: currentItem.user_id,
type: LEVEL_TYPES.USER,
persisted: true,
name: currentSelectedItem.name,
username: currentSelectedItem.username,
avatar_url: currentSelectedItem.avatar_url,
};
} else if (currentItem.group_id) {
return {
id: currentItem.id,
group_id: currentItem.group_id,
type: LEVEL_TYPES.GROUP,
persisted: true,
};
}
return {
id: currentItem.id,
access_level: currentItem.access_level,
type: LEVEL_TYPES.ROLE,
persisted: true,
};
});
this[dropdownName].setSelectedItems(itemsToAdd);
}
}
<script>
import AccessDropdown from '~/projects/settings/components/access_dropdown.vue';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import { ACCESS_LEVELS, LEVEL_TYPES } from './constants';
export const i18n = {
successMessage: __('Successfully updated the environment.'),
failureMessage: __('Failed to update environment!'),
};
export default {
i18n,
ACCESS_LEVELS,
accessLevelsData: gon?.deploy_access_levels?.roles ?? [],
components: {
AccessDropdown,
},
props: {
parentContainer: {
required: true,
type: HTMLElement,
},
url: {
type: String,
required: true,
},
label: {
type: String,
required: false,
default: i18n.selectUsers,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
preselectedItems: {
type: Array,
required: false,
default: () => [],
},
},
data() {
return {
preselected: this.preselectedItems,
selected: null,
};
},
computed: {
hasChanges() {
return this.selected.some(({ id, _destroy }) => id === undefined || _destroy);
},
},
methods: {
updatePermissions(permissions) {
this.selected = permissions;
if (!this.hasChanges) {
return;
}
axios
.patch(this.url, {
protected_environment: { [`${ACCESS_LEVELS.DEPLOY}_attributes`]: permissions },
})
.then(({ data }) => {
this.$toast.show(i18n.successMessage);
this.updatePreselected(data);
})
.catch(() => {
createFlash({
message: i18n.failureMessage,
parent: this.parentContainer,
});
});
},
updatePreselected(items = []) {
this.preselected = items[ACCESS_LEVELS.DEPLOY].map(
({ id, user_id: userId, group_id: groupId, access_level: accessLevel }) => {
if (userId) {
return {
id,
user_id: userId,
type: LEVEL_TYPES.USER,
};
}
if (groupId) {
return {
id,
group_id: groupId,
type: LEVEL_TYPES.GROUP,
};
}
return {
id,
access_level: accessLevel,
type: LEVEL_TYPES.ROLE,
};
},
);
},
},
};
</script>
<template>
<access-dropdown
:access-levels-data="$options.accessLevelsData"
:access-level="$options.ACCESS_LEVELS.DEPLOY"
:label="label"
:disabled="disabled"
:preselected-items="preselected"
@hidden="updatePermissions"
/>
</template>
/* eslint-disable no-new */ import * as Sentry from '@sentry/browser';
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
import ProtectedEnvironmentEdit from './protected_environment_edit.vue';
import $ from 'jquery'; Vue.use(GlToast);
import ProtectedEnvironmentEdit from './protected_environment_edit';
export default class ProtectedEnvironmentEditList { export const initProtectedEnvironmentEditList = () => {
constructor() { const parentContainer = document.querySelector('.js-protected-environments-list');
this.$wrap = $('.protected-branches-list'); const envEditFormEls = parentContainer.querySelectorAll('.js-protected-environment-edit-form');
this.initEditForm();
}
initEditForm() { envEditFormEls.forEach((el) => {
this.$wrap.find('.js-protected-environment-edit-form').each((i, el) => { const accessDropdownEl = el.querySelector('.js-allowed-to-deploy');
new ProtectedEnvironmentEdit({ if (!accessDropdownEl) {
$wrap: $(el), return false;
}); }
const { url } = el.dataset;
const { label, disabled, preselectedItems } = accessDropdownEl.dataset;
let preselected = [];
try {
preselected = JSON.parse(preselectedItems);
} catch (e) {
Sentry.captureException(e);
}
return new Vue({
el: accessDropdownEl,
render(createElement) {
return createElement(ProtectedEnvironmentEdit, {
props: {
parentContainer,
preselectedItems: preselected,
url,
label,
disabled,
},
});
},
}); });
} });
} };
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
%p.settings-message.text-center %p.settings-message.text-center
= s_('ProtectedEnvironment|There are currently no protected environments. Protect an environment with this form.') = s_('ProtectedEnvironment|There are currently no protected environments. Protect an environment with this form.')
- else - else
.flash-container
%table.table.table-bordered %table.table.table-bordered
%colgroup %colgroup
%col{ width: '30%' } %col{ width: '30%' }
......
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
= render partial: 'projects/protected_environments/environments_dropdown', locals: { f: f, project: @project } = render partial: 'projects/protected_environments/environments_dropdown', locals: { f: f, project: @project }
.form-group .form-group
%label#allowed-users-label.label-bold %label#allowed-users-label.label-bold.gl-display-block
= s_('ProtectedEnvironment|Allowed to deploy') = s_('ProtectedEnvironment|Allowed to deploy')
.js-allowed-to-deploy-dropdown .js-allowed-to-deploy-dropdown
......
- default_label = s_('RepositorySettingsAccessLevel|Select') - default_label = s_('RepositorySettingsAccessLevel|Select')
.deploy_access_levels-container .deploy_access_levels-container
= dropdown_tag(default_label, options: { toggle_class: 'js-allowed-to-deploy wide js-multiselect', disabled: local_assigns[:disabled], dropdown_class: 'dropdown-menu-selectable dropdown-menu-user', filter: true, data: { field_name: "allowed_to_deploy_#{protected_environment.id}", preselected_items: access_levels_data(access_levels) }}) .js-allowed-to-deploy{ data: {label: default_label, disabled: local_assigns[:disabled], preselected_items: access_levels_data(access_levels).to_json } }
import MockAdapter from 'axios-mock-adapter';
import { shallowMount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import AccessDropdown from '~/projects/settings/components/access_dropdown.vue';
import ProtectedEnvironmentEdit, {
i18n,
} from 'ee/protected_environments/protected_environment_edit.vue';
import { ACCESS_LEVELS, LEVEL_TYPES } from 'ee/protected_environments/constants';
import createFlash from '~/flash';
import httpStatusCodes from '~/lib/utils/http_status';
jest.mock('~/flash');
const $toast = {
show: jest.fn(),
};
describe('Protected Environment Edit', () => {
let wrapper;
let originalGon;
let mockAxios;
const url = 'http://some.url';
const parentContainer = document.createElement('div');
beforeEach(() => {
originalGon = window.gon;
window.gon = {
...window.gon,
deploy_access_levels: {
roles: [],
},
};
mockAxios = new MockAdapter(axios);
});
afterEach(() => {
window.gon = originalGon;
mockAxios.restore();
wrapper.destroy();
});
const createComponent = ({ preselectedItems = [], disabled = false, label = '' } = {}) => {
wrapper = shallowMount(ProtectedEnvironmentEdit, {
propsData: {
parentContainer,
url,
disabled,
label,
preselectedItems,
},
mocks: {
$toast,
},
});
};
const findAccessDropdown = () => wrapper.findComponent(AccessDropdown);
it('renders AccessDropdown and passes down the props', () => {
const label = 'Update permissions';
const disabled = true;
const preselectedItems = [1, 2, 3];
createComponent({
label,
disabled,
preselectedItems,
});
const dropdown = findAccessDropdown();
expect(dropdown.props()).toMatchObject({
accessLevel: ACCESS_LEVELS.DEPLOY,
disabled,
label,
preselectedItems,
});
});
it('should NOT make a request if updated permissions are the same as preselected', () => {
createComponent();
jest.spyOn(axios, 'patch');
findAccessDropdown().vm.$emit('hidden', []);
expect(axios.patch).not.toHaveBeenCalled();
});
it('should make a request if updated permissions are different than preselected', () => {
createComponent();
jest.spyOn(axios, 'patch');
const newPermissions = [{ user_id: 1 }];
findAccessDropdown().vm.$emit('hidden', newPermissions);
expect(axios.patch).toHaveBeenCalledWith(url, {
protected_environment: { deploy_access_levels_attributes: newPermissions },
});
});
describe('on successful permissions update', () => {
beforeEach(async () => {
createComponent();
const updatedPermissions = [
{ user_id: 1, id: 1 },
{ group_id: 1, id: 2 },
{ access_level: 3, id: 3 },
];
mockAxios
.onPatch()
.replyOnce(httpStatusCodes.OK, { [ACCESS_LEVELS.DEPLOY]: updatedPermissions });
findAccessDropdown().vm.$emit('hidden', [{ user_id: 1 }]);
await waitForPromises();
});
it('should show a toast with success message', () => {
expect($toast.show).toHaveBeenCalledWith(i18n.successMessage);
});
it('should update preselected', () => {
const newPreselected = [
{ user_id: 1, id: 1, type: LEVEL_TYPES.USER },
{ group_id: 1, id: 2, type: LEVEL_TYPES.GROUP },
{ access_level: 3, id: 3, type: LEVEL_TYPES.ROLE },
];
expect(findAccessDropdown().props('preselectedItems')).toEqual(newPreselected);
});
});
describe('on permissions update failure', () => {
beforeEach(() => {
mockAxios.onPatch().replyOnce(httpStatusCodes.BAD_REQUEST, {});
createComponent();
});
it('should show error message', async () => {
findAccessDropdown().vm.$emit('hidden', [{ user_id: 1 }]);
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: i18n.failureMessage,
parent: parentContainer,
});
});
});
});
...@@ -32804,6 +32804,9 @@ msgstr "" ...@@ -32804,6 +32804,9 @@ msgstr ""
msgid "Successfully updated %{last_updated_timeago}." msgid "Successfully updated %{last_updated_timeago}."
msgstr "" msgstr ""
msgid "Successfully updated the environment."
msgstr ""
msgid "Suggest code changes which can be immediately applied in one click. Try it out!" msgid "Suggest code changes which can be immediately applied in one click. Try it out!"
msgstr "" msgstr ""
......
...@@ -5,22 +5,33 @@ import { ...@@ -5,22 +5,33 @@ import {
GlDropdownSectionHeader, GlDropdownSectionHeader,
GlSearchBoxByType, GlSearchBoxByType,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { getUsers, getGroups, getDeployKeys } from '~/projects/settings/api/access_dropdown_api'; import { getUsers, getGroups, getDeployKeys } from '~/projects/settings/api/access_dropdown_api';
import AccessDropdown, { i18n } from '~/projects/settings/components/access_dropdown.vue'; import AccessDropdown, { i18n } from '~/projects/settings/components/access_dropdown.vue';
import { ACCESS_LEVELS } from '~/projects/settings/constants'; import { ACCESS_LEVELS, LEVEL_TYPES } from '~/projects/settings/constants';
jest.mock('~/projects/settings/api/access_dropdown_api', () => ({ jest.mock('~/projects/settings/api/access_dropdown_api', () => ({
getUsers: jest.fn().mockResolvedValue({ data: [{ id: 1 }, { id: 2 }] }), getGroups: jest.fn().mockResolvedValue({
getGroups: jest.fn().mockResolvedValue({ data: [{ id: 3 }, { id: 4 }, { id: 5 }] }), data: [
{ id: 4, name: 'group4' },
{ id: 5, name: 'group5' },
{ id: 6, name: 'group6' },
],
}),
getUsers: jest.fn().mockResolvedValue({
data: [
{ id: 7, name: 'user7' },
{ id: 8, name: 'user8' },
{ id: 9, name: 'user9' },
],
}),
getDeployKeys: jest.fn().mockResolvedValue({ getDeployKeys: jest.fn().mockResolvedValue({
data: [ data: [
{ id: 6, title: 'key1', fingerprint: 'abcdefghijklmnop', owner: { name: 'user1' } }, { id: 10, title: 'key10', fingerprint: 'abcdefghijklmnop', owner: { name: 'user1' } },
{ id: 7, title: 'key1', fingerprint: 'abcdefghijklmnop', owner: { name: 'user2' } }, { id: 11, title: 'key11', fingerprint: 'abcdefghijklmnop', owner: { name: 'user2' } },
{ id: 8, title: 'key1', fingerprint: 'abcdefghijklmnop', owner: { name: 'user3' } }, { id: 12, title: 'key12', fingerprint: 'abcdefghijklmnop', owner: { name: 'user3' } },
{ id: 9, title: 'key1', fingerprint: 'abcdefghijklmnop', owner: { name: 'user4' } },
], ],
}), }),
})); }));
...@@ -29,21 +40,35 @@ describe('Access Level Dropdown', () => { ...@@ -29,21 +40,35 @@ describe('Access Level Dropdown', () => {
let wrapper; let wrapper;
const mockAccessLevelsData = [ const mockAccessLevelsData = [
{ {
id: 42, id: 1,
text: 'Dummy Role', text: 'role1',
},
{
id: 2,
text: 'role2',
},
{
id: 3,
text: 'role3',
}, },
]; ];
const createComponent = ({ const createComponent = ({
accessLevelsData = mockAccessLevelsData, accessLevelsData = mockAccessLevelsData,
accessLevel = ACCESS_LEVELS.PUSH, accessLevel = ACCESS_LEVELS.PUSH,
hasLicense = true, hasLicense,
label,
disabled,
preselectedItems,
} = {}) => { } = {}) => {
wrapper = shallowMount(AccessDropdown, { wrapper = shallowMountExtended(AccessDropdown, {
propsData: { propsData: {
accessLevelsData, accessLevelsData,
accessLevel, accessLevel,
hasLicense, hasLicense,
label,
disabled,
preselectedItems,
}, },
stubs: { stubs: {
GlSprintf, GlSprintf,
...@@ -52,12 +77,19 @@ describe('Access Level Dropdown', () => { ...@@ -52,12 +77,19 @@ describe('Access Level Dropdown', () => {
}); });
}; };
afterEach(() => {
wrapper.destroy();
});
const findDropdown = () => wrapper.findComponent(GlDropdown); const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownToggleLabel = () => findDropdown().props('text'); const findDropdownToggleLabel = () => findDropdown().props('text');
const findAllDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem); const findAllDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem);
const findAllDropdownHeaders = () => findDropdown().findAllComponents(GlDropdownSectionHeader); const findAllDropdownHeaders = () => findDropdown().findAllComponents(GlDropdownSectionHeader);
const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
const findDropdownItemWithText = (items, text) =>
items.filter((item) => item.text().includes(text)).at(0);
describe('data request', () => { describe('data request', () => {
it('should make an api call for users, groups && deployKeys when user has a license', () => { it('should make an api call for users, groups && deployKeys when user has a license', () => {
createComponent(); createComponent();
...@@ -84,6 +116,7 @@ describe('Access Level Dropdown', () => { ...@@ -84,6 +116,7 @@ describe('Access Level Dropdown', () => {
expect(getDeployKeys).toHaveBeenCalledWith(query); expect(getDeployKeys).toHaveBeenCalledWith(query);
}); });
}); });
describe('layout', () => { describe('layout', () => {
beforeEach(async () => { beforeEach(async () => {
createComponent(); createComponent();
...@@ -95,79 +128,88 @@ describe('Access Level Dropdown', () => { ...@@ -95,79 +128,88 @@ describe('Access Level Dropdown', () => {
}); });
it('renders dropdown item for each access level type', () => { it('renders dropdown item for each access level type', () => {
expect(findAllDropdownItems()).toHaveLength(10); expect(findAllDropdownItems()).toHaveLength(12);
}); });
}); });
describe('toggleLabel', () => { describe('toggleLabel', () => {
let dropdownItems = [];
beforeEach(async () => { beforeEach(async () => {
createComponent(); createComponent();
await waitForPromises(); await waitForPromises();
dropdownItems = findAllDropdownItems();
}); });
const triggerNthItemClick = async (n) => { const findItemByNameAndClick = async (name) => {
findAllDropdownItems().at(n).trigger('click'); findDropdownItemWithText(dropdownItems, name).trigger('click');
await nextTick(); await nextTick();
}; };
it('when no items selected displays a default label and has default CSS class ', () => { it('when no items selected and custom label provided, displays it and has default CSS class', () => {
wrapper.destroy();
const customLabel = 'Set the access level';
createComponent({ label: customLabel });
expect(findDropdownToggleLabel()).toBe(customLabel);
expect(findDropdown().props('toggleClass')).toBe('gl-text-gray-500!');
});
it('when no items selected, displays a default fallback label and has default CSS class ', () => {
expect(findDropdownToggleLabel()).toBe(i18n.selectUsers); expect(findDropdownToggleLabel()).toBe(i18n.selectUsers);
expect(findDropdown().props('toggleClass')).toBe('gl-text-gray-500!'); expect(findDropdown().props('toggleClass')).toBe('gl-text-gray-500!');
}); });
it('displays a number of selected items for each group level', async () => { it('displays a number of selected items for each group level', async () => {
findAllDropdownItems().wrappers.forEach((item) => { dropdownItems.wrappers.forEach((item) => {
item.trigger('click'); item.trigger('click');
}); });
await nextTick(); await nextTick();
expect(findDropdownToggleLabel()).toBe('1 role, 2 users, 4 deploy keys, 3 groups'); expect(findDropdownToggleLabel()).toBe('3 roles, 3 users, 3 deploy keys, 3 groups');
}); });
it('with only role selected displays the role name and has no class applied', async () => { it('with only role selected displays the role name and has no class applied', async () => {
await triggerNthItemClick(0); await findItemByNameAndClick('role1');
expect(findDropdownToggleLabel()).toBe('Dummy Role'); expect(findDropdownToggleLabel()).toBe('role1');
expect(findDropdown().props('toggleClass')).toBe(''); expect(findDropdown().props('toggleClass')).toBe('');
}); });
it('with only groups selected displays the number of selected groups', async () => { it('with only groups selected displays the number of selected groups', async () => {
await triggerNthItemClick(1); await findItemByNameAndClick('group4');
await triggerNthItemClick(2); await findItemByNameAndClick('group5');
await triggerNthItemClick(3); await findItemByNameAndClick('group6');
expect(findDropdownToggleLabel()).toBe('3 groups'); expect(findDropdownToggleLabel()).toBe('3 groups');
expect(findDropdown().props('toggleClass')).toBe(''); expect(findDropdown().props('toggleClass')).toBe('');
}); });
it('with only users selected displays the number of selected users', async () => { it('with only users selected displays the number of selected users', async () => {
await triggerNthItemClick(4); await findItemByNameAndClick('user7');
await triggerNthItemClick(5); await findItemByNameAndClick('user8');
expect(findDropdownToggleLabel()).toBe('2 users'); expect(findDropdownToggleLabel()).toBe('2 users');
expect(findDropdown().props('toggleClass')).toBe(''); expect(findDropdown().props('toggleClass')).toBe('');
}); });
it('with users and groups selected displays the number of selected users & groups', async () => { it('with users and groups selected displays the number of selected users & groups', async () => {
await triggerNthItemClick(1); await findItemByNameAndClick('group4');
await triggerNthItemClick(2); await findItemByNameAndClick('group6');
await triggerNthItemClick(4); await findItemByNameAndClick('user7');
await triggerNthItemClick(5); await findItemByNameAndClick('user9');
expect(findDropdownToggleLabel()).toBe('2 users, 2 groups'); expect(findDropdownToggleLabel()).toBe('2 users, 2 groups');
expect(findDropdown().props('toggleClass')).toBe(''); expect(findDropdown().props('toggleClass')).toBe('');
}); });
it('with users and deploy keys selected displays the number of selected users & keys', async () => { it('with users and deploy keys selected displays the number of selected users & keys', async () => {
await triggerNthItemClick(1); await findItemByNameAndClick('user8');
await triggerNthItemClick(2); await findItemByNameAndClick('key10');
await triggerNthItemClick(6); await findItemByNameAndClick('key11');
expect(findDropdownToggleLabel()).toBe('1 deploy key, 2 groups'); expect(findDropdownToggleLabel()).toBe('1 user, 2 deploy keys');
expect(findDropdown().props('toggleClass')).toBe(''); expect(findDropdown().props('toggleClass')).toBe('');
}); });
}); });
describe('selecting an item', () => { describe('selecting an item', () => {
beforeEach(async () => { it('selects the item on click and deselects on the next click ', async () => {
createComponent(); createComponent();
await waitForPromises(); await waitForPromises();
});
it('selects the item on click and deselects on the next click ', async () => {
const item = findAllDropdownItems().at(1); const item = findAllDropdownItems().at(1);
item.trigger('click'); item.trigger('click');
await nextTick(); await nextTick();
...@@ -177,18 +219,101 @@ describe('Access Level Dropdown', () => { ...@@ -177,18 +219,101 @@ describe('Access Level Dropdown', () => {
expect(item.props('isChecked')).toBe(false); expect(item.props('isChecked')).toBe(false);
}); });
it('emits an update on selection ', async () => { it('emits a formatted update on selection ', async () => {
// ids: the items appear in that order in the dropdown
// 1 2 3 - roles
// 4 5 6 - groups
// 7 8 9 - users
// 10 11 12 - deploy_keys
// we set 2 from each group as preselected. Then for the sake of the test deselect one, leave one as-is
// and select a new one from the group.
// Preselected items should have `id` along with `user_id/group_id/access_level/deplo_key_id`.
// Items to be removed from previous selection will have `_deploy` flag set to true
// Newly selected items will have only `user_id/group_id/access_level/deploy_key_id` (depending on their type);
const preselectedItems = [
{ id: 112, type: 'role', access_level: 2 },
{ id: 113, type: 'role', access_level: 3 },
{ id: 115, type: 'group', group_id: 5 },
{ id: 116, type: 'group', group_id: 6 },
{ id: 118, type: 'user', user_id: 8, name: 'user8' },
{ id: 119, type: 'user', user_id: 9, name: 'user9' },
{ id: 121, type: 'deploy_key', deploy_key_id: 11 },
{ id: 122, type: 'deploy_key', deploy_key_id: 12 },
];
createComponent({ preselectedItems });
await waitForPromises();
const spy = jest.spyOn(wrapper.vm, '$emit'); const spy = jest.spyOn(wrapper.vm, '$emit');
findAllDropdownItems().at(4).trigger('click'); const dropdownItems = findAllDropdownItems();
findAllDropdownItems().at(3).trigger('click'); // select new item from each group
await nextTick(); findDropdownItemWithText(dropdownItems, 'role1').trigger('click');
findDropdownItemWithText(dropdownItems, 'group4').trigger('click');
findDropdownItemWithText(dropdownItems, 'user7').trigger('click');
findDropdownItemWithText(dropdownItems, 'key10').trigger('click');
// deselect one item from each group
findDropdownItemWithText(dropdownItems, 'role2').trigger('click');
findDropdownItemWithText(dropdownItems, 'group5').trigger('click');
findDropdownItemWithText(dropdownItems, 'user8').trigger('click');
findDropdownItemWithText(dropdownItems, 'key11').trigger('click');
expect(spy).toHaveBeenLastCalledWith('select', [ expect(spy).toHaveBeenLastCalledWith('select', [
{ id: 5, type: 'group' }, { access_level: 1 },
{ id: 1, type: 'user' }, { id: 112, access_level: 2, _destroy: true },
{ id: 113, access_level: 3 },
{ group_id: 4 },
{ id: 115, group_id: 5, _destroy: true },
{ id: 116, group_id: 6 },
{ user_id: 7 },
{ id: 118, user_id: 8, _destroy: true },
{ id: 119, user_id: 9 },
{ deploy_key_id: 10 },
{ id: 121, deploy_key_id: 11, _destroy: true },
{ id: 122, deploy_key_id: 12 },
]); ]);
}); });
}); });
describe('Handling preselected items', () => {
const preselectedItems = [
{ id: 112, type: 'role', access_level: 2 },
{ id: 115, type: 'group', group_id: 5 },
{ id: 118, type: 'user', user_id: 8, name: 'user2' },
{ id: 121, type: 'deploy_key', deploy_key_id: 11 },
];
const findSelected = (type) =>
wrapper.findAllByTestId(`${type}-dropdown-item`).filter((w) => w.props('isChecked'));
beforeEach(async () => {
createComponent({ preselectedItems });
await waitForPromises();
});
it('should set selected roles as intersection between the server response and preselected', () => {
const selectedRoles = findSelected(LEVEL_TYPES.ROLE);
expect(selectedRoles).toHaveLength(1);
expect(selectedRoles.at(0).text()).toBe('role2');
});
it('should set selected groups as intersection between the server response and preselected', () => {
const selectedGroups = findSelected(LEVEL_TYPES.GROUP);
expect(selectedGroups).toHaveLength(1);
expect(selectedGroups.at(0).text()).toBe('group5');
});
it('should set selected users to all preselected mapping `user_id` to `id`', () => {
const selectedUsers = findSelected(LEVEL_TYPES.USER);
expect(selectedUsers).toHaveLength(1);
expect(selectedUsers.at(0).text()).toBe('user2');
});
it('should set selected deploy keys as intersection between the server response and preselected mapping some keys', () => {
const selectedDeployKeys = findSelected(LEVEL_TYPES.DEPLOY_KEY);
expect(selectedDeployKeys).toHaveLength(1);
expect(selectedDeployKeys.at(0).text()).toContain('key11 (abcdefghijklmn...)');
});
});
describe('on dropdown open', () => { describe('on dropdown open', () => {
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
...@@ -201,4 +326,20 @@ describe('Access Level Dropdown', () => { ...@@ -201,4 +326,20 @@ describe('Access Level Dropdown', () => {
expect(wrapper.vm.$refs.search.focusInput).toHaveBeenCalled(); expect(wrapper.vm.$refs.search.focusInput).toHaveBeenCalled();
}); });
}); });
describe('on dropdown close', () => {
beforeEach(async () => {
createComponent();
await waitForPromises();
});
it('should emit `hidden` event with dropdown selection', () => {
jest.spyOn(wrapper.vm, '$emit');
findAllDropdownItems().at(1).trigger('click');
findDropdown().vm.$emit('hidden');
expect(wrapper.vm.$emit).toHaveBeenCalledWith('hidden', [{ access_level: 2 }]);
});
});
}); });
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment