Skip to content

Commit

Permalink
Improve inline relationship docs and examples (#6038)
Browse files Browse the repository at this point in the history
  • Loading branch information
timleslie committed Jul 2, 2021
1 parent 14aede7 commit 55d5057
Show file tree
Hide file tree
Showing 8 changed files with 160 additions and 16 deletions.
5 changes: 5 additions & 0 deletions .changeset/shy-ants-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystone-next/example-document-field': minor
---

Added inline relationship example.
119 changes: 106 additions & 13 deletions docs/pages/docs/guides/document-fields.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -153,12 +153,14 @@ const renderers: DocumentRendererProps['renderers'] = {

## Inline Relationships

The document field can also have inline relationships for things like mentions.
These are not stored like relationship fields on lists, they are stored as ids in the document structure.
The document field can also have inline relationships which reference other items in your system.
For example, you might want to include twitter-style mentions of other users in a blog application.
We can achieve this with the `relationships` option to the document field.

```tsx
import { config, createSchema, list } from '@keystone-next/keystone/schema';
import { document } from '@keystone-next/fields-document';
import { text } from '@keystone-next/fields';

export default config({
lists: createSchema({
Expand All @@ -168,29 +170,62 @@ export default config({
relationships: {
mention: {
kind: 'inline',
listKey: 'User',
listKey: 'Author',
label: 'Mention',
selection: 'name',
selection: 'id name',
},
},
/* ... */
}),
/* ... */
},
}),
User: list({
Author: list({
fields: {
name: text(),
}
}),
/* ... */
}),
/* ... */
});
```

By default, only the ids for the relationships are returned when querying for the document.
To include the `label` and extra data from the `selection` specified in the `relationship`, pass `hydrateRelationships: true` to the GraphQL query.
We use the `kind: 'inline'` option to indicate that we want to have an inline relationship.
The other option, `kind: 'prop'`, is used with custom component blocks, which are discussed [below](#component-blocks).

When you add an inline relationship to your document field, it becomes accessible in the Admin UI behind the `+` icon.
This menu uses the `label` specified in the relationship config.

![The Admin UI showing the relationship label behind the plus menu icon](/assets/guides/document-fields/inline-relationship-label.png)

You can also access the relationship directly using the `/` command and then starting to type the label.

![The Admin UI showing use of slash (/) to select the inline relationship](/assets/guides/document-fields/inline-relationship-slash.png)

You can then select an item from the list specified by `listKey` from the inline select component in the document editor.

![The Admin UI showing the select field used to choose a related item](/assets/guides/document-fields/inline-relationship-select.png)

!> **Tip**: The select component will use the [`ui.labelField`](../apis/schema#ui) of the related list in its options list.
Make sure you have this value configured to make finding related items easier for your users.

### Querying inline relationships

The document field stores the `id` of the related item in its data structure.
If you query for the document, the inline relationship block will include the ID as `data.id`.

```JSON
...
{
"type": "relationship",
"data": {
"id": "ckqk4hkcg0030f5mu6le6xydu"
},
"relationship": "mention",
"children": [{ "text": "" }
},
...
```

This is generally not very useful if you want to render the item in your document.
To obtain more useful data, we can pass the `hydrateRelationships: true` option to our query.

```graphql
query {
Expand All @@ -202,7 +237,65 @@ query {
}
```

Whenever a value is saved to the document field, the extra data from the `selection` and the `label` on `relationships` are removed (if they exist) so only the `id` is stored.
This will add a `data.label` value, based on the related item's label field, and a `data.data` value, which is populated with the data indicated by the `selection` config option.

```JSON
...
{
"type": "relationship",
"data": {
"id": "ckqk4hkcg0030f5mu6le6xydu",
"label": "Alice",
"data": {
"id": "ckqk4hkcg0030f5mu6le6xydu",
"name": "Alice"
}
},
"relationship": "mention",
"children": [{ "text": "" }
},
...
```

?> **Null data:** It is possible to add an inline relationship in the document editor without actually selecting a related item. In these cases, the value of `data` will be `null`.

?> **Dangling references:** The data for relationships are stored as IDs within the JSON data structure of the document.
If an item in your database is deleted, the document field will not have any knowledge of this, and you will be left with a dangling reference in your document data.
In other instances the person querying for the document may not have read access to the related item.
In both these cases the `data.label` and `data.data` values will be `undefined`.

### Rendering inline relationships

The `DocumentRenderer` has a rudimentary renderer built in for inline relationships which simply returns the `data.label` (or `data.id` if `hydrateRelationships` is `false`) inside a `<span>` tag.
This is unlikely to be what you want, so you will need to define a custom renderer for your relationship.

A custom renderer for our `mention` relationship might look like:

```typescript
import { DocumentRenderer, DocumentRendererProps } from '@keystone-next/document-renderer';

const renderers: DocumentRendererProps['renderers'] = {
inline: {
relationship({ relationship, data }) {
if (relationship === 'mention') {
if (data === null || data.data === undefined) {
return <span>[unknown author]</span>
} else {
return <Link href={`/author/${data.data.id}`}>{data.data.name}</Link>;
}
}
return null;
},
},
};

<DocumentRenderer document={document} renderers={renderers} />;
```

The `relationship` argument lets you write renderers for each of the different relationships defined in your document.
The `data` argument is provided directly from the query, and we can use the properies of `data.data` to render our mentions as links to the author's page.

?> **Missing data:** Make sure your renderer checks for `data === null` (no item selected) and `data.data === undefined` (selected item not found) and handles these cases appropriately.

## Component Blocks

Expand Down Expand Up @@ -486,7 +579,7 @@ export default config({
relationships: {
featuredAuthors: {
kind: 'prop',
listKey: 'User',
listKey: 'Author',
selection: 'posts { id title }',
many: true,
},
Expand Down
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions examples/document-field/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ The project contains two `document` fields which show how to use the field confi
For the blog post content we want the user to have the full complement of formatting and editor options available, including multi-column layouts.
To do this we use the short-hand notation of `formatting: true`, which enables all formatting features. We also enable `dividers`, `links`, and specify two additional column layouts.

We also want blog authors to be able to mention other authors in their blogs, so we enable an [inline relationship](https://next.keystonejs.com/docs/guides/document-fields#inline-relationships) for mentions.

```ts
content: document({
formatting: true,
Expand All @@ -42,6 +44,14 @@ content: document({
[1, 1],
[1, 1, 1],
],
relationships: {
mention: {
kind: 'inline',
listKey: 'Author',
label: 'Mention',
selection: 'id name',
},
},
}),
```

Expand Down
10 changes: 10 additions & 0 deletions examples/document-field/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ export const lists = createSchema({
[1, 1],
[1, 1, 1],
],
// We want to support twitter-style mentions in blogs, so we add an
// inline relationship which references the `Author` list.
relationships: {
mention: {
kind: 'inline',
listKey: 'Author',
label: 'Mention', // This will display in the Admin UI toolbar behind the `+` icon
selection: 'id name', // These fields will be available to the renderer
},
},
}),
publishDate: timestamp(),
author: relationship({ ref: 'Author.posts', many: false }),
Expand Down
32 changes: 29 additions & 3 deletions examples/document-field/src/pages/post/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,39 @@ import React from 'react';
import { DocumentRenderer, DocumentRendererProps } from '@keystone-next/document-renderer';
import { fetchGraphQL, gql } from '../../utils';

// by default the DocumentRenderer will render unstyled html elements
// we're customising how headings are rendered here but you can customise any of the renderers that the DocumentRenderer uses
// By default the DocumentRenderer will render unstyled html elements.
// We're customising how headings are rendered here but you can customise
// any of the renderers that the DocumentRenderer uses.
const renderers: DocumentRendererProps['renderers'] = {
// Render heading blocks
block: {
heading({ level, children, textAlign }) {
const Comp = `h${level}` as const;
return <Comp style={{ textAlign, textTransform: 'uppercase' }}>{children}</Comp>;
},
},
// Render inline relationships
inline: {
relationship({ relationship, data }) {
// If there is more than one inline relationship defined on the document
// field we need to handle each of them separately by checking the `relationship` argument.
// It is good practice to include this check even if you only have a single inline relationship.
if (relationship === 'mention') {
if (data === null || data.data === undefined) {
// data can be null if the content writer inserted a mention but didn't select an author to mention.
// data.data can be undefined if the logged in user does not have permission to read the linked item
// or if the linked item no longer exists.
return <span>[unknown author]</span>;
} else {
// If the data exists then we render the mention as a link to the author's bio.
// We have access to `id` an `name` fields here because we named them in the
// `selection` config argument.
return <Link href={`/author/${data.data.id}`}>{data.data.name}</Link>;
}
}
return null;
},
},
};

export default function Post({ post }: { post: any }) {
Expand Down Expand Up @@ -54,13 +78,15 @@ export async function getStaticPaths(): Promise<GetStaticPathsResult> {
}

export async function getStaticProps({ params }: GetStaticPropsContext) {
// We use (hydrateRelationships: true) to ensure we have the data we need
// to render the inline relationships.
const data = await fetchGraphQL(
gql`
query ($slug: String!) {
Post(where: { slug: $slug }) {
title
content {
document
document(hydrateRelationships: true)
}
publishDate
author {
Expand Down

1 comment on commit 55d5057

@vercel
Copy link

@vercel vercel bot commented on 55d5057 Jul 2, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.