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 @@
+
+
+
+
+
+
+
+
+ {{ line }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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]>