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 {
GlDropdownSectionHeader,
GlDropdownDivider,
GlSearchBoxByType,
GlLoadingIcon,
GlAvatar,
GlSprintf,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { debounce, intersectionWith, groupBy, differenceBy, intersectionBy } from 'lodash';
import createFlash from '~/flash';
import { __, s__, n__ } from '~/locale';
import { getUsers, getGroups, getDeployKeys } from '../api/access_dropdown_api';
......@@ -32,7 +31,6 @@ export default {
GlDropdownSectionHeader,
GlDropdownDivider,
GlSearchBoxByType,
GlLoadingIcon,
GlAvatar,
GlSprintf,
},
......@@ -50,10 +48,26 @@ export default {
type: Boolean,
default: true,
},
label: {
type: String,
required: false,
default: i18n.selectUsers,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
preselectedItems: {
type: Array,
required: false,
default: () => [],
},
},
data() {
return {
loading: false,
initialLoading: false,
query: '',
users: [],
groups: [],
......@@ -68,6 +82,9 @@ export default {
};
},
computed: {
preselected() {
return groupBy(this.preselectedItems, 'type');
},
showDeployKeys() {
return this.accessLevel === ACCESS_LEVELS.PUSH && this.deployKeys.length;
},
......@@ -105,10 +122,18 @@ export default {
labelPieces.push(n__('1 group', '%d groups', counts[LEVEL_TYPES.GROUP]));
}
return labelPieces.join(', ') || i18n.selectUsers;
return labelPieces.join(', ') || this.label;
},
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: {
......@@ -117,14 +142,14 @@ export default {
}, 500),
},
created() {
this.getData();
this.getData({ initial: true });
},
methods: {
focusInput() {
this.$refs.search.focusInput();
},
getData() {
getData({ initial = false } = {}) {
this.initialLoading = initial;
this.loading = true;
if (this.hasLicense) {
......@@ -133,20 +158,26 @@ export default {
getUsers(this.query),
this.groups.length ? Promise.resolve({ data: this.groups }) : getGroups(),
])
.then(([deployKeysResponse, usersResponse, groupsResponse]) =>
this.consolidateData(deployKeysResponse.data, usersResponse.data, groupsResponse.data),
)
.then(([deployKeysResponse, usersResponse, groupsResponse]) => {
this.consolidateData(deployKeysResponse.data, usersResponse.data, groupsResponse.data);
this.setSelected({ initial });
})
.catch(() =>
createFlash({ message: __('Failed to load groups, users and deploy keys.') }),
)
.finally(() => {
this.initialLoading = false;
this.loading = false;
});
} else {
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.') }))
.finally(() => {
this.initialLoading = false;
this.loading = false;
});
}
......@@ -159,7 +190,13 @@ export default {
if (this.hasLicense) {
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) => {
......@@ -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) {
this.toggleSelection(this.selected[item.type], item);
this.emitUpdate();
},
toggleSelection(arr, item) {
const itemIndex = arr.indexOf(item);
const itemIndex = arr.findIndex(({ id }) => id === item.id);
if (itemIndex > -1) {
arr.splice(itemIndex, 1);
} else arr.push(item);
......@@ -196,8 +305,10 @@ export default {
return this.selected[item.type].some((selected) => selected.id === item.id);
},
emitUpdate() {
const selected = Object.values(this.selected).flat();
this.$emit('select', selected);
this.$emit('select', this.selection);
},
onHide() {
this.$emit('hidden', this.selection);
},
},
};
......@@ -205,15 +316,16 @@ export default {
<template>
<gl-dropdown
:disabled="disabled || initialLoading"
:text="toggleLabel"
class="gl-display-block"
class="gl-min-w-20"
:toggle-class="toggleClass"
aria-labelledby="allowed-users-label"
@shown="focusInput"
@hidden="onHide"
>
<template #header>
<gl-search-box-by-type ref="search" v-model.trim="query" />
<gl-loading-icon v-if="loading" size="sm" />
<gl-search-box-by-type ref="search" v-model.trim="query" :is-loading="loading" />
</template>
<template v-if="roles.length">
<gl-dropdown-section-header>{{
......@@ -221,7 +333,8 @@ export default {
}}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="role in roles"
:key="role.id"
:key="`${role.id}${role.text}`"
data-testid="role-dropdown-item"
is-check-item
:is-checked="isSelected(role)"
@click.native.capture.stop="onItemClick(role)"
......@@ -237,7 +350,9 @@ export default {
}}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="group in groups"
:key="group.id"
:key="`${group.id}${group.name}`"
fingerprint
data-testid="group-dropdown-item"
:avatar-url="group.avatar_url"
is-check-item
:is-checked="isSelected(group)"
......@@ -254,7 +369,8 @@ export default {
}}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="user in users"
:key="user.id"
:key="`${user.id}${user.username}`"
data-testid="user-dropdown-item"
:avatar-url="user.avatar_url"
:secondary-text="user.username"
is-check-item
......@@ -272,7 +388,8 @@ export default {
}}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="key in deployKeys"
:key="key.id"
:key="`${key.id}${key.fingerprint}`"
data-testid="deploy_key-dropdown-item"
is-check-item
:is-checked="isSelected(key)"
class="gl-text-truncate"
......
import * as Sentry from '@sentry/browser';
import Vue from 'vue';
import AccessDropdown from './components/access_dropdown.vue';
......@@ -7,6 +8,13 @@ export const initAccessDropdown = (el, 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({
el,
......@@ -16,6 +24,9 @@ export const initAccessDropdown = (el, options) => {
props: {
accessLevel,
accessLevelsData: accessLevelsData.roles,
preselectedItems: preselected,
label,
disabled,
},
on: {
select(selected) {
......
import Vue from 'vue';
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 createStore from 'ee/vue_shared/license_compliance/store/index';
import showToast from '~/vue_shared/plugins/global_toast';
......@@ -32,5 +32,4 @@ toasts.forEach((toast) => showToast(toast.dataset.message));
// eslint-disable-next-line no-new
new ProtectedEnvironmentCreate();
// eslint-disable-next-line no-new
new ProtectedEnvironmentEditList();
initProtectedEnvironmentEditList();
......@@ -5,7 +5,7 @@ import AccessorUtilities from '~/lib/utils/accessor';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
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]"]';
......@@ -84,29 +84,7 @@ export default class ProtectedEnvironmentCreate {
name: this.$form.find(PROTECTED_ENVIRONMENT_INPUT).val(),
},
};
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;
});
formData.protected_environment[`${ACCESS_LEVELS.DEPLOY}_attributes`] = this.selected;
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';
import ProtectedEnvironmentEdit from './protected_environment_edit';
Vue.use(GlToast);
export default class ProtectedEnvironmentEditList {
constructor() {
this.$wrap = $('.protected-branches-list');
this.initEditForm();
}
export const initProtectedEnvironmentEditList = () => {
const parentContainer = document.querySelector('.js-protected-environments-list');
const envEditFormEls = parentContainer.querySelectorAll('.js-protected-environment-edit-form');
initEditForm() {
this.$wrap.find('.js-protected-environment-edit-form').each((i, el) => {
new ProtectedEnvironmentEdit({
$wrap: $(el),
});
envEditFormEls.forEach((el) => {
const accessDropdownEl = el.querySelector('.js-allowed-to-deploy');
if (!accessDropdownEl) {
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 @@
%p.settings-message.text-center
= s_('ProtectedEnvironment|There are currently no protected environments. Protect an environment with this form.')
- else
.flash-container
%table.table.table-bordered
%colgroup
%col{ width: '30%' }
......
......@@ -11,7 +11,7 @@
= render partial: 'projects/protected_environments/environments_dropdown', locals: { f: f, project: @project }
.form-group
%label#allowed-users-label.label-bold
%label#allowed-users-label.label-bold.gl-display-block
= s_('ProtectedEnvironment|Allowed to deploy')
.js-allowed-to-deploy-dropdown
......
- default_label = s_('RepositorySettingsAccessLevel|Select')
.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 ""
msgid "Successfully updated %{last_updated_timeago}."
msgstr ""
msgid "Successfully updated the environment."
msgstr ""
msgid "Suggest code changes which can be immediately applied in one click. Try it out!"
msgstr ""
......
......@@ -5,22 +5,33 @@ import {
GlDropdownSectionHeader,
GlSearchBoxByType,
} from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { getUsers, getGroups, getDeployKeys } from '~/projects/settings/api/access_dropdown_api';
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', () => ({
getUsers: jest.fn().mockResolvedValue({ data: [{ id: 1 }, { id: 2 }] }),
getGroups: jest.fn().mockResolvedValue({ data: [{ id: 3 }, { id: 4 }, { id: 5 }] }),
getGroups: jest.fn().mockResolvedValue({
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({
data: [
{ id: 6, title: 'key1', fingerprint: 'abcdefghijklmnop', owner: { name: 'user1' } },
{ id: 7, title: 'key1', fingerprint: 'abcdefghijklmnop', owner: { name: 'user2' } },
{ id: 8, title: 'key1', fingerprint: 'abcdefghijklmnop', owner: { name: 'user3' } },
{ id: 9, title: 'key1', fingerprint: 'abcdefghijklmnop', owner: { name: 'user4' } },
{ id: 10, title: 'key10', fingerprint: 'abcdefghijklmnop', owner: { name: 'user1' } },
{ id: 11, title: 'key11', fingerprint: 'abcdefghijklmnop', owner: { name: 'user2' } },
{ id: 12, title: 'key12', fingerprint: 'abcdefghijklmnop', owner: { name: 'user3' } },
],
}),
}));
......@@ -29,21 +40,35 @@ describe('Access Level Dropdown', () => {
let wrapper;
const mockAccessLevelsData = [
{
id: 42,
text: 'Dummy Role',
id: 1,
text: 'role1',
},
{
id: 2,
text: 'role2',
},
{
id: 3,
text: 'role3',
},
];
const createComponent = ({
accessLevelsData = mockAccessLevelsData,
accessLevel = ACCESS_LEVELS.PUSH,
hasLicense = true,
hasLicense,
label,
disabled,
preselectedItems,
} = {}) => {
wrapper = shallowMount(AccessDropdown, {
wrapper = shallowMountExtended(AccessDropdown, {
propsData: {
accessLevelsData,
accessLevel,
hasLicense,
label,
disabled,
preselectedItems,
},
stubs: {
GlSprintf,
......@@ -52,12 +77,19 @@ describe('Access Level Dropdown', () => {
});
};
afterEach(() => {
wrapper.destroy();
});
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownToggleLabel = () => findDropdown().props('text');
const findAllDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem);
const findAllDropdownHeaders = () => findDropdown().findAllComponents(GlDropdownSectionHeader);
const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
const findDropdownItemWithText = (items, text) =>
items.filter((item) => item.text().includes(text)).at(0);
describe('data request', () => {
it('should make an api call for users, groups && deployKeys when user has a license', () => {
createComponent();
......@@ -84,6 +116,7 @@ describe('Access Level Dropdown', () => {
expect(getDeployKeys).toHaveBeenCalledWith(query);
});
});
describe('layout', () => {
beforeEach(async () => {
createComponent();
......@@ -95,79 +128,88 @@ describe('Access Level Dropdown', () => {
});
it('renders dropdown item for each access level type', () => {
expect(findAllDropdownItems()).toHaveLength(10);
expect(findAllDropdownItems()).toHaveLength(12);
});
});
describe('toggleLabel', () => {
let dropdownItems = [];
beforeEach(async () => {
createComponent();
await waitForPromises();
dropdownItems = findAllDropdownItems();
});
const triggerNthItemClick = async (n) => {
findAllDropdownItems().at(n).trigger('click');
const findItemByNameAndClick = async (name) => {
findDropdownItemWithText(dropdownItems, name).trigger('click');
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(findDropdown().props('toggleClass')).toBe('gl-text-gray-500!');
});
it('displays a number of selected items for each group level', async () => {
findAllDropdownItems().wrappers.forEach((item) => {
dropdownItems.wrappers.forEach((item) => {
item.trigger('click');
});
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 () => {
await triggerNthItemClick(0);
expect(findDropdownToggleLabel()).toBe('Dummy Role');
await findItemByNameAndClick('role1');
expect(findDropdownToggleLabel()).toBe('role1');
expect(findDropdown().props('toggleClass')).toBe('');
});
it('with only groups selected displays the number of selected groups', async () => {
await triggerNthItemClick(1);
await triggerNthItemClick(2);
await triggerNthItemClick(3);
await findItemByNameAndClick('group4');
await findItemByNameAndClick('group5');
await findItemByNameAndClick('group6');
expect(findDropdownToggleLabel()).toBe('3 groups');
expect(findDropdown().props('toggleClass')).toBe('');
});
it('with only users selected displays the number of selected users', async () => {
await triggerNthItemClick(4);
await triggerNthItemClick(5);
await findItemByNameAndClick('user7');
await findItemByNameAndClick('user8');
expect(findDropdownToggleLabel()).toBe('2 users');
expect(findDropdown().props('toggleClass')).toBe('');
});
it('with users and groups selected displays the number of selected users & groups', async () => {
await triggerNthItemClick(1);
await triggerNthItemClick(2);
await triggerNthItemClick(4);
await triggerNthItemClick(5);
await findItemByNameAndClick('group4');
await findItemByNameAndClick('group6');
await findItemByNameAndClick('user7');
await findItemByNameAndClick('user9');
expect(findDropdownToggleLabel()).toBe('2 users, 2 groups');
expect(findDropdown().props('toggleClass')).toBe('');
});
it('with users and deploy keys selected displays the number of selected users & keys', async () => {
await triggerNthItemClick(1);
await triggerNthItemClick(2);
await triggerNthItemClick(6);
expect(findDropdownToggleLabel()).toBe('1 deploy key, 2 groups');
await findItemByNameAndClick('user8');
await findItemByNameAndClick('key10');
await findItemByNameAndClick('key11');
expect(findDropdownToggleLabel()).toBe('1 user, 2 deploy keys');
expect(findDropdown().props('toggleClass')).toBe('');
});
});
describe('selecting an item', () => {
beforeEach(async () => {
it('selects the item on click and deselects on the next click ', async () => {
createComponent();
await waitForPromises();
});
it('selects the item on click and deselects on the next click ', async () => {
const item = findAllDropdownItems().at(1);
item.trigger('click');
await nextTick();
......@@ -177,18 +219,101 @@ describe('Access Level Dropdown', () => {
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');
findAllDropdownItems().at(4).trigger('click');
findAllDropdownItems().at(3).trigger('click');
await nextTick();
const dropdownItems = findAllDropdownItems();
// select new item from each group
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', [
{ id: 5, type: 'group' },
{ id: 1, type: 'user' },
{ access_level: 1 },
{ 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', () => {
beforeEach(() => {
createComponent();
......@@ -201,4 +326,20 @@ describe('Access Level Dropdown', () => {
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