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 && }
+
+
+
+
+
+
+
+
+
+ );
+};
- render() {
- const { adminPath, list, updateInProgress, itemErrors, item: savedData } = this.props;
- const { item, itemHasChanged, validationErrors, validationWarnings } = this.state;
-
- return (
-
- {itemHasChanged && }
-
-
-
-
-
-
- {this.renderCreateModal()}
- {this.renderDeleteModal()}
-
- );
- }
- }
-);
const ItemNotFound = ({ adminPath, errorMessage, list }) => (