Commit 1cffa4a3 authored by Phil Hughes's avatar Phil Hughes

Merge branch '344555-migrate-app-views-admin-users-modal-content-to-vue' into 'master'

Update admin user delete modal to use eventHub

See merge request gitlab-org/gitlab!83111
parents 8c178ce5 3e773ae5
<script> <script>
import SharedDeleteAction from './shared/shared_delete_action.vue'; import { GlDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub, { EVENT_OPEN_DELETE_USER_MODAL } from '../modals/delete_user_modal_event_hub';
export default { export default {
components: { components: {
SharedDeleteAction, GlDropdownItem,
}, },
props: { props: {
username: { username: {
...@@ -20,17 +22,32 @@ export default { ...@@ -20,17 +22,32 @@ export default {
default: () => [], default: () => [],
}, },
}, },
methods: {
onClick() {
const { username, paths, userDeletionObstacles } = this;
eventHub.$emit(EVENT_OPEN_DELETE_USER_MODAL, {
username,
blockPath: paths.block,
deletePath: paths.delete,
userDeletionObstacles,
i18n: {
title: s__('AdminUsers|Delete User %{username}?'),
primaryButtonLabel: s__('AdminUsers|Delete user'),
messageBody: s__(`AdminUsers|You are about to permanently delete the user %{username}. Issues, merge requests,
and groups linked to them will be transferred to a system-wide "Ghost-user". To avoid data loss,
consider using the %{strongStart}block user%{strongEnd} feature instead. Once you %{strongStart}Delete user%{strongEnd},
it cannot be undone or recovered.`),
},
});
},
},
}; };
</script> </script>
<template> <template>
<shared-delete-action <gl-dropdown-item @click="onClick">
modal-type="delete" <span class="gl-text-red-500">
:username="username"
:paths="paths"
:delete-path="paths.delete"
:user-deletion-obstacles="userDeletionObstacles"
>
<slot></slot> <slot></slot>
</shared-delete-action> </span>
</gl-dropdown-item>
</template> </template>
<script> <script>
import SharedDeleteAction from './shared/shared_delete_action.vue'; import { GlDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub, { EVENT_OPEN_DELETE_USER_MODAL } from '../modals/delete_user_modal_event_hub';
export default { export default {
components: { components: {
SharedDeleteAction, GlDropdownItem,
}, },
props: { props: {
username: { username: {
...@@ -20,17 +22,32 @@ export default { ...@@ -20,17 +22,32 @@ export default {
default: () => [], default: () => [],
}, },
}, },
methods: {
onClick() {
const { username, paths, userDeletionObstacles } = this;
eventHub.$emit(EVENT_OPEN_DELETE_USER_MODAL, {
username,
blockPath: paths.block,
deletePath: paths.deleteWithContributions,
userDeletionObstacles,
i18n: {
title: s__('AdminUsers|Delete User %{username} and contributions?'),
primaryButtonLabel: s__('AdminUsers|Delete user and contributions'),
messageBody: s__(`AdminUsers|You are about to permanently delete the user %{username}. This will delete all of the issues,
merge requests, and groups linked to them. To avoid data loss,
consider using the %{strongStart}block user%{strongEnd} feature instead. Once you %{strongStart}Delete user%{strongEnd},
it cannot be undone or recovered.`),
},
});
},
},
}; };
</script> </script>
<template> <template>
<shared-delete-action <gl-dropdown-item @click="onClick">
modal-type="delete-with-contributions" <span class="gl-text-red-500">
:username="username"
:paths="paths"
:delete-path="paths.deleteWithContributions"
:user-deletion-obstacles="userDeletionObstacles"
>
<slot></slot> <slot></slot>
</shared-delete-action> </span>
</gl-dropdown-item>
</template> </template>
<script>
import { GlDropdownItem } from '@gitlab/ui';
export default {
components: {
GlDropdownItem,
},
props: {
username: {
type: String,
required: true,
},
paths: {
type: Object,
required: true,
},
deletePath: {
type: String,
required: true,
},
modalType: {
type: String,
required: true,
},
userDeletionObstacles: {
type: Array,
required: true,
},
},
computed: {
modalAttributes() {
return {
'data-block-user-url': this.paths.block,
'data-delete-user-url': this.deletePath,
'data-gl-modal-action': this.modalType,
'data-username': this.username,
'data-user-deletion-obstacles': JSON.stringify(this.userDeletionObstacles),
};
},
},
};
</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> <script>
import { GlModal, GlButton, GlFormInput, GlSprintf } from '@gitlab/ui'; import { GlModal, GlButton, GlFormInput, GlSprintf } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
import eventHub, { EVENT_OPEN_DELETE_USER_MODAL } from './delete_user_modal_event_hub';
export default { export default {
components: { components: {
...@@ -13,47 +13,23 @@ export default { ...@@ -13,47 +13,23 @@ export default {
UserDeletionObstaclesList, UserDeletionObstaclesList,
}, },
props: { props: {
title: {
type: String,
required: true,
},
content: {
type: String,
required: true,
},
action: {
type: String,
required: true,
},
secondaryAction: {
type: String,
required: true,
},
deleteUserUrl: {
type: String,
required: true,
},
blockUserUrl: {
type: String,
required: true,
},
username: {
type: String,
required: true,
},
csrfToken: { csrfToken: {
type: String, type: String,
required: true, required: true,
}, },
userDeletionObstacles: {
type: String,
required: false,
default: '[]',
},
}, },
data() { data() {
return { return {
enteredUsername: '', enteredUsername: '',
username: '',
blockPath: '',
deletePath: '',
userDeletionObstacles: [],
i18n: {
title: '',
primaryButtonLabel: '',
messageBody: '',
},
}; };
}, },
computed: { computed: {
...@@ -61,75 +37,80 @@ export default { ...@@ -61,75 +37,80 @@ export default {
return this.username.trim(); return this.username.trim();
}, },
modalTitle() { modalTitle() {
return sprintf(this.title, { username: this.trimmedUsername }, false); return sprintf(this.i18n.title, { username: this.trimmedUsername }, false);
},
canSubmit() {
return this.enteredUsername && this.enteredUsername === this.trimmedUsername;
}, },
secondaryButtonLabel() { secondaryButtonLabel() {
return s__('AdminUsers|Block user'); return s__('AdminUsers|Block user');
}, },
canSubmit() {
return this.enteredUsername === this.trimmedUsername;
}, },
obstacles() { mounted() {
try { eventHub.$on(EVENT_OPEN_DELETE_USER_MODAL, this.onOpenEvent);
return JSON.parse(this.userDeletionObstacles);
} catch (e) {
Sentry.captureException(e);
}
return [];
}, },
destroyed() {
eventHub.$off(EVENT_OPEN_DELETE_USER_MODAL, this.onOpenEvent);
}, },
methods: { methods: {
show() { onOpenEvent({ username, blockPath, deletePath, userDeletionObstacles, i18n }) {
this.username = username;
this.blockPath = blockPath;
this.deletePath = deletePath;
this.userDeletionObstacles = userDeletionObstacles;
this.i18n = i18n;
this.openModal();
},
openModal() {
this.$refs.modal.show(); this.$refs.modal.show();
}, },
onSubmit() {
this.$refs.form.submit();
this.enteredUsername = '';
},
onCancel() { onCancel() {
this.enteredUsername = ''; this.enteredUsername = '';
this.$refs.modal.hide(); this.$refs.modal.hide();
}, },
onSecondaryAction() { onSecondaryAction() {
const { form } = this.$refs; const { form } = this.$refs;
form.action = this.blockPath;
form.action = this.blockUserUrl;
this.$refs.method.value = 'put'; this.$refs.method.value = 'put';
form.submit(); form.submit();
}, },
onSubmit() {
this.$refs.form.submit();
this.enteredUsername = '';
},
}, },
}; };
</script> </script>
<template> <template>
<gl-modal ref="modal" modal-id="delete-user-modal" :title="modalTitle" kind="danger"> <gl-modal ref="modal" modal-id="delete-user-modal" :title="modalTitle" kind="danger">
<p> <p>
<gl-sprintf :message="content"> <gl-sprintf :message="i18n.messageBody">
<template #username> <template #username>
<strong>{{ trimmedUsername }}</strong> <strong data-testid="message-username">{{ trimmedUsername }}</strong>
</template> </template>
<template #strong="props"> <template #strong="{ content }">
<strong>{{ props.content }}</strong> <strong>{{ content }}</strong>
</template> </template>
</gl-sprintf> </gl-sprintf>
</p> </p>
<user-deletion-obstacles-list <user-deletion-obstacles-list
v-if="obstacles.length" v-if="userDeletionObstacles.length"
:obstacles="obstacles" :obstacles="userDeletionObstacles"
:user-name="trimmedUsername" :user-name="trimmedUsername"
/> />
<p> <p>
<gl-sprintf :message="s__('AdminUsers|To confirm, type %{username}')"> <gl-sprintf :message="s__('AdminUsers|To confirm, type %{username}')">
<template #username> <template #username>
<code class="gl-white-space-pre-wrap">{{ trimmedUsername }}</code> <code data-testid="confirm-username" class="gl-white-space-pre-wrap">{{
trimmedUsername
}}</code>
</template> </template>
</gl-sprintf> </gl-sprintf>
</p> </p>
<form ref="form" :action="deleteUserUrl" method="post" @submit.prevent> <form ref="form" :action="deletePath" method="post" @submit.prevent>
<input ref="method" type="hidden" name="_method" value="delete" /> <input ref="method" type="hidden" name="_method" value="delete" />
<input :value="csrfToken" type="hidden" name="authenticity_token" /> <input :value="csrfToken" type="hidden" name="authenticity_token" />
<gl-form-input <gl-form-input
...@@ -140,6 +121,7 @@ export default { ...@@ -140,6 +121,7 @@ export default {
autocomplete="off" autocomplete="off"
/> />
</form> </form>
<template #modal-footer> <template #modal-footer>
<gl-button @click="onCancel">{{ __('Cancel') }}</gl-button> <gl-button @click="onCancel">{{ __('Cancel') }}</gl-button>
<gl-button <gl-button
...@@ -148,10 +130,10 @@ export default { ...@@ -148,10 +130,10 @@ export default {
variant="danger" variant="danger"
@click="onSecondaryAction" @click="onSecondaryAction"
> >
{{ secondaryAction }} {{ secondaryButtonLabel }}
</gl-button> </gl-button>
<gl-button :disabled="!canSubmit" category="primary" variant="danger" @click="onSubmit">{{ <gl-button :disabled="!canSubmit" category="primary" variant="danger" @click="onSubmit">{{
action i18n.primaryButtonLabel
}}</gl-button> }}</gl-button>
</template> </template>
</gl-modal> </gl-modal>
......
import createEventHub from '~/helpers/event_hub_factory';
export default createEventHub();
export const EVENT_OPEN_DELETE_USER_MODAL = Symbol('OPEN');
<script>
import DeleteUserModal from './delete_user_modal.vue';
export default {
components: { DeleteUserModal },
props: {
modalConfiguration: {
required: true,
type: Object,
},
csrfToken: {
required: true,
type: String,
},
selector: {
required: true,
type: String,
},
},
data() {
return {
currentModalData: null,
};
},
computed: {
activeModal() {
return Boolean(this.currentModalData);
},
modalProps() {
const { glModalAction: requestedAction } = this.currentModalData;
return {
...this.modalConfiguration[requestedAction],
...this.currentModalData,
csrfToken: this.csrfToken,
};
},
},
mounted() {
/*
* Here we're looking for every button that needs to launch a modal
* on click, and then attaching a click event handler to show the modal
* if it's correctly configured.
*
* TODO: Replace this with integrated modal components https://gitlab.com/gitlab-org/gitlab/-/issues/320922
*/
document.querySelectorAll(this.selector).forEach((button) => {
button.addEventListener('click', (e) => {
if (!button.dataset.glModalAction) return;
e.preventDefault();
this.show(button.dataset);
});
});
},
methods: {
show(modalData) {
const { glModalAction: requestedAction } = modalData;
if (!this.modalConfiguration[requestedAction]) {
throw new Error(`Modal action ${requestedAction} has no configuration in HTML`);
}
this.currentModalData = modalData;
return this.$nextTick().then(() => {
this.$refs.modal.show();
});
},
},
};
</script>
<template>
<delete-user-modal v-if="activeModal" ref="modal" v-bind="modalProps" />
</template>
...@@ -20,9 +20,3 @@ export const I18N_USER_ACTIONS = { ...@@ -20,9 +20,3 @@ export const I18N_USER_ACTIONS = {
ban: s__('AdminUsers|Ban user'), ban: s__('AdminUsers|Ban user'),
unban: s__('AdminUsers|Unban user'), unban: s__('AdminUsers|Unban user'),
}; };
export const CONFIRM_DELETE_BUTTON_SELECTOR = '.js-delete-user-modal-button';
export const MODAL_TEXTS_CONTAINER_SELECTOR = '#js-modal-texts';
export const MODAL_MANAGER_SELECTOR = '#js-delete-user-modal';
...@@ -4,13 +4,8 @@ import createDefaultClient from '~/lib/graphql'; ...@@ -4,13 +4,8 @@ import createDefaultClient from '~/lib/graphql';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import csrf from '~/lib/utils/csrf'; import csrf from '~/lib/utils/csrf';
import AdminUsersApp from './components/app.vue'; import AdminUsersApp from './components/app.vue';
import ModalManager from './components/modals/user_modal_manager.vue'; import DeleteUserModal from './components/modals/delete_user_modal.vue';
import UserActions from './components/user_actions.vue'; import UserActions from './components/user_actions.vue';
import {
CONFIRM_DELETE_BUTTON_SELECTOR,
MODAL_TEXTS_CONTAINER_SELECTOR,
MODAL_MANAGER_SELECTOR,
} from './constants';
Vue.use(VueApollo); Vue.use(VueApollo);
...@@ -46,43 +41,13 @@ export const initAdminUserActions = (el = document.querySelector('#js-admin-user ...@@ -46,43 +41,13 @@ export const initAdminUserActions = (el = document.querySelector('#js-admin-user
initApp(el, UserActions, 'user', { showButtonLabels: true }); initApp(el, UserActions, 'user', { showButtonLabels: true });
export const initDeleteUserModals = () => { export const initDeleteUserModals = () => {
const modalsMountElement = document.querySelector(MODAL_TEXTS_CONTAINER_SELECTOR); return new Vue({
if (!modalsMountElement) {
return;
}
const modalConfiguration = Array.from(modalsMountElement.children).reduce((accumulator, node) => {
const { modal, ...config } = node.dataset;
return {
...accumulator,
[modal]: {
title: node.dataset.title,
...config,
content: node.innerHTML,
},
};
}, {});
// eslint-disable-next-line no-new
new Vue({
el: MODAL_MANAGER_SELECTOR,
functional: true, functional: true,
methods: { render: (createElement) =>
show(...args) { createElement(DeleteUserModal, {
this.$refs.manager.show(...args);
},
},
render(h) {
return h(ModalManager, {
ref: 'manager',
props: { props: {
selector: CONFIRM_DELETE_BUTTON_SELECTOR,
modalConfiguration,
csrfToken: csrf.token, csrfToken: csrf.token,
}, },
}); }),
}, }).$mount();
});
}; };
...@@ -15,5 +15,3 @@ ...@@ -15,5 +15,3 @@
= render @identities = render @identities
- else - else
%h4= _('This user has no identities') %h4= _('This user has no identities')
= render partial: 'admin/users/modals'
...@@ -28,5 +28,3 @@ ...@@ -28,5 +28,3 @@
impersonation: true, impersonation: true,
active_tokens: @active_impersonation_tokens, active_tokens: @active_impersonation_tokens,
revoke_route_helper: ->(token) { revoke_admin_user_impersonation_token_path(token.user, token) } revoke_route_helper: ->(token) { revoke_admin_user_impersonation_token_path(token.user, token) }
= render partial: 'admin/users/modals'
#js-delete-user-modal
#js-modal-texts.hidden{ "hidden": true, "aria-hidden": "true" }
%div{ data: { modal: "delete",
title: s_("AdminUsers|Delete User %{username}?"),
action: s_('AdminUsers|Delete user'),
'secondary-action': s_('AdminUsers|Block user') } }
= s_('AdminUsers|You are about to permanently delete the user %{username}. Issues, merge requests,
and groups linked to them will be transferred to a system-wide "Ghost-user". To avoid data loss,
consider using the %{strongStart}block user%{strongEnd} feature instead. Once you %{strongStart}Delete user%{strongEnd},
it cannot be undone or recovered.')
%div{ data: { modal: "delete-with-contributions",
title: s_("AdminUsers|Delete User %{username} and contributions?"),
action: s_('AdminUsers|Delete user and contributions') ,
'secondary-action': s_('AdminUsers|Block user') } }
= s_('AdminUsers|You are about to permanently delete the user %{username}. This will delete all of the issues,
merge requests, and groups linked to them. To avoid data loss,
consider using the %{strongStart}block user%{strongEnd} feature instead. Once you %{strongStart}Delete user%{strongEnd},
it cannot be undone or recovered.')
...@@ -68,5 +68,3 @@ ...@@ -68,5 +68,3 @@
= gl_loading_icon(size: 'lg', css_class: 'gl-my-7') = gl_loading_icon(size: 'lg', css_class: 'gl-my-7')
= paginate_collection @users = paginate_collection @users
= render partial: 'admin/users/modals'
...@@ -3,4 +3,3 @@ ...@@ -3,4 +3,3 @@
- page_title _("SSH Keys"), @user.name, _("Users") - page_title _("SSH Keys"), @user.name, _("Users")
= render 'admin/users/head' = render 'admin/users/head'
= render 'profiles/keys/key_table', admin: true = render 'profiles/keys/key_table', admin: true
= render partial: 'admin/users/modals'
...@@ -48,5 +48,3 @@ ...@@ -48,5 +48,3 @@
- if member.respond_to? :project - if member.respond_to? :project
= link_to project_project_member_path(project, member), data: { confirm: remove_member_message(member), confirm_btn_variant: 'danger' }, aria: { label: _('Remove') }, remote: true, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon gl-ml-3", title: _('Remove user from project') do = link_to project_project_member_path(project, member), data: { confirm: remove_member_message(member), confirm_btn_variant: 'danger' }, aria: { label: _('Remove') }, remote: true, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon gl-ml-3", title: _('Remove user from project') do
= sprite_icon('remove', size: 16, css_class: 'gl-icon') = sprite_icon('remove', size: 16, css_class: 'gl-icon')
= render partial: 'admin/users/modals'
...@@ -146,4 +146,3 @@ ...@@ -146,4 +146,3 @@
.col-md-6.gl-display-none.gl-md-display-block .col-md-6.gl-display-none.gl-md-display-block
= render 'admin/users/profile', user: @user = render 'admin/users/profile', user: @user
= render 'admin/users/user_detail_note' = render 'admin/users/user_detail_note'
= render partial: 'admin/users/modals'
import { GlDropdownItem } from '@gitlab/ui'; import { GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { kebabCase } from 'lodash';
import Actions from '~/admin/users/components/actions'; import Actions from '~/admin/users/components/actions';
import SharedDeleteAction from '~/admin/users/components/actions/shared/shared_delete_action.vue'; import eventHub, {
EVENT_OPEN_DELETE_USER_MODAL,
} from '~/admin/users/components/modals/delete_user_modal_event_hub';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants'; import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants';
import { CONFIRMATION_ACTIONS, DELETE_ACTIONS } from '../../constants'; import { CONFIRMATION_ACTIONS, DELETE_ACTIONS } from '../../constants';
...@@ -14,12 +14,11 @@ describe('Action components', () => { ...@@ -14,12 +14,11 @@ describe('Action components', () => {
const findDropdownItem = () => wrapper.find(GlDropdownItem); const findDropdownItem = () => wrapper.find(GlDropdownItem);
const initComponent = ({ component, props, stubs = {} } = {}) => { const initComponent = ({ component, props } = {}) => {
wrapper = shallowMount(component, { wrapper = shallowMount(component, {
propsData: { propsData: {
...props, ...props,
}, },
stubs,
}); });
}; };
...@@ -29,7 +28,7 @@ describe('Action components', () => { ...@@ -29,7 +28,7 @@ describe('Action components', () => {
}); });
describe('CONFIRMATION_ACTIONS', () => { describe('CONFIRMATION_ACTIONS', () => {
it.each(CONFIRMATION_ACTIONS)('renders a dropdown item for "%s"', async (action) => { it.each(CONFIRMATION_ACTIONS)('renders a dropdown item for "%s"', (action) => {
initComponent({ initComponent({
component: Actions[capitalizeFirstCharacter(action)], component: Actions[capitalizeFirstCharacter(action)],
props: { props: {
...@@ -38,20 +37,23 @@ describe('Action components', () => { ...@@ -38,20 +37,23 @@ describe('Action components', () => {
}, },
}); });
await nextTick();
expect(findDropdownItem().exists()).toBe(true); expect(findDropdownItem().exists()).toBe(true);
}); });
}); });
describe('DELETE_ACTION_COMPONENTS', () => { describe('DELETE_ACTION_COMPONENTS', () => {
beforeEach(() => {
jest.spyOn(eventHub, '$emit').mockImplementation();
});
const userDeletionObstacles = [ const userDeletionObstacles = [
{ name: 'schedule1', type: OBSTACLE_TYPES.oncallSchedules }, { name: 'schedule1', type: OBSTACLE_TYPES.oncallSchedules },
{ name: 'policy1', type: OBSTACLE_TYPES.escalationPolicies }, { name: 'policy1', type: OBSTACLE_TYPES.escalationPolicies },
]; ];
it.each(DELETE_ACTIONS.map((action) => [action, paths[action]]))( it.each(DELETE_ACTIONS)(
'renders a dropdown item for "%s"', 'renders a dropdown item that opens the delete user modal when clicked for "%s"',
async (action, expectedPath) => { async (action) => {
initComponent({ initComponent({
component: Actions[capitalizeFirstCharacter(action)], component: Actions[capitalizeFirstCharacter(action)],
props: { props: {
...@@ -59,21 +61,19 @@ describe('Action components', () => { ...@@ -59,21 +61,19 @@ describe('Action components', () => {
paths, paths,
userDeletionObstacles, userDeletionObstacles,
}, },
stubs: { SharedDeleteAction },
}); });
await nextTick(); await findDropdownItem().vm.$emit('click');
const sharedAction = wrapper.find(SharedDeleteAction);
expect(sharedAction.attributes('data-block-user-url')).toBe(paths.block); expect(eventHub.$emit).toHaveBeenCalledWith(
expect(sharedAction.attributes('data-delete-user-url')).toBe(expectedPath); EVENT_OPEN_DELETE_USER_MODAL,
expect(sharedAction.attributes('data-gl-modal-action')).toBe(kebabCase(action)); expect.objectContaining({
expect(sharedAction.attributes('data-username')).toBe('John Doe'); username: 'John Doe',
expect(sharedAction.attributes('data-user-deletion-obstacles')).toBe( blockPath: paths.block,
JSON.stringify(userDeletionObstacles), deletePath: paths[action],
userDeletionObstacles,
}),
); );
expect(findDropdownItem().exists()).toBe(true);
}, },
); );
}); });
......
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`User Operation confirmation modal renders modal with form included 1`] = ` exports[`Delete user modal renders modal with form included 1`] = `
<div> <form
<p> action=""
<gl-sprintf-stub
message="content"
/>
</p>
<user-deletion-obstacles-list-stub
obstacles="schedule1,policy1"
username="username"
/>
<p>
<gl-sprintf-stub
message="To confirm, type %{username}"
/>
</p>
<form
action="delete-url"
method="post"
>
<input
name="_method"
type="hidden"
value="delete"
/>
<input
name="authenticity_token"
type="hidden"
value="csrf"
/>
<gl-form-input-stub
autocomplete="off"
autofocus=""
name="username"
type="text"
value=""
/>
</form>
<gl-button-stub
buttontextclasses=""
category="primary"
icon=""
size="medium"
variant="default"
>
Cancel
</gl-button-stub>
<gl-button-stub
buttontextclasses=""
category="secondary"
disabled="true"
icon=""
size="medium"
variant="danger"
>
secondaryAction
</gl-button-stub>
<gl-button-stub
buttontextclasses=""
category="primary"
disabled="true"
icon=""
size="medium"
variant="danger"
>
action
</gl-button-stub>
</div>
`;
exports[`User Operation confirmation modal when user's name has leading and trailing whitespace displays user's name without whitespace 1`] = `
<div>
<p>
content
</p>
<user-deletion-obstacles-list-stub
obstacles="schedule1,policy1"
username="John Smith"
/>
<p>
To confirm, type
<code
class="gl-white-space-pre-wrap"
>
John Smith
</code>
</p>
<form
action="delete-url"
method="post" method="post"
> >
<input <input
name="_method" name="_method"
type="hidden" type="hidden"
...@@ -122,39 +24,5 @@ exports[`User Operation confirmation modal when user's name has leading and trai ...@@ -122,39 +24,5 @@ exports[`User Operation confirmation modal when user's name has leading and trai
type="text" type="text"
value="" value=""
/> />
</form> </form>
<gl-button-stub
buttontextclasses=""
category="primary"
icon=""
size="medium"
variant="default"
>
Cancel
</gl-button-stub>
<gl-button-stub
buttontextclasses=""
category="secondary"
disabled="true"
icon=""
size="medium"
variant="danger"
>
secondaryAction
</gl-button-stub>
<gl-button-stub
buttontextclasses=""
category="primary"
disabled="true"
icon=""
size="medium"
variant="danger"
>
action
</gl-button-stub>
</div>
`; `;
import { GlButton, GlFormInput, GlSprintf } from '@gitlab/ui'; import { GlButton, GlFormInput, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { nextTick } from 'vue'; import eventHub, {
EVENT_OPEN_DELETE_USER_MODAL,
} from '~/admin/users/components/modals/delete_user_modal_event_hub';
import DeleteUserModal from '~/admin/users/components/modals/delete_user_modal.vue'; import DeleteUserModal from '~/admin/users/components/modals/delete_user_modal.vue';
import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
import ModalStub from './stubs/modal_stub'; import ModalStub from './stubs/modal_stub';
...@@ -9,7 +11,7 @@ const TEST_DELETE_USER_URL = 'delete-url'; ...@@ -9,7 +11,7 @@ const TEST_DELETE_USER_URL = 'delete-url';
const TEST_BLOCK_USER_URL = 'block-url'; const TEST_BLOCK_USER_URL = 'block-url';
const TEST_CSRF = 'csrf'; const TEST_CSRF = 'csrf';
describe('User Operation confirmation modal', () => { describe('Delete user modal', () => {
let wrapper; let wrapper;
let formSubmitSpy; let formSubmitSpy;
...@@ -27,28 +29,36 @@ describe('User Operation confirmation modal', () => { ...@@ -27,28 +29,36 @@ describe('User Operation confirmation modal', () => {
const getMethodParam = () => new FormData(findForm().element).get('_method'); const getMethodParam = () => new FormData(findForm().element).get('_method');
const getFormAction = () => findForm().attributes('action'); const getFormAction = () => findForm().attributes('action');
const findUserDeletionObstaclesList = () => wrapper.findComponent(UserDeletionObstaclesList); const findUserDeletionObstaclesList = () => wrapper.findComponent(UserDeletionObstaclesList);
const findMessageUsername = () => wrapper.findByTestId('message-username');
const findConfirmUsername = () => wrapper.findByTestId('confirm-username');
const emitOpenModalEvent = (modalData) => {
return eventHub.$emit(EVENT_OPEN_DELETE_USER_MODAL, modalData);
};
const setUsername = (username) => { const setUsername = (username) => {
findUsernameInput().vm.$emit('input', username); return findUsernameInput().vm.$emit('input', username);
}; };
const username = 'username'; const username = 'username';
const badUsername = 'bad_username'; const badUsername = 'bad_username';
const userDeletionObstacles = '["schedule1", "policy1"]'; const userDeletionObstacles = ['schedule1', 'policy1'];
const createComponent = (props = {}, stubs = {}) => { const mockModalData = {
wrapper = shallowMount(DeleteUserModal, {
propsData: {
username, username,
title: 'title', blockPath: TEST_BLOCK_USER_URL,
content: 'content', deletePath: TEST_DELETE_USER_URL,
action: 'action',
secondaryAction: 'secondaryAction',
deleteUserUrl: TEST_DELETE_USER_URL,
blockUserUrl: TEST_BLOCK_USER_URL,
csrfToken: TEST_CSRF,
userDeletionObstacles, userDeletionObstacles,
...props, i18n: {
title: 'Modal for %{username}',
primaryButtonLabel: 'Delete user',
messageBody: 'Delete %{username} or rather %{strongStart}block user%{strongEnd}?',
},
};
const createComponent = (stubs = {}) => {
wrapper = shallowMountExtended(DeleteUserModal, {
propsData: {
csrfToken: TEST_CSRF,
}, },
stubs: { stubs: {
GlModal: ModalStub, GlModal: ModalStub,
...@@ -68,7 +78,7 @@ describe('User Operation confirmation modal', () => { ...@@ -68,7 +78,7 @@ describe('User Operation confirmation modal', () => {
it('renders modal with form included', () => { it('renders modal with form included', () => {
createComponent(); createComponent();
expect(wrapper.element).toMatchSnapshot(); expect(findForm().element).toMatchSnapshot();
}); });
describe('on created', () => { describe('on created', () => {
...@@ -83,11 +93,11 @@ describe('User Operation confirmation modal', () => { ...@@ -83,11 +93,11 @@ describe('User Operation confirmation modal', () => {
}); });
describe('with incorrect username', () => { describe('with incorrect username', () => {
beforeEach(async () => { beforeEach(() => {
createComponent(); createComponent();
setUsername(badUsername); emitOpenModalEvent(mockModalData);
await nextTick(); return setUsername(badUsername);
}); });
it('shows incorrect username', () => { it('shows incorrect username', () => {
...@@ -101,11 +111,11 @@ describe('User Operation confirmation modal', () => { ...@@ -101,11 +111,11 @@ describe('User Operation confirmation modal', () => {
}); });
describe('with correct username', () => { describe('with correct username', () => {
beforeEach(async () => { beforeEach(() => {
createComponent(); createComponent();
setUsername(username); emitOpenModalEvent(mockModalData);
await nextTick(); return setUsername(username);
}); });
it('shows correct username', () => { it('shows correct username', () => {
...@@ -117,11 +127,9 @@ describe('User Operation confirmation modal', () => { ...@@ -117,11 +127,9 @@ describe('User Operation confirmation modal', () => {
expect(findSecondaryButton().attributes('disabled')).toBeFalsy(); expect(findSecondaryButton().attributes('disabled')).toBeFalsy();
}); });
describe('when primary action is submitted', () => { describe('when primary action is clicked', () => {
beforeEach(async () => { beforeEach(() => {
findPrimaryButton().vm.$emit('click'); return findPrimaryButton().vm.$emit('click');
await nextTick();
}); });
it('clears the input', () => { it('clears the input', () => {
...@@ -136,11 +144,9 @@ describe('User Operation confirmation modal', () => { ...@@ -136,11 +144,9 @@ describe('User Operation confirmation modal', () => {
}); });
}); });
describe('when secondary action is submitted', () => { describe('when secondary action is clicked', () => {
beforeEach(async () => { beforeEach(() => {
findSecondaryButton().vm.$emit('click'); return findSecondaryButton().vm.$emit('click');
await nextTick();
}); });
it('has correct form attributes and calls submit', () => { it('has correct form attributes and calls submit', () => {
...@@ -154,22 +160,23 @@ describe('User Operation confirmation modal', () => { ...@@ -154,22 +160,23 @@ describe('User Operation confirmation modal', () => {
describe("when user's name has leading and trailing whitespace", () => { describe("when user's name has leading and trailing whitespace", () => {
beforeEach(() => { beforeEach(() => {
createComponent( createComponent({ GlSprintf });
{ return emitOpenModalEvent({ ...mockModalData, username: ' John Smith ' });
username: ' John Smith ',
},
{ GlSprintf },
);
}); });
it("displays user's name without whitespace", () => { it("displays user's name without whitespace", () => {
expect(wrapper.element).toMatchSnapshot(); expect(findMessageUsername().text()).toBe('John Smith');
expect(findConfirmUsername().text()).toBe('John Smith');
}); });
it("shows enabled buttons when user's name is entered without whitespace", async () => { it('passes user name without whitespace to the obstacles', () => {
setUsername('John Smith'); expect(findUserDeletionObstaclesList().props()).toMatchObject({
userName: 'John Smith',
});
});
await nextTick(); it("shows enabled buttons when user's name is entered without whitespace", async () => {
await setUsername('John Smith');
expect(findPrimaryButton().attributes('disabled')).toBeUndefined(); expect(findPrimaryButton().attributes('disabled')).toBeUndefined();
expect(findSecondaryButton().attributes('disabled')).toBeUndefined(); expect(findSecondaryButton().attributes('disabled')).toBeUndefined();
...@@ -177,17 +184,20 @@ describe('User Operation confirmation modal', () => { ...@@ -177,17 +184,20 @@ describe('User Operation confirmation modal', () => {
}); });
describe('Related user-deletion-obstacles list', () => { describe('Related user-deletion-obstacles list', () => {
it('does NOT render the list when user has no related obstacles', () => { it('does NOT render the list when user has no related obstacles', async () => {
createComponent({ userDeletionObstacles: '[]' }); createComponent();
await emitOpenModalEvent({ ...mockModalData, userDeletionObstacles: [] });
expect(findUserDeletionObstaclesList().exists()).toBe(false); expect(findUserDeletionObstaclesList().exists()).toBe(false);
}); });
it('renders the list when user has related obstalces', () => { it('renders the list when user has related obstalces', async () => {
createComponent(); createComponent();
await emitOpenModalEvent(mockModalData);
const obstacles = findUserDeletionObstaclesList(); const obstacles = findUserDeletionObstaclesList();
expect(obstacles.exists()).toBe(true); expect(obstacles.exists()).toBe(true);
expect(obstacles.props('obstacles')).toEqual(JSON.parse(userDeletionObstacles)); expect(obstacles.props('obstacles')).toEqual(userDeletionObstacles);
}); });
}); });
}); });
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import UserModalManager from '~/admin/users/components/modals/user_modal_manager.vue';
import ModalStub from './stubs/modal_stub';
describe('Users admin page Modal Manager', () => {
let wrapper;
const modalConfiguration = {
action1: {
title: 'action1',
content: 'Action Modal 1',
},
action2: {
title: 'action2',
content: 'Action Modal 2',
},
};
const findModal = () => wrapper.find({ ref: 'modal' });
const createComponent = (props = {}) => {
wrapper = mount(UserModalManager, {
propsData: {
selector: '.js-delete-user-modal-button',
modalConfiguration,
csrfToken: 'dummyCSRF',
...props,
},
stubs: {
DeleteUserModal: ModalStub,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('render behavior', () => {
it('does not renders modal when initialized', () => {
createComponent();
expect(findModal().exists()).toBeFalsy();
});
it('throws if action has no proper configuration', () => {
createComponent({
modalConfiguration: {},
});
expect(() => wrapper.vm.show({ glModalAction: 'action1' })).toThrow();
});
it('renders modal with expected props when valid configuration is passed', async () => {
createComponent();
wrapper.vm.show({
glModalAction: 'action1',
extraProp: 'extraPropValue',
});
await nextTick();
const modal = findModal();
expect(modal.exists()).toBeTruthy();
expect(modal.vm.$attrs.csrfToken).toEqual('dummyCSRF');
expect(modal.vm.$attrs.extraProp).toEqual('extraPropValue');
expect(modal.vm.showWasCalled).toBeTruthy();
});
});
describe('click handling', () => {
let button;
let button2;
const createButtons = () => {
button = document.createElement('button');
button2 = document.createElement('button');
button.setAttribute('class', 'js-delete-user-modal-button');
button.setAttribute('data-username', 'foo');
button.setAttribute('data-gl-modal-action', 'action1');
button.setAttribute('data-block-user-url', '/block');
button.setAttribute('data-delete-user-url', '/delete');
document.body.appendChild(button);
document.body.appendChild(button2);
};
const removeButtons = () => {
button.remove();
button = null;
button2.remove();
button2 = null;
};
beforeEach(() => {
createButtons();
createComponent();
});
afterEach(() => {
removeButtons();
});
it('renders the modal when the button is clicked', async () => {
button.click();
await nextTick();
expect(findModal().exists()).toBe(true);
});
it('does not render the modal when a misconfigured button is clicked', async () => {
button.removeAttribute('data-gl-modal-action');
button.click();
await nextTick();
expect(findModal().exists()).toBe(false);
});
it('does not render the modal when a button without the selector class is clicked', async () => {
button2.click();
await nextTick();
expect(findModal().exists()).toBe(false);
});
});
});
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