Skip to content

Commit

Permalink
Convert parts of the Admin UI createItemModal to hooks (#2279)
Browse files Browse the repository at this point in the history
* Convert the Admin UI createItemModal to hooks
  • Loading branch information
MadeByMike authored Jan 29, 2020
1 parent 9655621 commit 6bc87d4
Show file tree
Hide file tree
Showing 18 changed files with 867 additions and 397 deletions.
8 changes: 8 additions & 0 deletions .changeset/five-apples-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@keystonejs/app-admin-ui': minor
'@keystonejs/fields': minor
---

Added React hooks to the AdminUI.

This PR changes the way the <CreateItem/> component works internally. It also paves the way for future AdminUI extensions by exporting front-end components and utilities from `@keystonejs/app-admin-ui/components`. Initially this includes a <ListProvider/> component that is currently being consumed by the relationship field.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@
},
"preconstruct": {
"packages": [
"packages/app-admin-ui",
"packages/arch/packages/*",
"packages/apollo-helpers",
"packages/utils"
Expand Down
294 changes: 148 additions & 146 deletions packages/app-admin-ui/client/components/CreateItemModal.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** @jsx jsx */
import { jsx } from '@emotion/core';
import { Component, Fragment, useCallback, useMemo, Suspense } from 'react';
import { Fragment, useCallback, useMemo, Suspense, useState, useRef, useEffect } from 'react';
import { useMutation } from '@apollo/react-hooks';
import { useToasts } from 'react-toast-notifications';

Expand All @@ -11,21 +11,34 @@ import { gridSize } from '@arch-ui/theme';
import { AutocompleteCaptor } from '@arch-ui/input';

import PageLoading from './PageLoading';
import { useList } from '../providers/List';
import { validateFields, handleCreateUpdateMutationError } from '../util';

let Render = ({ children }) => children();

class CreateItemModal extends Component {
constructor(props) {
super(props);
const { list, prefillData = {} } = props;
const item = list.getInitialItemData({ prefill: prefillData });
const validationErrors = {};
const validationWarnings = {};

this.state = { item, validationErrors, validationWarnings };
}
onCreate = async event => {
function useEventCallback(callback) {
let callbackRef = useRef(callback);
let cb = useCallback((...args) => {
return callbackRef.current(...args);
}, []);
useEffect(() => {
callbackRef.current = callback;
});
return cb;
}

function CreateItemModal({ prefillData = {}, isLoading, createItem, onClose, onCreate }) {
const { list, closeCreateItemModal, isCreateItemModalOpen } = useList();
const [item, setItem] = useState(list.getInitialItemData({ prefill: prefillData }));
const [validationErrors, setValidationErrors] = useState({});
const [validationWarnings, setValidationWarnings] = useState({});

const { fields } = list;
const creatable = fields
.filter(({ isPrimaryKey }) => !isPrimaryKey)
.filter(({ maybeAccess }) => !!maybeAccess.create);

const _onCreate = useEventCallback(async event => {
// prevent form submission
event.preventDefault();
// we have to stop propagation so that if this modal is inside another form
Expand All @@ -36,163 +49,152 @@ class CreateItemModal extends Component {
// it's important to remember that react events
// propagate through portals as if they aren't there
event.stopPropagation();

const {
list: { fields },
createItem,
isLoading,
} = this.props;
if (isLoading) return;
const { item, validationErrors, validationWarnings } = this.state;

if (countArrays(validationErrors)) {
return;
}

const creatable = fields
.filter(({ isPrimaryKey }) => !isPrimaryKey)
.filter(({ maybeAccess }) => !!maybeAccess.create);

const data = arrayToObject(creatable, 'path', field => field.serialize(item));

if (isLoading) return;
if (countArrays(validationErrors)) return;
if (!countArrays(validationWarnings)) {
const { errors, warnings } = await validateFields(creatable, item, data);

if (countArrays(errors) + countArrays(warnings) > 0) {
this.setState(() => ({
validationErrors: errors,
validationWarnings: warnings,
}));

setValidationErrors(errors);
setValidationWarnings(warnings);
return;
}
}

createItem({
variables: { data },
}).then(data => {
this.props.onCreate(data);
this.setState({ item: this.props.list.getInitialItemData({}) });
createItem({ variables: { data } }).then(data => {
closeCreateItemModal();
setItem(list.getInitialItemData({}));
if (onCreate) {
onCreate(data);
}
});
};
onClose = () => {
const { isLoading } = this.props;
});

const _onClose = () => {
if (isLoading) return;
this.props.onClose();
closeCreateItemModal();
setItem(list.getInitialItemData({}));
const data = arrayToObject(creatable, 'path', field => field.serialize(item));
if (onClose) {
onClose(data);
}
};
onKeyDown = event => {

const _onKeyDown = event => {
if (event.defaultPrevented) return;
switch (event.key) {
case 'Escape':
return this.onClose();
return _onClose();
}
};
formComponent = props => <form autoComplete="off" onSubmit={this.onCreate} {...props} />;
render() {
const { isLoading, isOpen, list } = this.props;
const { item, validationErrors, validationWarnings } = this.state;

const hasWarnings = countArrays(validationWarnings);
const hasErrors = countArrays(validationErrors);

const cypressId = 'create-item-modal-submit-button';

return (
<Drawer
closeOnBlanketClick
component={this.formComponent}
isOpen={isOpen}
onClose={this.onClose}
heading={`Create ${list.singular}`}
onKeyDown={this.onKeyDown}
slideInFrom="right"
footer={
<Fragment>
<LoadingButton
appearance={hasWarnings && !hasErrors ? 'warning' : 'primary'}
id={cypressId}
isDisabled={hasErrors}
isLoading={isLoading}
onClick={this.onUpdate}
type="submit"
>
{hasWarnings && !hasErrors ? 'Ignore Warnings and Create' : 'Create'}
</LoadingButton>
<Button appearance="warning" variant="subtle" onClick={this.onClose}>
Cancel
</Button>
</Fragment>
}

const formComponent = useCallback(
props => <form autoComplete="off" onSubmit={_onCreate} {...props} />,
[_onCreate]
);

const hasWarnings = countArrays(validationWarnings);
const hasErrors = countArrays(validationErrors);
const cypressId = 'create-item-modal-submit-button';
return (
<Drawer
closeOnBlanketClick
component={formComponent}
isOpen={isCreateItemModalOpen}
onClose={_onClose}
heading={`Create ${list.singular}`}
onKeyDown={_onKeyDown}
slideInFrom="right"
footer={
<Fragment>
<LoadingButton
appearance={hasWarnings && !hasErrors ? 'warning' : 'primary'}
id={cypressId}
isDisabled={hasErrors}
isLoading={isLoading}
type="submit"
>
{hasWarnings && !hasErrors ? 'Ignore Warnings and Create' : 'Create'}
</LoadingButton>
<Button appearance="warning" variant="subtle" onClick={_onClose}>
Cancel
</Button>
</Fragment>
}
>
<div
css={{
marginBottom: gridSize,
marginTop: gridSize,
}}
>
<div
css={{
marginBottom: gridSize,
marginTop: gridSize,
}}
>
<Suspense fallback={<PageLoading />}>
<AutocompleteCaptor />
<Render>
{() => {
const creatable = list.fields
.filter(({ isPrimaryKey }) => !isPrimaryKey)
.filter(({ maybeAccess }) => !!maybeAccess.create);

captureSuspensePromises(creatable.map(field => () => field.initFieldView()));
return creatable.map((field, i) => (
<Render key={field.path}>
{() => {
let [Field] = field.adminMeta.readViews([field.views.Field]);
let onChange = useCallback(value => {
this.setState(({ item }) => ({
item: {
...item,
[field.path]: value,
},
validationErrors: {},
validationWarnings: {},
}));
}, []);
return useMemo(
() => (
<Field
autoFocus={!i}
value={item[field.path]}
savedValue={item[field.path]}
field={field}
/* TODO: Permission query results */
errors={validationErrors[field.path] || []}
warnings={validationWarnings[field.path] || []}
CreateItemModal={CreateItemModalWithMutation}
onChange={onChange}
renderContext="dialog"
/>
),
[
i,
item[field.path],
field,
onChange,
validationErrors[field.path],
validationWarnings[field.path],
]
);
}}
</Render>
));
}}
</Render>
</Suspense>
</div>
</Drawer>
);
}
<Suspense fallback={<PageLoading />}>
<AutocompleteCaptor />
<Render>
{() => {
const creatable = list.fields
.filter(({ isPrimaryKey }) => !isPrimaryKey)
.filter(({ maybeAccess }) => !!maybeAccess.create);

captureSuspensePromises(creatable.map(field => () => field.initFieldView()));

return creatable.map((field, i) => (
<Render key={field.path}>
{() => {
let [Field] = field.adminMeta.readViews([field.views.Field]);
// eslint-disable-next-line react-hooks/rules-of-hooks
let onChange = useCallback(value => {
setItem(item => ({
...item,
[field.path]: value,
}));
setValidationErrors({});
setValidationWarnings({});
}, []);
// eslint-disable-next-line react-hooks/rules-of-hooks
return useMemo(
() => (
<Field
autoFocus={!i}
value={item[field.path]}
savedValue={item[field.path]}
field={field}
/* TODO: Permission query results */
errors={validationErrors[field.path] || []}
warnings={validationWarnings[field.path] || []}
CreateItemModal={CreateItemModalWithMutation}
onChange={onChange}
renderContext="dialog"
/>
),
[
i,
item[field.path],
field,
onChange,
validationErrors[field.path],
validationWarnings[field.path],
]
);
}}
</Render>
));
}}
</Render>
</Suspense>
</div>
</Drawer>
);
}

export default function CreateItemModalWithMutation(props) {
const { list } = props;
const {
list: { createMutation },
} = useList();
const { addToast } = useToasts();
const [createItem, { loading }] = useMutation(list.createMutation, {
const [createItem, { loading }] = useMutation(createMutation, {
errorPolicy: 'all',
onError: error => handleCreateUpdateMutationError({ error, addToast }),
});
Expand Down
Loading

0 comments on commit 6bc87d4

Please sign in to comment.