Skip to content

Commit

Permalink
Virtual field updates (#6538)
Browse files Browse the repository at this point in the history
  • Loading branch information
emmatown authored Sep 14, 2021
1 parent ca0e789 commit 4e485a9
Show file tree
Hide file tree
Showing 8 changed files with 104 additions and 67 deletions.
5 changes: 5 additions & 0 deletions .changeset/shy-bananas-explode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystone-next/keystone': major
---

Renamed `graphQLReturnFragment` to `ui.query` in the virtual field options. The virtual field now checks if `ui.query` is required for the GraphQL output type, and throws an error if it is missing. If you don't want want the Admin UI to fetch the field, you can set `ui.itemView.fieldMode` and `ui.listView.fieldMode` to `'hidden'` instead of providing `ui.query`.
12 changes: 11 additions & 1 deletion docs/pages/docs/apis/fields.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,17 @@ See the [virtual fields guide](../guides/virtual-fields) for details on how to u
Options:

- `field` (required): The GraphQL field that defines the type, resolver and arguments.
- `graphQLReturnFragment` (default: `''` ): The sub-fields that should be fetched by the Admin UI when displaying this field.
- `ui.query` (default: `''` ):
Defines what the Admin UI should fetch from this field, it's interpolated into a query like this:
```graphql
query {
item(where: { id: "..." }) {
field${ui.query}
}
}
```
This is only needed when you your field returns a GraphQL type other than a scalar(String and etc.)
or an enum or you need to provide arguments to the field.

```typescript
import { config, list, graphql } from '@keystone-next/keystone';
Expand Down
14 changes: 7 additions & 7 deletions docs/pages/docs/guides/virtual-fields.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export default config({
}
},
}),
graphQLReturnFragment: '(length: 500)',
ui: { query: '(length: 500)' },
}),
},
}),
Expand Down Expand Up @@ -180,9 +180,9 @@ We can now perform the following query to get all the excerpts without over-fetc
}
```
As well as passing in the `field` definition, we have also passed in `graphQLReturnFragment: '(length: 500)'`.
As well as passing in the `field` definition, we have also passed in `ui: { query: '(length: 500)' }`.
This is the value used when displaying the field in the Admin UI, where we want to have a different length the default of `200`.
Had we not specified `defaultValue` in our field, the `graphQLReturnFragment` argument would be **required**, as the Admin UI would not be able to query this field without it.
Had we not specified `defaultValue` in our field, the `ui.query` argument would be **required**, as the Admin UI would not be able to query this field without it.
## GraphQL objects
Expand Down Expand Up @@ -220,7 +220,7 @@ export default config({
};
},
}),
graphQLReturnFragment: '{ words sentences paragraphs }',
ui: { query: '{ words sentences paragraphs }' },
}),
},
}),
Expand All @@ -231,7 +231,7 @@ export default config({
This example is written in TypeScript, so we need to specify the type of the root value expected by the `PostCounts` type.
This type must correspond to the return type of the `resolve` function.
Because our `virtual` field has an object type, we also need to provide a value for the option `graphQLReturnFragment`.
Because our `virtual` field has an object type, we also need to provide a value for the option `ui.query`.
This fragment tells the Keystone Admin UI which values to show in the item page for this field.
#### Self-referencing objects
Expand Down Expand Up @@ -295,14 +295,14 @@ export const lists = {
}
},
}),
graphQLReturnFragment: '{ title publishDate }',
ui: { query: '{ title publishDate }' },
}),
},
}),
};
```
Once again we need to specify `graphQLReturnFragment` on this virtual field to specify which fields of the `Post` to display in the Admin UI.
Once again we need to specify `ui.query` on this virtual field to specify which fields of the `Post` to display in the Admin UI.
## Working with virtual fields
Expand Down
4 changes: 2 additions & 2 deletions examples/virtual-field/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ counts: virtual({
return { content: item.content || '' };
},
}),
graphQLReturnFragment: '{ words sentences paragraphs }',
ui: { query: '{ words sentences paragraphs }' },
}),
```

