Rewrite showModal
This commit is contained in:
parent
84afc32d84
commit
e5b8a130a0
156
resources/assets/src/components/Modal.vue
Normal file
156
resources/assets/src/components/Modal.vue
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<template>
|
||||
<div
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
role="dialog"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="modal-dialog" :class="{ 'modal-dialog-centered': center }" role="document">
|
||||
<div class="modal-content" :class="[`bg-${type}`]">
|
||||
<div v-if="showHeader" class="modal-header">
|
||||
<h5 class="modal-title">{{ title }}</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="close"
|
||||
data-dismiss="modal"
|
||||
aria-label="Close"
|
||||
@click="dismiss"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<slot>
|
||||
<template v-if="text">
|
||||
<p v-for="(line, i) in lines" :key="i">{{ line }}</p>
|
||||
</template>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-else-if="dangerousHTML" v-html="dangerousHTML" />
|
||||
<template v-if="mode === 'prompt'">
|
||||
<div class="form-group">
|
||||
<input v-model="value" type="text" class="form-control">
|
||||
</div>
|
||||
</template>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="modal-footer" :class="footerClasses">
|
||||
<slot name="footer">
|
||||
<button
|
||||
v-if="mode !== 'alert'"
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="[`btn-${cancelButtonType}`]"
|
||||
data-dismiss="modal"
|
||||
@click="dismiss"
|
||||
>
|
||||
{{ cancelButtonText }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="[`btn-${okButtonType}`]"
|
||||
@click="confirm"
|
||||
>
|
||||
{{ okButtonText }}
|
||||
</button>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import $ from 'jquery'
|
||||
import { trans } from '../scripts/i18n'
|
||||
|
||||
export default {
|
||||
name: 'Modal',
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'confirm',
|
||||
validator: value => ['alert', 'confirm', 'prompt'].includes(value),
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: trans('general.tip'),
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
dangerousHTML: {
|
||||
type: String,
|
||||
},
|
||||
input: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
showHeader: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
center: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
okButtonText: {
|
||||
type: String,
|
||||
default: trans('general.confirm'),
|
||||
},
|
||||
okButtonType: {
|
||||
type: String,
|
||||
default: 'primary',
|
||||
},
|
||||
cancelButtonText: {
|
||||
type: String,
|
||||
default: trans('general.cancel'),
|
||||
},
|
||||
cancelButtonType: {
|
||||
type: String,
|
||||
default: 'secondary',
|
||||
},
|
||||
flexFooter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
value: this.input,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
lines() {
|
||||
return this.text.split(/\r?\n/)
|
||||
},
|
||||
footerClasses() {
|
||||
return {
|
||||
'd-flex': this.flexFooter,
|
||||
'justify-content-between': this.flexFooter,
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
$(this.$el).on('hide.bs.modal', /* istanbul ignore next */ () => {
|
||||
this.dismiss()
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
confirm() {
|
||||
this.$emit('confirm', { value: this.value })
|
||||
$(this.$el).modal('hide')
|
||||
this.$destroy()
|
||||
},
|
||||
dismiss() {
|
||||
this.$emit('dismiss')
|
||||
this.$destroy()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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<any> {
|
|||
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<any> {
|
|||
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',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@ export default async function handler(event: Event) {
|
|||
content: string
|
||||
time: string
|
||||
} = await get(`/user/notifications/${id!}`)
|
||||
showModal(`${content}<br><small>${time}</small>`, title)
|
||||
showModal({
|
||||
mode: 'alert',
|
||||
title,
|
||||
dangerousHTML: `${content}<br><small>${time}</small>`,
|
||||
})
|
||||
item.remove()
|
||||
const counter = document
|
||||
.querySelector('.notifications-counter') as HTMLSpanElement
|
||||
|
|
|
|||
|
|
@ -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, '<br>'),
|
||||
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<ModalResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
const dom = `
|
||||
<div class="modal fade in">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content bg-${type}">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">${title}</h4>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" ${onClick} class="btn btn-outline-light ${btnType}">
|
||||
${btnText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
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 } })
|
||||
|
|
|
|||
149
resources/assets/tests/components/Modal.test.ts
Normal file
149
resources/assets/tests/components/Modal.test.ts
Normal file
|
|
@ -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: '<div class="eupho">kumiko</div>',
|
||||
},
|
||||
})
|
||||
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: '<div class="trumpet">reina</div>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.modal-body > .trumpet').text()).toBe('reina')
|
||||
})
|
||||
|
||||
test('footer slot', () => {
|
||||
const wrapper = mount(Modal, {
|
||||
slots: {
|
||||
footer: '<div class="contrabass">sapphire</div>',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.modal-footer > .contrabass').text()).toBe('sapphire')
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<details>[1] k.php#L2\n[2] v.php#L3</details>',
|
||||
)
|
||||
expect(showModal).toBeCalledWith({
|
||||
mode: 'alert',
|
||||
title: 'general.fatalError',
|
||||
dangerousHTML: 'fake exception\n<details>[1] k.php#L2\n[2] v.php#L3</details>',
|
||||
type: 'danger',
|
||||
})
|
||||
})
|
||||
|
||||
test('inject to Vue instance', () => {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,11 @@ test('read notification', async () => {
|
|||
document.querySelector('a')!.click()
|
||||
await flushPromises()
|
||||
expect(get).toBeCalledWith('/user/notifications/1')
|
||||
expect(showModal).toBeCalledWith('content<br><small>time</small>', 'title')
|
||||
expect(showModal).toBeCalledWith({
|
||||
mode: 'alert',
|
||||
title: 'title',
|
||||
dangerousHTML: 'content<br><small>time</small>',
|
||||
})
|
||||
expect(document.querySelectorAll('a')).toHaveLength(1)
|
||||
expect(document.querySelector('span')!.textContent).toBe('1')
|
||||
|
||||
|
|
|
|||
|
|
@ -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<HTMLButtonElement>('.btn-primary')!.click()
|
||||
})
|
||||
const { value } = await notify.showModal()
|
||||
expect(value).toBe('')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<typeof notify.showAjaxError>,
|
||||
Parameters<typeof notify.showAjaxError>
|
||||
>
|
||||
|
||||
export const showModal = {} as jest.Mock<
|
||||
ReturnType<typeof notify.showModal>,
|
||||
Parameters<typeof notify.showModal>
|
||||
>
|
||||
export const showModal = {} as jest.Mock<Promise<ModalResult>, [ModalOptions | void]>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user