diff --git a/.changeset/good-plants-buy.md b/.changeset/good-plants-buy.md new file mode 100644 index 00000000000..a14493f3a10 --- /dev/null +++ b/.changeset/good-plants-buy.md @@ -0,0 +1,5 @@ +--- +'@keystonejs/app-admin-ui': patch +--- + +Refactored out uses of the `withRouter` HOC. diff --git a/packages/app-admin-ui/client/components/Nav/index.js b/packages/app-admin-ui/client/components/Nav/index.js index 0cb4a097c11..0eb7768e37a 100644 --- a/packages/app-admin-ui/client/components/Nav/index.js +++ b/packages/app-admin-ui/client/components/Nav/index.js @@ -1,7 +1,7 @@ /** @jsx jsx */ -import React, { Component } from 'react'; // eslint-disable-line no-unused-vars -import { withRouter, Route, Link } from 'react-router-dom'; +import React, { useState } from 'react'; // eslint-disable-line no-unused-vars +import { Route, Link } from 'react-router-dom'; import PropToggle from 'react-prop-toggle'; import { uid } from 'react-uid'; import styled from '@emotion/styled'; @@ -329,96 +329,93 @@ let PrimaryNavContent = ({ mouseIsOverNav }) => { ); }; -class Nav extends Component { - state = { mouseIsOverNav: false }; +const Nav = ({ children }) => { + const [mouseIsOverNav, setMouseIsOverNav] = useState(false); - handleMouseEnter = () => { - this.setState({ mouseIsOverNav: true }); + const handleMouseEnter = () => { + setMouseIsOverNav(true); }; - handleMouseLeave = () => { - this.setState({ mouseIsOverNav: false }); + + const handleMouseLeave = () => { + setMouseIsOverNav(false); }; - render() { - const { children } = this.props; - const { mouseIsOverNav } = this.state; - return ( - - {(resizeProps, clickProps, { isCollapsed, isDragging, width }) => { - const navWidth = isCollapsed ? 0 : width; - const makeResizeStyles = key => { - const pointers = isDragging ? { pointerEvents: 'none' } : null; - const transitions = isDragging - ? null - : { - transition: `${camelToKebab(key)} ${TRANSITION_DURATION} ${TRANSITION_EASING}`, - }; - return { [key]: navWidth, ...pointers, ...transitions }; - }; + return ( + + {(resizeProps, clickProps, { isCollapsed, isDragging, width }) => { + const navWidth = isCollapsed ? 0 : width; + const makeResizeStyles = key => { + const pointers = isDragging ? { pointerEvents: 'none' } : null; + const transitions = isDragging + ? null + : { + transition: `${camelToKebab(key)} ${TRANSITION_DURATION} ${TRANSITION_EASING}`, + }; + return { [key]: navWidth, ...pointers, ...transitions }; + }; - return ( - - - + + + + {isCollapsed ? null : ( + + )} + + {isCollapsed ? 'Click to Expand' : 'Click to Collapse'} + + } + placement="right" + hideOnMouseDown + hideOnKeyDown + delay={600} > - - {isCollapsed ? null : ( - - )} - - {isCollapsed ? 'Click to Expand' : 'Click to Collapse'} - - } - placement="right" - hideOnMouseDown - hideOnKeyDown - delay={600} - > - {ref => ( - ( + + - - - - - )} - - - {children} - - ); - }} - - ); - } -} + + + + )} + + + {children} + + ); + }} + + ); +}; -export default withRouter(Nav); +export default Nav; diff --git a/packages/app-admin-ui/client/components/ScrollToTop.js b/packages/app-admin-ui/client/components/ScrollToTop.js index 6a00d8ef760..40da46dfd5c 100644 --- a/packages/app-admin-ui/client/components/ScrollToTop.js +++ b/packages/app-admin-ui/client/components/ScrollToTop.js @@ -1,15 +1,14 @@ -import { Component } from 'react'; -import { withRouter } from 'react-router-dom'; - -class ScrollToTop extends Component { - componentDidUpdate(prevProps) { - if (this.props.location !== prevProps.location) { - window.scrollTo(0, 0); - } - } - render() { - return this.props.children; - } -} - -export default withRouter(ScrollToTop); +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; + +const ScrollToTop = ({ children }) => { + const location = useLocation(); + + useEffect(() => { + window.scrollTo(0, 0); + }, [location]); + + return children; +}; + +export default ScrollToTop; diff --git a/packages/app-admin-ui/client/pages/Home/index.js b/packages/app-admin-ui/client/pages/Home/index.js index 549a07ea83a..f9ca698190b 100644 --- a/packages/app-admin-ui/client/pages/Home/index.js +++ b/packages/app-admin-ui/client/pages/Home/index.js @@ -1,5 +1,5 @@ -import React, { Component, Fragment } from 'react'; -import { withRouter, Link } from 'react-router-dom'; +import React, { Fragment } from 'react'; +import { Link } from 'react-router-dom'; import { useQuery } from '@apollo/react-hooks'; import { Container, Grid, Cell } from '@arch-ui/layout'; @@ -12,44 +12,40 @@ import { Box, HeaderInset } from './components'; import ContainerQuery from '../../components/ContainerQuery'; import { gqlCountQueries } from '../../classes/List'; -class HomePage extends Component { - render() { - const { lists, data, adminPath } = this.props; - - return ( -
- - - Dashboard - - - {({ width }) => { - let cellWidth = 3; - if (width < 1024) cellWidth = 4; - if (width < 768) cellWidth = 6; - if (width < 480) cellWidth = 12; - return ( - - {lists.map(list => { - const { key, path } = list; - const meta = data && data[list.gqlNames.listQueryMetaName]; - return ( - - - - - - ); - })} - - ); - }} - - -
- ); - } -} +const HomePage = ({ lists, data, adminPath }) => { + return ( +
+ + + Dashboard + + + {({ width }) => { + let cellWidth = 3; + if (width < 1024) cellWidth = 4; + if (width < 768) cellWidth = 6; + if (width < 480) cellWidth = 12; + return ( + + {lists.map(list => { + const { key, path } = list; + const meta = data && data[list.gqlNames.listQueryMetaName]; + return ( + + + + + + ); + })} + + ); + }} + + +
+ ); +}; const HomepageListProvider = ({ getListByKey, listKeys, ...props }) => { // TODO: A permission query to limit which lists are visible @@ -118,4 +114,4 @@ const HomepageListProvider = ({ getListByKey, listKeys, ...props }) => { ); }; -export default withRouter(HomepageListProvider); +export default HomepageListProvider; diff --git a/packages/app-admin-ui/client/pages/Item/index.js b/packages/app-admin-ui/client/pages/Item/index.js index 18832779cba..797d9e0e91d 100644 --- a/packages/app-admin-ui/client/pages/Item/index.js +++ b/packages/app-admin-ui/client/pages/Item/index.js @@ -1,9 +1,9 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; -import { Component, Fragment, Suspense, useMemo, useCallback } from 'react'; +import { Fragment, Suspense, useMemo, useCallback, useState, useRef, useEffect } from 'react'; import styled from '@emotion/styled'; import { useMutation, useQuery } from '@apollo/react-hooks'; -import { withRouter } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import { useToasts } from 'react-toast-notifications'; import memoizeOne from 'memoize-one'; @@ -60,306 +60,281 @@ const getRenderableFields = memoizeOne(list => .filter(({ maybeAccess, config }) => !!maybeAccess.update || !!config.isReadOnly) ); -const ItemDetails = withRouter( - class ItemDetails extends Component { - constructor(props) { - super(props); - // memoized function so we can call it multiple times _per component_ - this.getFieldsObject = memoizeOne(() => - arrayToObject( - // NOTE: We _exclude_ read only fields - getRenderableFields(props.list).filter(({ config }) => !config.isReadOnly), - 'path' - ) - ); - } +const ItemDetails = ({ + adminPath, + list, + item: initialData, + itemErrors, + onUpdate, + updateItem, + updateInProgress, +}) => { + const [item, setItem] = useState(initialData); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [validationErrors, setValidationErrors] = useState({}); + const [validationWarnings, setValidationWarnings] = useState({}); + + const itemHasChanged = useRef(false); + const itemSaveCheckCache = useRef({}); + + const history = useHistory(); + const { addToast } = useToasts(); - state = { - item: this.props.item, - itemHasChanged: false, - showCreateModal: false, - showDeleteModal: false, - validationErrors: {}, - validationWarnings: {}, - }; + const getFieldsObject = memoizeOne(() => + arrayToObject( + // NOTE: We _exclude_ read only fields + getRenderableFields(list).filter(({ config }) => !config.isReadOnly), + 'path' + ) + ); - componentDidMount() { - this.mounted = true; - document.addEventListener('keydown', this.onKeyDown, false); - } - componentWillUnmount() { - this.mounted = false; - document.removeEventListener('keydown', this.onKeyDown, false); - } - onKeyDown = event => { - if (event.defaultPrevented) return; - - switch (event.key) { - case 'Enter': - if (event.metaKey) { - return this.onSave(); - } - } - }; - onDelete = deletePromise => { - const { - adminPath, - history, - list, - item, - toastManager: { addToast }, - } = this.props; - - deletePromise - .then(() => { - if (this.mounted) { - this.setState({ showDeleteModal: false }); - } - history.push(`${adminPath}/${list.path}`); - - toastItemSuccess({ addToast }, item, 'Deleted successfully'); - }) - .catch(error => { - toastError({ addToast }, error); - }); + const mounted = useRef(); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; }; + }, []); - openDeleteModal = () => { - this.setState({ showDeleteModal: true }); - }; - closeDeleteModal = () => { - this.setState({ showDeleteModal: false }); + useEffect(() => { + document.addEventListener('keydown', onKeyDown, false); + return () => { + document.removeEventListener('keydown', onKeyDown, false); }; + }, []); + + const onKeyDown = event => { + if (event.defaultPrevented) return; + + switch (event.key) { + case 'Enter': + if (event.metaKey) { + return onSave(); + } + } + }; + + const onDelete = deletePromise => { + deletePromise + .then(() => { + if (mounted) { + setShowDeleteModal(false); + } - onReset = () => { - this.setState({ - item: this.props.item, - itemHasChanged: false, + history.push(`${adminPath}/${list.path}`); + toastItemSuccess({ addToast }, initialData, 'Deleted successfully'); + }) + .catch(error => { + toastError({ addToast }, error); }); - }; + }; - renderDeleteModal() { - const { showDeleteModal } = this.state; - const { item, list } = this.props; - - return ( - - ); + const openDeleteModal = () => { + setShowDeleteModal(true); + }; + + const closeDeleteModal = () => { + setShowDeleteModal(false); + }; + + const onReset = () => { + setItem(initialData); + itemHasChanged.current = false; + }; + + const onSave = async () => { + // There are errors, no need to proceed - the entire save can be aborted. + if (countArrays(validationErrors)) { + return; } - onSave = async () => { - const { item, validationErrors, validationWarnings } = this.state; + const fieldsObject = getFieldsObject(); - // There are errors, no need to proceed - the entire save can be aborted. - if (countArrays(validationErrors)) { - return; - } + const initialValues = getInitialValues(fieldsObject, initialData); + const currentValues = getCurrentValues(fieldsObject, item); + + // Don't try to update anything that hasn't changed. + // This is particularly important for access control where a field + // may be `read: true, update: false`, so will appear in the item + // details, but is not editable, and would cause an error if a value + // was sent as part of the update query. + const data = omitBy( + currentValues, + path => !fieldsObject[path].hasChanged(initialValues, currentValues) + ); - const { - onUpdate, - toastManager: { addToast }, - updateItem, - item: initialData, - } = this.props; - - const fieldsObject = this.getFieldsObject(); - - const initialValues = getInitialValues(fieldsObject, initialData); - const currentValues = getCurrentValues(fieldsObject, item); - - // Don't try to update anything that hasn't changed. - // This is particularly important for access control where a field - // may be `read: true, update: false`, so will appear in the item - // details, but is not editable, and would cause an error if a value - // was sent as part of the update query. - const data = omitBy( - currentValues, - path => !fieldsObject[path].hasChanged(initialValues, currentValues) - ); - - const fields = Object.values(omitBy(fieldsObject, path => !data.hasOwnProperty(path))); - - // On the first pass through, there wont be any warnings, so we go ahead - // and check. - // On the second pass through, there _may_ be warnings, and by this point - // we know there are no errors (see the `validationErrors` check above), - // if so, we let the user force the update through anyway and hence skip - // this check. - // Later, on every change, we reset the warnings, so we know if things - // have changed since last time we checked. - if (!countArrays(validationWarnings)) { - const { errors, warnings } = await validateFields(fields, item, data); - - const totalErrors = countArrays(errors); - const totalWarnings = countArrays(warnings); - - if (totalErrors + totalWarnings > 0) { - const messages = []; - if (totalErrors > 0) { - messages.push(`${totalErrors} error${totalErrors > 1 ? 's' : ''}`); - } - if (totalWarnings > 0) { - messages.push(`${totalWarnings} warning${totalWarnings > 1 ? 's' : ''}`); - } - - addToast(`Validation failed: ${messages.join(' and ')}.`, { - autoDismiss: true, - appearance: errors.length ? 'error' : 'warning', - }); - - this.setState(() => ({ - validationErrors: errors, - validationWarnings: warnings, - })); - - return; + const fields = Object.values(omitBy(fieldsObject, path => !data.hasOwnProperty(path))); + + // On the first pass through, there wont be any warnings, so we go ahead + // and check. + // On the second pass through, there _may_ be warnings, and by this point + // we know there are no errors (see the `validationErrors` check above), + // if so, we let the user force the update through anyway and hence skip + // this check. + // Later, on every change, we reset the warnings, so we know if things + // have changed since last time we checked. + if (!countArrays(validationWarnings)) { + const { errors, warnings } = await validateFields(fields, item, data); + + const totalErrors = countArrays(errors); + const totalWarnings = countArrays(warnings); + + if (totalErrors + totalWarnings > 0) { + const messages = []; + if (totalErrors > 0) { + messages.push(`${totalErrors} error${totalErrors > 1 ? 's' : ''}`); } + + if (totalWarnings > 0) { + messages.push(`${totalWarnings} warning${totalWarnings > 1 ? 's' : ''}`); + } + + addToast(`Validation failed: ${messages.join(' and ')}.`, { + autoDismiss: true, + appearance: errors.length ? 'error' : 'warning', + }); + + setValidationErrors(errors); + setValidationWarnings(warnings); + + return; } + } - updateItem({ variables: { id: item.id, data } }) - .then(() => { - const toastContent = ( -
- {item._label_ ? {item._label_} : null} -
Saved successfully
-
- ); - - addToast(toastContent, { - autoDismiss: true, - appearance: 'success', - }); - this.setState(state => { - const newState = { - validationErrors: {}, - validationWarnings: {}, - }; - // we only want to set itemHasChanged to false - // when it hasn't changed since we did the mutation - // otherwise a user could edit the data and - // accidentally close the page without a warning - if (state.item === item) { - newState.itemHasChanged = false; - } - return newState; - }); - }) - .then(onUpdate) - .then(savedItem => { - // No changes since we kicked off the item saving - if (!this.state.itemHasChanged) { - // Then reset the state to the current server value - // This ensures we are able to pass any extra information returned - // from the server that otherwise would be unknown to client state - this.setState({ - item: savedItem, - }); - } + // Cache the current item data at the time of saving. + itemSaveCheckCache.current = item; + + updateItem({ variables: { id: item.id, data } }) + .then(() => { + const toastContent = ( +
+ {item._label_ ? {item._label_} : null} +
Saved successfully
+
+ ); + + addToast(toastContent, { + autoDismiss: true, + appearance: 'success', }); - }; - /** - * Create item - */ - renderCreateModal = () => ; + setValidationErrors({}); + setValidationWarnings({}); - onCreate = ({ data }) => { - const { list, adminPath, history } = this.props; - const { id } = data[list.gqlNames.createMutationName]; - history.push(`${adminPath}/${list.path}/${id}`); - }; + // we only want to set itemHasChanged to false + // when it hasn't changed since we did the mutation + // otherwise a user could edit the data and + // accidentally close the page without a warning + if (item === itemSaveCheckCache.current) { + itemHasChanged.current = false; + } + }) + .then(onUpdate) + .then(savedItem => { + // No changes since we kicked off the item saving + if (!itemHasChanged.current) { + // Then reset the state to the current server value + // This ensures we are able to pass any extra information returned + // from the server that otherwise would be unknown to client state + setItem(savedItem); + + // Clear the cache + itemSaveCheckCache.current = {}; + } + }); + }; + + const onCreate = ({ data }) => { + const { id } = data[list.gqlNames.createMutationName]; + history.push(`${adminPath}/${list.path}/${id}`); + }; + + return ( + + {itemHasChanged.current && } + + +
+ + {getRenderableFields(list).map((field, i) => ( + + {() => { + const [Field] = field.adminMeta.readViews([field.views.Field]); + // eslint-disable-next-line react-hooks/rules-of-hooks + const onChange = useCallback( + value => { + setItem(oldItem => ({ + ...oldItem, + [field.path]: value, + })); + + setValidationErrors({}); + setValidationWarnings({}); + + itemHasChanged.current = true; + }, + [field] + ); + // eslint-disable-next-line react-hooks/rules-of-hooks + return useMemo( + () => ( + + ), + [ + i, + field, + list, + itemErrors[field.path], + item[field.path], + item.id, + validationErrors[field.path], + validationWarnings[field.path], + initialData[field.path], + onChange, + ] + ); + }} + + ))} + +