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 <brent@backboneiq.com>
This commit is contained in:
Brent Clark 2022-02-15 19:40:30 -06:00 committed by GitHub
parent 88b861d632
commit 4c04b4ef5a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 164 additions and 13 deletions

View file

@ -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<string, ToastEntry>,
addToast: (key, children) => void,
openToast: (key) => void,
closeToast: (key) => void,
removeToast: (key) => void,
}
const ToastContext: Context<any> = createContext<ToastState>({
toasts: new Map<string, ToastEntry>(),
addToast: (key, children) => {},
openToast: (key) => {},
closeToast: (key) => {},
removeToast: (key) => {},
});
export const ToastProvider: FC<ReactNode> = (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 (
<ToastContext.Provider value={providerState}>
{children}
<div>
{Array.from(state.toasts).map(([key, value]) => {
const { isOpen, children } = value;
return (
<Toast key={key} open={isOpen} onClose={() => dispatch({ type: ACTIONS.CLOSE_TOAST, payload: key })}>
{children}
</Toast>
)
})}
</div>
</ToastContext.Provider>
)
}
export interface ToastHookOptions {
key: string,
children: ReactNode
}
export function useToast<ToastHookOptions>({ key, children }) {
const { addToast, openToast, closeToast, removeToast } = useContext(ToastContext)
useEffect(() => {
addToast(key, children)
}, [])
return {
openToast: () => openToast(key),
closeToast: () => closeToast(key),
removeToast: () => removeToast(key),
}
}

View file

@ -0,0 +1,8 @@
import { useToast, ToastProvider } from './ToastContext';
import Toast from './Toast';
export {
Toast as default,
useToast,
ToastProvider,
}

View file

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

View file

