Commit 0af13586 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch '285110-add-vue-admin-user-modals' into 'master'

Migrate admin/users confirmation modals to vue

See merge request gitlab-org/gitlab!53645
parents 610d4f34 0f9a35ea
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
export default {
components: {
GlDropdownItem,
},
props: {
username: {
type: String,
required: true,
},
path: {
type: String,
required: true,
},
},
computed: {
modalAttributes() {
return {
'data-path': this.path,
'data-method': 'put',
'data-modal-attributes': JSON.stringify({
title: sprintf(s__('AdminUsers|Activate user %{username}?'), {
username: this.username,
}),
message: s__('AdminUsers|You can always deactivate their account again if needed.'),
okVariant: 'confirm',
okTitle: s__('AdminUsers|Activate'),
}),
};
},
},
};
</script>
<template>
<div class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
<gl-dropdown-item>
<slot></slot>
</gl-dropdown-item>
</div>
</template>
<script>
import { GlDropdownItem } from '@gitlab/ui';
export default {
components: {
GlDropdownItem,
},
props: {
path: {
type: String,
required: true,
},
},
};
</script>
<template>
<gl-dropdown-item :href="path" data-method="put">
<slot></slot>
</gl-dropdown-item>
</template>
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922
const messageHtml = `
<p>${s__('AdminUsers|Blocking user has the following effects:')}</p>
<ul>
<li>${s__('AdminUsers|User will not be able to login')}</li>
<li>${s__('AdminUsers|User will not be able to access git repositories')}</li>
<li>${s__('AdminUsers|Personal projects will be left')}</li>
<li>${s__('AdminUsers|Owned groups will be left')}</li>
</ul>
`;
export default {
components: {
GlDropdownItem,
},
props: {
username: {
type: String,
required: true,
},
path: {
type: String,
required: true,
},
},
computed: {
modalAttributes() {
return {
'data-path': this.path,
'data-method': 'put',
'data-modal-attributes': JSON.stringify({
title: sprintf(s__('AdminUsers|Block user %{username}?'), { username: this.username }),
okVariant: 'confirm',
okTitle: s__('AdminUsers|Block'),
messageHtml,
}),
};
},
},
};
</script>
<template>
<div class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
<gl-dropdown-item>
<slot></slot>
</gl-dropdown-item>
</div>
</template>
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922
const messageHtml = `
<p>${s__('AdminUsers|Deactivating a user has the following effects:')}</p>
<ul>
<li>${s__('AdminUsers|The user will be logged out')}</li>
<li>${s__('AdminUsers|The user will not be able to access git repositories')}</li>
<li>${s__('AdminUsers|The user will not be able to access the API')}</li>
<li>${s__('AdminUsers|The user will not receive any notifications')}</li>
<li>${s__('AdminUsers|The user will not be able to use slash commands')}</li>
<li>${s__(
'AdminUsers|When the user logs back in, their account will reactivate as a fully active account',
)}</li>
<li>${s__('AdminUsers|Personal projects, group and user history will be left intact')}</li>
</ul>
`;
export default {
components: {
GlDropdownItem,
},
props: {
username: {
type: String,
required: true,
},
path: {
type: String,
required: true,
},
},
computed: {
modalAttributes() {
return {
'data-path': this.path,
'data-method': 'put',
'data-modal-attributes': JSON.stringify({
title: sprintf(s__('AdminUsers|Deactivate user %{username}?'), {
username: this.username,
}),
okVariant: 'confirm',
okTitle: s__('AdminUsers|Deactivate'),
messageHtml,
}),
};
},
},
};
</script>
<template>
<div class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
<gl-dropdown-item>
<slot></slot>
</gl-dropdown-item>
</div>
</template>
<script>
import SharedDeleteAction from './shared/shared_delete_action.vue';
export default {
components: {
SharedDeleteAction,
},
props: {
username: {
type: String,
required: true,
},
paths: {
type: Object,
required: true,
},
},
};
</script>
<template>
<shared-delete-action modal-type="delete" :username="username" :paths="paths">
<slot></slot>
</shared-delete-action>
</template>
<script>
import SharedDeleteAction from './shared/shared_delete_action.vue';
export default {
components: {
SharedDeleteAction,
},
props: {
username: {
type: String,
required: true,
},
paths: {
type: Object,
required: true,
},
},
};
</script>
<template>
<shared-delete-action modal-type="delete-with-contributions" :username="username" :paths="paths">
<slot></slot>
</shared-delete-action>
</template>
import Activate from './activate.vue';
import Approve from './approve.vue';
import Block from './block.vue';
import Deactivate from './deactivate.vue';
import Delete from './delete.vue';
import DeleteWithContributions from './delete_with_contributions.vue';
import Unblock from './unblock.vue';
import Unlock from './unlock.vue';
import Reject from './reject.vue';
export default {
Activate,
Approve,
Block,
Deactivate,
Delete,
DeleteWithContributions,
Unblock,
Unlock,
Reject,
};
<script>
import { GlDropdownItem } from '@gitlab/ui';
export default {
components: {
GlDropdownItem,
},
props: {
path: {
type: String,
required: true,
},
},
};
</script>
<template>
<gl-dropdown-item :href="path" data-method="delete">
<slot></slot>
</gl-dropdown-item>
</template>
<script>
import { GlDropdownItem } from '@gitlab/ui';
export default {
components: {
GlDropdownItem,
},
props: {
username: {
type: String,
required: true,
},
paths: {
type: Object,
required: true,
},
modalType: {
type: String,
required: true,
},
},
computed: {
modalAttributes() {
return {
'data-block-user-url': this.paths.block,
'data-delete-user-url': this.paths.delete,
'data-gl-modal-action': this.modalType,
'data-username': this.username,
};
},
},
};
</script>
<template>
<div class="js-delete-user-modal-button" v-bind="{ ...modalAttributes }">
<gl-dropdown-item>
<span class="gl-text-red-500">
<slot></slot>
</span>
</gl-dropdown-item>
</div>
</template>
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
export default {
components: {
GlDropdownItem,
},
props: {
username: {
type: String,
required: true,
},
path: {
type: String,
required: true,
},
},
computed: {
modalAttributes() {
return {
'data-path': this.path,
'data-method': 'put',
'data-modal-attributes': JSON.stringify({
title: sprintf(s__('AdminUsers|Unblock user %{username}?'), { username: this.username }),
message: s__(
'AdminUsers|You can always unblock their account, their data will remain intact.',
),
okVariant: 'confirm',
okTitle: s__('AdminUsers|Unblock'),
}),
};
},
},
};
</script>
<template>
<div class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
<gl-dropdown-item>
<slot></slot>
</gl-dropdown-item>
</div>
</template>
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
export default {
components: {
GlDropdownItem,
},
props: {
username: {
type: String,
required: true,
},
path: {
type: String,
required: true,
},
},
computed: {
modalAttributes() {
return {
'data-path': this.path,
'data-method': 'put',
'data-modal-attributes': JSON.stringify({
title: sprintf(s__('AdminUsers|Unlock user %{username}?'), { username: this.username }),
message: __('Are you sure?'),
okVariant: 'confirm',
okTitle: s__('AdminUsers|Unlock'),
}),
};
},
},
};
</script>
<template>
<div class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
<gl-dropdown-item>
<slot></slot>
</gl-dropdown-item>
</div>
</template>
......@@ -6,9 +6,11 @@ import {
GlDropdownSectionHeader,
GlDropdownDivider,
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { convertArrayToCamelCase } from '~/lib/utils/common_utils';
import { generateUserPaths } from '../utils';
import { I18N_USER_ACTIONS } from '../constants';
import Actions from './actions';
export default {
components: {
......@@ -17,6 +19,7 @@ export default {
GlDropdownItem,
GlDropdownSectionHeader,
GlDropdownDivider,
...Actions,
},
props: {
user: {
......@@ -58,21 +61,11 @@ export default {
isLdapAction(action) {
return action === 'ldapBlocked';
},
getActionComponent(action) {
return Actions[capitalizeFirstCharacter(action)];
},
i18n: {
edit: __('Edit'),
settings: __('Settings'),
unlock: __('Unlock'),
block: s__('AdminUsers|Block'),
unblock: s__('AdminUsers|Unblock'),
approve: s__('AdminUsers|Approve'),
reject: s__('AdminUsers|Reject'),
deactivate: s__('AdminUsers|Deactivate'),
activate: s__('AdminUsers|Activate'),
ldapBlocked: s__('AdminUsers|Cannot unblock LDAP blocked users'),
delete: s__('AdminUsers|Delete user'),
deleteWithContributions: s__('AdminUsers|Delete user and contributions'),
},
i18n: I18N_USER_ACTIONS,
};
</script>
......@@ -92,24 +85,35 @@ export default {
<gl-dropdown-section-header>{{ $options.i18n.settings }}</gl-dropdown-section-header>
<template v-for="action in dropdownSafeActions">
<gl-dropdown-item v-if="isLdapAction(action)" :key="action" :data-testid="action">
{{ $options.i18n.ldap }}
</gl-dropdown-item>
<gl-dropdown-item v-else :key="action" :href="userPaths[action]" :data-testid="action">
<component
:is="getActionComponent(action)"
v-if="getActionComponent(action)"
:key="action"
:path="userPaths[action]"
:username="user.name"
:data-testid="action"
>
{{ $options.i18n[action] }}
</component>
<gl-dropdown-item v-else-if="isLdapAction(action)" :key="action" :data-testid="action">
{{ $options.i18n[action] }}
</gl-dropdown-item>
</template>
<gl-dropdown-divider v-if="hasDeleteActions" />
<gl-dropdown-item
v-for="action in dropdownDeleteActions"
<template v-for="action in dropdownDeleteActions">
<component
:is="getActionComponent(action)"
v-if="getActionComponent(action)"
:key="action"
:href="userPaths[action]"
:paths="userPaths"
:username="user.name"
:data-testid="`delete-${action}`"
>
<span class="gl-text-red-500">{{ $options.i18n[action] }}</span>
</gl-dropdown-item>
{{ $options.i18n[action] }}
</component>
</template>
</gl-dropdown>
</div>
</template>
import { s__, __ } from '~/locale';
export const USER_AVATAR_SIZE = 32;
export const SHORT_DATE_FORMAT = 'd mmm, yyyy';
export const LENGTH_OF_USER_NOTE_TOOLTIP = 100;
export const I18N_USER_ACTIONS = {
edit: __('Edit'),
settings: __('Settings'),
unlock: __('Unlock'),
block: s__('AdminUsers|Block'),
unblock: s__('AdminUsers|Unblock'),
approve: s__('AdminUsers|Approve'),
reject: s__('AdminUsers|Reject'),
deactivate: s__('AdminUsers|Deactivate'),
activate: s__('AdminUsers|Activate'),
ldapBlocked: s__('AdminUsers|Cannot unblock LDAP blocked users'),
delete: s__('AdminUsers|Delete user'),
deleteWithContributions: s__('AdminUsers|Delete user and contributions'),
};
......@@ -34,6 +34,8 @@ function loadModalsConfigurationFromHtml(modalsElement) {
document.addEventListener('DOMContentLoaded', () => {
Vue.use(Translate);
initAdminUsersApp();
const modalConfiguration = loadModalsConfigurationFromHtml(
document.querySelector(MODAL_TEXTS_CONTAINER_SELECTOR),
);
......@@ -60,7 +62,6 @@ document.addEventListener('DOMContentLoaded', () => {
});
initConfirmModal();
initAdminUsersApp();
initCohortsEmptyState();
initTabs();
});
......@@ -2376,6 +2376,12 @@ msgstr ""
msgid "AdminUsers|Unblock user %{username}?"
msgstr ""
msgid "AdminUsers|Unlock"
msgstr ""
msgid "AdminUsers|Unlock user %{username}?"
msgstr ""
msgid "AdminUsers|User will not be able to access git repositories"
msgstr ""
......
import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
import { GlDropdownItem } from '@gitlab/ui';
import { kebabCase } from 'lodash';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import Actions from '~/admin/users/components/actions';
import SharedDeleteAction from '~/admin/users/components/actions/shared/shared_delete_action.vue';
import { CONFIRMATION_ACTIONS, DELETE_ACTIONS } from '../../constants';
describe('Action components', () => {
let wrapper;
const findDropdownItem = () => wrapper.find(GlDropdownItem);
const initComponent = ({ component, props, stubs = {} } = {}) => {
wrapper = shallowMount(component, {
propsData: {
...props,
},
stubs,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('CONFIRMATION_ACTIONS', () => {
it.each(CONFIRMATION_ACTIONS)('renders a dropdown item for "%s"', async (action) => {
initComponent({
component: Actions[capitalizeFirstCharacter(action)],
props: {
username: 'John Doe',
path: '/test',
},
});
await nextTick();
const div = wrapper.find('div');
expect(div.attributes('data-path')).toBe('/test');
expect(div.attributes('data-modal-attributes')).toContain('John Doe');
expect(findDropdownItem().exists()).toBe(true);
});
});
describe('LINK_ACTIONS', () => {
it.each`
action | method
${'Approve'} | ${'put'}
${'Reject'} | ${'delete'}
`(
'renders a dropdown item link with method "$method" for "$action"',
async ({ action, method }) => {
initComponent({
component: Actions[action],
props: {
path: '/test',
},
});
await nextTick();
const item = wrapper.find(GlDropdownItem);
expect(item.attributes('href')).toBe('/test');
expect(item.attributes('data-method')).toContain(method);
},
);
});
describe('DELETE_ACTION_COMPONENTS', () => {
it.each(DELETE_ACTIONS)('renders a dropdown item for "%s"', async (action) => {
initComponent({
component: Actions[capitalizeFirstCharacter(action)],
props: {
username: 'John Doe',
paths: {
delete: '/delete',
block: '/block',
},
},
stubs: { SharedDeleteAction },
});
await nextTick();
const sharedAction = wrapper.find(SharedDeleteAction);
expect(sharedAction.attributes('data-block-user-url')).toBe('/block');
expect(sharedAction.attributes('data-delete-user-url')).toBe('/delete');
expect(sharedAction.attributes('data-gl-modal-action')).toBe(kebabCase(action));
expect(sharedAction.attributes('data-username')).toBe('John Doe');
expect(findDropdownItem().exists()).toBe(true);
});
});
});
......@@ -2,14 +2,12 @@ import { shallowMount } from '@vue/test-utils';
import { GlDropdownDivider } from '@gitlab/ui';
import AdminUserActions from '~/admin/users/components/user_actions.vue';
import { generateUserPaths } from '~/admin/users/utils';
import { I18N_USER_ACTIONS } from '~/admin/users/constants';
import Actions from '~/admin/users/components/actions';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { users, paths } from '../mock_data';
const BLOCK = 'block';
const EDIT = 'edit';
const LDAP = 'ldapBlocked';
const DELETE = 'delete';
const DELETE_WITH_CONTRIBUTIONS = 'deleteWithContributions';
import { CONFIRMATION_ACTIONS, DELETE_ACTIONS, LINK_ACTIONS, LDAP, EDIT } from '../constants';
describe('AdminUserActions component', () => {
let wrapper;
......@@ -62,7 +60,7 @@ describe('AdminUserActions component', () => {
describe('actions dropdown', () => {
describe('when there are actions', () => {
const actions = [EDIT, BLOCK];
const actions = [EDIT, ...LINK_ACTIONS];
beforeEach(() => {
initComponent({ actions });
......@@ -72,10 +70,31 @@ describe('AdminUserActions component', () => {
expect(findActionsDropdown().exists()).toBe(true);
});
it.each(actions)('renders a dropdown item for %s', (action) => {
const dropdownAction = wrapper.find(`[data-testid="${action}"]`);
expect(dropdownAction.exists()).toBe(true);
expect(dropdownAction.attributes('href')).toBe(userPaths[action]);
describe('when there are actions that should render as links', () => {
beforeEach(() => {
initComponent({ actions: LINK_ACTIONS });
});
it.each(LINK_ACTIONS)('renders an action component item for "%s"', (action) => {
const component = wrapper.find(Actions[capitalizeFirstCharacter(action)]);
expect(component.props('path')).toBe(userPaths[action]);
expect(component.text()).toBe(I18N_USER_ACTIONS[action]);
});
});
describe('when there are actions that require confirmation', () => {
beforeEach(() => {
initComponent({ actions: CONFIRMATION_ACTIONS });
});
it.each(CONFIRMATION_ACTIONS)('renders an action component item for "%s"', (action) => {
const component = wrapper.find(Actions[capitalizeFirstCharacter(action)]);
expect(component.props('username')).toBe(user.name);
expect(component.props('path')).toBe(userPaths[action]);
expect(component.text()).toBe(I18N_USER_ACTIONS[action]);
});
});
describe('when there is a LDAP action', () => {
......@@ -87,14 +106,13 @@ describe('AdminUserActions component', () => {
const dropdownAction = wrapper.find(`[data-testid="${LDAP}"]`);
expect(dropdownAction.exists()).toBe(true);
expect(dropdownAction.attributes('href')).toBe(undefined);
expect(dropdownAction.text()).toBe(I18N_USER_ACTIONS[LDAP]);
});
});
describe('when there is a delete action', () => {
const deleteActions = [DELETE, DELETE_WITH_CONTRIBUTIONS];
beforeEach(() => {
initComponent({ actions: [BLOCK, ...deleteActions] });
initComponent({ actions: [LDAP, ...DELETE_ACTIONS] });
});
it('renders a dropdown divider', () => {
......@@ -103,13 +121,15 @@ describe('AdminUserActions component', () => {
it('only renders delete dropdown items for actions containing the word "delete"', () => {
const { length } = wrapper.findAll(`[data-testid*="delete-"]`);
expect(length).toBe(deleteActions.length);
expect(length).toBe(DELETE_ACTIONS.length);
});
it.each(deleteActions)('renders a delete dropdown item for %s', (action) => {
const deleteAction = wrapper.find(`[data-testid="delete-${action}"]`);
expect(deleteAction.exists()).toBe(true);
expect(deleteAction.attributes('href')).toBe(userPaths[action]);
it.each(DELETE_ACTIONS)('renders a delete action component item for "%s"', (action) => {
const component = wrapper.find(Actions[capitalizeFirstCharacter(action)]);
expect(component.props('username')).toBe(user.name);
expect(component.props('paths')).toEqual(userPaths);
expect(component.text()).toBe(I18N_USER_ACTIONS[action]);
});
});
......
const BLOCK = 'block';
const UNBLOCK = 'unblock';
const DELETE = 'delete';
const DELETE_WITH_CONTRIBUTIONS = 'deleteWithContributions';
const UNLOCK = 'unlock';
const ACTIVATE = 'activate';
const DEACTIVATE = 'deactivate';
const REJECT = 'reject';
const APPROVE = 'approve';
export const EDIT = 'edit';
export const LDAP = 'ldapBlocked';
export const LINK_ACTIONS = [APPROVE, REJECT];
export const CONFIRMATION_ACTIONS = [ACTIVATE, BLOCK, DEACTIVATE, UNLOCK, UNBLOCK];
export const DELETE_ACTIONS = [DELETE, DELETE_WITH_CONTRIBUTIONS];
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