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..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, @@ -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,15 @@ export const JsonBuilder = ({ 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); setPicked(spot); @@ -354,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)) + ) + ), }} /> @@ -373,6 +389,22 @@ export const JsonBuilder = ({ ? "A strip of tabs holds tabs only." : "It lands where you are standing."}

+ {section && Object.keys(shapes).length > 0 && holder?.type !== "tabs" && ( +
+
Custom
+ {Object.entries(shapes).map(([name, fields]) => ( + + ))} +
+ )} {section && GROUPS.map((group) => { const inGroup = offered.filter((type) => KINDS[type].group === group); 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..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", @@ -30,11 +31,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/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/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 34eb74a3..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,23 +2,14 @@ import { clsx as cn } from "clsx"; import type { ComponentProps } from "react"; -import { - CheckboxInput, - Collapsible, - FieldDescription, - 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"; @@ -108,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 ? ( @@ -137,155 +127,35 @@ export const JsonNode = ({ /> ); - if ("fields" in node) { - const fields = ( -