Commit 0f9a35ea authored by Jiaan Louw's avatar Jiaan Louw Committed by Nicolò Maria Mezzopera

Add delete action components

This adds dropdown items for delete actions
that call the confirm delete user modals using
the supplied user's name and paths.
parent 16d0720b
<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 { ...@@ -6,9 +6,11 @@ import {
GlDropdownSectionHeader, GlDropdownSectionHeader,
GlDropdownDivider, GlDropdownDivider,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { s__, __ } from '~/locale'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { convertArrayToCamelCase } from '~/lib/utils/common_utils'; import { convertArrayToCamelCase } from '~/lib/utils/common_utils';
import { generateUserPaths } from '../utils'; import { generateUserPaths } from '../utils';
import { I18N_USER_ACTIONS } from '../constants';
import Actions from './actions';
export default { export default {
components: { components: {
...@@ -17,6 +19,7 @@ export default { ...@@ -17,6 +19,7 @@ export default {
GlDropdownItem, GlDropdownItem,
GlDropdownSectionHeader, GlDropdownSectionHeader,
GlDropdownDivider, GlDropdownDivider,
...Actions,
}, },
props: { props: {
user: { user: {
...@@ -58,21 +61,11 @@ export default { ...@@ -58,21 +61,11 @@ export default {
isLdapAction(action) { isLdapAction(action) {
return action === 'ldapBlocked'; return action === 'ldapBlocked';
}, },
getActionComponent(action) {
return Actions[capitalizeFirstCharacter(action)];
},
}, },
i18n: { i18n: 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'),
},
}; };
</script> </script>
...@@ -92,24 +85,35 @@ export default { ...@@ -92,24 +85,35 @@ export default {
<gl-dropdown-section-header>{{ $options.i18n.settings }}</gl-dropdown-section-header> <gl-dropdown-section-header>{{ $options.i18n.settings }}</gl-dropdown-section-header>
<template v-for="action in dropdownSafeActions"> <template v-for="action in dropdownSafeActions">
<gl-dropdown-item v-if="isLdapAction(action)" :key="action" :data-testid="action"> <component
{{ $options.i18n.ldap }} :is="getActionComponent(action)"
</gl-dropdown-item> v-if="getActionComponent(action)"
<gl-dropdown-item v-else :key="action" :href="userPaths[action]" :data-testid="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] }} {{ $options.i18n[action] }}
</gl-dropdown-item> </gl-dropdown-item>
</template> </template>
<gl-dropdown-divider v-if="hasDeleteActions" /> <gl-dropdown-divider v-if="hasDeleteActions" />
<gl-dropdown-item <template v-for="action in dropdownDeleteActions">
v-for="action in dropdownDeleteActions" <component
:key="action" :is="getActionComponent(action)"
:href="userPaths[action]" v-if="getActionComponent(action)"
:data-testid="`delete-${action}`" :key="action"
> :paths="userPaths"
<span class="gl-text-red-500">{{ $options.i18n[action] }}</span> :username="user.name"
</gl-dropdown-item> :data-testid="`delete-${action}`"
>
{{ $options.i18n[action] }}
</component>
</template>
</gl-dropdown> </gl-dropdown>
</div> </div>
</template> </template>
import { s__, __ } from '~/locale';
export const USER_AVATAR_SIZE = 32; export const USER_AVATAR_SIZE = 32;
export const SHORT_DATE_FORMAT = 'd mmm, yyyy'; export const SHORT_DATE_FORMAT = 'd mmm, yyyy';
export const LENGTH_OF_USER_NOTE_TOOLTIP = 100; 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) { ...@@ -34,6 +34,8 @@ function loadModalsConfigurationFromHtml(modalsElement) {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
Vue.use(Translate); Vue.use(Translate);
initAdminUsersApp();
const modalConfiguration = loadModalsConfigurationFromHtml( const modalConfiguration = loadModalsConfigurationFromHtml(
document.querySelector(MODAL_TEXTS_CONTAINER_SELECTOR), document.querySelector(MODAL_TEXTS_CONTAINER_SELECTOR),
); );
...@@ -60,7 +62,6 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -60,7 +62,6 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
initConfirmModal(); initConfirmModal();
initAdminUsersApp();
initCohortsEmptyState(); initCohortsEmptyState();
initTabs(); initTabs();
}); });
...@@ -2376,6 +2376,12 @@ msgstr "" ...@@ -2376,6 +2376,12 @@ msgstr ""
msgid "AdminUsers|Unblock user %{username}?" msgid "AdminUsers|Unblock user %{username}?"
msgstr "" msgstr ""
msgid "AdminUsers|Unlock"
msgstr ""
msgid "AdminUsers|Unlock user %{username}?"
msgstr ""
msgid "AdminUsers|User will not be able to access git repositories" msgid "AdminUsers|User will not be able to access git repositories"
msgstr "" 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'; ...@@ -2,14 +2,12 @@ import { shallowMount } from '@vue/test-utils';
import { GlDropdownDivider } from '@gitlab/ui'; import { GlDropdownDivider } from '@gitlab/ui';
import AdminUserActions from '~/admin/users/components/user_actions.vue'; import AdminUserActions from '~/admin/users/components/user_actions.vue';
import { generateUserPaths } from '~/admin/users/utils'; 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'; import { users, paths } from '../mock_data';
import { CONFIRMATION_ACTIONS, DELETE_ACTIONS, LINK_ACTIONS, LDAP, EDIT } from '../constants';
const BLOCK = 'block';
const EDIT = 'edit';
const LDAP = 'ldapBlocked';
const DELETE = 'delete';
const DELETE_WITH_CONTRIBUTIONS = 'deleteWithContributions';
describe('AdminUserActions component', () => { describe('AdminUserActions component', () => {
let wrapper; let wrapper;
...@@ -62,7 +60,7 @@ describe('AdminUserActions component', () => { ...@@ -62,7 +60,7 @@ describe('AdminUserActions component', () => {
describe('actions dropdown', () => { describe('actions dropdown', () => {
describe('when there are actions', () => { describe('when there are actions', () => {
const actions = [EDIT, BLOCK]; const actions = [EDIT, ...LINK_ACTIONS];
beforeEach(() => { beforeEach(() => {
initComponent({ actions }); initComponent({ actions });
...@@ -72,10 +70,31 @@ describe('AdminUserActions component', () => { ...@@ -72,10 +70,31 @@ describe('AdminUserActions component', () => {
expect(findActionsDropdown().exists()).toBe(true); expect(findActionsDropdown().exists()).toBe(true);
}); });
it.each(actions)('renders a dropdown item for %s', (action) => { describe('when there are actions that should render as links', () => {
const dropdownAction = wrapper.find(`[data-testid="${action}"]`); beforeEach(() => {
expect(dropdownAction.exists()).toBe(true); initComponent({ actions: LINK_ACTIONS });
expect(dropdownAction.attributes('href')).toBe(userPaths[action]); });
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', () => { describe('when there is a LDAP action', () => {
...@@ -87,14 +106,13 @@ describe('AdminUserActions component', () => { ...@@ -87,14 +106,13 @@ describe('AdminUserActions component', () => {
const dropdownAction = wrapper.find(`[data-testid="${LDAP}"]`); const dropdownAction = wrapper.find(`[data-testid="${LDAP}"]`);
expect(dropdownAction.exists()).toBe(true); expect(dropdownAction.exists()).toBe(true);
expect(dropdownAction.attributes('href')).toBe(undefined); expect(dropdownAction.attributes('href')).toBe(undefined);
expect(dropdownAction.text()).toBe(I18N_USER_ACTIONS[LDAP]);
}); });
}); });
describe('when there is a delete action', () => { describe('when there is a delete action', () => {
const deleteActions = [DELETE, DELETE_WITH_CONTRIBUTIONS];
beforeEach(() => { beforeEach(() => {
initComponent({ actions: [BLOCK, ...deleteActions] }); initComponent({ actions: [LDAP, ...DELETE_ACTIONS] });
}); });
it('renders a dropdown divider', () => { it('renders a dropdown divider', () => {
...@@ -103,13 +121,15 @@ describe('AdminUserActions component', () => { ...@@ -103,13 +121,15 @@ describe('AdminUserActions component', () => {
it('only renders delete dropdown items for actions containing the word "delete"', () => { it('only renders delete dropdown items for actions containing the word "delete"', () => {
const { length } = wrapper.findAll(`[data-testid*="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) => { it.each(DELETE_ACTIONS)('renders a delete action component item for "%s"', (action) => {
const deleteAction = wrapper.find(`[data-testid="delete-${action}"]`); const component = wrapper.find(Actions[capitalizeFirstCharacter(action)]);
expect(deleteAction.exists()).toBe(true);
expect(deleteAction.attributes('href')).toBe(userPaths[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