From 7c144b3e14434558d13711de505d35b1878d8350 Mon Sep 17 00:00:00 2001 From: Pig Fang Date: Thu, 23 Jan 2020 22:52:09 +0800 Subject: [PATCH] rewrite "Modal" component in React --- package.json | 2 +- resources/assets/src/components/Modal.tsx | 164 +++++++++++ resources/assets/src/components/ModalBody.tsx | 52 ++++ .../assets/src/components/ModalFooter.tsx | 46 ++++ .../assets/src/components/ModalHeader.tsx | 25 ++ resources/assets/src/scripts/modal.ts | 51 ---- resources/assets/src/scripts/modal.tsx | 32 +++ resources/assets/src/scripts/validators.ts | 2 +- resources/assets/src/shims.d.ts | 2 +- .../assets/tests/components/Modal.test.tsx | 255 ++++++++++++++++++ resources/assets/tests/scripts/modal.test.ts | 6 +- resources/assets/tests/ts-shims/notify.ts | 2 +- 12 files changed, 583 insertions(+), 56 deletions(-) create mode 100644 resources/assets/src/components/Modal.tsx create mode 100644 resources/assets/src/components/ModalBody.tsx create mode 100644 resources/assets/src/components/ModalFooter.tsx create mode 100644 resources/assets/src/components/ModalHeader.tsx delete mode 100644 resources/assets/src/scripts/modal.ts create mode 100644 resources/assets/src/scripts/modal.tsx create mode 100644 resources/assets/tests/components/Modal.test.tsx diff --git a/package.json b/package.json index 690fc6f2..a49b44a3 100644 --- a/package.json +++ b/package.json @@ -194,9 +194,9 @@ }, "moduleFileExtensions": [ "js", - "vue", "ts", "tsx", + "vue", "json", "node" ], diff --git a/resources/assets/src/components/Modal.tsx b/resources/assets/src/components/Modal.tsx new file mode 100644 index 00000000..21678959 --- /dev/null +++ b/resources/assets/src/components/Modal.tsx @@ -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( + (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(null)) as React.RefObject + + const handleInputChange = (event: React.ChangeEvent) => { + 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 ( + + ) + }, +) + +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 diff --git a/resources/assets/src/components/ModalBody.tsx b/resources/assets/src/components/ModalBody.tsx new file mode 100644 index 00000000..8c97a164 --- /dev/null +++ b/resources/assets/src/components/ModalBody.tsx @@ -0,0 +1,52 @@ +import React from 'react' + +interface Props { + text?: string + dangerousHTML?: string + showInput: boolean + inputType?: string + value?: string + onChange?: React.ChangeEventHandler + placeholder?: string + invalid?: boolean + validatorMessage?: string +} + +const ModalBody: React.FC = props => { + const main = (() => { + if (props.children) { + return props.children + } else if (props.text) { + return props.text.split(/\r?\n/).map((line, i) =>

{line}

) + } else if (props.dangerousHTML) { + return
+ } + })() + + return ( +
+ {main} + {props.showInput && ( + <> +
+ +
+ {props.invalid && ( +
+ + {props.validatorMessage} +
+ )} + + )} +
+ ) +} + +export default ModalBody diff --git a/resources/assets/src/components/ModalFooter.tsx b/resources/assets/src/components/ModalFooter.tsx new file mode 100644 index 00000000..dc26b81f --- /dev/null +++ b/resources/assets/src/components/ModalFooter.tsx @@ -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 => { + const classes = ['modal-footer'] + if (props.flexFooter) { + classes.push('d-flex', 'justify-content-between') + } + const footerClass = classes.join(' ') + + return props.children ? ( +
{props.children}
+ ) : ( +
+ {props.showCancelButton && ( + + )} + +
+ ) +} + +export default ModalFooter diff --git a/resources/assets/src/components/ModalHeader.tsx b/resources/assets/src/components/ModalHeader.tsx new file mode 100644 index 00000000..b363639f --- /dev/null +++ b/resources/assets/src/components/ModalHeader.tsx @@ -0,0 +1,25 @@ +import React from 'react' + +interface Props { + show?: boolean + title?: string + onDismiss?(): void +} + +const ModalHeader: React.FC = props => + props.show ? ( +
+
{props.title}
+ +
+ ) : null + +export default ModalHeader diff --git a/resources/assets/src/scripts/modal.ts b/resources/assets/src/scripts/modal.ts deleted file mode 100644 index c74e94c8..00000000 --- a/resources/assets/src/scripts/modal.ts +++ /dev/null @@ -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 { - 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() - }) - }) -} diff --git a/resources/assets/src/scripts/modal.tsx b/resources/assets/src/scripts/modal.tsx new file mode 100644 index 00000000..b5b1a985 --- /dev/null +++ b/resources/assets/src/scripts/modal.tsx @@ -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 { + return new Promise((resolve, reject) => { + const container = document.createElement('div') + document.body.appendChild(container) + + const ref = React.createRef() + ReactDOM.render( + , + container, + ) + + $(ref.current!) + .modal('show') + .on('hidden.bs.modal', () => { + setTimeout(() => { + ReactDOM.unmountComponentAtNode(container) + container.remove() + }, 0) + }) + }) +} diff --git a/resources/assets/src/scripts/validators.ts b/resources/assets/src/scripts/validators.ts index 06ea3805..17ca43bd 100644 --- a/resources/assets/src/scripts/validators.ts +++ b/resources/assets/src/scripts/validators.ts @@ -1,5 +1,5 @@ export function truthy(message: string) { - return (value?: unknown): string | void => { + return (value?: unknown): string | undefined => { if (!value) { return message } diff --git a/resources/assets/src/shims.d.ts b/resources/assets/src/shims.d.ts index 707fd334..c9e3758d 100644 --- a/resources/assets/src/shims.d.ts +++ b/resources/assets/src/shims.d.ts @@ -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 { diff --git a/resources/assets/tests/components/Modal.test.tsx b/resources/assets/tests/components/Modal.test.tsx new file mode 100644 index 00000000..fcddd081 --- /dev/null +++ b/resources/assets/tests/components/Modal.test.tsx @@ -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() + expect(queryByRole('dialog')).toBeNull() +}) + +test('receive id', () => { + const { getByRole } = render() + expect(getByRole('dialog')).toHaveAttribute('id', 'kumiko') +}) + +test('centered dialog', () => { + const { getByRole } = render() + expect(getByRole('document')).toHaveClass('modal-dialog-centered') +}) + +test('background color', () => { + const { container } = render() + expect(container.querySelector('.modal-content')).toHaveClass('bg-primary') +}) + +test('forward ref', () => { + const ref = React.createRef() + render() + expect(ref.current).not.toBeNull() +}) + +test('jQuery events', () => { + const ref = React.createRef() + render() + act(() => { + $(ref.current!) + .trigger('hide.bs.modal') + .trigger('hidden.bs.modal') + }) +}) + +describe('modal header', () => { + it('modal title', () => { + const { queryByText } = render() + expect(queryByText('Tips')).toBeInTheDocument() + }) + + it('hide modal header', () => { + const { queryByText } = render( + , + ) + expect(queryByText('Tips')).not.toBeInTheDocument() + }) +}) + +describe('modal body', () => { + it('custom children', () => { + const { queryByText } = render(body) + expect(queryByText('body')).toBeInTheDocument() + }) + + it('text with linebreaks', () => { + const { queryByText } = render() + expect(queryByText('L1')).toBeInTheDocument() + expect(queryByText('L2')).toBeInTheDocument() + }) + + it('dangerous HTML', () => { + const { getByText } = render( + , + ) + expect(getByText('ab')).toHaveClass('h1') + }) + + describe('input control', () => { + it('set default value', () => { + const { queryByDisplayValue } = render( + , + ) + expect(queryByDisplayValue('val')).toBeInTheDocument() + }) + + it('placeholder', () => { + const { queryByPlaceholderText } = render( + , + ) + expect(queryByPlaceholderText('hint')).toBeInTheDocument() + }) + + it('input control type', () => { + const { getByPlaceholderText } = render( + , + ) + expect(getByPlaceholderText('password')).toHaveAttribute( + 'type', + 'password', + ) + }) + }) +}) + +describe('modal footer', () => { + it('custom footer content', () => { + const { queryByText } = render(footer
} show />) + expect(queryByText('footer')).toBeInTheDocument() + expect(queryByText(trans('general.confirm'))).not.toBeInTheDocument() + }) + + it('flex footer', () => { + const { getByText } = render() + expect(getByText('footer')).toHaveClass('d-flex', 'justify-content-between') + }) + + it('custom ok button', () => { + const { getByText } = render( + , + ) + expect(getByText('kumiko')).toHaveClass('btn-primary') + }) + + it('custom cancel button', () => { + const { getByText } = render( + , + ) + expect(getByText('reina')).toHaveClass('btn-success') + }) +}) + +describe('"alert" mode', () => { + it('buttons', () => { + const resolve = jest.fn() + const { getByText, queryByText } = render( + , + ) + fireEvent.click(getByText(trans('general.confirm'))) + expect(resolve).toBeCalledWith({ value: '' }) + expect(queryByText(trans('general.cancel'))).toBeNull() + }) + + it('confirm callback is optional', () => { + const { getByText } = render() + fireEvent.click(getByText(trans('general.confirm'))) + }) +}) + +describe('"confirm" mode', () => { + it('default mode is "confirm"', () => { + const { queryByText } = render() + 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( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + expect(queryByText(message)).not.toBeInTheDocument() + + fireEvent.click(getByText(trans('general.confirm'))) + expect(queryByText(message)).toBeInTheDocument() + expect(resolve).not.toBeCalled() + expect(reject).not.toBeCalled() + }) +}) diff --git a/resources/assets/tests/scripts/modal.test.ts b/resources/assets/tests/scripts/modal.test.ts index e47e529c..203bf39c 100644 --- a/resources/assets/tests/scripts/modal.test.ts +++ b/resources/assets/tests/scripts/modal.test.ts @@ -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() }) diff --git a/resources/assets/tests/ts-shims/notify.ts b/resources/assets/tests/ts-shims/notify.ts index 7b2b8f46..ce5267bd 100644 --- a/resources/assets/tests/ts-shims/notify.ts +++ b/resources/assets/tests/ts-shims/notify.ts @@ -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, [ModalOptions | void]>