From a31cf5386359cd922a0c5fc990587fae9332f101 Mon Sep 17 00:00:00 2001 From: proddy Date: Mon, 20 Sep 2021 21:43:56 +0200 Subject: [PATCH] replace class components (HOCs) with React Hooks --- interface/package.json | 12 +-- .../authentication/UnauthenticatedRoute.tsx | 22 ++---- interface/src/components/FormLoader.tsx | 56 +++++++++++++ interface/src/components/index.ts | 1 + interface/src/features/FeaturesWrapper.tsx | 65 +++++---------- interface/src/hooks/index.ts | 2 + interface/src/hooks/useAuthorizedRest.ts | 12 +++ interface/src/hooks/useRest.ts | 79 +++++++++++++++++++ interface/src/utils/binding.ts | 33 ++++++++ interface/src/utils/index.ts | 1 + 10 files changed, 214 insertions(+), 69 deletions(-) create mode 100644 interface/src/components/FormLoader.tsx create mode 100644 interface/src/hooks/index.ts create mode 100644 interface/src/hooks/useAuthorizedRest.ts create mode 100644 interface/src/hooks/useRest.ts create mode 100644 interface/src/utils/binding.ts create mode 100644 interface/src/utils/index.ts diff --git a/interface/package.json b/interface/package.json index fd3d99c94..abd7f4a96 100644 --- a/interface/package.json +++ b/interface/package.json @@ -3,13 +3,13 @@ "version": "0.1.0", "private": true, "dependencies": { - "@material-ui/core": "^4.11.4", + "@material-ui/core": "^4.12.3", "@material-ui/icons": "^4.11.2", "@msgpack/msgpack": "^2.7.0", - "@types/lodash": "^4.14.168", - "@types/node": "^15.0.1", - "@types/react": "^17.0.4", - "@types/react-dom": "^17.0.3", + "@types/lodash": "^4.14.172", + "@types/node": "^12.20.20", + "@types/react": "^17.0.19", + "@types/react-dom": "^17.0.9", "@types/react-material-ui-form-validator": "^2.1.0", "@types/react-router": "^5.1.13", "@types/react-router-dom": "^5.1.7", @@ -30,7 +30,7 @@ "react-router-dom": "^5.2.0", "react-scripts": "4.0.3", "sockette": "^2.0.6", - "typescript": "4.2.4", + "typescript": "4.3.5", "zlib": "^1.0.5" }, "scripts": { diff --git a/interface/src/authentication/UnauthenticatedRoute.tsx b/interface/src/authentication/UnauthenticatedRoute.tsx index ca6512b7d..9396c44b3 100644 --- a/interface/src/authentication/UnauthenticatedRoute.tsx +++ b/interface/src/authentication/UnauthenticatedRoute.tsx @@ -22,25 +22,13 @@ interface UnauthenticatedRouteProps | React.ComponentType; } -type RenderComponent = (props: RouteComponentProps) => React.ReactNode; - class UnauthenticatedRoute extends Route { public render() { - const { - authenticationContext, - component: Component, - features, - ...rest - } = this.props; - const renderComponent: RenderComponent = (props) => { - if (authenticationContext.me) { - return ; - } - if (Component) { - return ; - } - }; - return ; + const { authenticationContext, features, ...rest } = this.props; + if (authenticationContext.me) { + return ; + } + return ; } } diff --git a/interface/src/components/FormLoader.tsx b/interface/src/components/FormLoader.tsx new file mode 100644 index 000000000..3efeeb663 --- /dev/null +++ b/interface/src/components/FormLoader.tsx @@ -0,0 +1,56 @@ +import { FC } from 'react'; + +import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'; +import { Button, LinearProgress, Typography } from '@material-ui/core'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + loadingSettings: { + margin: theme.spacing(0.5) + }, + loadingSettingsDetails: { + margin: theme.spacing(4), + textAlign: 'center' + }, + button: { + marginRight: theme.spacing(2), + marginTop: theme.spacing(2) + } + }) +); + +interface FormLoaderProps { + errorMessage?: string; + loadData: () => void; +} + +const FormLoader: FC = ({ errorMessage, loadData }) => { + const classes = useStyles(); + if (errorMessage) { + return ( +
+ + {errorMessage} + + +
+ ); + } + return ( +
+ + + Loading… + +
+ ); +}; + +export default FormLoader; diff --git a/interface/src/components/index.ts b/interface/src/components/index.ts index 47348a94d..e98047039 100644 --- a/interface/src/components/index.ts +++ b/interface/src/components/index.ts @@ -5,6 +5,7 @@ export { default as HighlightAvatar } from './HighlightAvatar'; export { default as MenuAppBar } from './MenuAppBar'; export { default as PasswordValidator } from './PasswordValidator'; export { default as RestFormLoader } from './RestFormLoader'; +export { default as FormLoader } from './FormLoader'; export { default as SectionContent } from './SectionContent'; export { default as WebSocketFormLoader } from './WebSocketFormLoader'; export { default as ErrorButton } from './ErrorButton'; diff --git a/interface/src/features/FeaturesWrapper.tsx b/interface/src/features/FeaturesWrapper.tsx index 4742a4bcc..e5c762f23 100644 --- a/interface/src/features/FeaturesWrapper.tsx +++ b/interface/src/features/FeaturesWrapper.tsx @@ -1,58 +1,31 @@ -import { Component } from 'react'; +import { FC } from 'react'; -import { Features } from './types'; -import { FeaturesContext } from './FeaturesContext'; import FullScreenLoading from '../components/FullScreenLoading'; import ApplicationError from '../components/ApplicationError'; import { FEATURES_ENDPOINT } from '../api'; +import { useRest } from '../hooks'; -interface FeaturesWrapperState { - features?: Features; - error?: string; -} +import { Features } from './types'; +import { FeaturesContext } from './FeaturesContext'; -class FeaturesWrapper extends Component<{}, FeaturesWrapperState> { - state: FeaturesWrapperState = {}; +const FeaturesWrapper: FC = ({ children }) => { + const { data: features, errorMessage: error } = useRest({ + endpoint: FEATURES_ENDPOINT + }); - componentDidMount() { - this.fetchFeaturesDetails(); + if (features) { + return ( + + {children} + + ); } - fetchFeaturesDetails = () => { - fetch(FEATURES_ENDPOINT) - .then((response) => { - if (response.status === 200) { - return response.json(); - } else { - throw Error('Unexpected status code: ' + response.status); - } - }) - .then((features) => { - this.setState({ features }); - }) - .catch((error) => { - this.setState({ error: error.message }); - }); - }; - - render() { - const { features, error } = this.state; - if (features) { - return ( - - {this.props.children} - - ); - } - if (error) { - return ; - } - return ; + if (error) { + return ; } -} + + return ; +}; export default FeaturesWrapper; diff --git a/interface/src/hooks/index.ts b/interface/src/hooks/index.ts new file mode 100644 index 000000000..94897b22a --- /dev/null +++ b/interface/src/hooks/index.ts @@ -0,0 +1,2 @@ +export { default as useRest } from './useRest'; +export { default as useAuthorizedRest } from './useAuthorizedRest'; diff --git a/interface/src/hooks/useAuthorizedRest.ts b/interface/src/hooks/useAuthorizedRest.ts new file mode 100644 index 000000000..78fa7ce56 --- /dev/null +++ b/interface/src/hooks/useAuthorizedRest.ts @@ -0,0 +1,12 @@ +import { redirectingAuthorizedFetch } from '../authentication'; +import useRest, { RestRequestOptions } from './useRest'; + +const useAuthorizedRest = ({ + endpoint +}: Omit) => + useRest({ + endpoint, + fetchFunction: redirectingAuthorizedFetch + }); + +export default useAuthorizedRest; diff --git a/interface/src/hooks/useRest.ts b/interface/src/hooks/useRest.ts new file mode 100644 index 000000000..0d6b30743 --- /dev/null +++ b/interface/src/hooks/useRest.ts @@ -0,0 +1,79 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useSnackbar } from 'notistack'; + +export interface RestRequestOptions { + endpoint: string; + fetchFunction?: typeof fetch; +} + +const useRest = ({ + endpoint, + fetchFunction = fetch +}: RestRequestOptions) => { + const { enqueueSnackbar } = useSnackbar(); + + const [saving, setSaving] = useState(false); + const [data, setData] = useState(); + const [errorMessage, setErrorMessage] = useState(); + + const handleError = useCallback( + (error: any) => { + const errorMessage = error.message || 'Unknown error'; + enqueueSnackbar('Problem fetching: ' + errorMessage, { + variant: 'error' + }); + setErrorMessage(errorMessage); + }, + [enqueueSnackbar] + ); + + const loadData = useCallback(async () => { + setData(undefined); + setErrorMessage(undefined); + try { + const response = await fetchFunction(endpoint); + if (response.status !== 200) { + throw Error('Invalid status code: ' + response.status); + } + setData(await response.json()); + } catch (error) { + handleError(error); + } + }, [handleError, fetchFunction, endpoint]); + + const save = useCallback( + async (data: D) => { + setSaving(true); + setErrorMessage(undefined); + try { + const response = await fetchFunction(endpoint, { + method: 'POST', + body: JSON.stringify(data), + headers: { + 'Content-Type': 'application/json' + } + }); + if (response.status !== 200) { + throw Error('Invalid status code: ' + response.status); + } + enqueueSnackbar('Update successful.', { variant: 'success' }); + setData(await response.json()); + } catch (error) { + handleError(error); + } finally { + setSaving(false); + } + }, + [enqueueSnackbar, handleError, fetchFunction, endpoint] + ); + + const saveData = () => data && save(data); + + useEffect(() => { + loadData(); + }, [loadData]); + + return { loadData, saveData, saving, setData, data, errorMessage } as const; +}; + +export default useRest; diff --git a/interface/src/utils/binding.ts b/interface/src/utils/binding.ts new file mode 100644 index 000000000..dac942044 --- /dev/null +++ b/interface/src/utils/binding.ts @@ -0,0 +1,33 @@ +type UpdateEntity = (state: (prevState: Readonly) => S) => void; + +export const extractEventValue = ( + event: React.ChangeEvent +) => { + switch (event.target.type) { + case 'number': + return event.target.valueAsNumber; + case 'checkbox': + return event.target.checked; + default: + return event.target.value; + } +}; + +export const updateValue = (updateEntity: UpdateEntity) => ( + event: React.ChangeEvent +) => { + updateEntity((prevState) => ({ + ...prevState, + [event.target.name]: extractEventValue(event) + })); +}; + +export const updateBooleanValue = (updateEntity: UpdateEntity) => ( + name: string, + value?: boolean +) => { + updateEntity((prevState) => ({ + ...prevState, + [name]: value + })); +}; diff --git a/interface/src/utils/index.ts b/interface/src/utils/index.ts new file mode 100644 index 000000000..1ad877297 --- /dev/null +++ b/interface/src/utils/index.ts @@ -0,0 +1 @@ +export * from './binding';