[Keystone] ドキュメントフィールドの使い方

ドキュメントフィールドの使い方

ドキュメントフィールドタイプは、システム内のコンテンツ作成者が迅速かつ簡単にコンテンツを編集できる高度にカスタマイズ可能なリッチテキストエディタです。
このエディタは Slate で構築され、コンテンツを JSON 構造化データとして保存し、以下のようなことができます。

  • ドキュメントで使用される書式の種類を設定する
  • アプリケーションでドキュメントを簡単にレンダリングする
  • Keystone データベースの他のアイテムとの関連付けを挿入する
  • React コンポーネントに基づいた独自のカスタムエディタブロックを定義する

構成

ドキュメントフィールドは、設定可能な複数の異なる書式オプションを提供します。完全な機能を備えたエディター体験を開始するには、組み込みのすべてのオプションをオンにすることができます。

import { list } from '@keystone-6/core';
import { document } from '@keystone-6/fields-document';

export const lists = {
  Post: list({
    fields: {
      content: document({
        formatting: true,
        dividers: true,
        links: true,
        layouts: [
          [1, 1],
          [1, 1, 1],
        ],
      }),
    },
  }),
};

これにより、すべての書式オプションが有効になり、インラインリンク、セクションの区切り、2 列および3列のレイアウトが有効になりました。 これらの機能のいずれかを無効にするには、単に構成からそのオプションを省略することができます。

書式設定

「formatting: true」と設定することで、ドキュメントのすべての書式オプションを有効にすることができます。特定のオプションを有効にするかどうかをより詳細に制御する必要がある場合は、明示的に使用する機能をリストアップすることができます。例えば、以下のようにします。

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,
  },
}),

すべての機能が true に設定されると、ドキュメントフィールドで有効になります。特定の機能を無効にする場合は、構成から単にその機能を省略することができます。
特定のサブグループ内のすべてのオプションを有効にする場合は、グループをtrueに設定することができます。たとえば、すべての listType オプションを有効にするには、listType: true と設定します。

クエリ

各ドキュメントフィールドは、GraphQL スキーマ内にタイプを生成します。投稿リストのコンテンツフィールドの例では、以下のようなタイプが生成されます。

type Post_content_DocumentField {
  document(hydrateRelationships: Boolean! = false): JSON!
}

コンテンツをクエリするためには、次の GraphQL クエリを実行します。これにより、posts.content.document 内のコンテンツのJSON表現が返されます。

query {
  posts {
    content {
      document
    }
  }
}

下記で hydrateRelationships オプションについて説明します。
ドキュメントデータは JSON として保存されます。ドキュメントエディターで変更を行うと、どのようにデータが保存されるかをインタラクティブに探索するために、ドキュメントフィールドのデモを使用することができます。

レンダリング

React アプリでドキュメントをレンダリングする場合は、@keystone-6/document-renderer パッケージを使用します。

import { DocumentRenderer } from '@keystone-6/document-renderer';

<DocumentRenderer document={document} />;

DocumentRenderer コンポーネントは、GraphQL API から返されるドキュメントの JSON 表現を受け入れます。

デフォルトのレンダラーを上書きする

DocumentRenderer には、JSON 形式のデータに格納されているさまざまなタイプのデータに対して、組み込みのレンダラーがあります。これらのデフォルトをオーバーライドする必要がある場合は、独自のレンダラーを DocumentRenderer に提供することで実現できます。

import { DocumentRenderer, DocumentRendererProps } from '@keystone-6/document-renderer';

const renderers: DocumentRendererProps['renderers'] = {
  // use your editor's autocomplete to see what other renderers you can override
  inline: {
    bold: ({ children }) => {
      return <strong>{children}</strong>;
    },
  },
  block: {
    paragraph: ({ children, textAlign }) => {
      return <p style={{ textAlign }}>{children}</p>;
    },
  },
};

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

インラインリレーションシップ

ドキュメントフィールドには、システム内の他のアイテムを参照するインラインリレーションシップを持つこともできます。たとえば、ブログアプリケーションで他のユーザーの Twitter スタイルのメンションを含めたい場合があります。これは、ドキュメントフィールドの relationships オプションを使用して実現できます。

import { config, list } from '@keystone-6/core';
import { document } from '@keystone-6/fields-document';
import { text } from '@keystone-6/core/fields';

