diff --git a/resources/assets/src/components/Modal.vue b/resources/assets/src/components/Modal.vue new file mode 100644 index 00000000..32ce5db8 --- /dev/null +++ b/resources/assets/src/components/Modal.vue @@ -0,0 +1,156 @@ + + + diff --git a/resources/assets/src/scripts/logout.ts b/resources/assets/src/scripts/logout.ts index 56b35ca6..20fa1c9d 100644 --- a/resources/assets/src/scripts/logout.ts +++ b/resources/assets/src/scripts/logout.ts @@ -1,19 +1,19 @@ -import { Message, MessageBox } from 'element-ui' import { post } from './net' import { trans } from './i18n' +import { showModal } from './notify' export async function logout() { try { - await MessageBox.confirm(trans('general.confirmLogout'), { - type: 'warning', + await showModal({ + text: trans('general.confirmLogout'), + center: true, }) } catch { return } - const { message } = await post('/auth/logout') - setTimeout(() => (window.location.href = blessing.base_url), 1000) - Message.success(message) + await post('/auth/logout') + window.location.href = blessing.base_url } const button = document.querySelector('#logout-button') diff --git a/resources/assets/src/scripts/net.ts b/resources/assets/src/scripts/net.ts index 583492bd..fe27d2c9 100644 --- a/resources/assets/src/scripts/net.ts +++ b/resources/assets/src/scripts/net.ts @@ -1,7 +1,8 @@ import Vue from 'vue' import { emit } from './event' import { queryStringify } from './utils' -import { showAjaxError, showModal } from './notify' +import { showModal } from './notify' +import { trans } from './i18n' class HTTPError extends Error { response: Response @@ -52,7 +53,11 @@ export async function walkFetch(request: Request): Promise { message: Object.keys(errors).map(field => errors[field][0])[0], } } else if (response.status === 403) { - showModal(message, undefined, 'warning') + showModal({ + mode: 'alert', + text: message, + type: 'warning', + }) return } @@ -66,7 +71,12 @@ export async function walkFetch(request: Request): Promise { throw new HTTPError(message || body, cloned) } catch (error) { emit('fetchError', error) - showAjaxError(error) + showModal({ + mode: 'alert', + title: trans('general.fatalError'), + dangerousHTML: error.message, + type: 'danger', + }) } } diff --git a/resources/assets/src/scripts/notification.ts b/resources/assets/src/scripts/notification.ts index 2ebc5e87..38599406 100644 --- a/resources/assets/src/scripts/notification.ts +++ b/resources/assets/src/scripts/notification.ts @@ -11,7 +11,11 @@ export default async function handler(event: Event) { content: string time: string } = await get(`/user/notifications/${id!}`) - showModal(`${content}
${time}`, title) + showModal({ + mode: 'alert', + title, + dangerousHTML: `${content}
${time}`, + }) item.remove() const counter = document .querySelector('.notifications-counter') as HTMLSpanElement diff --git a/resources/assets/src/scripts/notify.ts b/resources/assets/src/scripts/notify.ts index b35472f1..a482cb67 100644 --- a/resources/assets/src/scripts/notify.ts +++ b/resources/assets/src/scripts/notify.ts @@ -1,65 +1,44 @@ -/* eslint-disable max-params */ import $ from 'jquery' -import { ModalOption as BootstrapModalOption } from 'bootstrap' -import { trans } from './i18n' +import Vue from 'vue' +import Modal from '../components/Modal.vue' -export function showAjaxError(error: Error): void { - showModal( - error.message.replace(/\n/g, '
'), - trans('general.fatalError'), - 'danger', - ) +export interface ModalOptions { + mode?: 'alert' | 'confirm' | 'prompt' + title?: string + text?: string + dangerousHTML?: string + input?: boolean + type?: string + showHeader?: boolean + center?: boolean + okButtonText?: string + okButtonType?: string + cancelButtonText?: string + cancelButtonType?: string + flexFooter?: boolean } -export type ModalOptions = { - btnText?: string - callback?: CallableFunction - destroyOnClose?: boolean -} & BootstrapModalOption +export interface ModalResult { + value: string +} -export function showModal( - message: string, title = 'Message', - type = 'default', - options: ModalOptions = {}, -): void { - const btnType = type === 'default' ? 'btn-primary' : 'btn-outline' - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - const btnText = options.btnText || 'OK' - const onClick = options.callback === undefined - ? 'data-dismiss="modal"' - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - : `onclick="${options.callback}"` - const destroyOnClose = options.destroyOnClose !== false +export function showModal(options: ModalOptions = {}): Promise { + return new Promise((resolve, reject) => { + const container = document.createElement('div') + document.body.appendChild(container) - const dom = ` - ` + const instance = new Vue({ + render: h => h(Modal, { + props: options, + on: { + confirm: resolve, + dismiss: reject, + }, + }), + }).$mount(container) - $(dom) - .on('hidden.bs.modal', /* istanbul ignore next */ function modal() { - if (destroyOnClose) { - $(this).remove() - } - }) - .modal(options) + $(instance.$el).modal('show') + }) } Object.assign(blessing, { notify: { showModal } }) diff --git a/resources/assets/tests/components/Modal.test.ts b/resources/assets/tests/components/Modal.test.ts new file mode 100644 index 00000000..46100063 --- /dev/null +++ b/resources/assets/tests/components/Modal.test.ts @@ -0,0 +1,149 @@ +import 'bootstrap' +import { mount } from '@vue/test-utils' +import Modal from '@/components/Modal.vue' + +test('title', () => { + const wrapper = mount(Modal, { + propsData: { + title: 'kumiko', + }, + }) + expect(wrapper.find('.modal-title').text()).toBe('kumiko') +}) + +test('close button at header', () => { + const wrapper = mount(Modal) + wrapper.find('.modal-header > button').trigger('click') + expect(wrapper.emitted().dismiss).toHaveLength(1) +}) + +test('render lines', () => { + const wrapper = mount(Modal, { + propsData: { + text: 'kumiko\nreina', + }, + }) + const paragraphs = wrapper.findAll('p') + expect(paragraphs).toHaveLength(2) + expect(paragraphs.at(0).text()).toBe('kumiko') + expect(paragraphs.at(1).text()).toBe('reina') +}) + +test('dynamic html', () => { + const wrapper = mount(Modal, { + propsData: { + dangerousHTML: '
kumiko
', + }, + }) + expect(wrapper.find('.eupho').text()).toBe('kumiko') +}) + +test('cancel by button at footer', () => { + const wrapper = mount(Modal) + wrapper.find('.btn-secondary').trigger('click') + expect(wrapper.emitted().dismiss).toHaveLength(1) +}) + +test('alert mode', () => { + const wrapper = mount(Modal, { + propsData: { + mode: 'alert', + }, + }) + expect(wrapper.find('.btn-secondary').exists()).toBeFalse() +}) + +test('prompt mode', () => { + const wrapper = mount(Modal, { + propsData: { + mode: 'prompt', + input: 'default-value', + }, + }) + expect(wrapper.find('.btn-secondary').exists()).toBeTrue() + + wrapper.find('input').setValue('hazuki') + wrapper.find('.btn-primary').trigger('click') + expect(wrapper.emitted().confirm[0][0]).toStrictEqual({ value: 'hazuki' }) +}) + +test('modal type', () => { + const wrapper = mount(Modal, { + propsData: { + type: 'danger', + }, + }) + expect(wrapper.find('.modal-content').classes('bg-danger')).toBeTrue() +}) + +test('hide header', () => { + const wrapper = mount(Modal, { + propsData: { + showHeader: false, + }, + }) + expect(wrapper.find('.modal-header').exists()).toBeFalse() +}) + +test('centered modal', () => { + const wrapper = mount(Modal, { + propsData: { + center: true, + }, + }) + expect( + wrapper.find('.modal-dialog').classes('modal-dialog-centered'), + ).toBeTrue() +}) + +test('customize ok button', () => { + const wrapper = mount(Modal, { + propsData: { + okButtonText: 'OK', + okButtonType: 'danger', + }, + }) + const button = wrapper.find('.btn:nth-child(2)') + expect(button.text().trim()).toBe('OK') + expect(button.classes('btn-danger')).toBeTrue() +}) + +test('customize cancel button', () => { + const wrapper = mount(Modal, { + propsData: { + cancelButtonText: 'CANCEL', + cancelButtonType: 'danger', + }, + }) + const button = wrapper.find('.btn:nth-child(1)') + expect(button.text().trim()).toBe('CANCEL') + expect(button.classes('btn-danger')).toBeTrue() +}) + +test('flex footer', () => { + const wrapper = mount(Modal, { + propsData: { + flexFooter: true, + }, + }) + expect(wrapper.find('.modal-footer').classes()) + .toContainValues(['d-flex', 'justify-content-between']) +}) + +test('default slot', () => { + const wrapper = mount(Modal, { + slots: { + default: '
reina
', + }, + }) + expect(wrapper.find('.modal-body > .trumpet').text()).toBe('reina') +}) + +test('footer slot', () => { + const wrapper = mount(Modal, { + slots: { + footer: '
sapphire
', + }, + }) + expect(wrapper.find('.modal-footer > .contrabass').text()).toBe('sapphire') +}) diff --git a/resources/assets/tests/scripts/logout.test.ts b/resources/assets/tests/scripts/logout.test.ts index 5ab3d232..4b28b711 100644 --- a/resources/assets/tests/scripts/logout.test.ts +++ b/resources/assets/tests/scripts/logout.test.ts @@ -1,14 +1,14 @@ import { logout } from '@/scripts/logout' import { post } from '@/scripts/net' -import { MessageBox } from 'element-ui' +import { showModal } from '@/scripts/notify' -jest.mock('element-ui') jest.mock('@/scripts/net') +jest.mock('@/scripts/notify') test('log out', async () => { - jest.spyOn(MessageBox, 'confirm') - .mockRejectedValueOnce('cancel') - .mockResolvedValue('confirm') + showModal + .mockRejectedValueOnce({}) + .mockResolvedValueOnce({ value: '' }) post.mockResolvedValue({ message: '' }) await logout() @@ -16,5 +16,4 @@ test('log out', async () => { await logout() expect(post).toBeCalledWith('/auth/logout') - jest.runAllTimers() }) diff --git a/resources/assets/tests/scripts/net.test.ts b/resources/assets/tests/scripts/net.test.ts index e0d42ed3..e023788c 100644 --- a/resources/assets/tests/scripts/net.test.ts +++ b/resources/assets/tests/scripts/net.test.ts @@ -1,7 +1,7 @@ import Vue from 'vue' import * as net from '@/scripts/net' import { on } from '@/scripts/event' -import { showAjaxError, showModal } from '@/scripts/notify' +import { showModal } from '@/scripts/notify' jest.mock('@/scripts/notify') @@ -148,17 +148,31 @@ test('low level fetch', async () => { on('fetchError', stub) await net.walkFetch(request as Request) - expect(showAjaxError.mock.calls[0][0]).toBeInstanceOf(Error) - expect(showAjaxError.mock.calls[0][0]).toHaveProperty('message', 'network') + expect(showModal).toBeCalledWith({ + mode: 'alert', + title: 'general.fatalError', + dangerousHTML: 'network', + type: 'danger', + }) expect(stub).toBeCalledWith(expect.any(Error)) await net.walkFetch(request as Request) - expect(showAjaxError.mock.calls[1][0]).toBeInstanceOf(Error) + expect(showModal).toBeCalledWith({ + mode: 'alert', + title: 'general.fatalError', + dangerousHTML: '404', + type: 'danger', + }) expect(stub.mock.calls[1][0]).toHaveProperty('message', '404') expect(stub.mock.calls[1][0]).toHaveProperty('response') await net.walkFetch(request as Request) - expect(showAjaxError.mock.calls[2][0]).toBeInstanceOf(Error) + expect(showModal).toBeCalledWith({ + mode: 'alert', + title: 'general.fatalError', + dangerousHTML: 'error', + type: 'danger', + }) expect(stub.mock.calls[2][0]).toHaveProperty('message', 'error') expect(stub.mock.calls[2][0]).toHaveProperty('response') @@ -212,12 +226,19 @@ test('process backend errors', async () => { expect(result.message).toBe('required') await net.walkFetch({ headers: new Headers() } as Request) - expect(showModal).toBeCalledWith('forbidden', undefined, 'warning') + expect(showModal).toBeCalledWith({ + mode: 'alert', + text: 'forbidden', + type: 'warning', + }) await net.walkFetch({ headers: new Headers() } as Request) - expect(showAjaxError.mock.calls[0][0].message).toBe( - 'fake exception\n
[1] k.php#L2\n[2] v.php#L3
', - ) + expect(showModal).toBeCalledWith({ + mode: 'alert', + title: 'general.fatalError', + dangerousHTML: 'fake exception\n
[1] k.php#L2\n[2] v.php#L3
', + type: 'danger', + }) }) test('inject to Vue instance', () => { diff --git a/resources/assets/tests/scripts/notification.test.ts b/resources/assets/tests/scripts/notification.test.ts index d7e6f21c..7dfcc220 100644 --- a/resources/assets/tests/scripts/notification.test.ts +++ b/resources/assets/tests/scripts/notification.test.ts @@ -24,7 +24,11 @@ test('read notification', async () => { document.querySelector('a')!.click() await flushPromises() expect(get).toBeCalledWith('/user/notifications/1') - expect(showModal).toBeCalledWith('content
time', 'title') + expect(showModal).toBeCalledWith({ + mode: 'alert', + title: 'title', + dangerousHTML: 'content
time', + }) expect(document.querySelectorAll('a')).toHaveLength(1) expect(document.querySelector('span')!.textContent).toBe('1') diff --git a/resources/assets/tests/scripts/notify.test.ts b/resources/assets/tests/scripts/notify.test.ts index 07a0750b..31bc2af8 100644 --- a/resources/assets/tests/scripts/notify.test.ts +++ b/resources/assets/tests/scripts/notify.test.ts @@ -1,21 +1,13 @@ -import $ from 'jquery' +import 'bootstrap' import * as notify from '@/scripts/notify' -test('show AJAX error', () => { - // @ts-ignore - $.fn.modal = function () { - document.body.innerHTML = this.html() - } - notify.showAjaxError(new Error('an-error')) - expect(document.body.innerHTML).toContain('an-error') -}) - -test('show modal', () => { - notify.showModal('message') - expect($('.modal-title').html()).toBe('Message') - - notify.showModal('message', '', 'default', { - callback: () => undefined, - destroyOnClose: false, +test('show modal', async () => { + process.nextTick(() => { + expect( + document.querySelector('.modal-title')!.textContent, + ).toBe('general.tip') + document.querySelector('.btn-primary')!.click() }) + const { value } = await notify.showModal() + expect(value).toBe('') }) diff --git a/resources/assets/tests/ts-shims/notify.ts b/resources/assets/tests/ts-shims/notify.ts index 830fc7ed..3d480ee8 100644 --- a/resources/assets/tests/ts-shims/notify.ts +++ b/resources/assets/tests/ts-shims/notify.ts @@ -1,12 +1,3 @@ -/* eslint-disable @typescript-eslint/indent */ -import * as notify from '../../src/scripts/notify' +import { ModalOptions, ModalResult } from '../../src/scripts/notify' -export const showAjaxError = {} as jest.Mock< - ReturnType, - Parameters -> - -export const showModal = {} as jest.Mock< - ReturnType, - Parameters -> +export const showModal = {} as jest.Mock, [ModalOptions | void]>