Rewrite showModal

This commit is contained in:
Pig Fang 2019-11-28 16:24:12 +08:00
parent 84afc32d84
commit e5b8a130a0
11 changed files with 414 additions and 109 deletions

View 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">&times;</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>

View File

@ -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')

View File

@ -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',
})
}
}

View File

@ -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

View File

@ -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 } })

View 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')
})

View File

@ -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()
})

View File

@ -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', () => {

View File

@ -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')

View File

@ -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('')
})

View File

@ -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]>