Commit 5332de96 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Merge branch 'pairing-good-stuff' into 'master'

Refactor confirm modal to work without querySelector

See merge request gitlab-org/gitlab!73848
parents 568cab5c 17dd37c6
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922
......@@ -26,16 +27,15 @@ export default {
required: true,
},
},
computed: {
modalAttributes() {
return {
'data-path': this.path,
'data-method': 'put',
'data-modal-attributes': JSON.stringify({
methods: {
onClick() {
eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, {
path: this.path,
method: 'put',
modalAttributes: {
title: sprintf(s__('AdminUsers|Activate user %{username}?'), {
username: this.username,
}),
messageHtml,
actionCancel: {
text: __('Cancel'),
},
......@@ -43,15 +43,16 @@ export default {
text: I18N_USER_ACTIONS.activate,
attributes: [{ variant: 'confirm' }],
},
}),
};
messageHtml,
},
});
},
},
};
</script>
<template>
<gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
<gl-dropdown-item @click="onClick">
<slot></slot>
</gl-dropdown-item>
</template>
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922
......@@ -28,12 +29,12 @@ export default {
required: true,
},
},
computed: {
attributes() {
return {
'data-path': this.path,
'data-method': 'put',
'data-modal-attributes': JSON.stringify({
methods: {
onClick() {
eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, {
path: this.path,
method: 'put',
modalAttributes: {
title: sprintf(s__('AdminUsers|Approve user %{username}?'), {
username: this.username,
}),
......@@ -45,16 +46,16 @@ export default {
attributes: [{ variant: 'confirm', 'data-qa-selector': 'approve_user_confirm_button' }],
},
messageHtml,
}),
'data-qa-selector': 'approve_user_button',
};
'data-qa-selector': 'approve_user_button',
},
});
},
},
};
</script>
<template>
<gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...attributes }">
<gl-dropdown-item @click="onClick">
<slot></slot>
</gl-dropdown-item>
</template>
......@@ -2,6 +2,7 @@
import { GlDropdownItem } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922
......@@ -39,12 +40,12 @@ export default {
required: true,
},
},
computed: {
modalAttributes() {
return {
'data-path': this.path,
'data-method': 'put',
'data-modal-attributes': JSON.stringify({
methods: {
onClick() {
eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, {
path: this.path,
method: 'put',
modalAttributes: {
title: sprintf(s__('AdminUsers|Ban user %{username}?'), {
username: this.username,
}),
......@@ -56,15 +57,15 @@ export default {
attributes: [{ variant: 'confirm' }],
},
messageHtml,
}),
};
},
});
},
},
};
</script>
<template>
<gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
<gl-dropdown-item @click="onClick">
<slot></slot>
</gl-dropdown-item>
</template>
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922
......@@ -29,12 +30,12 @@ export default {
required: true,
},
},
computed: {
modalAttributes() {
return {
'data-path': this.path,
'data-method': 'put',
'data-modal-attributes': JSON.stringify({
methods: {
onClick() {
eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, {
path: this.path,
method: 'put',
modalAttributes: {
title: sprintf(s__('AdminUsers|Block user %{username}?'), { username: this.username }),
actionCancel: {
text: __('Cancel'),
......@@ -44,15 +45,15 @@ export default {
attributes: [{ variant: 'confirm' }],
},
messageHtml,
}),
};
},
});
},
},
};
</script>
<template>
<gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
<gl-dropdown-item @click="onClick">
<slot></slot>
</gl-dropdown-item>
</template>
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922
......@@ -36,12 +37,12 @@ export default {
required: true,
},
},
computed: {
modalAttributes() {
return {
'data-path': this.path,
'data-method': 'put',
'data-modal-attributes': JSON.stringify({
methods: {
onClick() {
eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, {
path: this.path,
method: 'put',
modalAttributes: {
title: sprintf(s__('AdminUsers|Deactivate user %{username}?'), {
username: this.username,
}),
......@@ -53,15 +54,15 @@ export default {
attributes: [{ variant: 'confirm' }],
},
messageHtml,
}),
};
},
});
},
},
};
</script>
<template>
<gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
<gl-dropdown-item @click="onClick">
<slot></slot>
</gl-dropdown-item>
</template>
......@@ -2,6 +2,7 @@
import { GlDropdownItem } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922
......@@ -39,12 +40,12 @@ export default {
required: true,
},
},
computed: {
modalAttributes() {
return {
'data-path': this.path,
'data-method': 'delete',
'data-modal-attributes': JSON.stringify({
methods: {
onClick() {
eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, {
path: this.path,
method: 'delete',
modalAttributes: {
title: sprintf(s__('AdminUsers|Reject user %{username}?'), {
username: this.username,
}),
......@@ -56,15 +57,15 @@ export default {
attributes: [{ variant: 'danger' }],
},
messageHtml,
}),
};
},
});
},
},
};
</script>
<template>
<gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
<gl-dropdown-item @click="onClick">
<slot></slot>
</gl-dropdown-item>
</template>
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922
......@@ -22,12 +23,12 @@ export default {
required: true,
},
},
computed: {
modalAttributes() {
return {
'data-path': this.path,
'data-method': 'put',
'data-modal-attributes': JSON.stringify({
methods: {
onClick() {
eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, {
path: this.path,
method: 'put',
modalAttributes: {
title: sprintf(s__('AdminUsers|Unban user %{username}?'), {
username: this.username,
}),
......@@ -39,15 +40,15 @@ export default {
attributes: [{ variant: 'confirm' }],
},
messageHtml,
}),
};
},
});
},
},
};
</script>
<template>
<gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
<gl-dropdown-item @click="onClick">
<slot></slot>
</gl-dropdown-item>
</template>
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
export default {
......@@ -17,12 +18,13 @@ export default {
required: true,
},
},
computed: {
modalAttributes() {
return {
'data-path': this.path,
'data-method': 'put',
'data-modal-attributes': JSON.stringify({
methods: {
onClick() {
eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, {
path: this.path,
method: 'put',
modalAttributes: {
title: sprintf(s__('AdminUsers|Unblock user %{username}?'), { username: this.username }),
message: s__('AdminUsers|You can always block their account again if needed.'),
actionCancel: {
......@@ -32,15 +34,15 @@ export default {
text: I18N_USER_ACTIONS.unblock,
attributes: [{ variant: 'confirm' }],
},
}),
};
},
});
},
},
};
</script>
<template>
<gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
<gl-dropdown-item @click="onClick">
<slot></slot>
</gl-dropdown-item>
</template>
<script>
import { GlDropdownItem } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import { I18N_USER_ACTIONS } from '../../constants';
export default {
......@@ -17,12 +18,12 @@ export default {
required: true,
},
},
computed: {
modalAttributes() {
return {
'data-path': this.path,
'data-method': 'put',
'data-modal-attributes': JSON.stringify({
methods: {
onClick() {
eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, {
path: this.path,
method: 'put',
modalAttributes: {
title: sprintf(s__('AdminUsers|Unlock user %{username}?'), { username: this.username }),
message: __('Are you sure?'),
actionCancel: {
......@@ -32,15 +33,15 @@ export default {
text: I18N_USER_ACTIONS.unlock,
attributes: [{ variant: 'confirm' }],
},
}),
};
},
});
},
},
};
</script>
<template>
<gl-dropdown-item button-class="js-confirm-modal-button" v-bind="{ ...modalAttributes }">
<gl-dropdown-item @click="onClick">
<slot></slot>
</gl-dropdown-item>
</template>
......@@ -2,10 +2,13 @@
import { GlModal, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import csrf from '~/lib/utils/csrf';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from './confirm_modal_eventhub';
import DomElementListener from './dom_element_listener.vue';
export default {
components: {
GlModal,
DomElementListener,
},
directives: {
SafeHtml,
......@@ -30,18 +33,35 @@ export default {
};
},
mounted() {
document.querySelectorAll(this.selector).forEach((button) => {
button.addEventListener('click', (e) => {
e.preventDefault();
this.path = button.dataset.path;
this.method = button.dataset.method;
this.modalAttributes = JSON.parse(button.dataset.modalAttributes);
this.openModal();
});
});
eventHub.$on(EVENT_OPEN_CONFIRM_MODAL, this.onOpenEvent);
},
destroyed() {
eventHub.$off(EVENT_OPEN_CONFIRM_MODAL, this.onOpenEvent);
},
methods: {
onButtonPress(e) {
const element = e.currentTarget;
if (!element.dataset.path) {
return;
}
const modalAttributes = element.dataset.modalAttributes
? JSON.parse(element.dataset.modalAttributes)
: {};
this.onOpenEvent({
path: element.dataset.path,
method: element.dataset.method,
modalAttributes,
});
},
onOpenEvent({ path, method, modalAttributes }) {
this.path = path;
this.method = method;
this.modalAttributes = modalAttributes;
this.openModal();
},
openModal() {
this.$refs.modal.show();
},
......@@ -61,21 +81,23 @@ export default {
</script>
<template>
<gl-modal
ref="modal"
:modal-id="modalId"
v-bind="modalAttributes"
@primary="submitModal"
@cancel="closeModal"
>
<form ref="form" :action="path" method="post">
<!-- Rails workaround for <form method="delete" />
<dom-element-listener :selector="selector" @click.prevent="onButtonPress">
<gl-modal
ref="modal"
:modal-id="modalId"
v-bind="modalAttributes"
@primary="submitModal"
@cancel="closeModal"
>
<form ref="form" :action="path" method="post">
<!-- Rails workaround for <form method="delete" />
https://github.com/rails/rails/blob/master/actionview/app/assets/javascripts/rails-ujs/features/method.coffee
-->
<input type="hidden" name="_method" :value="method" />
<input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
<div v-if="modalAttributes.messageHtml" v-safe-html="modalAttributes.messageHtml"></div>
<div v-else>{{ modalAttributes.message }}</div>
</form>
</gl-modal>
<input type="hidden" name="_method" :value="method" />
<input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
<div v-if="modalAttributes.messageHtml" v-safe-html="modalAttributes.messageHtml"></div>
<div v-else>{{ modalAttributes.message }}</div>
</form>
</gl-modal>
</dom-element-listener>
</template>
import createEventHub from '~/helpers/event_hub_factory';
export default createEventHub();
export const EVENT_OPEN_CONFIRM_MODAL = Symbol('OPEN');
<script>
export default {
props: {
selector: {
type: String,
required: true,
},
},
mounted() {
this.disposables = Array.from(document.querySelectorAll(this.selector)).flatMap((button) => {
return Object.entries(this.$listeners).map(([key, value]) => {
button.addEventListener(key, value);
return () => {
button.removeEventListener(key, value);
};
});
});
},
destroyed() {
this.disposables.forEach((x) => {
x();
});
},
render() {
return this.$slots.default;
},
};
</script>
import { GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { kebabCase } from 'lodash';
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 { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
......@@ -39,9 +39,6 @@ describe('Action components', () => {
});
await nextTick();
expect(wrapper.attributes('data-path')).toBe('/test');
expect(wrapper.attributes('data-modal-attributes')).toContain('John Doe');
expect(findDropdownItem().exists()).toBe(true);
});
});
......@@ -66,7 +63,6 @@ describe('Action components', () => {
});
await nextTick();
const sharedAction = wrapper.find(SharedDeleteAction);
expect(sharedAction.attributes('data-block-user-url')).toBe(paths.block);
......@@ -76,6 +72,7 @@ describe('Action components', () => {
expect(sharedAction.attributes('data-user-deletion-obstacles')).toBe(
JSON.stringify(userDeletionObstacles),
);
expect(findDropdownItem().exists()).toBe(true);
},
);
......
import { shallowMount } from '@vue/test-utils';
import { merge } from 'lodash';
import { TEST_HOST } from 'helpers/test_constants';
import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import ConfirmModal from '~/vue_shared/components/confirm_modal.vue';
import DomElementListener from '~/vue_shared/components/dom_element_listener.vue';
jest.mock('~/lib/utils/csrf', () => ({ token: 'test-csrf-token' }));
......@@ -54,12 +57,50 @@ describe('vue_shared/components/confirm_modal', () => {
findForm()
.findAll('input')
.wrappers.map((x) => ({ name: x.attributes('name'), value: x.attributes('value') }));
const findDomElementListener = () => wrapper.find(DomElementListener);
const triggerOpenWithEventHub = (modalData) => {
eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, modalData);
};
const triggerOpenWithDomListener = (modalData) => {
const element = document.createElement('button');
element.dataset.path = modalData.path;
element.dataset.method = modalData.method;
element.dataset.modalAttributes = JSON.stringify(modalData.modalAttributes);
findDomElementListener().vm.$emit('click', {
preventDefault: jest.fn(),
currentTarget: element,
});
};
describe('default', () => {
beforeEach(() => {
createComponent();
});
it('renders empty GlModal', () => {
expect(findModal().props()).toEqual({});
});
it('renders form missing values', () => {
expect(findForm().attributes('action')).toBe('');
expect(findFormData()).toEqual([
{ name: '_method', value: undefined },
{ name: 'authenticity_token', value: 'test-csrf-token' },
]);
});
});
describe('template', () => {
describe('when modal data is set', () => {
describe.each`
desc | trigger
${'when opened from eventhub'} | ${triggerOpenWithEventHub}
${'when opened from dom listener'} | ${triggerOpenWithDomListener}
`('$desc', ({ trigger }) => {
beforeEach(() => {
createComponent();
wrapper.vm.modalAttributes = MOCK_MODAL_DATA.modalAttributes;
trigger(MOCK_MODAL_DATA);
});
it('renders GlModal with data', () => {
......@@ -71,6 +112,14 @@ describe('vue_shared/components/confirm_modal', () => {
}),
);
});
it('renders form', () => {
expect(findForm().attributes('action')).toBe(MOCK_MODAL_DATA.path);
expect(findFormData()).toEqual([
{ name: '_method', value: MOCK_MODAL_DATA.method },
{ name: 'authenticity_token', value: 'test-csrf-token' },
]);
});
});
describe.each`
......@@ -79,11 +128,10 @@ describe('vue_shared/components/confirm_modal', () => {
${'when message has html'} | ${{ messageHtml: '<p>Header</p><ul onhover="alert(1)"><li>First</li></ul>' }} | ${'<p>Header</p><ul><li>First</li></ul>'}
`('$desc', ({ attrs, expectation }) => {
beforeEach(() => {
const modalData = merge({ ...MOCK_MODAL_DATA }, { modalAttributes: attrs });
createComponent();
wrapper.vm.modalAttributes = {
...MOCK_MODAL_DATA.modalAttributes,
...attrs,
};
triggerOpenWithEventHub(modalData);
});
it('renders message', () => {
......@@ -96,8 +144,7 @@ describe('vue_shared/components/confirm_modal', () => {
describe('submitModal', () => {
beforeEach(() => {
createComponent();
wrapper.vm.path = MOCK_MODAL_DATA.path;
wrapper.vm.method = MOCK_MODAL_DATA.method;
triggerOpenWithEventHub(MOCK_MODAL_DATA);
});
it('does not submit form', () => {
......
import { mount } from '@vue/test-utils';
import { setHTMLFixture } from 'helpers/fixtures';
import DomElementListener from '~/vue_shared/components/dom_element_listener.vue';
const DEFAULT_SLOT_CONTENT = 'Default slot content';
const SELECTOR = '.js-test-include';
const HTML = `
<div>
<button class="js-test-include" data-testid="lorem">Lorem</button>
<button class="js-test-include" data-testid="ipsum">Ipsum</button>
<button data-testid="hello">Hello</a>
</div>
`;
describe('~/vue_shared/components/dom_element_listener.vue', () => {
let wrapper;
let spies;
const createComponent = () => {
wrapper = mount(DomElementListener, {
propsData: {
selector: SELECTOR,
},
listeners: spies,
slots: {
default: DEFAULT_SLOT_CONTENT,
},
});
};
const findElement = (testId) => document.querySelector(`[data-testid="${testId}"]`);
const spiesCallCount = () =>
Object.values(spies)
.map((x) => x.mock.calls.length)
.reduce((a, b) => a + b);
beforeEach(() => {
setHTMLFixture(HTML);
spies = {
click: jest.fn(),
focus: jest.fn(),
};
});
afterEach(() => {
wrapper.destroy();
});
describe('default', () => {
beforeEach(() => {
createComponent();
});
it('renders default slot', () => {
expect(wrapper.text()).toBe(DEFAULT_SLOT_CONTENT);
});
it('does not initially trigger listeners', () => {
expect(spiesCallCount()).toBe(0);
});
describe.each`
event | testId
${'click'} | ${'lorem'}
${'focus'} | ${'ipsum'}
`(
'when matching element triggers event (testId=$testId, event=$event)',
({ event, testId }) => {
beforeEach(() => {
findElement(testId).dispatchEvent(new Event(event));
});
it('triggers listener', () => {
expect(spiesCallCount()).toBe(1);
expect(spies[event]).toHaveBeenCalledWith(expect.any(Event));
expect(spies[event]).toHaveBeenCalledWith(
expect.objectContaining({
target: findElement(testId),
}),
);
});
},
);
describe.each`
desc | event | testId
${'when non-matching element triggers event'} | ${'click'} | ${'hello'}
${'when matching element triggers unlistened event'} | ${'hover'} | ${'lorem'}
`('$desc', ({ event, testId }) => {
beforeEach(() => {
findElement(testId).dispatchEvent(new Event(event));
});
it('does not trigger listeners', () => {
expect(spiesCallCount()).toBe(0);
});
});
});
describe('after destroyed', () => {
beforeEach(() => {
createComponent();
wrapper.destroy();
});
describe('when matching element triggers event', () => {
beforeEach(() => {
findElement('lorem').dispatchEvent(new Event('click'));
});
it('does not trigger any listeners', () => {
expect(spiesCallCount()).toBe(0);
});
});
});
});
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