@ -1,5 +1,4 @@
// eslint-disable-next-line import { Component } from 'react';
import React, { Component } from "react";
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { MemoryRouter as Router } from 'react-router-dom'; import { MemoryRouter as Router } from 'react-router-dom';
import CssBaseline from '@material-ui/core/CssBaseline'; import CssBaseline from '@material-ui/core/CssBaseline';
@ -10,6 +9,8 @@ import FeatureDetection from './FeatureDetection';
import './AppShell.css'; import './AppShell.css';
import { ToastProvider } from 'components/Toast'
class AppShell extends Component { class AppShell extends Component {
componentDidMount() { componentDidMount() {
// @TODO (1) // @TODO (1)
@ -24,14 +25,16 @@ class AppShell extends Component {
return ( return (
<Provider store={store}> <Provider store={store}>
<CssBaseline /> <CssBaseline />
<div className="AppShell" onContextMenu={this.handleContextMenu}> <ToastProvider>
<Router> <div className="AppShell" onContextMenu={this.handleContextMenu}>
<Header /> <Router>
<Header />
<FeatureDetection /> <FeatureDetection />
<Routes /> <Routes />
</Router> </Router>
</div> </div>
</ToastProvider>
</Provider> </Provider>
); );
} }

View file

@ -17,6 +17,7 @@ import { RouteEnum, WebSocketConnectOptions, getHostPort } from 'types';
import { ServerSelectors, ServerTypes } from 'store'; import { ServerSelectors, ServerTypes } from 'store';
import './Login.css'; import './Login.css';
import { useToast } from 'components/Toast';
const useStyles = makeStyles(theme => ({ const useStyles = makeStyles(theme => ({
root: { root: {
@ -68,16 +69,21 @@ const Login = ({ state, description }: LoginProps) => {
}); });
const [userToResetPassword, setUserToResetPassword] = useState(null); 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(() => { useReduxEffect(() => {
closeRequestPasswordResetDialog(); closeRequestPasswordResetDialog();
openResetPasswordDialog(); openResetPasswordDialog();
}, ServerTypes.RESET_PASSWORD_REQUESTED, []); }, ServerTypes.RESET_PASSWORD_REQUESTED, []);
useReduxEffect(() => { useReduxEffect(() => {
passwordResetToast.openToast()
closeResetPasswordDialog(); closeResetPasswordDialog();
}, ServerTypes.RESET_PASSWORD_SUCCESS, []); }, ServerTypes.RESET_PASSWORD_SUCCESS, []);
useReduxEffect(() => { useReduxEffect(() => {
accountActivatedToast.openToast()
closeActivateAccountDialog(); closeActivateAccountDialog();
}, ServerTypes.ACCOUNT_ACTIVATION_SUCCESS, []); }, ServerTypes.ACCOUNT_ACTIVATION_SUCCESS, []);

View file

@ -1,6 +1,4 @@
// eslint-disable-next-line import { useState } from 'react';
import React, { Component, useState } from 'react';
import { connect } from 'react-redux';
import { Form, Field } from 'react-final-form'; import { Form, Field } from 'react-final-form';
import { OnChange } from 'react-final-form-listeners'; import { OnChange } from 'react-final-form-listeners';
import setFieldTouched from 'final-form-set-field-touched' 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 { CountryDropdown, InputField, KnownHosts } from 'components';
import { useReduxEffect } from 'hooks'; import { useReduxEffect } from 'hooks';
import { ServerTypes } from 'store'; import { ServerTypes } from 'store';
import { FormKey } from 'types';
import './RegisterForm.css'; import './RegisterForm.css';
import { useToast } from 'components/Toast';
const RegisterForm = ({ onSubmit }: RegisterFormProps) => { const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
const [emailRequired, setEmailRequired] = useState(false); const [emailRequired, setEmailRequired] = useState(false);
@ -21,6 +19,7 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
const [emailError, setEmailError] = useState(null); const [emailError, setEmailError] = useState(null);
const [passwordError, setPasswordError] = useState(null); const [passwordError, setPasswordError] = useState(null);
const [userNameError, setUserNameError] = useState(null); const [userNameError, setUserNameError] = useState(null);
const { openToast } = useToast({ key: 'registration-success', children: 'Registration Successful!' })
const onHostChange = (host) => setEmailRequired(false); const onHostChange = (host) => setEmailRequired(false);
const onEmailChange = () => emailError && setEmailError(null); const onEmailChange = () => emailError && setEmailError(null);
@ -35,6 +34,10 @@ const RegisterForm = ({ onSubmit }: RegisterFormProps) => {
setError(error); setError(error);
}, ServerTypes.REGISTRATION_FAILED); }, ServerTypes.REGISTRATION_FAILED);
useReduxEffect(() => {
openToast()
}, ServerTypes.REGISTRATION_SUCCES);
useReduxEffect(({ error }) => { useReduxEffect(({ error }) => {
setEmailError(error); setEmailError(error);
}, ServerTypes.REGISTRATION_EMAIL_ERROR); }, ServerTypes.REGISTRATION_EMAIL_ERROR);

View file

@ -86,6 +86,9 @@ export const Actions = {
registrationRequiresEmail: () => ({ registrationRequiresEmail: () => ({
type: Types.REGISTRATION_REQUIRES_EMAIL, type: Types.REGISTRATION_REQUIRES_EMAIL,
}), }),
registrationSuccess: () => ({
type: Types.REGISTRATION_SUCCES,
}),
registrationFailed: (error) => ({ registrationFailed: (error) => ({
type: Types.REGISTRATION_FAILED, type: Types.REGISTRATION_FAILED,
error error

View file

@ -77,6 +77,9 @@ export const Dispatch = {
registrationRequiresEmail: () => { registrationRequiresEmail: () => {
store.dispatch(Actions.registrationRequiresEmail()); store.dispatch(Actions.registrationRequiresEmail());
}, },
registrationSuccess: () => {
store.dispatch(Actions.registrationSuccess())
},
registrationFailed: (error) => { registrationFailed: (error) => {
store.dispatch(Actions.registrationFailed(error)); store.dispatch(Actions.registrationFailed(error));
}, },

View file

@ -21,6 +21,7 @@ export const Types = {
VIEW_LOGS: '[Server] View Logs', VIEW_LOGS: '[Server] View Logs',
CLEAR_LOGS: '[Server] Clear Logs', CLEAR_LOGS: '[Server] Clear Logs',
REGISTRATION_REQUIRES_EMAIL: '[Server] Registration Requires Email', REGISTRATION_REQUIRES_EMAIL: '[Server] Registration Requires Email',
REGISTRATION_SUCCES: '[Server] Registration Success',
REGISTRATION_FAILED: '[Server] Registration Failed', REGISTRATION_FAILED: '[Server] Registration Failed',
REGISTRATION_EMAIL_ERROR: '[Server] Registration Email Error', REGISTRATION_EMAIL_ERROR: '[Server] Registration Email Error',
REGISTRATION_PASSWORD_ERROR: '[Server] Registration Password Error', REGISTRATION_PASSWORD_ERROR: '[Server] Registration Password Error',

View file

@ -216,6 +216,7 @@ export class SessionCommands {
webClient.protobuf.sendSessionCommand(sc, raw => { webClient.protobuf.sendSessionCommand(sc, raw => {
if (raw.responseCode === webClient.protobuf.controller.Response.ResponseCode.RespRegistrationAccepted) { if (raw.responseCode === webClient.protobuf.controller.Response.ResponseCode.RespRegistrationAccepted) {
SessionCommands.login(passwordSalt); SessionCommands.login(passwordSalt);
SessionPersistence.registrationSuccess()
return; return;
} }

View file

@ -105,6 +105,10 @@ export class SessionPersistence {
ServerDispatch.registrationRequiresEmail(); ServerDispatch.registrationRequiresEmail();
} }
static registrationSuccess() {
ServerDispatch.registrationSuccess();
}
static registrationFailed(error: string) { static registrationFailed(error: string) {
ServerDispatch.registrationFailed(error); ServerDispatch.registrationFailed(error);
} }