event-manual / chapter 13

スキーマ駆動 Admin CMS

@event/cms

admin の編集 UI は手書きしない。ResourceSchema を1つ宣言すると、GenericForm(フォーム自動生成)と SplitView(一覧 + 編集ペイン)が一覧 / 作成 / 更新 / 削除 / CSV インポートまで供給する。

FieldSchema — 全 variant

typeUI補足
text1行テキストrequired / placeholder / help
textarea複数行rows
svgSVG ソース編集 + プレビューpreviewWidth / previewHeight
markdownMarkdown エディタrows
number数値min / max
select単一選択options: SelectOption[]
multiselect複数選択options
booleanトグル
colorカラーピッカー
imageアップロード(/api/admin/uploadaspect は square / wide / free。variants で sharp 変換(第39章)
datetime日時
stringList文字列配列ティッカー文言等
arrayネスト配列(折りたたみ行)itemFields / itemLabel / defaultItem

全 variant 共通の装飾が FieldDecorations:

シリアライズ制約: schema は RSC 境界を props で越えるため、関数を含めてはならない。defaultItem は plain object、itemLabel"{time} — {name}" 形式のテンプレート文字列で宣言し、renderItemLabel() が展開する。この制約により docs/DB_MCP_DESIGN.md の Field Manifest(layer / writableBy)とも互換になる — locked: truelayer: "core" である。

locked の二層防御

UI の readonly だけでは防御にならない(DevTools で disabled を外せる)。必ず二層で守る:

  1. UI 層GenericForm が locked フィールドを <fieldset disabled> で包み、LOCKED バッジと lockedReason を表示する
  2. サーバ層 — 保存直前に applyLockedGuard(schema, values, existing) を必ず通す
// applyLockedGuard の規則
// UPDATE: locked フィールドは existing 値で常に上書き(client 送信値を無視)
// CREATE: locked フィールドを drop(4S sync 経由でしか入らない)
// nested array(例 venues.rooms[].nameJa)も id ペアリングで再帰的に保護
const guarded = applyLockedGuard(schema, values, existing);

createCmsActions(saveSingleton / saveListItem / deleteListItem)と createCmsRouteHandlers はこのガードと validatePayload(required の最終検証)を内蔵しており、アプリ側が呼び忘れる余地がない。

GenericForm / SplitView

GenericForm は schema からフォームを生成し、dirty 追跡(未変更なら保存ボタン無効)、⌘S ショートカット、liveFooter(live 値 + 更新関数を受けてプレビュー等を差し込むスロット)を持つ。スタイルは inline + var(--accent, ...) 方式で、テーマはシェル側の CSS 変数で上書きする。

SplitView は Linear 風の list + edit ペイン:

CSV / TSV 一括インポート

schema.csvImport を宣言すると SplitView に CSV ボタンが生える。Sheets からのコピペ(TSV)を想定した列マップ宣言型で、パーサ関数を書かない:

csvImport: {
  help: "列順: title<TAB>slug<TAB>body",
  headers: ["title", "slug", "body"],   // 列順どおりのフィールド名
  arrayFields: ["tags"],                // カンマ区切り → 配列
  numberFields: ["order"],              // Number() 変換
  booleanFields: ["visible"],           // true/1/yes/公開 → true
}

dot-path

field の name には edition.id / socialUrls.x のようなドットパスを許す。getByPath / setByPath(clone-on-write。配列は traverse しない)が読み書きを担い、ネストした singleton 設定を平坦なフォームで編集できる。validatePayload の required 判定も同じ getByPath を使うため、UI と検証で解釈がずれない。