From 4814de7c7b919cdcd1c48cd4738a087a9689c1c9 Mon Sep 17 00:00:00 2001 From: Lero Date: Thu, 8 Oct 2026 21:46:01 +0300 Subject: [PATCH 1/4] feat: a radio kind, drawn as buttons in a row --- .../src/components/JsonBuilderSettings.tsx | 4 +++- .../src/components/JsonNode.tsx | 24 +++++++++++++++++++ .../src/field/checks.ts | 8 +++++-- .../src/field/kinds.ts | 1 + .../src/field/typedJson.ts | 6 +++-- 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/packages/payload-plugin-json-form-builder/src/components/JsonBuilderSettings.tsx b/packages/payload-plugin-json-form-builder/src/components/JsonBuilderSettings.tsx index b0f0455b..11f436de 100644 --- a/packages/payload-plugin-json-form-builder/src/components/JsonBuilderSettings.tsx +++ b/packages/payload-plugin-json-form-builder/src/components/JsonBuilderSettings.tsx @@ -99,7 +99,9 @@ export const JsonBuilderSettings = ({ node, onChange }: Settings) => { value={node.label ?? ""} /> )} - {node.type === "select" && } + {(node.type === "select" || node.type === "radio") && ( + + )} {node.type === "number" && ( <> diff --git a/packages/payload-plugin-json-form-builder/src/components/JsonNode.tsx b/packages/payload-plugin-json-form-builder/src/components/JsonNode.tsx index 34eb74a3..51195a1f 100644 --- a/packages/payload-plugin-json-form-builder/src/components/JsonNode.tsx +++ b/packages/payload-plugin-json-form-builder/src/components/JsonNode.tsx @@ -6,6 +6,7 @@ import { CheckboxInput, Collapsible, FieldDescription, + FieldLabel, SelectInput, TextareaInput, } from "@payloadcms/ui"; @@ -260,6 +261,29 @@ export const JsonNode = ({ {fault ?

{fault}

: null} + ) : node.type === "radio" ? ( +
+ {own ? : null} +
    + {(node.options ?? []).map((option) => ( +
  • + +
  • + ))} +
+ {fault ?

{fault}

: null} +
) : node.type === "select" ? (
= { min: { kind: "number", only: ["number"] }, minRows: { kind: "number", only: ["array"] }, name: { kind: "string" }, - options: { kind: "array", needed: ["select"], only: ["select"] }, + options: { kind: "array", needed: ["select", "radio"], only: ["select", "radio"] }, readOnly: { kind: "boolean" }, required: { kind: "boolean" }, rows: { kind: "array", needed: ["array"], only: ["array"] }, @@ -162,7 +162,11 @@ const nodeErrors = (node: TypedNode, where: string, named: boolean): string[] => const fields = (node as Container).fields; const options = (node as Leaf).options; - if (node.type === "select" && Array.isArray(options) && !options.length) + if ( + (node.type === "select" || node.type === "radio") && + Array.isArray(options) && + !options.length + ) say(`${where}: “options” is empty`); if (node.type === "tabs" && Array.isArray(fields) && fields.some((field) => field.type !== "tab")) say(`${where}: “fields” may hold tabs only`); diff --git a/packages/payload-plugin-json-form-builder/src/field/kinds.ts b/packages/payload-plugin-json-form-builder/src/field/kinds.ts index 9397637c..0e9c6ec3 100644 --- a/packages/payload-plugin-json-form-builder/src/field/kinds.ts +++ b/packages/payload-plugin-json-form-builder/src/field/kinds.ts @@ -10,6 +10,7 @@ export const KINDS: Record = { date: { draws: "A date from the calendar.", glyph: "31", group: "Values" }, checkbox: { draws: "True or false.", glyph: "✓", group: "Values" }, select: { draws: "One of a list of options.", glyph: "▼", group: "Values" }, + radio: { draws: "The same, drawn as buttons in a row.", glyph: "◉", group: "Values" }, upload: { draws: "A file from the Media collection.", glyph: "↑", group: "Media" }, group: { draws: "Fields under a heading.", glyph: "{ }", group: "Structure" }, collapsible: { draws: "The same, drawn as an accordion.", glyph: "Ξ", group: "Structure" }, diff --git a/packages/payload-plugin-json-form-builder/src/field/typedJson.ts b/packages/payload-plugin-json-form-builder/src/field/typedJson.ts index dadb4d16..d0bf7ab1 100644 --- a/packages/payload-plugin-json-form-builder/src/field/typedJson.ts +++ b/packages/payload-plugin-json-form-builder/src/field/typedJson.ts @@ -6,7 +6,8 @@ export type LeafType = | "richText" | "upload" | "checkbox" - | "select"; + | "select" + | "radio"; export type Condition = { field: string } & ({ equals: unknown } | { notEquals: unknown }); type Base = { name?: string; @@ -52,6 +53,7 @@ export const TYPES = [ "upload", "checkbox", "select", + "radio", "array", "group", "collapsible", @@ -148,7 +150,7 @@ export const blankNode = (type: NodeType): TypedNode => { name: "", type, value: blankValue(type), - ...(type === "select" ? { options: ["option"] } : {}), + ...(type === "select" || type === "radio" ? { options: ["option"] } : {}), } as Leaf; }; From 3f9ccbfa914393ec54261f3f4bba972bad433203 Mon Sep 17 00:00:00 2001 From: Lero Date: Thu, 8 Oct 2026 21:46:02 +0300 Subject: [PATCH 2/4] feat: shapes offer ready-made field sets in the palette --- .../src/components/JsonBuilder.tsx | 38 ++++++++++++- .../src/components/jsonForm.scss | 5 ++ .../src/config.ts | 11 ++++ .../src/field/shapes.ts | 55 +++++++++++++++++++ .../src/index.ts | 1 + .../src/plugin.ts | 5 +- .../src/shared.ts | 14 +++++ 7 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 packages/payload-plugin-json-form-builder/src/field/shapes.ts diff --git a/packages/payload-plugin-json-form-builder/src/components/JsonBuilder.tsx b/packages/payload-plugin-json-form-builder/src/components/JsonBuilder.tsx index f23b75cd..7b5a48cc 100644 --- a/packages/payload-plugin-json-form-builder/src/components/JsonBuilder.tsx +++ b/packages/payload-plugin-json-form-builder/src/components/JsonBuilder.tsx @@ -35,6 +35,7 @@ import { } from "../field/edits.js"; import type { Spot } from "../field/edits.js"; import { GROUPS, KINDS } from "../field/kinds.js"; +import type { JsonFormShape } from "../config.js"; import { useJsonFormConfig } from "../useJsonFormConfig.js"; import { blankNode, TYPES } from "../field/typedJson.js"; import type { NodeType, TypedNode, TypedRoot } from "../field/typedJson.js"; @@ -65,7 +66,7 @@ export const JsonBuilder = ({ const [key, setKey] = useState(""); const [hovered, setHovered] = useState(""); const [making, setMaking] = useState<"field" | "section" | null>(null); - const { anchor: richTextAnchor, library: held, uploads } = useJsonFormConfig(); + const { anchor: richTextAnchor, library: held, shapes, uploads } = useJsonFormConfig(); const missing: NodeType[] = [ ...(uploads ? [] : ["upload" as NodeType]), ...(richTextAnchor ? [] : ["richText" as NodeType]), @@ -159,6 +160,16 @@ export const JsonBuilder = ({ openModal(formSlug); }; + // A shape lands through the same form as a kind, already holding its fields, so the editor only + // answers the name — and renames it or takes it apart afterwards like anything else. + const startShape = (shape: JsonFormShape) => { + setPicked(null); + setMaking("field"); + setEdited({ type: "group", fields: shape.fields } as TypedNode); + setKey(shape.name); + openModal(formSlug); + }; + const edit = (spot: Spot) => { const node = nodeAt(draft, spot); setPicked(spot); @@ -373,6 +384,31 @@ export const JsonBuilder = ({ ? "A strip of tabs holds tabs only." : "It lands where you are standing."}

+ {section && shapes.length > 0 && holder?.type !== "tabs" && ( +
+
Custom
+ {shapes.map((shape) => ( + + ))} +
+ )} {section && GROUPS.map((group) => { const inGroup = offered.filter((type) => KINDS[type].group === group); diff --git a/packages/payload-plugin-json-form-builder/src/components/jsonForm.scss b/packages/payload-plugin-json-form-builder/src/components/jsonForm.scss index d18a171d..474ed03a 100644 --- a/packages/payload-plugin-json-form-builder/src/components/jsonForm.scss +++ b/packages/payload-plugin-json-form-builder/src/components/jsonForm.scss @@ -598,6 +598,11 @@ body.json-builder-open { background: var(--theme-error-100); } +.json-builder__tile--custom { + background: var(--theme-elevation-150); + box-shadow: inset 0 0 0 1px var(--theme-elevation-300); +} + .json-builder__head { align-items: baseline; display: flex; diff --git a/packages/payload-plugin-json-form-builder/src/config.ts b/packages/payload-plugin-json-form-builder/src/config.ts index d8b51d78..dedcb543 100644 --- a/packages/payload-plugin-json-form-builder/src/config.ts +++ b/packages/payload-plugin-json-form-builder/src/config.ts @@ -1,4 +1,14 @@ import type { Holds } from "./field/htmlToLexical.js"; +import type { TypedNode } from "./field/typedJson.js"; + +// A ready-made subtree offered in the palette above the kinds it is built from. What it drops is +// ordinary content from that moment on — free to rename, extend or take apart. +export type JsonFormShape = { + name: string; + glyph?: string; + draws?: string; + fields: TypedNode[]; +}; // No React and no `@payloadcms/ui` here: a Payload config is loaded by a plain Node process that // cannot read the stylesheets those packages pull in. @@ -7,6 +17,7 @@ export type JsonFormClientConfig = { holds: Holds; uploads: false | string; library: null | { global: string; field: string }; + shapes: JsonFormShape[]; }; export const CONFIG_KEY = "jsonFormBuilder"; diff --git a/packages/payload-plugin-json-form-builder/src/field/shapes.ts b/packages/payload-plugin-json-form-builder/src/field/shapes.ts new file mode 100644 index 00000000..a106e84a --- /dev/null +++ b/packages/payload-plugin-json-form-builder/src/field/shapes.ts @@ -0,0 +1,55 @@ +import type { ArrayNode, Container, Leaf, TypedNode } from "./typedJson.js"; + +// Builders for the `shapes` the palette offers. They write the same nodes the builder writes, so a +// shape is ordinary content the moment it lands — these only save spelling `type` and a blank value. + +type Over = Partial>; + +const leaf = + (type: T, blank: unknown) => + (name: string, over: Over = {}): Leaf => ({ name, type, value: blank, ...over }); + +export const text = leaf("text", ""); +export const textarea = leaf("textarea", ""); +export const richText = leaf("richText", ""); +export const upload = leaf("upload", ""); +export const number = leaf("number", null); +export const date = leaf("date", null); +export const checkbox = leaf("checkbox", false); + +const chosen = + (type: "select" | "radio") => + (name: string, options: string[], over: Over = {}): Leaf => ({ + name, + type, + value: "", + options, + ...over, + }); + +export const select = chosen("select"); +export const radio = chosen("radio"); + +const held = + (type: "group" | "collapsible") => + (name: string, fields: TypedNode[], over: Over = {}): Container => ({ + name, + type, + fields, + ...over, + }); + +export const group = held("group"); +export const collapsible = held("collapsible"); + +export const array = ( + name: string, + fields: TypedNode[], + over: Over = {} +): ArrayNode => ({ + name, + type: "array", + fields, + rows: [], + ...over, +}); diff --git a/packages/payload-plugin-json-form-builder/src/index.ts b/packages/payload-plugin-json-form-builder/src/index.ts index f3f9f1d7..12dd29b7 100644 --- a/packages/payload-plugin-json-form-builder/src/index.ts +++ b/packages/payload-plugin-json-form-builder/src/index.ts @@ -1,3 +1,4 @@ +export type { JsonFormShape } from "./config.js"; export { jsonFormPlugin } from "./plugin.js"; export type { JsonFormGlobalConfig, JsonFormPluginConfig } from "./plugin.js"; export { jsonField } from "./field/index.js"; diff --git a/packages/payload-plugin-json-form-builder/src/plugin.ts b/packages/payload-plugin-json-form-builder/src/plugin.ts index 736020b1..90c05e6d 100644 --- a/packages/payload-plugin-json-form-builder/src/plugin.ts +++ b/packages/payload-plugin-json-form-builder/src/plugin.ts @@ -6,7 +6,7 @@ import { MARKER } from "./field/index.js"; import type { BuildGate, Mark } from "./field/index.js"; import type { Holds } from "./field/htmlToLexical.js"; import { CONFIG_KEY } from "./config.js"; -import type { JsonFormClientConfig } from "./config.js"; +import type { JsonFormClientConfig, JsonFormShape } from "./config.js"; const FIELD = "@focus-reactive/payload-plugin-json-form-builder/rsc#JsonFormField"; const ANCHOR_NAME = "jsonFormAnchor"; @@ -32,6 +32,7 @@ export type JsonFormPluginConfig = { holds?: Holds; }; uploads?: false | string; + shapes?: JsonFormShape[]; }; const walk = (fields: Field[] | undefined, visit: (field: Field) => void) => { @@ -64,6 +65,7 @@ export const jsonFormPlugin = global = {}, library: wanted = false, richText = false, + shapes = [], uploads = "media", } = options; const config: Config = { ...incoming }; @@ -144,6 +146,7 @@ export const jsonFormPlugin = anchor, holds: richText ? (richText.holds ?? {}) : {}, library, + shapes, uploads, }; config.admin = { ...config.admin, custom: { ...config.admin?.custom, [CONFIG_KEY]: client } }; diff --git a/packages/payload-plugin-json-form-builder/src/shared.ts b/packages/payload-plugin-json-form-builder/src/shared.ts index c6b9176d..15068fe4 100644 --- a/packages/payload-plugin-json-form-builder/src/shared.ts +++ b/packages/payload-plugin-json-form-builder/src/shared.ts @@ -18,5 +18,19 @@ export { SIMPLE, } from "./field/htmlToLexical.js"; export type { Holds } from "./field/htmlToLexical.js"; +export { + array, + checkbox, + collapsible, + date, + group, + number, + radio, + richText, + select, + text, + textarea, + upload, +} from "./field/shapes.js"; export { jsonErrors, problems, faults, schemaErrors } from "./field/checks.js"; export type { Fault } from "./field/checks.js"; From 3f66b1328185712b0bd370c7da5b2dbd4282f9fa Mon Sep 17 00:00:00 2001 From: Lero Date: Thu, 8 Oct 2026 22:32:11 +0300 Subject: [PATCH 3/4] update: a shape lands flat and is edited only in code --- .../src/components/JsonBuilder.tsx | 46 ++--- .../src/components/JsonBuilderLevel.tsx | 186 +++++++++++++----- .../src/components/JsonNode.tsx | 43 ++-- .../src/components/jsonForm.scss | 46 +++++ .../src/config.ts | 11 +- .../src/field/checks.ts | 1 + .../src/field/edits.ts | 3 + .../src/field/shapes.ts | 3 - .../src/field/typedJson.ts | 1 + .../src/index.ts | 1 - .../src/plugin.ts | 7 +- 11 files changed, 238 insertions(+), 110 deletions(-) diff --git a/packages/payload-plugin-json-form-builder/src/components/JsonBuilder.tsx b/packages/payload-plugin-json-form-builder/src/components/JsonBuilder.tsx index 7b5a48cc..d3851a58 100644 --- a/packages/payload-plugin-json-form-builder/src/components/JsonBuilder.tsx +++ b/packages/payload-plugin-json-form-builder/src/components/JsonBuilder.tsx @@ -25,6 +25,7 @@ import { labelOf } from "./JsonNode.js"; import { schemaErrors } from "../field/checks.js"; import { addAt, + addManyAt, addSection, editAt, keyFault, @@ -35,7 +36,6 @@ import { } from "../field/edits.js"; import type { Spot } from "../field/edits.js"; import { GROUPS, KINDS } from "../field/kinds.js"; -import type { JsonFormShape } from "../config.js"; import { useJsonFormConfig } from "../useJsonFormConfig.js"; import { blankNode, TYPES } from "../field/typedJson.js"; import type { NodeType, TypedNode, TypedRoot } from "../field/typedJson.js"; @@ -160,15 +160,14 @@ export const JsonBuilder = ({ openModal(formSlug); }; - // A shape lands through the same form as a kind, already holding its fields, so the editor only - // answers the name — and renames it or takes it apart afterwards like anything else. - const startShape = (shape: JsonFormShape) => { - setPicked(null); - setMaking("field"); - setEdited({ type: "group", fields: shape.fields } as TypedNode); - setKey(shape.name); - openModal(formSlug); - }; + const addShape = (shape: string, fields: TypedNode[]) => + setDraft( + addManyAt( + draft, + here, + fields.map((field) => ({ ...field, shape })) + ) + ); const edit = (spot: Spot) => { const node = nodeAt(draft, spot); @@ -365,6 +364,12 @@ export const JsonBuilder = ({ onOrder: reorder, onOpen: (index) => setAt([...at, index]), onRemove: (index) => remove({ section: current, at: [...at, index] }), + onRemoveShape: (from, upto) => + setDraft( + editAt(draft, here, (node) => + withShape(node, shapeOf(node).toSpliced(from, upto - from)) + ) + ), }} /> @@ -384,27 +389,18 @@ export const JsonBuilder = ({ ? "A strip of tabs holds tabs only." : "It lands where you are standing."}

- {section && shapes.length > 0 && holder?.type !== "tabs" && ( + {section && Object.keys(shapes).length > 0 && holder?.type !== "tabs" && (
Custom
- {shapes.map((shape) => ( + {Object.entries(shapes).map(([name, fields]) => ( ))}
diff --git a/packages/payload-plugin-json-form-builder/src/components/JsonBuilderLevel.tsx b/packages/payload-plugin-json-form-builder/src/components/JsonBuilderLevel.tsx index 75a4b109..09221670 100644 --- a/packages/payload-plugin-json-form-builder/src/components/JsonBuilderLevel.tsx +++ b/packages/payload-plugin-json-form-builder/src/components/JsonBuilderLevel.tsx @@ -30,11 +30,101 @@ type Hands = { onOpen: (index: number) => void; onEdit: (index: number) => void; onRemove: (index: number) => void; + onRemoveShape: (from: number, upto: number) => void; onOrder: (fields: TypedNode[]) => void; }; +type Row = { from: number; upto: number; shape?: string }; + +const rowsOf = (fields: TypedNode[]): Row[] => { + const rows: Row[] = []; + for (let at = 0; at < fields.length; ) { + const shape = fields[at].shape; + if (!shape) { + rows.push({ from: at, upto: at + 1 }); + at += 1; + continue; + } + let upto = at + 1; + while (upto < fields.length && fields[upto].shape === shape) upto += 1; + rows.push({ from: at, shape, upto }); + at = upto; + } + return rows; +}; + +const Card = ({ + grip, + hands, + index, + node, +}: { + grip: React.ReactNode; + hands: Hands; + index: number; + node: TypedNode; +}) => { + const kind = KINDS[node.type as NodeType]; + const holds = holdsFields(node); + const fixed = Boolean(node.shape); + const Body = fixed ? "span" : "button"; + + return ( + <> + {grip} + (holds ? hands.onOpen(index) : hands.onEdit(index))} + type={fixed ? undefined : "button"} + > + + {kind?.glyph ?? "?"} + + + + {labelOf(node) || no name} + {node.name ? #{node.name} : null} + + + {marks(node).map((mark) => ( + + {mark} + + ))} + + + + {fixed ? null : ( +
+ hands.onEdit(index)}> + + + hands.onRemove(index)} + > + + + {holds ? ( + + + + ) : null} +
+ )} + + ); +}; + export const JsonBuilderLevel = ({ fields, hands }: { fields: TypedNode[]; hands: Hands }) => { - const ids = fields.map((node, index) => node.name || `#${index + 1}`); + const rows = rowsOf(fields); + const ids = rows.map((row) => fields[row.from].name || `#${row.from + 1}`); return ( hands.onOrder( - fields.toSpliced(moveFromIndex, 1).toSpliced(moveToIndex, 0, fields[moveFromIndex]) + rows + .toSpliced(moveFromIndex, 1) + .toSpliced(moveToIndex, 0, rows[moveFromIndex]) + .flatMap((row) => fields.slice(row.from, row.upto)) ) } > - {fields.map((node, index) => { - const kind = KINDS[node.type as NodeType]; - const holds = holdsFields(node); - return ( - + {rows.map((row, at) => + row.shape ? ( + {({ attributes, isDragging, listeners, setNodeRef, transform, transition }) => (
- - - - -
- hands.onEdit(index)}> - - + {row.shape} hands.onRemove(index)} + label={`Remove this ${row.shape}`} + onClick={() => hands.onRemoveShape(row.from, row.upto)} > - {holds ? ( - - - - ) : null}
+ {fields.slice(row.from, row.upto).map((node, offset) => ( +
+ +
+ ))}
)}
- ); - })} + ) : ( + + {({ attributes, isDragging, listeners, setNodeRef, transform, transition }) => ( +
+ + + + } + hands={hands} + index={row.from} + node={fields[row.from]} + /> +
+ )} +
+ ) + )} {fields.length ? null : ( Nothing here yet. Pick a kind on the right and it lands in this level. diff --git a/packages/payload-plugin-json-form-builder/src/components/JsonNode.tsx b/packages/payload-plugin-json-form-builder/src/components/JsonNode.tsx index 51195a1f..964c2c2f 100644 --- a/packages/payload-plugin-json-form-builder/src/components/JsonNode.tsx +++ b/packages/payload-plugin-json-form-builder/src/components/JsonNode.tsx @@ -262,25 +262,32 @@ export const JsonNode = ({ {fault ?

{fault}

: null}
) : node.type === "radio" ? ( -
+
{own ? : null} -
    - {(node.options ?? []).map((option) => ( -
  • - -
  • - ))} +
      + {(node.options ?? []).map((option) => { + const chosen = String(value) === option; + return ( +
    • + +
    • + ); + })}
    {fault ?

    {fault}

    : null}
diff --git a/packages/payload-plugin-json-form-builder/src/components/jsonForm.scss b/packages/payload-plugin-json-form-builder/src/components/jsonForm.scss index 474ed03a..c3bbee70 100644 --- a/packages/payload-plugin-json-form-builder/src/components/jsonForm.scss +++ b/packages/payload-plugin-json-form-builder/src/components/jsonForm.scss @@ -424,6 +424,52 @@ body.json-builder-open { margin: 0; } +.json-builder__shape { + border: 1px solid var(--theme-elevation-100); + border-radius: var(--style-radius-m); + display: flex; + flex-direction: column; + gap: calc(var(--base) * 0.4); + padding: calc(var(--base) * 0.55); +} + +.json-builder__shape .json-builder__card-open { + padding-left: calc(var(--base) * 1.65 - 7px); +} + +.json-builder__shape .json-builder__card:hover { + background: var(--theme-elevation-0); + border-color: var(--theme-elevation-100); +} + +.json-builder__shape .json-builder__card-open { + cursor: default; +} + +.json-builder__shape-head { + align-items: center; + display: flex; + gap: calc(var(--base) * 0.4); + padding-bottom: calc(var(--base) * 0.15); +} + +.json-builder__shape-name { + margin-right: auto; +} + +.json-builder__shape-head .json-builder__grip { + height: auto; + margin-right: 0; + padding-left: 0; +} + +.json-builder__shape-name { + color: var(--theme-elevation-500); + font-size: 0.75rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + .json-builder__card:hover { background: var(--theme-elevation-50); border-color: var(--theme-elevation-200); diff --git a/packages/payload-plugin-json-form-builder/src/config.ts b/packages/payload-plugin-json-form-builder/src/config.ts index dedcb543..75c3742d 100644 --- a/packages/payload-plugin-json-form-builder/src/config.ts +++ b/packages/payload-plugin-json-form-builder/src/config.ts @@ -1,15 +1,6 @@ import type { Holds } from "./field/htmlToLexical.js"; import type { TypedNode } from "./field/typedJson.js"; -// A ready-made subtree offered in the palette above the kinds it is built from. What it drops is -// ordinary content from that moment on — free to rename, extend or take apart. -export type JsonFormShape = { - name: string; - glyph?: string; - draws?: string; - fields: TypedNode[]; -}; - // No React and no `@payloadcms/ui` here: a Payload config is loaded by a plain Node process that // cannot read the stylesheets those packages pull in. export type JsonFormClientConfig = { @@ -17,7 +8,7 @@ export type JsonFormClientConfig = { holds: Holds; uploads: false | string; library: null | { global: string; field: string }; - shapes: JsonFormShape[]; + shapes: Record; }; export const CONFIG_KEY = "jsonFormBuilder"; diff --git a/packages/payload-plugin-json-form-builder/src/field/checks.ts b/packages/payload-plugin-json-form-builder/src/field/checks.ts index 7a03bad3..66833f50 100644 --- a/packages/payload-plugin-json-form-builder/src/field/checks.ts +++ b/packages/payload-plugin-json-form-builder/src/field/checks.ts @@ -121,6 +121,7 @@ const KEYS: Record = { readOnly: { kind: "boolean" }, required: { kind: "boolean" }, rows: { kind: "array", needed: ["array"], only: ["array"] }, + shape: { kind: "string" }, showIf: { kind: "object" }, source: { kind: "string" }, time: { kind: "boolean", only: ["date"] }, diff --git a/packages/payload-plugin-json-form-builder/src/field/edits.ts b/packages/payload-plugin-json-form-builder/src/field/edits.ts index 4286b3d9..14760844 100644 --- a/packages/payload-plugin-json-form-builder/src/field/edits.ts +++ b/packages/payload-plugin-json-form-builder/src/field/edits.ts @@ -124,6 +124,9 @@ export const addAt = (root: TypedRoot, spot: Spot, node: TypedNode): TypedRoot = ]); }); +export const addManyAt = (root: TypedRoot, spot: Spot, nodes: TypedNode[]): TypedRoot => + editAt(root, spot, (parent) => withShape(parent, [...shapeOf(parent), ...nodes])); + export const addSection = (root: TypedRoot, key: string, node: TypedNode): TypedRoot => [ ...root, { ...node, name: key } as TypedNode, diff --git a/packages/payload-plugin-json-form-builder/src/field/shapes.ts b/packages/payload-plugin-json-form-builder/src/field/shapes.ts index a106e84a..6a2c7db8 100644 --- a/packages/payload-plugin-json-form-builder/src/field/shapes.ts +++ b/packages/payload-plugin-json-form-builder/src/field/shapes.ts @@ -1,8 +1,5 @@ import type { ArrayNode, Container, Leaf, TypedNode } from "./typedJson.js"; -// Builders for the `shapes` the palette offers. They write the same nodes the builder writes, so a -// shape is ordinary content the moment it lands — these only save spelling `type` and a blank value. - type Over = Partial>; const leaf = diff --git a/packages/payload-plugin-json-form-builder/src/field/typedJson.ts b/packages/payload-plugin-json-form-builder/src/field/typedJson.ts index d0bf7ab1..22707c9a 100644 --- a/packages/payload-plugin-json-form-builder/src/field/typedJson.ts +++ b/packages/payload-plugin-json-form-builder/src/field/typedJson.ts @@ -11,6 +11,7 @@ export type LeafType = export type Condition = { field: string } & ({ equals: unknown } | { notEquals: unknown }); type Base = { name?: string; + shape?: string; source?: string; label?: string; required?: boolean; diff --git a/packages/payload-plugin-json-form-builder/src/index.ts b/packages/payload-plugin-json-form-builder/src/index.ts index 12dd29b7..f3f9f1d7 100644 --- a/packages/payload-plugin-json-form-builder/src/index.ts +++ b/packages/payload-plugin-json-form-builder/src/index.ts @@ -1,4 +1,3 @@ -export type { JsonFormShape } from "./config.js"; export { jsonFormPlugin } from "./plugin.js"; export type { JsonFormGlobalConfig, JsonFormPluginConfig } from "./plugin.js"; export { jsonField } from "./field/index.js"; diff --git a/packages/payload-plugin-json-form-builder/src/plugin.ts b/packages/payload-plugin-json-form-builder/src/plugin.ts index 90c05e6d..782d9379 100644 --- a/packages/payload-plugin-json-form-builder/src/plugin.ts +++ b/packages/payload-plugin-json-form-builder/src/plugin.ts @@ -6,7 +6,8 @@ import { MARKER } from "./field/index.js"; import type { BuildGate, Mark } from "./field/index.js"; import type { Holds } from "./field/htmlToLexical.js"; import { CONFIG_KEY } from "./config.js"; -import type { JsonFormClientConfig, JsonFormShape } from "./config.js"; +import type { JsonFormClientConfig } from "./config.js"; +import type { TypedNode } from "./field/typedJson.js"; const FIELD = "@focus-reactive/payload-plugin-json-form-builder/rsc#JsonFormField"; const ANCHOR_NAME = "jsonFormAnchor"; @@ -32,7 +33,7 @@ export type JsonFormPluginConfig = { holds?: Holds; }; uploads?: false | string; - shapes?: JsonFormShape[]; + shapes?: Record; }; const walk = (fields: Field[] | undefined, visit: (field: Field) => void) => { @@ -65,7 +66,7 @@ export const jsonFormPlugin = global = {}, library: wanted = false, richText = false, - shapes = [], + shapes = {}, uploads = "media", } = options; const config: Config = { ...incoming }; From a01a8770e492cc1062736ea060745d9204e78bf9 Mon Sep 17 00:00:00 2001 From: Lero Date: Fri, 9 Oct 2026 05:36:20 +0300 Subject: [PATCH 4/4] feat: a shape follows the schema it was declared from --- .../src/components/JsonBuilderLevel.tsx | 1 + .../src/components/JsonHolder.tsx | 91 ++++++++ .../src/components/JsonLeaf.tsx | 131 +++++++++++ .../src/components/JsonNode.tsx | 211 +++--------------- .../src/components/JsonRadio.tsx | 52 +++++ .../src/field/flatten.ts | 4 +- .../src/field/shaped.ts | 61 +++++ 7 files changed, 364 insertions(+), 187 deletions(-) create mode 100644 packages/payload-plugin-json-form-builder/src/components/JsonHolder.tsx create mode 100644 packages/payload-plugin-json-form-builder/src/components/JsonLeaf.tsx create mode 100644 packages/payload-plugin-json-form-builder/src/components/JsonRadio.tsx create mode 100644 packages/payload-plugin-json-form-builder/src/field/shaped.ts diff --git a/packages/payload-plugin-json-form-builder/src/components/JsonBuilderLevel.tsx b/packages/payload-plugin-json-form-builder/src/components/JsonBuilderLevel.tsx index 09221670..71876ef7 100644 --- a/packages/payload-plugin-json-form-builder/src/components/JsonBuilderLevel.tsx +++ b/packages/payload-plugin-json-form-builder/src/components/JsonBuilderLevel.tsx @@ -19,6 +19,7 @@ import type { NodeType, TypedNode } from "../field/typedJson.js"; const marks = (node: TypedNode) => [ + node.type, node.required && "required", node.readOnly && "read only", node.hidden && "hidden", diff --git a/packages/payload-plugin-json-form-builder/src/components/JsonHolder.tsx b/packages/payload-plugin-json-form-builder/src/components/JsonHolder.tsx new file mode 100644 index 00000000..d73f86de --- /dev/null +++ b/packages/payload-plugin-json-form-builder/src/components/JsonHolder.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { clsx as cn } from "clsx"; +import { Collapsible, FieldDescription } from "@payloadcms/ui"; +import { JsonFields } from "./JsonNode.js"; +import { JsonFolded } from "./JsonFolded.js"; +import { JsonTabs } from "./JsonTabs.js"; +import type { Drag } from "./JsonNode.js"; +import type { Container, TypedNode } from "../field/typedJson.js"; +import type { ReactNode } from "react"; + +export const JsonHolder = ({ + actions, + drag, + faults, + head, + hidden, + id, + label, + node, + onChange, + readOnly, + top, +}: { + actions: ReactNode; + drag?: Drag; + faults?: boolean; + head: ReactNode; + hidden?: boolean; + id: string; + label: string; + node: Container; + onChange: (node: TypedNode) => void; + readOnly?: boolean; + top?: boolean; +}) => { + const Fold = top ? JsonFolded : Collapsible; + + const fields = ( + onChange({ ...node, fields: next })} + readOnly={readOnly} + /> + ); + if (node.type === "group" && !drag) { + return ( + + ); + } + if (node.type === "tabs" && !drag && !top) { + return ( + + ); + } + return ( +
+ +
+ ); +}; diff --git a/packages/payload-plugin-json-form-builder/src/components/JsonLeaf.tsx b/packages/payload-plugin-json-form-builder/src/components/JsonLeaf.tsx new file mode 100644 index 00000000..d0ef7d26 --- /dev/null +++ b/packages/payload-plugin-json-form-builder/src/components/JsonLeaf.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { clsx as cn } from "clsx"; +import { CheckboxInput, SelectInput, TextareaInput } from "@payloadcms/ui"; +import { JsonInput } from "./JsonInput.js"; +import { JsonMediaInput } from "./JsonMediaInput.js"; +import { JsonRadio } from "./JsonRadio.js"; +import { JsonRichText } from "./JsonRichText.js"; +import type { Leaf, TypedNode } from "../field/typedJson.js"; + +export const JsonLeaf = ({ + fault, + id, + label, + node, + onChange, + own, + readOnly, +}: { + fault: string; + id: string; + label: string; + node: Leaf; + onChange: (node: TypedNode) => void; + own: string; + readOnly?: boolean; +}) => { + const value = node.value ?? ""; + const set = (next: unknown) => onChange({ ...node, value: next }); + return node.type === "textarea" ? ( + {fault}

: undefined} + label={own} + onChange={(event) => set(event.target.value)} + path={id} + readOnly={readOnly} + required={node.required} + showError={Boolean(fault)} + value={String(value)} + /> + ) : node.type === "number" ? ( + + ) : node.type === "date" ? ( + + ) : node.type === "checkbox" ? ( +
+ !readOnly && set(!value)} + readOnly={readOnly} + required={node.required} + /> + {fault ?

{fault}

: null} +
+ ) : node.type === "upload" ? ( + + ) : node.type === "richText" ? ( +
+ + {fault ?

{fault}

: null} +
+ ) : node.type === "radio" ? ( + + ) : node.type === "select" ? ( +
+ set(option && !Array.isArray(option) ? option.value : "")} + options={(node.options ?? []).map((option) => ({ label: option, value: option }))} + path={id} + readOnly={readOnly} + required={node.required} + value={String(value)} + /> + {fault ?

{fault}

: null} +
+ ) : ( + + ); +}; diff --git a/packages/payload-plugin-json-form-builder/src/components/JsonNode.tsx b/packages/payload-plugin-json-form-builder/src/components/JsonNode.tsx index 964c2c2f..e0fe26cf 100644 --- a/packages/payload-plugin-json-form-builder/src/components/JsonNode.tsx +++ b/packages/payload-plugin-json-form-builder/src/components/JsonNode.tsx @@ -2,24 +2,14 @@ import { clsx as cn } from "clsx"; import type { ComponentProps } from "react"; -import { - CheckboxInput, - Collapsible, - FieldDescription, - FieldLabel, - SelectInput, - TextareaInput, -} from "@payloadcms/ui"; +import { Collapsible, FieldDescription } from "@payloadcms/ui"; import { toWords } from "payload/shared"; import { EyeIcon } from "./icons/EyeIcon.js"; import { JsonArray } from "./JsonArray.js"; -import { JsonFolded } from "./JsonFolded.js"; -import { JsonInput } from "./JsonInput.js"; -import { JsonMediaInput } from "./JsonMediaInput.js"; -import { JsonRichText } from "./JsonRichText.js"; +import { JsonHolder } from "./JsonHolder.js"; +import { JsonLeaf } from "./JsonLeaf.js"; import { JsonRowMenu } from "./JsonRowMenu.js"; import type { MenuItem } from "./JsonRowMenu.js"; -import { JsonTabs } from "./JsonTabs.js"; import { nodeFault } from "../field/checks.js"; import { HIDDEN, visible } from "../field/typedJson.js"; import type { TypedNode } from "../field/typedJson.js"; @@ -109,7 +99,6 @@ export const JsonNode = ({ }: NodeProps) => { const readOnly = parentReadOnly || node.readOnly; const actions = menu && !readOnly && ; - const Fold = top ? JsonFolded : Collapsible; const fault = faults ? nodeFault(node) : ""; const head = badge ? ( @@ -138,185 +127,35 @@ export const JsonNode = ({ /> ); - if ("fields" in node) { - const fields = ( -