Skip to content

Commit

Permalink
Cherry-pick dynamic field loading from #745
Browse files Browse the repository at this point in the history
  • Loading branch information
jesstelford committed Mar 13, 2019
1 parent 3030211 commit 5a727f8
Show file tree
Hide file tree
Showing 42 changed files with 575 additions and 463 deletions.
65 changes: 54 additions & 11 deletions packages/admin-ui/client/classes/List.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import gql from 'graphql-tag';

import FieldTypes from '../FIELD_TYPES';
import { viewMeta, loadView } from '../FIELD_TYPES';
import { arrayToObject } from '@voussoir/utils';

export const gqlCountQueries = lists => gql`{
Expand All @@ -10,14 +10,11 @@ export const gqlCountQueries = lists => gql`{
export default class List {
constructor(config, adminMeta) {
this.config = config;
this.adminMeta = adminMeta;

// TODO: undo this
Object.assign(this, config);

this.fields = config.fields.map(fieldConfig => {
const { Controller } = FieldTypes[config.key][fieldConfig.path];
return new Controller(fieldConfig, this, adminMeta);
});
delete this.fields;

this.createMutation = gql`
mutation create($data: ${this.gqlNames.createInputName}!) {
Expand Down Expand Up @@ -68,11 +65,52 @@ export default class List {
`;
}

/**
* @return Promise<undefined> resolves when all the field modules are loaded
*/
initFields() {
// Ensure we only trigger loading once, and any new requests to load are
// resolved when the first request is resolved (or rejected);
if (!this._loadingPromise) {
// NOTE: We purposely don't `await` here as we want the `_loadingPromise` to
// be resolved _after_ the `.then()` because it contains the
// `this._fields` assignment.
this._loadingPromise = Promise.all(
this.config.fields.map(fieldConfig => loadView(viewMeta[this.config.key][fieldConfig.path]))
).then(fieldModules => {
this._fields = fieldModules.map(({ Controller, ...views }, index) => {
const controller = new Controller(this.config.fields[index], this, this.adminMeta);
// Mix the `.views` fields into the controller for use throughout the
// UI
controller.views = views;
return controller;
});

// Flag as loaded
this._fieldsLoaded = true;
});
}

return this._loadingPromise;
}

getFields() {
if (!this.loaded()) {
throw new Error(
`Attempted to read fields from list ${this.config.key} before they were laoded`
);
}
return this._fields;
}

loaded() {
return !!this._fieldsLoaded;
}

buildQuery(queryName, queryArgs = '', fields = []) {
return `
${queryName}${queryArgs} {
id
_label_
${fields.map(field => field.getQueryFragment()).join(' ')}
}`;
}
Expand All @@ -93,16 +131,19 @@ export default class List {

getItemQuery(itemId) {
return gql`{
${this.buildQuery(this.gqlNames.itemQueryName, `(where: { id: "${itemId}" })`, this.fields)}
${this.buildQuery(
this.gqlNames.itemQueryName,
`(where: { id: "${itemId}" })`,
this.getFields()
)}
}`;
}

getQuery({ fields, filters, search, orderBy, skip, first }) {
const queryArgs = List.getQueryArgs({ first, filters, search, skip, orderBy });
const metaQueryArgs = List.getQueryArgs({ filters, search });
const safeFields = fields.filter(field => field.path !== '_label_');
return gql`{
${this.buildQuery(this.gqlNames.listQueryName, queryArgs, safeFields)}
${this.buildQuery(this.gqlNames.listQueryName, queryArgs, fields)}
${this.countQuery(metaQueryArgs)}
}`;
}
Expand All @@ -119,7 +160,9 @@ export default class List {
}

getInitialItemData() {
return arrayToObject(this.fields, 'path', field => field.getInitialData());
return arrayToObject(this.getFields().filter(field => field.isEditable()), 'path', field =>
field.getInitialData()
);
}
formatCount(items) {
const count = Array.isArray(items) ? items.length : items;
Expand Down
144 changes: 91 additions & 53 deletions packages/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 } from 'react';
import { Component, Fragment, useCallback, useMemo, useEffect, useState } from 'react';
import { Mutation } from 'react-apollo';

import { Button } from '@arch-ui/button';
Expand All @@ -9,17 +9,81 @@ import { resolveAllKeys, arrayToObject } from '@voussoir/utils';
import { gridSize } from '@arch-ui/theme';
import { AutocompleteCaptor } from '@arch-ui/input';

import FieldTypes from '../FIELD_TYPES';
import PageWithListFields from '../pages/PageWithListFields';
import PageLoading from './PageLoading';

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

class CreateItemModal extends Component {
constructor(props) {
super(props);
const { list } = props;
const item = list.getInitialItemData();
this.state = { item };
const CreateItemBody = ({ list, onChange, item }) => {
const [defaultsLoaded, setDefaultsLoaded] = useState(false);
useEffect(
() => {
if (!defaultsLoaded) {
const initialData = list.getInitialItemData();
setDefaultsLoaded(true);
onChange(initialData);
}
},
[list, defaultsLoaded, setDefaultsLoaded]
);

if (!defaultsLoaded) {
// Don't render anything on the first pass, we'll have actual data next time
// through once the useEffect() hook has run
return <PageLoading />;
}

return (
<Fragment>
<AutocompleteCaptor />
{list
.getFields()
.filter(field => field.isEditable())
.map((field, i) => {
const { Field } = field.views;
if (!Field) {
return null;
}
// TODO: Replace this with an access on the `list._fields[]` object?
// It should have all the views, etc, loaded by now
return (
<Render key={field.path}>
{() => {
// eslint-disable-next-line react-hooks/rules-of-hooks
let onFieldChange = useCallback(
value => {
onChange({
...item,
[field.path]: value,
});
},
[onChange, item]
);
// eslint-disable-next-line react-hooks/rules-of-hooks
return useMemo(
() => (
<Field
autoFocus={!i}
value={item[field.path]}
field={field}
/* TODO: Permission query results */
// error={}
onChange={onFieldChange}
renderContext="dialog"
/>
),
[i, item[field.path], field, item, onFieldChange]
);
}}
</Render>
);
})}
</Fragment>
);
};

class CreateItemModal extends Component {
state = { item: {} };
onCreate = event => {
// prevent form submission
event.preventDefault();
Expand All @@ -32,24 +96,24 @@ class CreateItemModal extends Component {
// propagate through portals as if they aren't there
event.stopPropagation();

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

resolveAllKeys(arrayToObject(fields, 'path', field => field.getValue(item)))
resolveAllKeys(
arrayToObject(list.getFields().filter(field => field.isEditable()), 'path', field =>
field.getValue(item)
)
)
.then(data => createItem({ variables: { data } }))
.then(data => {
this.props.onCreate(data);
this.setState({ item: this.props.list.getInitialItemData() });
});
};
onClose = () => {
const { isLoading } = this.props;
if (isLoading) return;
const { isSaving } = this.props;
if (isSaving) return;
this.props.onClose();
};
onKeyDown = event => {
Expand All @@ -61,8 +125,7 @@ class CreateItemModal extends Component {
};
formComponent = props => <form autoComplete="off" onSubmit={this.onCreate} {...props} />;
render() {
const { isLoading, isOpen, list } = this.props;
const { item } = this.state;
const { isSaving, isOpen, list } = this.props;
return (
<Drawer
closeOnBlanketClick
Expand All @@ -75,7 +138,7 @@ class CreateItemModal extends Component {
footer={
<Fragment>
<Button appearance="create" type="submit">
{isLoading ? 'Loading...' : 'Create'}
{isSaving ? 'Loading...' : 'Create'}
</Button>
<Button appearance="warning" variant="subtle" onClick={this.onClose}>
Cancel
Expand All @@ -89,38 +152,13 @@ class CreateItemModal extends Component {
marginTop: gridSize,
}}
>
<AutocompleteCaptor />
{list.fields.map((field, i) => {
const { Field } = FieldTypes[list.key][field.path];
return (
<Render key={field.path}>
{() => {
let onChange = useCallback(value => {
this.setState(({ item }) => ({
item: {
...item,
[field.path]: value,
},
}));
}, []);
return useMemo(
() => (
<Field
autoFocus={!i}
value={item[field.path]}
field={field}
/* TODO: Permission query results */
// error={}
onChange={onChange}
renderContext="dialog"
/>
),
[i, item[field.path], field, onChange]
);
}}
</Render>
);
})}
<PageWithListFields list={this.props.list}>
<CreateItemBody
list={this.props.list}
item={this.state.item}
onChange={item => this.setState({ item })}
/>
</PageWithListFields>
</div>
</Drawer>
);
Expand All @@ -133,7 +171,7 @@ export default class CreateItemModalWithMutation extends Component {
return (
<Mutation mutation={list.createMutation}>
{(createItem, { loading }) => (
<CreateItemModal createItem={createItem} isLoading={loading} {...this.props} />
<CreateItemModal createItem={createItem} isSaving={loading} {...this.props} />
)}
</Mutation>
);
Expand Down
19 changes: 3 additions & 16 deletions packages/admin-ui/client/components/ListTable.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,6 @@ import { A11yText } from '@arch-ui/typography';
import DeleteItemModal from './DeleteItemModal';
import { copyToClipboard } from '../util';

// This import is loaded by the @voussoir/field-views-loader loader.
// It imports all the views required for a keystone app by looking at the adminMetaData
import FieldTypes from '../FIELD_TYPES';

// Styled Components
const Table = styled('table')({
borderCollapse: 'collapse',
Expand Down Expand Up @@ -225,19 +221,9 @@ class ListRow extends Component {
);
}

if (path === '_label_') {
return (
<BodyCellTruncated isSelected={isSelected} key={path}>
<Link passHref href={link({ path: list.path, id: item.id })}>
<ItemLink href={link({ path: list.path, id: item.id })}>{item._label_}</ItemLink>
</Link>
</BodyCellTruncated>
);
}

let content;

const Cell = FieldTypes[list.key][path].Cell;
const { Cell } = field.views;

if (Cell) {
// TODO
Expand All @@ -254,6 +240,7 @@ class ListRow extends Component {
isSelected={isSelected}
list={list}
data={item[path]}
itemId={item.id}
field={field}
Link={LinkComponent}
/>
Expand Down Expand Up @@ -337,7 +324,7 @@ export default class ListTable extends Component {
<SortLink
data-field={field.path}
key={field.path}
sortable={field.path !== '_label_'}
sortable={field.isSortable()}
field={field}
handleSortChange={handleSortChange}
active={sortBy.field.path === field.path}
Expand Down
Loading

0 comments on commit 5a727f8

Please sign in to comment.