export default config({
  lists: {
    Post: list({
      fields: {
        content: document({
          relationships: {
            mention: {
              listKey: 'Author',
              label: 'Mention',
              selection: 'id name',
            },
          },
        }),
      },
    }),
    Author: list({
      fields: {
        name: text(),
      }
    }),
  },
});

インラインリレーションシップのクエリ化

ドキュメントフィールドは、関連するアイテムの ID をそのデータ構造に格納します。ドキュメントをクエリする場合、インラインリレーションシップブロックには、データ .id として ID が含まれます。

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

これは、ドキュメント内のアイテムをレンダリングする場合には一般的にあまり有用ではありません。より有用なデータを取得するには、クエリに hydrateRelationships: true オプションを渡すことができます。

query {
  posts {
    content {
      document(hydrateRelationships: true)
    }
  }
}

これにより、関連するアイテムのラベルフィールドに基づいて data.label 値が追加され、選択構成オプションで示されるデータが data.data に格納されます。

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

インラインリレーションシップのレンダリング

DocumentRenderer には、単純に <span> タグ内で data.label (または hydrateRelationships が false の場合は data.id) を返す基本的なレンダラーが組み込まれています。これはおそらく望ましいものではないため、関連付けのカスタムレンダラーを定義する必要があります。
mention の関係に対するカスタムレンダラーは次のようになります。

import { DocumentRenderer, DocumentRendererProps } from '@keystone-6/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} />;

relationship 引数を使用することで、ドキュメントで定義された異なるリレーションシップのレンダラを記述できます。data 引数はクエリから直接提供され、data.data のプロパティを使用して、メンションを著者ページへのリンクとしてレンダリングできます。以下は、メンションのカスタムレンダラの例です。

コンポーネントブロック

コンポーネントブロックを使用すると、構造化されていないコンテンツを受け入れ、入力のための任意の React コンポーネントをレンダリングするフォームをレンダリングできるカスタムブロックをエディタに追加できます。
コンポーネントブロックを追加するには、どこかにファイルを作成し、そこからコンポーネントブロックをエクスポートする必要があります。
component-blocks.tsx

import React from 'react';
import { NotEditable, component, fields } from '@keystone-6/fields-document/component-blocks';

// naming the export componentBlocks is important because the Admin UI
// expects to find the components like on the componentBlocks export
export const componentBlocks = {
  quote: component({
    preview: (props) => {
      return (
        <div
          style={{
            borderLeft: '3px solid #CBD5E0',
            paddingLeft: 16,
          }}
        >
          <div style={{ fontStyle: 'italic', color: '#4A5568' }}>{props.fields.content.element}</div>
          <div style={{ fontWeight: 'bold', color: '#718096' }}>
            <NotEditable>— </NotEditable>
            {props.fields.attribution.element}
          </div>
        </div>
      );
    },
    label: 'Quote',
    schema: {
      content: fields.child({
        kind: 'block',
        placeholder: 'Quote...',
        formatting: { inlineMarks: 'inherit', softBreaks: 'inherit' },
        links: 'inherit',
      }),
      attribution: fields.child({ kind: 'inline', placeholder: 'Attribution...' }),
    },
    chromeless: true,
  }),
};

componentBlocks をインポートし、ui.views にコンポーネントブロックを含むファイルのパスと一緒に ドキュメントフィールドに渡す必要があります。
keystone.ts

import { config, list } from '@keystone-6/core';
import { document } from '@keystone-6/fields-document';
import { componentBlocks } from './component-blocks';

export default config({
  lists: {
    ListName: list({
      fields: {
        fieldName: document({
          ui: {
            views: './component-blocks'
          },
          componentBlocks,
        }),
      },
    }),
  },
});

ドキュメントエディタのデモでは、挿入可能な Quote、Notice、Hero、Checkbox List アイテムは、コンポーネントブロックとして実装されています。Notice、Hero、Checkbox List の実装を表示するには、これを展開してください。

Fields

様々なフィールドを設定することができます。

Child Fields

