rewrite "Modal" component in React

This commit is contained in:
Pig Fang 2020-01-23 22:52:09 +08:00
parent 11e927cc61
commit 7c144b3e14
12 changed files with 583 additions and 56 deletions

View File

@ -194,9 +194,9 @@
},
"moduleFileExtensions": [
"js",
"vue",
"ts",
"tsx",
"vue",
"json",
"node"
],

View File

@ -0,0 +1,164 @@
import React, { useState, useEffect, useRef } from 'react'
import $ from 'jquery'
import 'bootstrap'
import { trans } from '../scripts/i18n'
import ModalHeader from './ModalHeader'
import ModalBody from './ModalBody'
import ModalFooter from './ModalFooter'
export type ModalOptions = {
mode?: 'alert' | 'confirm' | 'prompt'
show?: boolean
title?: string
text?: string
dangerousHTML?: string
input?: string
placeholder?: string
inputType?: string
validator?(value: any): string | boolean | undefined
type?: string
showHeader?: boolean
center?: boolean
okButtonText?: string
okButtonType?: string
cancelButtonText?: string
cancelButtonType?: string
flexFooter?: boolean
}
type Props = {
id?: string
children?: React.ReactNode
footer?: React.ReactNode
onConfirm?(payload: { value: string }): void
onDismiss?(): void
}
export type ModalResult = {
value: string
}
const Modal = React.forwardRef<HTMLDivElement, ModalOptions & Props>(
(props, forwardedRef) => {
const [hidden, setHidden] = useState(false)
const [value, setValue] = useState(props.input!)
const [valid, setValid] = useState(true)
const [validatorMessage, setValidatorMessage] = useState('')
const ref = (forwardedRef ??
useRef<HTMLDivElement>(null)) as React.RefObject<HTMLDivElement>
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value)
}
const confirm = () => {
const { validator } = props
if (typeof validator === 'function') {
const result = validator(value)
if (typeof result === 'string') {
setValidatorMessage(result)
setValid(false)
return
}
}
setHidden(true)
props.onConfirm?.({ value })
$(ref.current!).modal('hide')
}
const dismiss = () => {
setHidden(true)
props.onDismiss?.()
}
useEffect(() => {
const onHide = () => {
/* istanbul ignore else */
if (!hidden) {
dismiss()
}
}
const onHidden = () => setHidden(false)
const el = $(ref.current!)
el.on('hide.bs.modal', onHide).on('hidden.bs.modal', onHidden)
return () => {
el.off('hide.bs.modal', onHide).off('hidden.bs.modal', onHidden)
}
}, [])
return (
<div
id={props.id}
className={`modal fade ${props.show ? 'show' : ''}`}
tabIndex={-1}
role="dialog"
aria-hidden={!props.show}
ref={ref}
>
<div
className={`modal-dialog ${
props.center ? 'modal-dialog-centered' : ''
}`}
role="document"
>
<div className={`modal-content bg-${props.type}`}>
<ModalHeader
show={props.showHeader}
title={props.title}
onDismiss={dismiss}
/>
<ModalBody
text={props.text}
dangerousHTML={props.dangerousHTML}
showInput={props.mode === 'prompt'}
value={value}
onChange={handleInputChange}
inputType={props.inputType}
placeholder={props.placeholder}
invalid={!valid}
validatorMessage={validatorMessage}
>
{props.children}
</ModalBody>
<ModalFooter
showCancelButton={props.mode !== 'alert'}
flexFooter={props.flexFooter}
okButtonType={props.okButtonType}
okButtonText={props.okButtonText}
cancelButtonType={props.cancelButtonType}
cancelButtonText={props.cancelButtonText}
onConfirm={confirm}
onDismiss={dismiss}
>
{props.footer}
</ModalFooter>
</div>
</div>
</div>
)
},
)
Modal.displayName = 'Modal'
Modal.defaultProps = {
mode: 'confirm',
title: trans('general.tip'),
text: '',
input: '',
placeholder: '',
inputType: 'text',
type: 'default',
showHeader: true,
center: false,
okButtonText: trans('general.confirm'),
okButtonType: 'primary',
cancelButtonText: trans('general.cancel'),
cancelButtonType: 'secondary',
flexFooter: false,
}
export default Modal

View File

