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>
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 {
components: {
SharedDeleteAction,
GlDropdownItem,
},
props: {
username: {
......@@ -20,17 +22,32 @@ export 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>
<template>
<shared-delete-action
modal-type="delete"
:username="username"
:paths="paths"
:delete-path="paths.delete"
:user-deletion-obstacles="userDeletionObstacles"
>
<gl-dropdown-item @click="onClick">
<span class="gl-text-red-500">
<slot></slot>
</shared-delete-action>
</span>
</gl-dropdown-item>
</template>
<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 {
components: {
SharedDeleteAction,
GlDropdownItem,
},
props: {
username: {
......@@ -20,17 +22,32 @@ export 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>
<template>
<shared-delete-action
modal-type="delete-with-contributions"
:username="username"
:paths="paths"
:delete-path="paths.deleteWithContributions"
:user-deletion-obstacles="userDeletionObstacles"
>
<gl-dropdown-item @click="onClick">
<span class="gl-text-red-500">
<slot></slot>
</shared-delete-action>
</span>
</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,
},
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>
import { GlModal, GlButton, GlFormInput, GlSprintf } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { s__, sprintf } from '~/locale';
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 {
components: {
......@@ -13,47 +13,23 @@ export default {
UserDeletionObstaclesList,
},
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: {
type: String,
required: true,
},
userDeletionObstacles: {
type: String,
required: false,
default: '[]',
},
},
data() {
return {
enteredUsername: '',
username: '',
blockPath: '',
deletePath: '',
userDeletionObstacles: [],
i18n: {
title: '',
primaryButtonLabel: '',
messageBody: '',
},
};
},
computed: {
......@@ -61,75 +37,80 @@ export default {
return this.username.trim();
},
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() {
return s__('AdminUsers|Block user');
},
canSubmit() {
return this.enteredUsername === this.trimmedUsername;
},
obstacles() {
try {
return JSON.parse(this.userDeletionObstacles);
} catch (e) {
Sentry.captureException(e);
}
return [];
mounted() {
eventHub.$on(EVENT_OPEN_DELETE_USER_MODAL, this.onOpenEvent);
},
destroyed() {
eventHub.$off(EVENT_OPEN_DELETE_USER_MODAL, this.onOpenEvent);
},
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();
},
onSubmit() {
this.$refs.form.submit();
this.enteredUsername = '';
},
onCancel() {
this.enteredUsername = '';
this.$refs.modal.hide();
},
onSecondaryAction() {
const { form } = this.$refs;
form.action = this.blockUserUrl;
form.action = this.blockPath;
this.$refs.method.value = 'put';
form.submit();
},
onSubmit() {
this.$refs.form.submit();
this.enteredUsername = '';
},
},
};
</script>
<template>
<gl-modal ref="modal" modal-id="delete-user-modal" :title="modalTitle" kind="danger">
<p>
<gl-sprintf :message="content">
<gl-sprintf :message="i18n.messageBody">
<template #username>
<strong>{{ trimmedUsername }}</strong>
<strong data-testid="message-username">{{ trimmedUsername }}</strong>
</template>
<template #strong="props">
<strong>{{ props.content }}</strong>
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</p>
<user-deletion-obstacles-list
v-if="obstacles.length"
:obstacles="obstacles"
v-if="userDeletionObstacles.length"
:obstacles="userDeletionObstacles"
:user-name="trimmedUsername"
/>
<p>
<gl-sprintf :message="s__('AdminUsers|To confirm, type %{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>
</gl-sprintf>
</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 :value="csrfToken" type="hidden" name="authenticity_token" />
<gl-form-input
......@@ -140,6 +121,7 @@ export default {
autocomplete="off"
/>
</form>
<template #modal-footer>
<gl-button @click="onCancel">{{ __('Cancel') }}</gl-button>
<gl-button
......@@ -148,10 +130,10 @@ export default {
variant="danger"
@click="onSecondaryAction"
>
{{ secondaryAction }}
{{ secondaryButtonLabel }}
</gl-button>
<gl-button :disabled="!canSubmit" category="primary" variant="danger" @click="onSubmit">{{
action
i18n.primaryButtonLabel
}}</gl-button>
</template>
</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 = {
ban: s__('AdminUsers|Ban 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';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import csrf from '~/lib/utils/csrf';
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 {
CONFIRM_DELETE_BUTTON_SELECTOR,
MODAL_TEXTS_CONTAINER_SELECTOR,
MODAL_MANAGER_SELECTOR,
} from './constants';
Vue.use(VueApollo);
......@@ -46,43 +41,13 @@ export const initAdminUserActions = (el = document.querySelector('#js-admin-user
initApp(el, UserActions, 'user', { showButtonLabels: true });
export const initDeleteUserModals = () => {
const modalsMountElement = document.querySelector(MODAL_TEXTS_CONTAINER_SELECTOR);
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,
return new Vue({
functional: true,
methods: {
show(...args) {
this.$refs.manager.show(...args);
},
},
render(h) {
return h(ModalManager, {
ref: 'manager',
render: (createElement) =>
createElement(DeleteUserModal, {
props: {
selector: CONFIRM_DELETE_BUTTON_SELECTOR,
modalConfiguration,
csrfToken: csrf.token,
},
});
},
});
}),
}).$mount();
};
......@@ -15,5 +15,3 @@
= render @identities
- else
%h4= _('This user has no identities')
= render partial: 'admin/users/modals'
......@@ -28,5 +28,3 @@
impersonation: true,
active_tokens: @active_impersonation_tokens,
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 @@
= gl_loading_icon(size: 'lg', css_class: 'gl-my-7')
= paginate_collection @users
= render partial: 'admin/users/modals'
......@@ -3,4 +3,3 @@
- page_title _("SSH Keys"), @user.name, _("Users")
= render 'admin/users/head'
= render 'profiles/keys/key_table', admin: true
= render partial: 'admin/users/modals'
......@@ -48,5 +48,3 @@
- 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
= sprite_icon('remove', size: 16, css_class: 'gl-icon')
= render partial: 'admin/users/modals'
......@@ -146,4 +146,3 @@
.col-md-6.gl-display-none.gl-md-display-block
= render 'admin/users/profile', user: @user
= render 'admin/users/user_detail_note'
= render partial: 'admin/users/modals'
import { GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { kebabCase } from 'lodash';
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 { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants';
import { CONFIRMATION_ACTIONS, DELETE_ACTIONS } from '../../constants';
......@@ -14,12 +14,11 @@ describe('Action components', () => {
const findDropdownItem = () => wrapper.find(GlDropdownItem);
const initComponent = ({ component, props, stubs = {} } = {}) => {
const initComponent = ({ component, props } = {}) => {
wrapper = shallowMount(component, {
propsData: {
...props,
},
stubs,
});
};
......@@ -29,7 +28,7 @@ describe('Action components', () => {
});
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({
component: Actions[capitalizeFirstCharacter(action)],
props: {
......@@ -38,20 +37,23 @@ describe('Action components', () => {
},
});
await nextTick();
expect(findDropdownItem().exists()).toBe(true);
});
});
describe('DELETE_ACTION_COMPONENTS', () => {
beforeEach(() => {
jest.spyOn(eventHub, '$emit').mockImplementation();
});
const userDeletionObstacles = [
{ name: 'schedule1', type: OBSTACLE_TYPES.oncallSchedules },
{ name: 'policy1', type: OBSTACLE_TYPES.escalationPolicies },
];
it.each(DELETE_ACTIONS.map((action) => [action, paths[action]]))(
'renders a dropdown item for "%s"',
async (action, expectedPath) => {
it.each(DELETE_ACTIONS)(
'renders a dropdown item that opens the delete user modal when clicked for "%s"',
async (action) => {
initComponent({
component: Actions[capitalizeFirstCharacter(action)],
props: {
......@@ -59,21 +61,19 @@ describe('Action components', () => {
paths,
userDeletionObstacles,
},
stubs: { SharedDeleteAction },
});
await nextTick();
const sharedAction = wrapper.find(SharedDeleteAction);
await findDropdownItem().vm.$emit('click');
expect(sharedAction.attributes('data-block-user-url')).toBe(paths.block);
expect(sharedAction.attributes('data-delete-user-url')).toBe(expectedPath);
expect(sharedAction.attributes('data-gl-modal-action')).toBe(kebabCase(action));
expect(sharedAction.attributes('data-username')).toBe('John Doe');
expect(sharedAction.attributes('data-user-deletion-obstacles')).toBe(
JSON.stringify(userDeletionObstacles),
expect(eventHub.$emit).toHaveBeenCalledWith(
EVENT_OPEN_DELETE_USER_MODAL,
expect.objectContaining({
username: 'John Doe',
blockPath: paths.block,
deletePath: paths[action],
userDeletionObstacles,
}),
);
expect(findDropdownItem().exists()).toBe(true);
},
);
});
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`User Operation confirmation modal renders modal with form included 1`] = `
<div>
<p>
<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"
exports[`Delete user modal renders modal with form included 1`] = `
<form
action=""
method="post"
>
>
<input
name="_method"
type="hidden"
......@@ -122,39 +24,5 @@ exports[`User Operation confirmation modal when user's name has leading and trai
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>
</form>
`;
import { GlButton, GlFormInput, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
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 UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
import ModalStub from './stubs/modal_stub';
......@@ -9,7 +11,7 @@ const TEST_DELETE_USER_URL = 'delete-url';
const TEST_BLOCK_USER_URL = 'block-url';
const TEST_CSRF = 'csrf';
describe('User Operation confirmation modal', () => {
describe('Delete user modal', () => {
let wrapper;
let formSubmitSpy;
......@@ -27,28 +29,36 @@ describe('User Operation confirmation modal', () => {
const getMethodParam = () => new FormData(findForm().element).get('_method');
const getFormAction = () => findForm().attributes('action');
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) => {
findUsernameInput().vm.$emit('input', username);
return findUsernameInput().vm.$emit('input', username);
};
const username = 'username';
const badUsername = 'bad_username';
const userDeletionObstacles = '["schedule1", "policy1"]';
const userDeletionObstacles = ['schedule1', 'policy1'];
const createComponent = (props = {}, stubs = {}) => {
wrapper = shallowMount(DeleteUserModal, {
propsData: {
const mockModalData = {
username,
title: 'title',
content: 'content',
action: 'action',
secondaryAction: 'secondaryAction',
deleteUserUrl: TEST_DELETE_USER_URL,
blockUserUrl: TEST_BLOCK_USER_URL,
csrfToken: TEST_CSRF,
blockPath: TEST_BLOCK_USER_URL,
deletePath: TEST_DELETE_USER_URL,
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: {
GlModal: ModalStub,
......@@ -68,7 +78,7 @@ describe('User Operation confirmation modal', () => {
it('renders modal with form included', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
expect(findForm().element).toMatchSnapshot();
});
describe('on created', () => {
......@@ -83,11 +93,11 @@ describe('User Operation confirmation modal', () => {
});
describe('with incorrect username', () => {
beforeEach(async () => {
beforeEach(() => {
createComponent();
setUsername(badUsername);
emitOpenModalEvent(mockModalData);
await nextTick();
return setUsername(badUsername);
});
it('shows incorrect username', () => {
......@@ -101,11 +111,11 @@ describe('User Operation confirmation modal', () => {
});
describe('with correct username', () => {
beforeEach(async () => {
beforeEach(() => {
createComponent();
setUsername(username);
emitOpenModalEvent(mockModalData);
await nextTick();
return setUsername(username);
});
it('shows correct username', () => {
......@@ -117,11 +127,9 @@ describe('User Operation confirmation modal', () => {
expect(findSecondaryButton().attributes('disabled')).toBeFalsy();
});
describe('when primary action is submitted', () => {
beforeEach(async () => {
findPrimaryButton().vm.$emit('click');
await nextTick();
describe('when primary action is clicked', () => {
beforeEach(() => {
return findPrimaryButton().vm.$emit('click');
});
it('clears the input', () => {
......@@ -136,11 +144,9 @@ describe('User Operation confirmation modal', () => {
});
});
describe('when secondary action is submitted', () => {
beforeEach(async () => {
findSecondaryButton().vm.$emit('click');
await nextTick();
describe('when secondary action is clicked', () => {
beforeEach(() => {
return findSecondaryButton().vm.$emit('click');
});
it('has correct form attributes and calls submit', () => {
......@@ -154,22 +160,23 @@ describe('User Operation confirmation modal', () => {
describe("when user's name has leading and trailing whitespace", () => {
beforeEach(() => {
createComponent(
{
username: ' John Smith ',
},
{ GlSprintf },
);
createComponent({ GlSprintf });
return emitOpenModalEvent({ ...mockModalData, username: ' John Smith ' });
});
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 () => {
setUsername('John Smith');
it('passes user name without whitespace to the obstacles', () => {
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(findSecondaryButton().attributes('disabled')).toBeUndefined();
......@@ -177,17 +184,20 @@ describe('User Operation confirmation modal', () => {
});
describe('Related user-deletion-obstacles list', () => {
it('does NOT render the list when user has no related obstacles', () => {
createComponent({ userDeletionObstacles: '[]' });
it('does NOT render the list when user has no related obstacles', async () => {
createComponent();
await emitOpenModalEvent({ ...mockModalData, userDeletionObstacles: [] });
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();
await emitOpenModalEvent(mockModalData);
const obstacles = findUserDeletionObstaclesList();
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