Commit 1920bf2d authored by Mike Greiling's avatar Mike Greiling

Merge branch '284784-migrate-access-dropdown-for-environments' into 'master'

Migrate `access dropdown.js` to GlDropdown

See merge request gitlab-org/gitlab!70208
parents dd51b17c 1ca830d3
......@@ -2,8 +2,8 @@
import { escape, find, countBy } from 'lodash';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { n__, s__, __, sprintf } from '~/locale';
import { getUsers, getGroups, getDeployKeys } from './api/access_dropdown_api';
import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVELS, ACCESS_LEVEL_NONE } from './constants';
export default class AccessDropdown {
......@@ -16,9 +16,6 @@ export default class AccessDropdown {
this.accessLevelsData = accessLevelsData.roles;
this.$dropdown = $dropdown;
this.$wrap = this.$dropdown.closest(`.${this.accessLevel}-container`);
this.usersPath = '/-/autocomplete/users.json';
this.groupsPath = '/-/autocomplete/project_groups.json';
this.deployKeysPath = '/-/autocomplete/deploy_keys_with_owners.json';
this.defaultLabel = this.$dropdown.data('defaultLabel');
this.setSelectedItems([]);
......@@ -318,9 +315,9 @@ export default class AccessDropdown {
getData(query, callback) {
if (this.hasLicense) {
Promise.all([
this.getDeployKeys(query),
this.getUsers(query),
this.groupsData ? Promise.resolve(this.groupsData) : this.getGroups(),
getDeployKeys(query),
getUsers(query),
this.groupsData ? Promise.resolve(this.groupsData) : getGroups(),
])
.then(([deployKeysResponse, usersResponse, groupsResponse]) => {
this.groupsData = groupsResponse;
......@@ -332,7 +329,7 @@ export default class AccessDropdown {
createFlash({ message: __('Failed to load groups, users and deploy keys.') });
});
} else {
this.getDeployKeys(query)
getDeployKeys(query)
.then((deployKeysResponse) => callback(this.consolidateData(deployKeysResponse.data)))
.catch(() => createFlash({ message: __('Failed to load deploy keys.') }));
}
......@@ -473,46 +470,6 @@ export default class AccessDropdown {
return consolidatedData;
}
getUsers(query) {
return axios.get(this.buildUrl(gon.relative_url_root, this.usersPath), {
params: {
search: query,
per_page: 20,
active: true,
project_id: gon.current_project_id,
push_code: true,
},
});
}
getGroups() {
return axios.get(this.buildUrl(gon.relative_url_root, this.groupsPath), {
params: {
project_id: gon.current_project_id,
},
});
}
getDeployKeys(query) {
return axios.get(this.buildUrl(gon.relative_url_root, this.deployKeysPath), {
params: {
search: query,
per_page: 20,
active: true,
project_id: gon.current_project_id,
push_code: true,
},
});
}
buildUrl(urlRoot, url) {
let newUrl;
if (urlRoot != null) {
newUrl = urlRoot.replace(/\/$/, '') + url;
}
return newUrl;
}
renderRow(item) {
let criteria = {};
let groupRowEl;
......
import axios from '~/lib/utils/axios_utils';
const USERS_PATH = '/-/autocomplete/users.json';
const GROUPS_PATH = '/-/autocomplete/project_groups.json';
const DEPLOY_KEYS_PATH = '/-/autocomplete/deploy_keys_with_owners.json';
const buildUrl = (urlRoot, url) => {
let newUrl;
if (urlRoot != null) {
newUrl = urlRoot.replace(/\/$/, '') + url;
}
return newUrl;
};
export const getUsers = (query) => {
return axios.get(buildUrl(gon.relative_url_root || '', USERS_PATH), {
params: {
search: query,
per_page: 20,
active: true,
project_id: gon.current_project_id,
push_code: true,
},
});
};
export const getGroups = () => {
return axios.get(buildUrl(gon.relative_url_root || '', GROUPS_PATH), {
params: {
project_id: gon.current_project_id,
},
});
};
export const getDeployKeys = (query) => {
return axios.get(buildUrl(gon.relative_url_root || '', DEPLOY_KEYS_PATH), {
params: {
search: query,
per_page: 20,
active: true,
project_id: gon.current_project_id,
push_code: true,
},
});
};
<script>
import {
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlDropdownDivider,
GlSearchBoxByType,
GlLoadingIcon,
GlAvatar,
GlSprintf,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import createFlash from '~/flash';
import { __, s__, n__ } from '~/locale';
import { getUsers, getGroups, getDeployKeys } from '../api/access_dropdown_api';
import { LEVEL_TYPES, ACCESS_LEVELS } from '../constants';
export const i18n = {
selectUsers: s__('ProtectedEnvironment|Select users'),
rolesSectionHeader: s__('AccessDropdown|Roles'),
groupsSectionHeader: s__('AccessDropdown|Groups'),
usersSectionHeader: s__('AccessDropdown|Users'),
deployKeysSectionHeader: s__('AccessDropdown|Deploy Keys'),
ownedBy: __('Owned by %{image_tag}'),
};
export default {
i18n,
components: {
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlDropdownDivider,
GlSearchBoxByType,
GlLoadingIcon,
GlAvatar,
GlSprintf,
},
props: {
accessLevelsData: {
type: Array,
required: true,
},
accessLevel: {
required: true,
type: String,
},
hasLicense: {
required: false,
type: Boolean,
default: true,
},
},
data() {
return {
loading: false,
query: '',
users: [],
groups: [],
roles: [],
deployKeys: [],
selected: {
[LEVEL_TYPES.GROUP]: [],
[LEVEL_TYPES.USER]: [],
[LEVEL_TYPES.ROLE]: [],
[LEVEL_TYPES.DEPLOY_KEY]: [],
},
};
},
computed: {
showDeployKeys() {
return this.accessLevel === ACCESS_LEVELS.PUSH && this.deployKeys.length;
},
toggleLabel() {
const counts = Object.entries(this.selected).reduce((acc, [key, value]) => {
acc[key] = value.length;
return acc;
}, {});
const isOnlyRoleSelected =
counts[LEVEL_TYPES.ROLE] === 1 &&
[counts[LEVEL_TYPES.USER], counts[LEVEL_TYPES.GROUP], counts[LEVEL_TYPES.DEPLOY_KEY]].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.USER] > 0) {
labelPieces.push(n__('1 user', '%d users', counts[LEVEL_TYPES.USER]));
}
if (counts[LEVEL_TYPES.DEPLOY_KEY] > 0) {
labelPieces.push(n__('1 deploy key', '%d deploy keys', counts[LEVEL_TYPES.DEPLOY_KEY]));
}
if (counts[LEVEL_TYPES.GROUP] > 0) {
labelPieces.push(n__('1 group', '%d groups', counts[LEVEL_TYPES.GROUP]));
}
return labelPieces.join(', ') || i18n.selectUsers;
},
toggleClass() {
return this.toggleLabel === i18n.selectUsers ? 'gl-text-gray-500!' : '';
},
},
watch: {
query: debounce(function debouncedSearch() {
return this.getData();
}, 500),
},
created() {
this.getData();
},
methods: {
focusInput() {
this.$refs.search.focusInput();
},
getData() {
this.loading = true;
if (this.hasLicense) {
Promise.all([
getDeployKeys(this.query),
getUsers(this.query),
this.groups.length ? Promise.resolve({ data: this.groups }) : getGroups(),
])
.then(([deployKeysResponse, usersResponse, groupsResponse]) =>
this.consolidateData(deployKeysResponse.data, usersResponse.data, groupsResponse.data),
)
.catch(() =>
createFlash({ message: __('Failed to load groups, users and deploy keys.') }),
)
.finally(() => {
this.loading = false;
});
} else {
getDeployKeys(this.query)
.then((deployKeysResponse) => this.consolidateData(deployKeysResponse.data))
.catch(() => createFlash({ message: __('Failed to load deploy keys.') }))
.finally(() => {
this.loading = false;
});
}
},
consolidateData(deployKeysResponse, usersResponse = [], groupsResponse = []) {
// This re-assignment is intentional as level.type property is being used for comparision,
// and accessLevelsData is provided by gon.create_access_levels which doesn't have `type` included.
// See this discussion https://gitlab.com/gitlab-org/gitlab/merge_requests/1629#note_31285823
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((user) => ({ ...user, type: LEVEL_TYPES.USER }));
}
this.deployKeys = deployKeysResponse.map((response) => {
const {
id,
fingerprint,
title,
owner: { avatar_url, name, username },
} = response;
const shortFingerprint = `(${fingerprint.substring(0, 14)}...)`;
return {
id,
title: title.concat(' ', shortFingerprint),
avatar_url,
fullname: name,
username,
type: LEVEL_TYPES.DEPLOY_KEY,
};
});
},
onItemClick(item) {
this.toggleSelection(this.selected[item.type], item);
this.emitUpdate();
},
toggleSelection(arr, item) {
const itemIndex = arr.indexOf(item);
if (itemIndex > -1) {
arr.splice(itemIndex, 1);
} else arr.push(item);
},
isSelected(item) {
return this.selected[item.type].some((selected) => selected.id === item.id);
},
emitUpdate() {
const selected = Object.values(this.selected).flat();
this.$emit('select', selected);
},
},
};
</script>
<template>
<gl-dropdown
:text="toggleLabel"
class="gl-display-block"
:toggle-class="toggleClass"
aria-labelledby="allowed-users-label"
@shown="focusInput"
>
<template #header>
<gl-search-box-by-type ref="search" v-model.trim="query" />
<gl-loading-icon v-if="loading" size="sm" />
</template>
<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"
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 || showDeployKeys" />
</template>
<template v-if="groups.length">
<gl-dropdown-section-header>{{
$options.i18n.groupsSectionHeader
}}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="group in groups"
:key="group.id"
:avatar-url="group.avatar_url"
is-check-item
:is-checked="isSelected(group)"
@click.native.capture.stop="onItemClick(group)"
>
{{ group.name }}
</gl-dropdown-item>
<gl-dropdown-divider v-if="users.length || showDeployKeys" />
</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"
: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>
<gl-dropdown-divider v-if="showDeployKeys" />
</template>
<template v-if="showDeployKeys">
<gl-dropdown-section-header>{{
$options.i18n.deployKeysSectionHeader
}}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="key in deployKeys"
:key="key.id"
is-check-item
:is-checked="isSelected(key)"
class="gl-text-truncate"
@click.native.capture.stop="onItemClick(key)"
>
<div class="gl-text-truncate gl-font-weight-bold">{{ key.title }}</div>
<div class="gl-text-gray-700 gl-text-truncate">
<gl-sprintf :message="$options.i18n.ownedBy">
<template #image_tag>
<gl-avatar :src="key.avatar_url" :size="24" />
</template> </gl-sprintf
>{{ key.fullname }} ({{ key.username }})
</div>
</gl-dropdown-item>
</template>
</gl-dropdown>
</template>
import Vue from 'vue';
import AccessDropdown from './components/access_dropdown.vue';
export const initAccessDropdown = (el, options) => {
if (!el) {
return false;
}
const { accessLevelsData, accessLevel } = options;
return new Vue({
el,
render(createElement) {
const vm = this;
return createElement(AccessDropdown, {
props: {
accessLevel,
accessLevelsData: accessLevelsData.roles,
},
on: {
select(selected) {
vm.$emit('select', selected);
},
},
});
},
});
};
......@@ -4,7 +4,7 @@ import createFlash from '~/flash';
import AccessorUtilities from '~/lib/utils/accessor';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import AccessDropdown from '~/projects/settings/access_dropdown';
import { initAccessDropdown } from '~/projects/settings/init_access_dropdown';
import { ACCESS_LEVELS, LEVEL_TYPES } from './constants';
const PROTECTED_ENVIRONMENT_INPUT = 'input[name="protected_environment[name]"]';
......@@ -13,9 +13,9 @@ export default class ProtectedEnvironmentCreate {
constructor() {
this.$form = $('.js-new-protected-environment');
this.isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage();
this.currentProjectUserDefaults = {};
this.buildDropdowns();
this.bindEvents();
this.selected = [];
}
bindEvents() {
......@@ -23,17 +23,21 @@ export default class ProtectedEnvironmentCreate {
}
buildDropdowns() {
const $allowedToDeployDropdown = this.$form.find('.js-allowed-to-deploy');
// Cache callback
this.onSelectCallback = this.onSelect.bind(this);
// Allowed to Deploy dropdown
this[`${ACCESS_LEVELS.DEPLOY}_dropdown`] = new AccessDropdown({
$dropdown: $allowedToDeployDropdown,
const accessDropdown = initAccessDropdown(
document.querySelector('.js-allowed-to-deploy-dropdown'),
{
accessLevelsData: gon.deploy_access_levels,
onSelect: this.onSelectCallback,
accessLevel: ACCESS_LEVELS.DEPLOY,
},
);
accessDropdown.$on('select', (selected) => {
this.selected = selected;
this.onSelect();
});
this.createItemDropdown = new CreateItemDropdown({
......@@ -46,10 +50,9 @@ export default class ProtectedEnvironmentCreate {
});
}
// Enable submit button after selecting an option
// Enable submit button after selecting an option on select
onSelect() {
const $allowedToDeploy = this[`${ACCESS_LEVELS.DEPLOY}_dropdown`].getSelectedItems();
const toggle = !(this.$form.find(PROTECTED_ENVIRONMENT_INPUT).val() && $allowedToDeploy.length);
const toggle = !(this.$form.find(PROTECTED_ENVIRONMENT_INPUT).val() && this.selected.length);
this.$form.find('input[type="submit"]').attr('disabled', toggle);
}
......@@ -84,21 +87,20 @@ export default class ProtectedEnvironmentCreate {
Object.keys(ACCESS_LEVELS).forEach((level) => {
const accessLevel = ACCESS_LEVELS[level];
const selectedItems = this[`${accessLevel}_dropdown`].getSelectedItems();
const levelAttributes = [];
selectedItems.forEach((item) => {
this.selected.forEach((item) => {
if (item.type === LEVEL_TYPES.USER) {
levelAttributes.push({
user_id: item.user_id,
user_id: item.id,
});
} else if (item.type === LEVEL_TYPES.ROLE) {
levelAttributes.push({
access_level: item.access_level,
access_level: item.id,
});
} else if (item.type === LEVEL_TYPES.GROUP) {
levelAttributes.push({
group_id: item.group_id,
group_id: item.id,
});
}
});
......
.deploy_access_levels-container
= dropdown_tag(s_('ProtectedEnvironment|Select users'),
options: { toggle_class: 'js-allowed-to-deploy wide js-multiselect',
dropdown_class: 'dropdown-menu-user dropdown-menu-selectable',
filter: true,
data: { field_name: 'protected_environments[deploy_access_levels_attributes][0][access_level]', input_id: 'deploy_access_levels_attributes' }})
......@@ -11,9 +11,9 @@
= render partial: 'projects/protected_environments/environments_dropdown', locals: { f: f, project: @project }
.form-group
= f.label :deploy_access_levels_attributes, class: 'label-bold' do
%label#allowed-users-label.label-bold
= s_('ProtectedEnvironment|Allowed to deploy')
= render partial: 'projects/protected_environments/deploy_access_levels_dropdown', locals: { f: f }
.js-allowed-to-deploy-dropdown
.card-footer
= f.submit s_('ProtectedEnvironment|Protect'), class: 'gl-button btn btn-confirm', disabled: true
......@@ -118,12 +118,10 @@ RSpec.describe 'Protected Environments' do
end
def set_allowed_to_deploy(option)
find('.js-allowed-to-deploy').click
click_button('Select users')
within('.dropdown-content') do
Array(option).each { |opt| click_on(opt) }
within '.gl-new-dropdown-contents' do
Array(option).each { |opt| find('.gl-new-dropdown-item', text: opt).click }
end
find('.js-allowed-to-deploy').click
end
end
import {
GlSprintf,
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlSearchBoxByType,
} from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
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';
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 }] }),
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' } },
],
}),
}));
describe('Access Level Dropdown', () => {
let wrapper;
const mockAccessLevelsData = [
{
id: 42,
text: 'Dummy Role',
},
];
const createComponent = ({
accessLevelsData = mockAccessLevelsData,
accessLevel = ACCESS_LEVELS.PUSH,
hasLicense = true,
} = {}) => {
wrapper = shallowMount(AccessDropdown, {
propsData: {
accessLevelsData,
accessLevel,
hasLicense,
},
stubs: {
GlSprintf,
GlDropdown,
},
});
};
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);
describe('data request', () => {
it('should make an api call for users, groups && deployKeys when user has a license', () => {
createComponent();
expect(getUsers).toHaveBeenCalled();
expect(getGroups).toHaveBeenCalled();
expect(getDeployKeys).toHaveBeenCalled();
});
it('should make an api call for deployKeys but not for users or groups when user does not have a license', () => {
createComponent({ hasLicense: false });
expect(getUsers).not.toHaveBeenCalled();
expect(getGroups).not.toHaveBeenCalled();
expect(getDeployKeys).toHaveBeenCalled();
});
it('should make api calls when search query is updated', async () => {
createComponent();
const query = 'root';
findSearchBox().vm.$emit('input', query);
await nextTick();
expect(getUsers).toHaveBeenCalledWith(query);
expect(getGroups).toHaveBeenCalled();
expect(getDeployKeys).toHaveBeenCalledWith(query);
});
});
describe('layout', () => {
beforeEach(async () => {
createComponent();
await waitForPromises();
});
it('renders headers for each section ', () => {
expect(findAllDropdownHeaders()).toHaveLength(4);
});
it('renders dropdown item for each access level type', () => {
expect(findAllDropdownItems()).toHaveLength(10);
});
});
describe('toggleLabel', () => {
beforeEach(async () => {
createComponent();
await waitForPromises();
});
const triggerNthItemClick = async (n) => {
findAllDropdownItems().at(n).trigger('click');
await nextTick();
};
it('when no items selected displays a default 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) => {
item.trigger('click');
});
await nextTick();
expect(findDropdownToggleLabel()).toBe('1 role, 2 users, 4 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');
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);
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);
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);
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');
expect(findDropdown().props('toggleClass')).toBe('');
});
});
describe('selecting an item', () => {
beforeEach(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();
expect(item.props('isChecked')).toBe(true);
item.trigger('click');
await nextTick();
expect(item.props('isChecked')).toBe(false);
});
it('emits an update on selection ', async () => {
const spy = jest.spyOn(wrapper.vm, '$emit');
findAllDropdownItems().at(4).trigger('click');
findAllDropdownItems().at(3).trigger('click');
await nextTick();
expect(spy).toHaveBeenLastCalledWith('select', [
{ id: 5, type: 'group' },
{ id: 1, type: 'user' },
]);
});
});
describe('on dropdown open', () => {
beforeEach(() => {
createComponent();
});
it('should set the search input focus', () => {
wrapper.vm.$refs.search.focusInput = jest.fn();
findDropdown().vm.$emit('shown');
expect(wrapper.vm.$refs.search.focusInput).toHaveBeenCalled();
});
});
});
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