子フィールドを使うと、コンポーネントブロックのプレビュー内に編集可能な領域を埋め込むことができます。
kind プロパティは、子フィールドがどの種類の要素を含むかを示します。kind: ‘inline’ の子フィールドは、マークやリンク、インラインリレーションシップを含むテキストのみを含めることができます。kind: ‘block’ の子フィールドは、段落、リストなどのブロックレベルの要素を含めることができます。
子フィールドには、テキストが含まれていない場合に表示されるプレースホルダーが必要です。プレースホルダーは必須ですが、子フィールドの場所が編集可能であることが明確であれば、空の文字列にすることもできます。
デフォルトでは、子フィールドにはプレーンテキストしか含めることができません。子フィールド内でドキュメントエディタの他の機能を有効にするには、ドキュメントフィールド構成と同様に機能を有効にすることができます。ただし、これらのオプションは true ではなく、‘inherit’ を受け入れます。これは、‘inherit’ が設定されている場合、その機能がドキュメントフィールド構成レベルでも有効になっている場合、その機能が有効になるためです。つまり、子フィールドで機能を有効にすることができますが、ドキュメントフィールドの他の場所で有効にしないようにすることはできません。
プレビューでは、子フィールドはレンダリングする必要があります。子フィールドがレンダリングされない場合、エラーが発生します。

import { NotEditable, component, fields } from '@keystone-6/fields-document/component-blocks';

component({
  preview: (props) => {
    return (
      <div
        style={{
          borderLeft: '3px solid #CBD5E0',
          paddingLeft: 16,
        }}
      >
        <div style={{ fontStyle: 'italic', color: '#4A5568' }}>{props.fields.content.element}</div>
        <div style={{ fontWeight: 'bold', color: '#718096' }}>
          <NotEditable>— </NotEditable>
          {props.fields.attribution.element}
        </div>
      </div>
    );
  },
  label: 'Quote',
  schema: {
    content: fields.child({
      kind: 'block',
      placeholder: 'Quote...',
      formatting: { inlineMarks: 'inherit', softBreaks: 'inherit' },
      links: 'inherit',
    }),
    attribution: fields.child({ kind: 'inline', placeholder: 'Attribution...' }),
  },
  chromeless: true,
})
Form Fields

@keystone-6/core/component-blocks には、一般的な用途に対するフォームフィールドが含まれています。

  • fields.text({ label: ‘…’, defaultValue: ‘…’ })
  • fields.integer({ 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 })
    この 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

コンポーネントブロックのフィールドをグループ化するには、fields.object を使用します。

import { fields } from '@keystone-6/fields-document/component-blocks';

fields.object({
  a: fields.text({ label: 'A' }),
  b: fields.text({ label: 'B' }),
});
Relationship Fields

コンポーネントブロックでリレーションシップフィールドを使用するには、リレーションシップフィールドを追加し、list キー、ラベル、およびオプション選択を提供する必要があります。フォームでは、リレーションシップフィールドがリストのリレーションシップフィールドと同様にリレーションシップセレクトをレンダリングします。インラインリレーションシップと同様に、hydrateRelationships:true を取得時に提供すると、この選択でハイドレートされます。

import { fields } from '@keystone-6/fields-document/component-blocks';

...
  someField: fields.relationship({
    label: 'Authors',
    listKey: 'Author',
    selection: 'id name posts { title }',
    many: true,
  });
...

Objects

import { fields } from '@keystone-6/fields-document/component-blocks';

fields.object({
  text: fields.text({ label: 'Text' }),
  child: fields.placeholder({ placeholder: 'Content...' }),
});

Conditional Fields

fields.conditional を使うと、条件に応じて異なるフィールドを表示できます。discriminant として文字列またはブール値を持つフォームフィールドと、値に対応するフィールドのオブジェクトが必要です。

import { fields } from '@keystone-6/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(),
});

Array Fields

配列フィールドは、別のコンポーネントブロックフィールドタイプの配列を保存できるようにします。

import { fields } from '@keystone-6/fields-document/component-blocks';

fields.array(fields.object({
  isComplete: fields.checkbox({ label: 'Is Complete' }),
  content: fields.child({ kind: 'inline', placeholder: '' }),
}))

Chromeless

もしコンポーネントブロックの周りにあるデフォルトのUIを非表示にしたい場合、chromeless: true を設定できます。これにより、境界線、ツールバー、および生成されたフォームが削除されます。

quote: component({
  preview: ({ attribution, content }) => {
    ...
  },
  label: 'Quote',
  schema: {
    ...
  },
  chromeless: true,
}),

Rendering Component blocks

コンポーネントブロックのレンダリングのための props を型付けする

TypeScript を使用している場合は、@keystone-6/fields-document/component-blocks の InferRenderersForComponentBlocks を使用して、コンポーネントのプロップスタイプを推論できます。

import { DocumentRenderer } from '@keystone-6/document-renderer';
import { InferRenderersForComponentBlocks } from '@keystone-6/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} />;