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:
- Embed multi-column layouts (that your front-end can decide how to render)
- Insert relationships to other items in your Keystone database
- Define your own custom blocks based on React Components
- Choose between automatically generated forms for editing Component Props, or create your own custom Component UI and Toolbars
Try the demo ↓
This is the document editor.
Here’s just a few things you can do with it:
Add layout blocks
and all the usual formatting
options
You can insert links
and format text with **markdown syntax**
We’re really excited to show you what we’ve built, and what you can build with it!
The really cool stuff is behind the +
button on the right of the toolbar – these are the Custom Components.
This component is the Notice, but you can build your own by just defining their prop types (like you do your Keystone schema) and providing a React Component to render the preview.
They store structured data, and can be inserted (and edited!) anywhere in the document. You can even link them to other items in your database with the Relationship field type.
They can also have props that are edited with an inline form, for more complex use cases (including conditional fields)
Try inserting a Hero component and you'll see how it works. Remember, you can build your own, so your content authors can insert components from your website's Design System, and your front-end still gets structured data to render!
This is the end of the editable document. Expand the block below to see how the data is stored ↓
View the Document Structure
[ { "type": "heading", "children": [ { "text": "This is the document editor." } ], "level": 1 }, { "type": "paragraph", "children": [ { "text": "Here’s just a few things you can do with it:" } ] }, { "type": "layout", "layout": [ 1, 1 ], "children": [ { "type": "layout-area", "children": [ { "type": "paragraph", "children": [ { "text": "Add layout blocks" } ] }, { "type": "paragraph", "children": [ { "text": "and all the usual " }, { "text": "formatting", "code": true }, { "text": " options" } ] } ] }, { "type": "layout-area", "children": [ { "type": "paragraph", "children": [ { "text": "You can insert " }, { "type": "link", "href": "https://next.keystonejs.com/", "children": [ { "text": "links" } ] }, { "text": "" } ] }, { "type": "paragraph", "children": [ { "text": "and format text with **" }, { "text": "markdown syntax", "bold": true }, { "text": "**" } ] } ] } ] }, { "type": "component-block", "component": "quote", "props": {}, "children": [ { "type": "component-block-prop", "propPath": [ "content" ], "children": [ { "type": "paragraph", "children": [ { "text": "We’re really excited to show you what we’ve built, and what you can build with it!" } ] } ] }, { "type": "component-inline-prop", "propPath": [ "attribution" ], "children": [ { "text": "The KeystoneJS Team" } ] } ] }, { "type": "component-block", "component": "notice", "props": { "intent": "info" }, "children": [ { "type": "component-block-prop", "propPath": [ "content" ], "children": [ { "type": "paragraph", "children": [ { "text": "The really cool stuff is behind the " }, { "text": "+", "code": true, "bold": true }, { "text": " button on the right of the toolbar – these are the " }, { "text": "Custom Components", "bold": true }, { "text": "." } ] }, { "type": "paragraph", "children": [ { "text": "This component is the " }, { "text": "Notice", "bold": true }, { "text": ", but you can build your own by just defining their prop types (like you do your Keystone schema) and providing a React Component to render the preview." } ] }, { "type": "paragraph", "children": [ { "text": "They store structured data, and can be inserted (and edited!) anywhere in the document. You can even link them to other items in your database with the " }, { "text": "Relationship", "bold": true }, { "text": " field type." } ] } ] } ] }, { "type": "component-block", "component": "notice", "props": { "intent": "success" }, "children": [ { "type": "component-block-prop", "propPath": [ "content" ], "children": [ { "type": "paragraph", "children": [ { "text": "They can also have props that are edited with an inline form, for more complex use cases (including conditional fields)" } ] }, { "type": "paragraph", "children": [ { "text": "Try inserting a " }, { "text": "Hero", "bold": true }, { "text": " component and you'll see how it works. Remember, you can build your own, so your content authors can insert components from your website's Design System, and your front-end still gets structured data to render!" } ] } ] } ] }, { "type": "paragraph", "children": [ { "text": "This is the end of the editable document. Expand the block below to see how the data is stored ↓", "bold": true } ] } ]
Customize the editor
You can configure which fields appear in the editor based on what your team needs. Try adding and removing features below ↓ to see them update in the editor above ↑.
import { config, createSchema, list } from '@keystone-next/keystone/schema';import { document } from '@keystone-next/fields-document';export default config({lists: createSchema({ListName: list({fields: {fieldName: document({formatting: true,links: true,layouts: [[1, 1],[1, 1, 1],[2, 1],[1, 2],[1, 2, 1]],dividers: true}/* ... */}),/* ... */},}),/* ... */}),/* ... */});
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.
import { config, createSchema, list } from '@keystone-next/keystone/schema';import { document } from '@keystone-next/fields-document';export default config({lists: createSchema({ListName: list({fields: {fieldName: document({relationships: {mention: {kind: 'inline',listKey: 'User',label: 'Mention',selection: 'some GraphQL selection',},},/* ... */}),/* ... */},}),/* ... */}),/* ... */});
By default, only the ids for relationships are sent in document.
To include the label and extra data from the selection specified in the relationship, pass hydrateRelationships: true
to the field.
query {allPosts {content(hydrateRelationships: true) {document}}}
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.
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 { 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, createSchema, list } from '@keystone-next/keystone/schema';import { document } from '@keystone-next/fields-document';import { componentBlocks } from './component-blocks';export default config({lists: createSchema({ListName: list({fields: {fieldName: document({ui: {views: require.resolve('./component-blocks')},componentBlocks,}),},}),}),});
In the document editor at the top of this page, the Quote(shown above), Notice and Hero 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
Child Fields
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, createSchema, list } from '@keystone-next/keystone/schema';import { document } from '@keystone-next/fields-document';export default config({lists: createSchema({ListName: list({fields: {fieldName: document({relationships: {featuredAuthors: {kind: 'prop',listKey: 'User',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
Preview Props
Chromeless
If you want to give your component blocks a more native feel in the editor, you can set chromeless: true
.
When you disable it, the generated form is disabled.
In the editor at the top of this page, the Notice and Quote blocks are chromeless and the Hero has the chrome enabled.
You will likely want to provide a custom toolbar when you set chromeless: true
.
component({chromeless: false,});
Toolbar
component({chromeless: false,});
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} />;
Overriding the default renderers
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} />;
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} />;