Expand Down Expand Up @@ -114,6 +114,6 @@ relatedPosts: virtual({
});
},
}),
graphQLReturnFragment: '{ title }',
ui: { query: '{ title }' },
}),
```
4 changes: 2 additions & 2 deletions examples/virtual-field/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export const lists = {
}
},
}),
graphQLReturnFragment: '(length: 10)',
ui: { query: '(length: 10)' },
}),
publishDate: timestamp(),
author: relationship({ ref: 'Author.posts', many: false }),
Expand Down Expand Up @@ -113,7 +113,7 @@ export const lists = {
}
},
}),
graphQLReturnFragment: '{ title publishDate }',
ui: { query: '{ title publishDate }' },
}),
},
}),
Expand Down
49 changes: 44 additions & 5 deletions packages/keystone/src/fields/types/virtual/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getNamedType, isLeafType } from 'graphql';
import {
BaseGeneratedListTypes,
graphql,
Expand All @@ -6,29 +7,67 @@ import {
FieldTypeFunc,
fieldType,
ListInfo,
getGqlNames,
} from '../../../types';
import { resolveView } from '../../resolve-view';

type VirtualFieldGraphQLField = graphql.Field<ItemRootValue, any, any, string>;
type VirtualFieldGraphQLField = graphql.Field<ItemRootValue, any, graphql.OutputType, string>;

export type VirtualFieldConfig<TGeneratedListTypes extends BaseGeneratedListTypes> =
CommonFieldConfig<TGeneratedListTypes> & {
field:
| VirtualFieldGraphQLField
| ((lists: Record<string, ListInfo>) => VirtualFieldGraphQLField);
unreferencedConcreteInterfaceImplementations?: graphql.ObjectType<any>[];
graphQLReturnFragment?: string;
ui?: {
/**
* Defines what the Admin UI should fetch from this field, it's interpolated into a query like this:
* ```graphql
* query {
* item(where: { id: "..." }) {
* field${ui.query}
* }
* }
* ```
*
* This is only needed when you your field returns a GraphQL type other than a scalar(String and etc.)
* or an enum or you need to provide arguments to the field.
*/
query?: string;
};
};

export const virtual =
<TGeneratedListTypes extends BaseGeneratedListTypes>({
graphQLReturnFragment = '',
field,
...config
}: VirtualFieldConfig<TGeneratedListTypes>): FieldTypeFunc =>
meta => {
const usableField = typeof field === 'function' ? field(meta.lists) : field;

const namedType = getNamedType(usableField.type.graphQLType);
const hasRequiredArgs =
usableField.args &&
Object.values(
usableField.args as Record<string, graphql.Arg<graphql.InputType, boolean>>
).some(x => x.type.kind === 'non-null' && x.defaultValue === undefined);
if (
(!isLeafType(namedType) || hasRequiredArgs) &&
!config.ui?.query &&
(config.ui?.itemView?.fieldMode !== 'hidden' || config.ui?.listView?.fieldMode !== 'hidden')
) {
throw new Error(
`The virtual field at ${meta.listKey}.${meta.fieldKey} requires a selection for the Admin UI but ui.query is unspecified and ui.listView.fieldMode and ui.itemView.fieldMode are not both set to 'hidden'.\n` +
`Either set ui.query with what the Admin UI should fetch or hide the field from the Admin UI by setting ui.listView.fieldMode and ui.itemView.fieldMode to 'hidden'.\n` +
`When setting ui.query, it is interpolated into a GraphQL query like this:\n` +
`query {\n` +
` ${
getGqlNames({ listKey: meta.listKey, pluralGraphQLName: '' }).itemQueryName
}(where: { id: "..." }) {\n` +
` ${meta.fieldKey}\${ui.query}\n` +
` }\n` +
`}`
);
}
return fieldType({
kind: 'none',
})({
Expand All @@ -40,6 +79,6 @@ export const virtual =
},
}),
views: resolveView('virtual/views'),
getAdminMeta: () => ({ graphQLReturnFragment }),
getAdminMeta: () => ({ query: config.ui?.query || '' }),
});
};
4 changes: 2 additions & 2 deletions packages/keystone/src/fields/types/virtual/views/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,12 @@ export const CardValue: CardValueComponent = ({ item, field }) => {
const createViewValue = Symbol('create view virtual field value');

export const controller = (
config: FieldControllerConfig<{ graphQLReturnFragment: string }>
config: FieldControllerConfig<{ query: string }>
): FieldController<any> => {
return {
path: config.path,
label: config.label,
graphqlSelection: `${config.path}${config.fieldMeta.graphQLReturnFragment}`,
graphqlSelection: `${config.path}${config.fieldMeta.query}`,
defaultValue: createViewValue,
deserialize: data => {
return data[config.path];
Expand Down
79 changes: 31 additions & 48 deletions tests/api-tests/fields/types/Virtual.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { integer, relationship, text, virtual } from '@keystone-next/keystone/fields';
import { BaseFields, list } from '@keystone-next/keystone';
import { setupTestRunner } from '@keystone-next/keystone/testing';
import { setupTestEnv, setupTestRunner } from '@keystone-next/keystone/testing';
import { graphql } from '@keystone-next/keystone/types';
import { apiTestConfig } from '../../utils';

Expand Down Expand Up @@ -64,29 +64,6 @@ describe('Virtual field type', () => {
})
);

test(
'args - use defaults',
makeRunner({
foo: virtual({
field: graphql.field({
type: graphql.Int,
args: {
x: graphql.arg({ type: graphql.Int }),
y: graphql.arg({ type: graphql.Int }),
},
resolve: (item, { x = 5, y = 6 }) => x! * y!,
}),
}),
})(async ({ context }) => {
const data = await context.lists.Post.createOne({
data: { value: 1 },
query: 'value foo',
});
expect(data.value).toEqual(1);
expect(data.foo).toEqual(30);
})
);

test(
'referencing other list type',
setupTestRunner({
Expand Down Expand Up @@ -117,6 +94,7 @@ describe('Virtual field type', () => {
organisationAuthor: relationship({ ref: 'Organisation.authoredPosts' }),
personAuthor: relationship({ ref: 'Person.authoredPosts' }),
author: virtual({
ui: { listView: { fieldMode: 'hidden' }, itemView: { fieldMode: 'hidden' } },
field: lists =>
graphql.field({
type: graphql.union({
Expand Down Expand Up @@ -185,32 +163,37 @@ describe('Virtual field type', () => {
})
);

test(
'graphQLReturnFragment',
makeRunner({
foo: virtual({
field: graphql.field({
type: graphql.list(
graphql.object<{ title: string; rating: number }>()({
name: 'Movie',
test("errors when a non leaf type is used but the field isn't hidden in the Admin UI and ui.query isn't provided", async () => {
await expect(
setupTestEnv({
config: apiTestConfig({
lists: {
Post: list({
fields: {
title: graphql.field({ type: graphql.String }),
rating: graphql.field({ type: graphql.Int }),
virtual: virtual({
field: graphql.field({
type: graphql.object<any>()({
name: 'Something',
fields: {
something: graphql.field({ type: graphql.String }),
},
}),
}),
}),
},
})
),
resolve() {
return [{ title: 'CATS!', rating: 100 }];
}),
},
}),
}),
})(async ({ context }) => {
const data = await context.lists.Post.createOne({
data: { value: 1 },
query: 'value foo { title rating }',
});
expect(data.value).toEqual(1);
expect(data.foo).toEqual([{ title: 'CATS!', rating: 100 }]);
})
);
})
).rejects.toMatchInlineSnapshot(`
[Error: The virtual field at Post.virtual requires a selection for the Admin UI but ui.query is unspecified and ui.listView.fieldMode and ui.itemView.fieldMode are not both set to 'hidden'.
Either set ui.query with what the Admin UI should fetch or hide the field from the Admin UI by setting ui.listView.fieldMode and ui.itemView.fieldMode to 'hidden'.
When setting ui.query, it is interpolated into a GraphQL query like this:
query {
post(where: { id: "..." }) {
virtual\${ui.query}
}
}]
`);
});
});

0 comments on commit 4e485a9

Please sign in to comment.