Skip to content

Commit

Permalink
Virtual fields (keystonejs#1978)
Browse files Browse the repository at this point in the history
* Add computed field

* Make field non-filterable or sortable

* Fix test

* Add isOrderable

* Change computed to virtual

* Add test

* Item never used

* Merge remote

* Remove filter methods from controller
  • Loading branch information
MadeByMike authored Nov 28, 2019
1 parent 640cbd9 commit 6a348b9
Show file tree
Hide file tree
Showing 13 changed files with 297 additions and 2 deletions.
7 changes: 7 additions & 0 deletions .changeset/wild-frogs-beam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@keystonejs/app-admin-ui': minor
'@keystonejs/fields': minor
'@keystonejs/keystone': minor
---

Added a new field type 'Computed'. This allows creation of fields that return data computed from other field values or outside Keystone.
4 changes: 3 additions & 1 deletion packages/app-admin-ui/client/pages/Item/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,9 @@ const ItemDetails = withRouter(
<AutocompleteCaptor />
{list.fields
.filter(({ isPrimaryKey }) => !isPrimaryKey)
.filter(({ maybeAccess }) => !!maybeAccess.update)
.filter(({ maybeAccess, config }) => {
return !!maybeAccess.update || config.isReadOnly;
})
.map((field, i) => (
<Render key={field.path}>
{() => {
Expand Down
1 change: 1 addition & 0 deletions packages/fields/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ export { default as Text } from './types/Text';
export { default as Unsplash } from './types/Unsplash';
export { default as Url } from './types/Url';
export { default as Uuid } from './types/Uuid';
export { default as Virtual } from './types/Virtual';
58 changes: 58 additions & 0 deletions packages/fields/src/types/Virtual/Implementation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Implementation } from '../../Implementation';
import { MongooseFieldAdapter } from '@keystonejs/adapter-mongoose';
import { KnexFieldAdapter } from '@keystonejs/adapter-knex';
import { parseFieldAccess } from '@keystonejs/access-control';

export class Virtual extends Implementation {
constructor() {
super(...arguments);
}

gqlOutputFields() {
return [`${this.path}: ${this.config.graphQLReturnType || `String`}`];
}
getGqlAuxTypes() {
return this.config.extendGraphQLTypes || [];
}
gqlOutputFieldResolvers() {
return { [`${this.path}`]: this.config.resolver };
}
gqlQueryInputFields() {
return [];
}
extendAdminMeta(meta) {
return {
...meta,
isOrderable: false,
graphQLSelection: this.config.graphQLReturnFragment || '',
isReadOnly: true,
};
}

parseFieldAccess(args) {
const parsedAccess = parseFieldAccess(args);
const fieldDefaults = { create: false, update: false, delete: false };
return Object.keys(parsedAccess).reduce((prev, schemaName) => {
prev[schemaName] = { ...fieldDefaults, read: parsedAccess[schemaName].read };
return prev;
}, {});
}
}

const CommonTextInterface = superclass =>
class extends superclass {
getQueryConditions() {
return {};
}
};

export class MongoVirtualInterface extends CommonTextInterface(MongooseFieldAdapter) {
addToMongooseSchema() {}
}

export class KnexVirtualInterface extends CommonTextInterface(KnexFieldAdapter) {
constructor() {
super(...arguments);
}
addToTableSchema() {}
}
71 changes: 71 additions & 0 deletions packages/fields/src/types/Virtual/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<!--[meta]
section: api
subSection: field-types
title: Virtual
[meta]-->

# Virtual

## Usage

If the resolver is a function that returns a string you don't need to define a return type.

```js
keystone.createList('Example', {
fields: {
firstName: { type: Text },
lastName: { type: Text },
name: {
type: Virtual,
resolver: item => (`${item.firstName} ${item.lastName}`)
};
},
},
});
```

If the return type is not a string define a `graphQLReturnType`.

```js
keystone.createList('Example', {
fields: {
fortyTwo: {
type: Virtual,
graphQLReturnType: `Int`,
resolver: () => 42,
},
},
});
```

For more complex types you can define a `graphQLReturnFragment` as well as `extendGraphQLTypes`. Resolver functions can be `async` so you can even fetch data from the file system or an external API:

```js
keystone.createList('Example', {
fields: {
movies: {
type: Virtual,
extendGraphQLTypes: [`type Movie { title: String, rating: Int }`],
graphQLReturnType: `[Movie]`,
graphQLReturnFragment: `{
title
rating
}`,
resolver: async () => {
const response = await fetch('http://example.com/api/movies/');
const data = await response.json();
return data.map(({ title, rating }) => ({ title, rating }));
},
},
},
});
```

### Config

| Option | Type | Default | Description |
| ----------------------- | ---------- | ---------- | ----------------------------------------------------------- |
| `resolver` | `Function` | (required) | |
| `graphQLReturnType` | `String` | 'String' | A GraphQL Type String |
| `graphQLReturnFragment` | `String` | '' | A GraphQL Fragment String -required for nested return types |
| `extendGraphQLTypes` | `Array` | [] | An array of custom GraphQL type definitions |
17 changes: 17 additions & 0 deletions packages/fields/src/types/Virtual/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Virtual, MongoVirtualInterface, KnexVirtualInterface } from './Implementation';
import { importView } from '@keystonejs/build-field-types';

export default {
type: 'Virtual',
implementation: Virtual,
views: {
Controller: importView('./views/Controller'),
Cell: importView('./views/Cell'),
Field: importView('./views/Field'),
Filter: importView('./views/Filter'),
},
adapters: {
mongoose: MongoVirtualInterface,
knex: KnexVirtualInterface,
},
};
25 changes: 25 additions & 0 deletions packages/fields/src/types/Virtual/prettyData.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from 'react';

// We don't know what type of data we're getting back from a Virtual-field
// but I'd like to present it as best as possible.
// ToDo: Better presentation for more types of data

const stringify = data => {
const omitTypename = (key, value) => (key === '__typename' ? undefined : value);
const dataWitoutTypename = JSON.parse(JSON.stringify(data), omitTypename);
return JSON.stringify(dataWitoutTypename, null, 2);
};
export default ({ data }) => {
if (!data) return null;

let prettyData = '';
if (typeof data === 'string') prettyData = data;
else if (typeof data === 'number') prettyData = data;
else if (typeof data === 'object') {
prettyData = <pre>{stringify(data)}</pre>;
} else {
prettyData = <pre>{stringify(data)}</pre>;
}

return prettyData;
};
6 changes: 6 additions & 0 deletions packages/fields/src/types/Virtual/views/Cell.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import React from 'react';
import PrettyData from '../prettyData';

export default ({ data }) => {
return <PrettyData data={data} />;
};
8 changes: 8 additions & 0 deletions packages/fields/src/types/Virtual/views/Controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import FieldController from '../../../Controller';
export default class VirtualController extends FieldController {
getQueryFragment = () => {
return `${this.path}${this.config.graphQLSelection}`;
};

getFilterTypes = () => [];
}
56 changes: 56 additions & 0 deletions packages/fields/src/types/Virtual/views/Field.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/** @jsx jsx */

import { jsx } from '@emotion/core';
import { Component } from 'react';

import { colors, gridSize } from '@arch-ui/theme';
import { ShieldIcon } from '@arch-ui/icons';

import { FieldContainer } from '@arch-ui/fields';
import PrettyData from '../prettyData';

export const FieldLabel = props => {
const accessError = (props.errors || []).find(
error => error instanceof Error && error.name === 'AccessDeniedError'
);
return (
<span
css={{
color: colors.N60,
fontSize: '0.9rem',
fontWeight: 500,
paddingBottom: gridSize,
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
}}
htmlFor={props.htmlFor}
>
{props.field.label}
{accessError ? (
<ShieldIcon title={accessError.message} css={{ color: colors.N20, marginRight: '1em' }} />
) : null}
</span>
);
};

export default class VirtualField extends Component {
onChange = event => {
this.props.onChange(event.target.value);
};

render() {
const { field, errors, value: serverValue } = this.props;
const value = serverValue || '';
const canRead = errors.every(
error => !(error instanceof Error && error.name === 'AccessDeniedError')
);

return (
<FieldContainer>
<FieldLabel field={field} errors={errors} />
<PrettyData data={canRead ? value : undefined} />
</FieldContainer>
);
}
}
25 changes: 25 additions & 0 deletions packages/fields/src/types/Virtual/views/Filter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// @flow

import React, { Component } from 'react';
import { Input } from '@arch-ui/input';
import type { FilterProps } from '../../../types';

type Props = FilterProps<string>;

export default class VirtualFilterView extends Component<Props> {
handleChange = ({ target: { value } }: Object) => {
this.props.onChange(value);
};

render() {
const { filter, field, innerRef, value } = this.props;

if (!filter) return null;

const placeholder = field.getFilterLabel(filter);

return (
<Input onChange={this.handleChange} ref={innerRef} placeholder={placeholder} value={value} />
);
}
}
2 changes: 1 addition & 1 deletion test-projects/basic/cypress/mock/upload.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Some important content 0.100393664270916
Some important content 0.42365754562902613
19 changes: 19 additions & 0 deletions test-projects/basic/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const {
Decimal,
OEmbed,
Unsplash,
Virtual,
} = require('@keystonejs/fields');
const { Content } = require('@keystonejs/field-content');
const { CloudinaryAdapter, LocalFileAdapter } = require('@keystonejs/file-adapters');
Expand Down Expand Up @@ -138,6 +139,24 @@ keystone.createList('Post', {
currency: { type: Text },
hero: { type: File, adapter: fileAdapter },
markdownValue: { type: Markdown },
fortyTwo: {
type: Virtual,
graphQLReturnType: `Int`,
resolver: () => 42,
},
virtual: {
type: Virtual,
extendGraphQLTypes: [`type Movie { title: String, rating: Int }`],
graphQLReturnType: `[Movie]`,
graphQLReturnFragment: `{
title
rating
}`,
resolver: async () => {
const data = [{ title: 'A movie', rating: 2 }, { title: 'Another movie', rating: 4 }];
return data.map(({ title, rating }) => ({ title, rating }));
},
},
value: {
type: Content,
blocks: [
Expand Down

0 comments on commit 6a348b9

Please sign in to comment.