From 4c04b4ef5add46be4a110607aa776b479a641844 Mon Sep 17 00:00:00 2001 From: Brent Clark Date: Tue, 15 Feb 2022 19:40:30 -0600 Subject: [PATCH] Webatrice: Registration toasts (#4566) * wip * Registration Success Toast * remove debugging code * remove unused field * Show toast on successful password reset * Toast on account activation success * lint and PR feedback * Rework interface names to avoid collision * Move CssBaseline to sibling of ToastProvider Co-authored-by: Brent Clark --- .../src/components/Toast/ToastContext.tsx | 71 +++++++++++++++++++ webclient/src/components/Toast/index.ts | 8 +++ webclient/src/components/Toast/reducer.ts | 48 +++++++++++++ webclient/src/containers/App/AppShell.tsx | 21 +++--- webclient/src/containers/Login/Login.tsx | 6 ++ .../src/forms/RegisterForm/RegisterForm.tsx | 11 +-- webclient/src/store/server/server.actions.ts | 3 + webclient/src/store/server/server.dispatch.ts | 3 + webclient/src/store/server/server.types.ts | 1 + .../src/websocket/commands/SessionCommands.ts | 1 + .../persistence/SessionPersistence.ts | 4 ++ 11 files changed, 164 insertions(+), 13 deletions(-) create mode 100644 webclient/src/components/Toast/ToastContext.tsx create mode 100644 webclient/src/components/Toast/index.ts create mode 100644 webclient/src/components/Toast/reducer.ts diff --git a/webclient/src/components/Toast/ToastContext.tsx b/webclient/src/components/Toast/ToastContext.tsx new file mode 100644 index 00000000..f1449a88 --- /dev/null +++ b/webclient/src/components/Toast/ToastContext.tsx @@ -0,0 +1,71 @@ +import { createContext, FC, ReactChild, ReactNode, useContext, useEffect, useReducer, ContextType, Context } from 'react' + +import { ACTIONS, initialState, reducer } from './reducer'; +import Toast from './Toast' + +interface ToastEntry { + isOpen: boolean, + children: ReactChild, +} + +interface ToastState { + toasts: Map, + addToast: (key, children) => void, + openToast: (key) => void, + closeToast: (key) => void, + removeToast: (key) => void, +} + +const ToastContext: Context = createContext({ + toasts: new Map(), + addToast: (key, children) => {}, + openToast: (key) => {}, + closeToast: (key) => {}, + removeToast: (key) => {}, +}); + +export const ToastProvider: FC = (props) => { + const { children } = props + const [state, dispatch] = useReducer(reducer, initialState) + const providerState = { + toasts: state.toasts, + addToast: (key, children) => dispatch({ type: ACTIONS.ADD_TOAST, payload: { key, children } }), + openToast: key => dispatch({ type: ACTIONS.OPEN_TOAST, payload: key }), + closeToast: key => dispatch({ type: ACTIONS.CLOSE_TOAST, payload: key }), + removeToast: key => dispatch({ type: ACTIONS.REMOVE_TOAST, payload: key }), + } + return ( + + {children} +
+ {Array.from(state.toasts).map(([key, value]) => { + const { isOpen, children } = value; + return ( + dispatch({ type: ACTIONS.CLOSE_TOAST, payload: key })}> + {children} + + ) + })} +
+
+ ) +} + +export interface ToastHookOptions { + key: string, + children: ReactNode +} + +export function useToast({ key, children }) { + const { addToast, openToast, closeToast, removeToast } = useContext(ToastContext) + + useEffect(() => { + addToast(key, children) + }, []) + + return { + openToast: () => openToast(key), + closeToast: () => closeToast(key), + removeToast: () => removeToast(key), + } +} diff --git a/webclient/src/components/Toast/index.ts b/webclient/src/components/Toast/index.ts new file mode 100644 index 00000000..40e7c736 --- /dev/null +++ b/webclient/src/components/Toast/index.ts @@ -0,0 +1,8 @@ +import { useToast, ToastProvider } from './ToastContext'; +import Toast from './Toast'; + +export { + Toast as default, + useToast, + ToastProvider, +} diff --git a/webclient/src/components/Toast/reducer.ts b/webclient/src/components/Toast/reducer.ts new file mode 100644 index 00000000..ca5788c6 --- /dev/null +++ b/webclient/src/components/Toast/reducer.ts @@ -0,0 +1,48 @@ +export const ACTIONS = { + ADD_TOAST: 'ADD_TOAST', + OPEN_TOAST: 'OPEN_TOAST', + CLOSE_TOAST: 'CLOSE_TOAST', + REMOVE_TOAST: 'REMOVE_TOAST', +} + +export const initialState = { + toasts: new Map() +} + +export function reducer(state, action) { + const { type, payload } = action + switch (type) { + case ACTIONS.ADD_TOAST: { + const newState = { ...state } + newState.toasts = new Map(Array.from(state.toasts)) + const { toasts } = newState; + const { key, children } = payload + toasts.set(key, { isOpen: false, children }) + return newState + } + case ACTIONS.OPEN_TOAST: { + const newState = { ...state } + newState.toasts = new Map(Array.from(state.toasts)) + const { toasts } = newState; + const toast = toasts.get(payload) + toasts.set(payload, { isOpen: true, children: toast.children }) + return newState + } + case ACTIONS.CLOSE_TOAST: { + const newState = { ...state } + newState.toasts = new Map(Array.from(state.toasts)) + const { toasts } = newState; + const toast = toasts.get(payload) + toasts.set(payload, { isOpen: false, children: toast.children }) + return newState + } + case ACTIONS.REMOVE_TOAST: { + const newState = { ...state } + newState.toasts = new Map(Array.from(state.toasts)) + newState.toasts.delete(payload) + return newState + } + default: + throw Error('Please pick an available action') + } +} diff --git a/webclient/src/containers/App/AppShell.tsx b/webclient/src/containers/App/AppShell.tsx index ee2d5ecb..14ee0077 100644 --- a/webclient/src/containers/App/AppShell.tsx +++ b/webclient/src/containers/App/AppShell.tsx @@ -1,5 +1,4 @@ -// eslint-disable-next-line -import React, { Component } from "react"; +import { Component } from 'react'; import { Provider } from 'react-redux'; import { MemoryRouter as Router } from 'react-router-dom'; import CssBaseline from '@material-ui/core/CssBaseline'; @@ -10,6 +9,8 @@ import FeatureDetection from './FeatureDetection'; import './AppShell.css'; +import { ToastProvider } from 'components/Toast' + class AppShell extends Component { componentDidMount() { // @TODO (1) @@ -24,14 +25,16 @@ class AppShell extends Component { return ( -
- -
+ +
+ +
- - - -
+ + + +
+
); } diff --git a/webclient/src/containers/Login/Login.tsx b/webclient/src/containers/Login/Login.tsx index c7ee6c72..f107feb8 100644 --- a/webclient/src/containers/Login/Login.tsx +++ b/webclient/src/containers/Login/Login.tsx @@ -17,6 +17,7 @@ import { RouteEnum, WebSocketConnectOptions, getHostPort } from 'types'; import { ServerSelectors, ServerTypes } from 'store'; import './Login.css'; +import { useToast } from 'components/Toast'; const useStyles = makeStyles(theme => ({ root: { @@ -68,16 +69,21 @@ const Login = ({ state, description }: LoginProps) => { }); const [userToResetPassword, setUserToResetPassword] = useState(null); + const passwordResetToast = useToast({ key: 'password-reset-success', children: 'Password Reset Successfully' }) + const accountActivatedToast = useToast({ key: 'account-activation-success', children: 'Account Activated Successfully' }) + useReduxEffect(() => { closeRequestPasswordResetDialog(); openResetPasswordDialog(); }, ServerTypes.RESET_PASSWORD_REQUESTED, []); useReduxEffect(() => { + passwordResetToast.openToast() closeResetPasswordDialog(); }, ServerTypes.RESET_PASSWORD_SUCCESS, []); useReduxEffect(() => { + accountActivatedToast.openToast() closeActivateAccountDialog(); }, ServerTypes.ACCOUNT_ACTIVATION_SUCCESS, []); diff --git a/webclient/src/forms/RegisterForm/RegisterForm.tsx b/webclient/src/forms/RegisterForm/RegisterForm.tsx index f2ab0a4e..9f5f4c4d 100644 --- a/webclient/src/forms/RegisterForm/RegisterForm.tsx +++ b/webclient/src/forms/RegisterForm/RegisterForm.tsx @@ -1,6 +1,4 @@ -// eslint-disable-next-line -import React, { Component, useState } from 'react'; -import { connect } from 'react-redux'; +import { useState } from 'react'; import { Form, Field } from 'react-final-form'; import { OnChange } from 'react-final-form-listeners'; import setFieldTouched from 'final-form-set-field-touched' @@ -11,9 +9,9 @@ import Typography from '@material-ui/core/Typography'; import { CountryDropdown, InputField, KnownHosts } from 'components'; import { useReduxEffect } from 'hooks'; import { ServerTypes } from 'store'; -import { FormKey } from 'types'; import './RegisterForm.css'; +import { useToast } from 'components/Toast'; const RegisterForm = ({ onSubmit }: RegisterFormProps) => { const [emailRequired, setEmailRequired] = useState(false); @@ -21,6 +19,7 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => { const [emailError, setEmailError] = useState(null); const [passwordError, setPasswordError] = useState(null); const [userNameError, setUserNameError] = useState(null); + const { openToast } = useToast({ key: 'registration-success', children: 'Registration Successful!' }) const onHostChange = (host) => setEmailRequired(false); const onEmailChange = () => emailError && setEmailError(null); @@ -35,6 +34,10 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => { setError(error); }, ServerTypes.REGISTRATION_FAILED); + useReduxEffect(() => { + openToast() + }, ServerTypes.REGISTRATION_SUCCES); + useReduxEffect(({ error }) => { setEmailError(error); }, ServerTypes.REGISTRATION_EMAIL_ERROR); diff --git a/webclient/src/store/server/server.actions.ts b/webclient/src/store/server/server.actions.ts index c528ca50..767d597b 100644 --- a/webclient/src/store/server/server.actions.ts +++ b/webclient/src/store/server/server.actions.ts @@ -86,6 +86,9 @@ export const Actions = { registrationRequiresEmail: () => ({ type: Types.REGISTRATION_REQUIRES_EMAIL, }), + registrationSuccess: () => ({ + type: Types.REGISTRATION_SUCCES, + }), registrationFailed: (error) => ({ type: Types.REGISTRATION_FAILED, error diff --git a/webclient/src/store/server/server.dispatch.ts b/webclient/src/store/server/server.dispatch.ts index d4b1639b..bd4c85c3 100644 --- a/webclient/src/store/server/server.dispatch.ts +++ b/webclient/src/store/server/server.dispatch.ts @@ -77,6 +77,9 @@ export const Dispatch = { registrationRequiresEmail: () => { store.dispatch(Actions.registrationRequiresEmail()); }, + registrationSuccess: () => { + store.dispatch(Actions.registrationSuccess()) + }, registrationFailed: (error) => { store.dispatch(Actions.registrationFailed(error)); }, diff --git a/webclient/src/store/server/server.types.ts b/webclient/src/store/server/server.types.ts index f68b8e78..2e8db280 100644 --- a/webclient/src/store/server/server.types.ts +++ b/webclient/src/store/server/server.types.ts @@ -21,6 +21,7 @@ export const Types = { VIEW_LOGS: '[Server] View Logs', CLEAR_LOGS: '[Server] Clear Logs', REGISTRATION_REQUIRES_EMAIL: '[Server] Registration Requires Email', + REGISTRATION_SUCCES: '[Server] Registration Success', REGISTRATION_FAILED: '[Server] Registration Failed', REGISTRATION_EMAIL_ERROR: '[Server] Registration Email Error', REGISTRATION_PASSWORD_ERROR: '[Server] Registration Password Error', diff --git a/webclient/src/websocket/commands/SessionCommands.ts b/webclient/src/websocket/commands/SessionCommands.ts index 648a8a00..b4fa790e 100644 --- a/webclient/src/websocket/commands/SessionCommands.ts +++ b/webclient/src/websocket/commands/SessionCommands.ts @@ -216,6 +216,7 @@ export class SessionCommands { webClient.protobuf.sendSessionCommand(sc, raw => { if (raw.responseCode === webClient.protobuf.controller.Response.ResponseCode.RespRegistrationAccepted) { SessionCommands.login(passwordSalt); + SessionPersistence.registrationSuccess() return; } diff --git a/webclient/src/websocket/persistence/SessionPersistence.ts b/webclient/src/websocket/persistence/SessionPersistence.ts index ef598bd9..0b46ad96 100644 --- a/webclient/src/websocket/persistence/SessionPersistence.ts +++ b/webclient/src/websocket/persistence/SessionPersistence.ts @@ -105,6 +105,10 @@ export class SessionPersistence { ServerDispatch.registrationRequiresEmail(); } + static registrationSuccess() { + ServerDispatch.registrationSuccess(); + } + static registrationFailed(error: string) { ServerDispatch.registrationFailed(error); }