From 54b948d01e1ae65645791c3c840bbfb0cd7ff937 Mon Sep 17 00:00:00 2001 From: Pig Fang Date: Tue, 4 Feb 2020 12:23:14 +0800 Subject: [PATCH] rework toast with React --- package.json | 2 + resources/assets/src/components/Toast.scss | 12 +++ resources/assets/src/components/Toast.tsx | 61 ++++++++++++ .../src/components/mixins/addClosetItem.ts | 4 +- .../src/components/mixins/removeClosetItem.ts | 4 +- .../src/components/mixins/setAsAvatar.ts | 4 +- resources/assets/src/scripts/toast.ts | 96 ------------------- resources/assets/src/scripts/toast.tsx | 69 +++++++++++++ resources/assets/src/styles/common.styl | 10 -- resources/assets/tests/scripts/toast.test.ts | 71 -------------- resources/assets/tests/scripts/toast.test.tsx | 28 ++++++ yarn.lock | 12 +++ 12 files changed, 190 insertions(+), 183 deletions(-) create mode 100644 resources/assets/src/components/Toast.scss create mode 100644 resources/assets/src/components/Toast.tsx delete mode 100644 resources/assets/src/scripts/toast.ts create mode 100644 resources/assets/src/scripts/toast.tsx delete mode 100644 resources/assets/tests/scripts/toast.test.ts create mode 100644 resources/assets/tests/scripts/toast.test.tsx diff --git a/package.json b/package.json index 2bc49859..02d3058e 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "admin-lte": "^3.0.1", "echarts": "^4.6.0", "jquery": "^3.4.1", + "nanoid": "^2.1.11", "react": "^16.12.0", "react-dom": "^16.12.0", "react-hot-loader": "^4.12.18", @@ -47,6 +48,7 @@ "@types/jest": "^24.0.25", "@types/jquery": "^3.3.29", "@types/js-yaml": "^3.12.2", + "@types/nanoid": "^2.1.0", "@types/react": "^16.9.17", "@types/react-dom": "^16.9.4", "@types/tween.js": "^17.2.0", diff --git a/resources/assets/src/components/Toast.scss b/resources/assets/src/components/Toast.scss new file mode 100644 index 00000000..c7d37f8d --- /dev/null +++ b/resources/assets/src/components/Toast.scss @@ -0,0 +1,12 @@ +.toast { + position: fixed; + right: calc((100% - 350px) / 2); + width: 350px; + z-index: 1050; + transition-property: top; + transition-duration: 0.3s; +} + +.shadow { + box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.1); +} diff --git a/resources/assets/src/components/Toast.tsx b/resources/assets/src/components/Toast.tsx new file mode 100644 index 00000000..a55a6c8e --- /dev/null +++ b/resources/assets/src/components/Toast.tsx @@ -0,0 +1,61 @@ +import React, { useState, useEffect } from 'react' +import styles from './Toast.scss' + +export type ToastType = 'success' | 'info' | 'warning' | 'error' + +interface Props { + type: ToastType + distance: number + onClose(): void | Promise +} + +const icons = new Map([ + ['success', 'check'], + ['info', 'info'], + ['warning', 'exclamation-triangle'], + ['error', 'times-circle'], +]) + +const Toast: React.FC = props => { + const [show, setShow] = useState(false) + + useEffect(() => { + setTimeout(() => setShow(true), 100) + setTimeout(() => setShow(false), 3000) + setTimeout(props.onClose, 3100) + }, [props.onClose]) + + const type = props.type === 'error' ? 'danger' : props.type + + const classes = [ + `alert alert-${type}`, + 'd-flex justify-content-between', + 'fade', + styles.shadow, + ] + if (show) { + classes.push('show') + } + + return ( +
+
+ + + {props.children} + + +
+
+ ) +} + +Toast.displayName = 'Toast' + +export default Toast diff --git a/resources/assets/src/components/mixins/addClosetItem.ts b/resources/assets/src/components/mixins/addClosetItem.ts index 6a0b8185..603727dc 100644 --- a/resources/assets/src/components/mixins/addClosetItem.ts +++ b/resources/assets/src/components/mixins/addClosetItem.ts @@ -26,10 +26,10 @@ export default Vue.extend<{ { tid: this.tid, name: value }, ) if (code === 0) { - toast.success(message) + toast.success(message!) this.$emit('like-toggled', true) } else { - toast.error(message) + toast.error(message!) } }, }, diff --git a/resources/assets/src/components/mixins/removeClosetItem.ts b/resources/assets/src/components/mixins/removeClosetItem.ts index 82016eeb..a17b8de4 100644 --- a/resources/assets/src/components/mixins/removeClosetItem.ts +++ b/resources/assets/src/components/mixins/removeClosetItem.ts @@ -19,9 +19,9 @@ export default Vue.extend<{ const { code, message } = await this.$http.post(`/user/closet/remove/${this.tid}`) if (code === 0) { this.$emit('item-removed') - toast.success(message) + toast.success(message!) } else { - toast.error(message) + toast.error(message!) } }, }, diff --git a/resources/assets/src/components/mixins/setAsAvatar.ts b/resources/assets/src/components/mixins/setAsAvatar.ts index 4af8b9ae..14b8d8a2 100644 --- a/resources/assets/src/components/mixins/setAsAvatar.ts +++ b/resources/assets/src/components/mixins/setAsAvatar.ts @@ -20,7 +20,7 @@ export default Vue.extend<{ { tid: this.tid }, ) if (code === 0) { - toast.success(message) + toast.success(message!) Array .from( @@ -28,7 +28,7 @@ export default Vue.extend<{ ) .forEach(el => (el.src += `?${new Date().getTime()}`)) } else { - toast.error(message) + toast.error(message!) } }, }, diff --git a/resources/assets/src/scripts/toast.ts b/resources/assets/src/scripts/toast.ts deleted file mode 100644 index 328a6cc4..00000000 --- a/resources/assets/src/scripts/toast.ts +++ /dev/null @@ -1,96 +0,0 @@ -import $ from 'jquery' -import 'bootstrap' - -const toastIcons: Record = { - success: 'check', - info: 'info', - warning: 'exclamation-triangle', - error: 'times-circle', -} - -export type ToastQueue = QueueElement[] -type QueueElement = { el: HTMLDivElement, height: number } -type ToastType = - | 'success' - | 'info' - | 'warning' - | 'error' - -export function showToast( - queue: ToastQueue, - type: ToastType, - message = '', -): void { - const alertType = type === 'error' ? 'danger' : type - - const container = document.createElement('div') - container.className = 'alert-toast' - const last = queue[queue.length - 1] - if (last) { - container.style.top = `${last.el.offsetTop + last.el.offsetHeight + 12}px` - } else { - container.style.top = '35px' - } - - const toast = document.createElement('div') - toast.className = `alert alert-${alertType} d-flex justify-content-between fade` - - const icon = document.createElement('i') - icon.className = `icon fas fa-${toastIcons[type]}` - - const text = document.createElement('span') - text.textContent = message - - const span = document.createElement('span') - span.className = 'mr-auto' - span.appendChild(icon) - span.appendChild(text) - toast.appendChild(span) - - const button = document.createElement('button') - button.type = 'button' - button.className = 'ml-2 mr-1 close' - button.dataset.dismiss = 'alert' - button.textContent = '×' - toast.appendChild(button) - - container.appendChild(toast) - document.body.appendChild(container) - queue.push({ el: container, height: container.offsetHeight }) - setTimeout(() => toast.classList.add('show'), 100) - - setTimeout(() => $(toast).alert('close'), 3000) - $(toast).on('closed.bs.alert', () => { - container.remove() - let i = queue.findIndex(({ el }) => el === container) - const distance = queue[i].height + 12 - for (i += 1; i < queue.length; i += 1) { - const element = queue[i].el - element.style.top = `${element.offsetTop - distance}px` - } - }) -} - -export class Toast { - private queue: ToastQueue - - constructor() { - this.queue = [] - } - - success(message = '') { - return showToast(this.queue, 'success', message) - } - - info(message = '') { - return showToast(this.queue, 'info', message) - } - - warning(message = '') { - return showToast(this.queue, 'warning', message) - } - - error(message = '') { - return showToast(this.queue, 'error', message) - } -} diff --git a/resources/assets/src/scripts/toast.tsx b/resources/assets/src/scripts/toast.tsx new file mode 100644 index 00000000..d32d73f3 --- /dev/null +++ b/resources/assets/src/scripts/toast.tsx @@ -0,0 +1,69 @@ +import React, { useState, useEffect } from 'react' +import ReactDOM from 'react-dom' +import nanoid from 'nanoid' +import * as emitter from './event' +import ToastBox, { ToastType } from '../components/Toast' + +type QueueElement = { id: string; type: ToastType; message: string } +type ToastQueue = QueueElement[] + +const TOAST_EVENT = Symbol('toast') + +export const ToastContainer: React.FC = () => { + const [queue, setQueue] = useState([]) + + useEffect(() => { + const off = emitter.on(TOAST_EVENT, (toast: QueueElement) => { + setQueue(queue => { + queue.push(toast) + return queue.slice() + }) + }) + + return off + }, []) + + const handleClose = (id: string) => { + setQueue(queue => queue.filter(el => el.id !== id)) + } + + return ( + <> + {queue.map((el, i) => ( + handleClose(el.id)} + > + {el.message} + + ))} + + ) +} + +export class Toast { + constructor() { + const container = document.createElement('div') + document.body.appendChild(container) + + ReactDOM.render(, container) + } + + success(message: string) { + emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'success', message }) + } + + info(message: string) { + emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'info', message }) + } + + warning(message: string) { + emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'warning', message }) + } + + error(message: string) { + emitter.emit(TOAST_EVENT, { id: nanoid(4), type: 'error', message }) + } +} diff --git a/resources/assets/src/styles/common.styl b/resources/assets/src/styles/common.styl index 5e2dfba8..015b1a9d 100644 --- a/resources/assets/src/styles/common.styl +++ b/resources/assets/src/styles/common.styl @@ -30,13 +30,3 @@ .user-panel .info cursor default - -.alert-toast - position fixed - right calc((100% - 350px) / 2) - width 350px - z-index 1050 - transition-property top - transition-duration 0.3s - .alert - box-shadow 0 .25rem .75rem rgba(0,0,0,.1) diff --git a/resources/assets/tests/scripts/toast.test.ts b/resources/assets/tests/scripts/toast.test.ts deleted file mode 100644 index 7c8a9b89..00000000 --- a/resources/assets/tests/scripts/toast.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { - showToast, Toast, ToastQueue, -} from '@/scripts/toast' - -beforeEach(() => { - document.body.innerHTML = '' -}) - -test('"Toast" class', () => { - const toast = new Toast() - - toast.success('success') - expect( - document.querySelector('.alert-success')!.textContent, - ).toContain('success') - - toast.info('info') - expect( - document.querySelector('.alert-info')!.textContent, - ).toContain('info') - - toast.warning('warning') - expect( - document.querySelector('.alert-warning')!.textContent, - ).toContain('warning') - - toast.error('error') - expect( - document.querySelector('.alert-danger')!.textContent, - ).toContain('error') - - // Should pass. - toast.success() - toast.info() - toast.warning() - toast.error() -}) - -test('top position', () => { - showToast([], 'info') - expect( - document.querySelector('.alert-info')!.parentElement!.style.top, - ).toBe('35px') - - showToast( - [{ height: 20, el: { offsetTop: 30, offsetHeight: 20 } as HTMLDivElement }], - 'error', - ) - expect( - document.querySelector('.alert-danger')!.parentElement!.style.top, - ).toBe('62px') -}) - -test('delay show', () => { - const queue: ToastQueue = [] - showToast(queue, 'info') - jest.advanceTimersByTime(100) - expect(document.querySelector('.fade')!.classList.contains('show')).toBeTrue() - expect(queue).toHaveLength(1) -}) - -test('move queue', () => { - const queue: ToastQueue = [] - const fake = { offsetTop: 30, style: {} } as HTMLDivElement - - showToast(queue, 'info') - queue[0].height = 10 - queue.push({ height: 0, el: fake }) - jest.runAllTimers() - expect(fake.style.top).toBe('8px') -}) diff --git a/resources/assets/tests/scripts/toast.test.tsx b/resources/assets/tests/scripts/toast.test.tsx new file mode 100644 index 00000000..462ec3b7 --- /dev/null +++ b/resources/assets/tests/scripts/toast.test.tsx @@ -0,0 +1,28 @@ +import React from 'react' +import { render } from '@testing-library/react' +import { Toast, ToastContainer } from '@/scripts/toast' + +test('"Toast" class', () => { + render() + const toast = new Toast() + + toast.success('success') + expect(document.querySelector('.alert-success')!.textContent).toContain( + 'success', + ) + + toast.info('info') + expect(document.querySelector('.alert-info')!.textContent).toContain('info') + + toast.warning('warning') + expect(document.querySelector('.alert-warning')!.textContent).toContain( + 'warning', + ) + + toast.error('error') + expect(document.querySelector('.alert-danger')!.textContent).toContain( + 'error', + ) + + jest.runAllTimers() +}) diff --git a/yarn.lock b/yarn.lock index c77a81bd..5039ac26 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1340,6 +1340,13 @@ resolved "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== +"@types/nanoid@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@types/nanoid/-/nanoid-2.1.0.tgz#41edfda78986e9127d0dc14de982de766f994020" + integrity sha512-xdkn/oRTA0GSNPLIKZgHWqDTWZsVrieKomxJBOQUK9YDD+zfSgmwD5t4WJYra5S7XyhTw7tfvwznW+pFexaepQ== + dependencies: + "@types/node" "*" + "@types/node@*": version "12.7.5" resolved "https://registry.npmjs.org/@types/node/-/node-12.7.5.tgz#e19436e7f8e9b4601005d73673b6dc4784ffcc2f" @@ -6991,6 +6998,11 @@ nan@^2.12.1: resolved "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg== +nanoid@^2.1.11: + version "2.1.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-2.1.11.tgz#ec24b8a758d591561531b4176a01e3ab4f0f0280" + integrity sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA== + nanomatch@^1.2.9: version "1.2.13" resolved "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"