@ -0,0 +1,52 @@
import React from 'react'
interface Props {
text?: string
dangerousHTML?: string
showInput: boolean
inputType?: string
value?: string
onChange?: React.ChangeEventHandler<HTMLInputElement>
placeholder?: string
invalid?: boolean
validatorMessage?: string
}
const ModalBody: React.FC<Props> = props => {
const main = (() => {
if (props.children) {
return props.children
} else if (props.text) {
return props.text.split(/\r?\n/).map((line, i) => <p key={i}>{line}</p>)
} else if (props.dangerousHTML) {
return <div dangerouslySetInnerHTML={{ __html: props.dangerousHTML }} />
}
})()
return (
<div className="modal-body">
{main}
{props.showInput && (
<>
<div className="form-group">
<input
value={props.value}
onChange={props.onChange}
type={props.inputType}
className="form-control"
placeholder={props.placeholder}
></input>
</div>
{props.invalid && (
<div className="alert alert-danger">
<i className="icon far fa-times-circle"></i>
<span className="ml-1">{props.validatorMessage}</span>
</div>
)}
</>
)}
</div>
)
}
export default ModalBody

View File

@ -0,0 +1,46 @@
import React from 'react'
interface Props {
showCancelButton: boolean
flexFooter?: boolean
okButtonText?: string
okButtonType?: string
cancelButtonText?: string
cancelButtonType?: string
onConfirm?(): void
onDismiss?(): void
}
const ModalFooter: React.FC<Props> = props => {
const classes = ['modal-footer']
if (props.flexFooter) {
classes.push('d-flex', 'justify-content-between')
}
const footerClass = classes.join(' ')
return props.children ? (
<div className={footerClass}>{props.children}</div>
) : (
<div className={footerClass}>
{props.showCancelButton && (
<button
type="button"
className={`btn btn-${props.cancelButtonType}`}
data-dismiss="modal"
onClick={props.onDismiss}
>
{props.cancelButtonText}
</button>
)}
<button
type="button"
className={`btn btn-${props.okButtonType}`}
onClick={props.onConfirm}
>
{props.okButtonText}
</button>
</div>
)
}
export default ModalFooter

View File

@ -0,0 +1,25 @@
import React from 'react'
interface Props {
show?: boolean
title?: string
onDismiss?(): void
}
const ModalHeader: React.FC<Props> = props =>
props.show ? (
<div className="modal-header">
<h5 className="modal-title">{props.title}</h5>
<button
type="button"
className="close"
data-dismiss="modal"
aria-label="Close"
onClick={props.onDismiss}
>
<span aria-hidden>&times;</span>
</button>
</div>
) : null
export default ModalHeader

View File

@ -1,51 +0,0 @@
import $ from 'jquery'
import 'bootstrap'
import Vue from 'vue'
import Modal from '../components/Modal.vue'
export interface ModalOptions {
mode?: 'alert' | 'confirm' | 'prompt'
title?: string
text?: string
dangerousHTML?: string
input?: string
placeholder?: string
inputType?: string
validator?(value: any): string | boolean | void
type?: string
showHeader?: boolean
center?: boolean
okButtonText?: string
okButtonType?: string
cancelButtonText?: string
cancelButtonType?: string
flexFooter?: boolean
}
export interface ModalResult {
value: string
}
export function showModal(options: ModalOptions = {}): Promise<ModalResult> {
return new Promise((resolve, reject) => {
const container = document.createElement('div')
document.body.appendChild(container)
const instance = new Vue({
render: h => h(Modal, {
props: Object.assign({ center: true }, options),
on: {
confirm: resolve,
dismiss: reject,
},
}),
}).$mount(container)
$(instance.$el)
.modal('show')
.on('hidden.bs.modal', () => {
instance.$el.remove()
instance.$destroy()
})
})
}

View File

@ -0,0 +1,32 @@
import $ from 'jquery'
import React from 'react'
import ReactDOM from 'react-dom'
import Modal, { ModalOptions, ModalResult } from '../components/Modal'
export function showModal(options: ModalOptions = {}): Promise<ModalResult> {
return new Promise((resolve, reject) => {
const container = document.createElement('div')
document.body.appendChild(container)
const ref = React.createRef<HTMLDivElement>()
ReactDOM.render(
<Modal
{...options}
ref={ref}
center
onConfirm={resolve}
onDismiss={reject}
/>,
container,
)
$(ref.current!)
.modal('show')
.on('hidden.bs.modal', () => {
setTimeout(() => {
ReactDOM.unmountComponentAtNode(container)
container.remove()
}, 0)
})
})
}

