How To Use Document Fields
The document
field type is a highly customizable rich text editor that lets content creators quickly and easily edit content in your system.
It's built with Slate, stores your content as JSON-structured data, and lets you do things like:
- Configure the types of formatting used in your documents
- Easily render the document in your application
- Insert relationships to other items in your Keystone database
- Define your own custom editor blocks based on React Components
To see the document field in action, try out the demo.
Configuration
The document field provides a number of different formatting options, all of which can be configured. To get started with a fully featured editor experience, you can turn on all of the built-in options.
import { list } from '@keystone-next/keystone';import { document } from '@keystone-next/fields-document';export const lists = {Post: list({fields: {content: document({formatting: true,dividers: true,links: true,layouts: [[1, 1],[1, 1, 1],],}),},}),};
This has enabled all of the formatting options, enabled inline links, section dividers, and both 2 and 3 column layouts.
We can disable any of these features by simply omitting the option from our configuration.
Formatting
Setting formatting: true
turns on all the formatting options for the document.
If you need more fine-grained control over which options are enabled, you can explicitly list the features you want, e.g.
content: document({formatting: {inlineMarks: {bold: true,italic: true,underline: true,strikethrough: true,code: true,superscript: true,subscript: true,keyboard: true,},listTypes: {ordered: true,unordered: true,},alignment: {center: true,end: true,},headingLevels: [1, 2, 3, 4, 5, 6],blockTypes: {blockquote: true,code: true},softBreaks: true,},}),
All the features set to true
will be enabled in your document field.
To disable a specific feature you can simply omit it from the configuration.
If you want to enable all the options in a particular sub-group, you can set the group to true
.
For example, to enable all listType
options you could set listType: true
.
You can experiment with the different configuration settings in the document field demo.
Querying
Each document field will generate a type within your GraphQL schema. The example above of a content
field in the Post
list would generate the type:
type Post_content_DocumentField {document(hydrateRelationships: Boolean! = false): JSON!}
To query the content we can run the following GraphQL query, which will return the JSON representation of the content in posts.content.document
.
query {posts {content {document}}}
We will discuss the hydrateRelationships
option below.
The document data is stored as JSON. You can use the document field demo to interactively explore how data is stored when you make changes in the document editor.
Rendering
To render the document in a React app, use the @keystone-next/document-renderer
package.
import { DocumentRenderer } from '@keystone-next/document-renderer';<DocumentRenderer document={document} />;
The DocumentRenderer
component accepts the JSON
representation of the document returned by the GraphQL API.
Overriding the default renderers
The DocumentRenderer
has built in renderers for all the different types of data stored in the JSON formatted data.
If you need to override these defaults, you can do so by providing your own renderers to DocumentRenderer
.
import { DocumentRenderer, DocumentRendererProps } from '@keystone-next/document-renderer';const renderers: DocumentRendererProps['renderers'] = {// use your editor's autocomplete to see what other renderers you can overrideinline: {bold: ({ children }) => {return <strong>{children}</strong>;},},block: {paragraph: ({ children, textAlign }) => {return <p style={{ textAlign }}>{children}</p>;},},};<DocumentRenderer document={document} renderers={renderers} />;
Inline Relationships
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.
import { config, list } from '@keystone-next/keystone';import { document } from '@keystone-next/fields-document';import { text } from '@keystone-next/keystone/fields';export default config({lists: {Post: list({fields: {content: document({relationships: {mention: {kind: 'inline',listKey: 'Author',label: 'Mention',selection: 'id name',},},}),},}),Author: list({fields: {name: text(),},}),},});
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.
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.
You can also access the relationship directly using the /
command and then starting to type the label.
You can then select an item from the list specified by listKey
from the inline select component in the document editor.
Tip: The select component will use the ui.labelField
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
.
...{"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.
query {posts {content {document(hydrateRelationships: true)}}}
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.
...{"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:
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
Component blocks let you add custom blocks to the editor that can accept unstructured content and render a form that renders arbitrary React components for input.
To add component blocks, you need to create a file somewhere and export component blocks from there
component-blocks.tsx
import React from 'react';import { NotEditable, component, fields } from '@keystone-next/fields-document/component-blocks';// naming the export componentBlocks is important because the Admin UI// expects to find the components like on the componentBlocks exportexport const componentBlocks = {quote: component({component: ({ attribution, content }) => {return (<divstyle={{borderLeft: '3px solid #CBD5E0',paddingLeft: 16,}}><div style={{ fontStyle: 'italic', color: '#4A5568' }}>{content}</div><div style={{ fontWeight: 'bold', color: '#718096' }}><NotEditable>— </NotEditable>{attribution}</div></div>);},label: 'Quote',props: {content: fields.child({kind: 'block',placeholder: 'Quote...',formatting: { inlineMarks: 'inherit', softBreaks: 'inherit' },links: 'inherit',}),attribution: fields.child({ kind: 'inline', placeholder: 'Attribution...' }),},chromeless: true,}),};
You need to import the componentBlocks
and pass it to the document field along with the path to the file with the component blocks to ui.views
.
keystone.ts
import { config, list } from '@keystone-next/keystone';import { document } from '@keystone-next/fields-document';import { componentBlocks } from './component-blocks';export default config({lists: {ListName: list({fields: {fieldName: document({ui: {views: require.resolve('./component-blocks'),},componentBlocks,}),},}),},});
In the document editor demo, the insertable Quote, Notice and
Hero items are implemented as component blocks, see the implementation for Notice and Hero by
expanding this.
/** @jsx */import { jsx } from '@keystone-ui/core';import { component, fields } from '@keystone-next/fields-document/component-blocks';export const componentBlocks = {notice: component({component: function Notice({ content, intent }) {const { palette, radii, spacing } = useTheme();const intentMap = {info: {background: palette.blue100,foreground: palette.blue700,icon: noticeIconMap.info,},error: {background: palette.red100,foreground: palette.red700,icon: noticeIconMap.error,},warning: {background: palette.yellow100,foreground: palette.yellow700,icon: noticeIconMap.warning,},success: {background: palette.green100,foreground: palette.green700,icon: noticeIconMap.success,},};const intentConfig = intentMap[intent.value];return (<divcss={{borderRadius: radii.small,display: 'flex',paddingLeft: spacing.medium,paddingRight: spacing.medium,}}style={{background: intentConfig.background,}}><divcontentEditable={false}css={{color: intentConfig.foreground,marginRight: spacing.small,marginTop: '1em',userSelect: 'none',}}><intentConfig.icon /></div><div css={{ flex: 1 }}>{content}</div></div>);},label: 'Notice',chromeless: true,props: {intent: fields.select({label: 'Intent',options: [{ value: 'info', label: 'Info' },{ value: 'warning', label: 'Warning' },{ value: 'error', label: 'Error' },{ value: 'success', label: 'Success' },] as const,defaultValue: 'info',}),content: fields.child({kind: 'block',placeholder: '',formatting: 'inherit',dividers: 'inherit',links: 'inherit',relationships: 'inherit',}),},toolbar({ props, onRemove }) {return (<ToolbarGroup>{props.intent.options.map(opt => {const Icon = noticeIconMap[opt.value];return (<Tooltip key={opt.value} content={opt.label} weight="subtle">{attrs => (<ToolbarButtonisSelected={props.intent.value === opt.value}onClick={() => {props.intent.onChange(opt.value);}}{...attrs}><Icon size="small" /></ToolbarButton>)}</Tooltip>);})}<ToolbarSeparator /><Tooltip content="Remove" weight="subtle">{attrs => (<ToolbarButton variant="destructive" onClick={onRemove} {...attrs}><Trash2Icon size="small" /></ToolbarButton>)}</Tooltip></ToolbarGroup>);},}),};
Fields
There are a variety of fields you can configure.
Form Fields
@keystone-next/keystone/component-blocks
ships with a set of form fields for common purposes:
fields.text({ label: '...', defaultValue: '...' })
fields.url({ label: '...', defaultValue: '...' })
fields.select({ label: '...', options: [{ label:'A', value:'a' }, { label:'B', value:'b' }] defaultValue: 'a' })
fields.checkbox({ label: '...', defaultValue: false })
You can write your own form fields that conform to this API.
type FormField<Value, Options> = {kind: 'form';Input(props: {value: Value;onChange(value: Value): void;autoFocus: boolean;/*** This will be true when validate has returned false and the user has attempted to close the form* or when the form is open and they attempt to save the item*/forceValidation: boolean;}): ReactElement | null;/*** The options are config about the field that are available on the* preview props when rendering the toolbar and preview component*/options: Options;defaultValue: Value;/*** validate will be called in two cases:* - on the client in the editor when a user is changing the value.* Returning `false` will block closing the form* and saving the item.* - on the server when a change is received before allowing it to be saved* if `true` is returned* @param value The value of the form field. You should NOT trust* this value to be of the correct type because it could come from* a potentially malicious client*/validate(value: unknown): boolean;};
Object Fields
To nest a group of component block fields, you can use fields.object
import { fields } from '@keystone-next/fields-document/component-blocks';fields.object({a: fields.text({ label: 'A' }),a: fields.text({ label: 'B' }),});
Relationship Fields
To use relationship fields on component blocks, you need to add a relationship to the document field config.
import { config, list } from '@keystone-next/keystone';import { document } from '@keystone-next/fields-document';export default config({lists: {ListName: list({fields: {fieldName: document({relationships: {featuredAuthors: {kind: 'prop',listKey: 'Author',selection: 'posts { id title }',many: true,},},/* ... */}),/* ... */},}),/* ... */},/* ... */});
You can reference the key of the relationship in the relationship and in the form, it will render a relationship select like the relationship field on lists.
import { fields } from '@keystone-next/fields-document/component-blocks';fields.relationship({ label: 'Authors', relationship: 'featuredAuthors' });
Note: Like inline relationships, relationship fields on component blocks are not stored like relationship fields on lists, they are stored as ids in the document structure.
Objects
import { fields } from '@keystone-next/fields-document/component-blocks';fields.object({text: fields.text({ label: 'Text' }),child: fields.placeholder({ placeholder: 'Content...' }),});
Conditional Fields
You can conditionally show different fields with fields.conditional
, they require a form field with a value that is either a string or a boolean as the discriminant and an object of fields for the values.
import { fields } from '@keystone-next/fields-document/component-blocks';fields.conditional(fields.checkbox({ label: 'Show Call to action' }), {true: fields.object({url: fields.url({ label: 'URL' }),content: fields.child({ kind: 'inline', placeholder: 'Call to Action' }),}),false: fields.empty(),});
You might find
fields.empty()
useful which stores and renders nothing if you want to have a field in one case and nothing anything in another
Chromeless
If you want to hide the default UI around your component block, you can set chromeless: true
. This removes the border, toolbar, and generated form.
quote: component({component: ({ attribution, content }) => {...},label: 'Quote',props: {...},chromeless: true,}),
You can see the differences between each below:
In the document editor demo, the Notice and Quote blocks are chromeless, but the Hero block has the standard chrome styling.
Rendering Component blocks
Typing props for rendering component blocks
If you're using TypeScript, you can infer the props types for component with InferRenderersForComponentBlocks
from @keystone-next/fields-document/component-blocks
.
import { DocumentRenderer } from '@keystone-next/document-renderer';import { InferRenderersForComponentBlocks } from '@keystone-next/fields-document/component-blocks';import { componentBlocks } from '../path/to/your/custom/views';const componentBlockRenderers: InferRenderersForComponentBlocks<typeof componentBlocks> = {someComponentBlock: props => {// props will be inferred from your component blocks},};<DocumentRenderer document={document} componentBlocks={componentBlockRenderers} />;