View File

@ -1,5 +1,5 @@
export function truthy(message: string) {
return (value?: unknown): string | void => {
return (value?: unknown): string | undefined => {
if (!value) {
return message
}

View File

@ -1,6 +1,6 @@
import Vue from 'vue'
import JQuery from 'jquery'
import { ModalOptions, ModalResult } from './scripts/modal'
import { ModalOptions, ModalResult } from './components/Modal'
import { Toast } from './scripts/toast'
declare global {

View File

@ -0,0 +1,255 @@
import React from 'react'
import { render, fireEvent, act } from '@testing-library/react'
import { trans } from '@/scripts/i18n'
import $ from 'jquery'
import Modal from '@/components/Modal'
test('hidden by default', () => {
const { queryByRole } = render(<Modal />)
expect(queryByRole('dialog')).toBeNull()
})
test('receive id', () => {
const { getByRole } = render(<Modal id="kumiko" show />)
expect(getByRole('dialog')).toHaveAttribute('id', 'kumiko')
})
test('centered dialog', () => {
const { getByRole } = render(<Modal center show />)
expect(getByRole('document')).toHaveClass('modal-dialog-centered')
})
test('background color', () => {
const { container } = render(<Modal type="primary" show />)
expect(container.querySelector('.modal-content')).toHaveClass('bg-primary')
})
test('forward ref', () => {
const ref = React.createRef<HTMLDivElement>()
render(<Modal ref={ref} show />)
expect(ref.current).not.toBeNull()
})
test('jQuery events', () => {
const ref = React.createRef<HTMLDivElement>()
render(<Modal ref={ref} show />)
act(() => {
$(ref.current!)
.trigger('hide.bs.modal')
.trigger('hidden.bs.modal')
})
})
describe('modal header', () => {
it('modal title', () => {
const { queryByText } = render(<Modal title="Tips" show />)
expect(queryByText('Tips')).toBeInTheDocument()
})
it('hide modal header', () => {
const { queryByText } = render(
<Modal title="Tips" showHeader={false} show />,
)
expect(queryByText('Tips')).not.toBeInTheDocument()
})
})
describe('modal body', () => {
it('custom children', () => {
const { queryByText } = render(<Modal show>body</Modal>)
expect(queryByText('body')).toBeInTheDocument()
})
it('text with linebreaks', () => {
const { queryByText } = render(<Modal text={'L1\nL2'} show />)
expect(queryByText('L1')).toBeInTheDocument()
expect(queryByText('L2')).toBeInTheDocument()
})
it('dangerous HTML', () => {
const { getByText } = render(
<Modal dangerousHTML="<h1 class='h1'>ab</h1>" show />,
)
expect(getByText('ab')).toHaveClass('h1')
})
describe('input control', () => {
it('set default value', () => {
const { queryByDisplayValue } = render(
<Modal mode="prompt" input="val" show />,
)
expect(queryByDisplayValue('val')).toBeInTheDocument()
})
it('placeholder', () => {
const { queryByPlaceholderText } = render(
<Modal mode="prompt" placeholder="hint" show />,
)
expect(queryByPlaceholderText('hint')).toBeInTheDocument()
})
it('input control type', () => {
const { getByPlaceholderText } = render(
<Modal
mode="prompt"
placeholder="password"
inputType="password"
show
/>,
)
expect(getByPlaceholderText('password')).toHaveAttribute(
'type',
'password',
)
})
})
})
describe('modal footer', () => {
it('custom footer content', () => {
const { queryByText } = render(<Modal footer={<div>footer</div>} show />)
expect(queryByText('footer')).toBeInTheDocument()
expect(queryByText(trans('general.confirm'))).not.toBeInTheDocument()
})
it('flex footer', () => {
const { getByText } = render(<Modal footer="footer" flexFooter show />)
expect(getByText('footer')).toHaveClass('d-flex', 'justify-content-between')
})
it('custom ok button', () => {
const { getByText } = render(
<Modal okButtonType="primary" okButtonText="kumiko" show />,
)
expect(getByText('kumiko')).toHaveClass('btn-primary')
})
it('custom cancel button', () => {
const { getByText } = render(
<Modal cancelButtonType="success" cancelButtonText="reina" show />,
)
expect(getByText('reina')).toHaveClass('btn-success')
})
})
describe('"alert" mode', () => {
it('buttons', () => {
const resolve = jest.fn()
const { getByText, queryByText } = render(
<Modal mode="alert" onConfirm={resolve} show />,
)
fireEvent.click(getByText(trans('general.confirm')))
expect(resolve).toBeCalledWith({ value: '' })
expect(queryByText(trans('general.cancel'))).toBeNull()
})
it('confirm callback is optional', () => {
const { getByText } = render(<Modal mode="alert" show />)
fireEvent.click(getByText(trans('general.confirm')))
})
})
describe('"confirm" mode', () => {
it('default mode is "confirm"', () => {
const { queryByText } = render(<Modal show />)
expect(queryByText(trans('general.confirm'))).toBeInTheDocument()
expect(queryByText(trans('general.cancel'))).toBeInTheDocument()
})
it('"confirm" button', () => {
const resolve = jest.fn()
const reject = jest.fn()
const { getByText } = render(
<Modal mode="prompt" onConfirm={resolve} onDismiss={reject} show />,
)
fireEvent.click(getByText(trans('general.confirm')))
expect(resolve).toBeCalledWith({ value: '' })
expect(reject).not.toBeCalled()
})
it('"cancel" button', () => {
const resolve = jest.fn()
const reject = jest.fn()
const { getByText } = render(
<Modal mode="prompt" onConfirm={resolve} onDismiss={reject} show />,
)
fireEvent.click(getByText(trans('general.cancel')))
expect(resolve).not.toBeCalled()
expect(reject).toBeCalled()
})
})
describe('"prompt" mode', () => {
it('retrieve input value', () => {
const resolve = jest.fn()
const reject = jest.fn()
const { getByPlaceholderText, getByText } = render(
<Modal
mode="prompt"
placeholder="hint"
onConfirm={resolve}
onDismiss={reject}
show
/>,
)
fireEvent.change(getByPlaceholderText('hint'), {
target: { value: 'my' },
})
fireEvent.click(getByText(trans('general.confirm')))
expect(resolve).toBeCalledWith({ value: 'my' })
expect(reject).not.toBeCalled()
})
it('cancel dialog', () => {
const resolve = jest.fn()
const reject = jest.fn()
const { getByText } = render(
<Modal mode="prompt" onConfirm={resolve} onDismiss={reject} show />,
)
fireEvent.click(getByText(trans('general.cancel')))
expect(resolve).not.toBeCalled()
expect(reject).toBeCalled()
})
it('validate input', () => {
const resolve = jest.fn()
const reject = jest.fn()
const validator = jest.fn().mockReturnValue(true)
const { getByText } = render(
<Modal
mode="prompt"
input="val"
validator={validator}
onConfirm={resolve}
onDismiss={reject}
show
/>,
)
fireEvent.click(getByText(trans('general.confirm')))
expect(resolve).toBeCalledWith({ value: 'val' })
expect(reject).not.toBeCalled()
})
it('report validator message', () => {
const message = 'Invalid input.'
const resolve = jest.fn()
const reject = jest.fn()
const validator = jest.fn().mockReturnValue(message)
const { getByText, queryByText } = render(
<Modal
mode="prompt"
input="val"
validator={validator}
onConfirm={resolve}
onDismiss={reject}
show
/>,
)
expect(queryByText(message)).not.toBeInTheDocument()
fireEvent.click(getByText(trans('general.confirm')))
expect(queryByText(message)).toBeInTheDocument()
expect(resolve).not.toBeCalled()
expect(reject).not.toBeCalled()
})
})

View File

@ -1,4 +1,5 @@
import $ from 'jquery'
import { act } from 'react-dom/test-utils'
import { showModal } from '@/scripts/modal'
test('show modal', async () => {
@ -11,6 +12,9 @@ test('show modal', async () => {
const { value } = await showModal()
expect(value).toBe('')
$('.modal').trigger('hidden.bs.modal')
act(() => {
$('.modal').trigger('hidden.bs.modal')
jest.runAllTimers()
})
expect(document.querySelector('.modal')).toBeNull()
})

View File

@ -1,4 +1,4 @@
import { ModalOptions, ModalResult } from '../../src/scripts/modal'
import { ModalOptions, ModalResult } from '../../src/components/Modal'
import { Toast } from '../../src/scripts/toast'
export const showModal = {} as jest.Mock<Promise<ModalResult>, [ModalOptions | void]>