diff --git a/bun.lock b/bun.lock index 67ea7c46..ca389258 100644 --- a/bun.lock +++ b/bun.lock @@ -133,7 +133,6 @@ "@focus-reactive/payload-plugin-ab": "workspace:*", "@focus-reactive/payload-plugin-analytics": "workspace:*", "@focus-reactive/payload-plugin-comments": "workspace:*", - "@focus-reactive/payload-plugin-json-form-builder": "workspace:*", "@focus-reactive/payload-plugin-presets": "workspace:*", "@focus-reactive/payload-plugin-scheduling": "workspace:*", "@focus-reactive/payload-plugin-seo": "workspace:*", @@ -195,7 +194,7 @@ }, "packages/create-ideal-cms": { "name": "create-ideal-cms", - "version": "1.2.1", + "version": "1.3.0", "bin": { "create-ideal-cms": "./dist/index.js", }, @@ -352,7 +351,7 @@ }, "packages/payload-plugin-json-form-builder": { "name": "@focus-reactive/payload-plugin-json-form-builder", - "version": "1.0.0", + "version": "1.1.1", "dependencies": { "clsx": "2.1.1", }, @@ -483,7 +482,7 @@ }, "packages/payload-plugin-translator": { "name": "@focus-reactive/payload-plugin-translator", - "version": "0.14.0", + "version": "0.15.0", "dependencies": { "@hookform/resolvers": "^3.9.0", "@radix-ui/react-popover": "^1.1.0", @@ -519,7 +518,7 @@ }, "packages/payload-plugin-visual-editing": { "name": "@focus-reactive/payload-plugin-visual-editing", - "version": "1.0.0", + "version": "1.1.0", "dependencies": { "@vercel/stega": "^1.1.0", }, diff --git a/packages/payload-plugin-json-form-builder/README.md b/packages/payload-plugin-json-form-builder/README.md index cf67e1c5..f050b7d3 100644 --- a/packages/payload-plugin-json-form-builder/README.md +++ b/packages/payload-plugin-json-form-builder/README.md @@ -1,6 +1,10 @@ # @focus-reactive/payload-plugin-json-form-builder -Turn a Payload CMS v3 `json` field into real admin fields — text, number, date, select, checkbox, upload, rich text, lists, groups and tabs — and let an admin build that form from inside the admin panel, in a drawer, without touching code. +**A `json` field becomes real admin fields — and an admin can add new ones without a deploy.** + +Text, number, date, select, checkbox, upload, rich text, lists, groups and tabs: the editor fills in a form, not a textarea. And when a field is missing, nobody writes a migration — it is added from inside the admin panel, in a builder, and it is there on the next save. + +![The form a json field becomes](https://raw.githubusercontent.com/focusreactive/payload-plugins/main/packages/payload-plugin-json-form-builder/docs/form.png) The unusual part: **there is no schema in your repository.** The shape lives inside the value itself. Every node says what it is, so the form can be drawn from the data alone, and changing the shape is a content edit rather than a deploy. Your frontend never sees that bookkeeping: everything outside the app reads plain values by key. @@ -27,12 +31,23 @@ Libraries like `rjsf` and `jsonforms` render a form **from a schema you write in ## What you get -- **A form instead of a textarea.** Thirteen kinds, nested as deep as you like. -- **A builder.** A drawer behind the gear: pick a kind from the palette, name the key, set it required, give it a minimum. One level at a time. -- **A JSON view, always.** The same value as code, for reading, diffing and the occasional hand fix. Editable by whoever may build. -- **Validation that behaves like Payload's.** `required`, `min`/`max`, `minRows`/`maxRows` are judged on the server, the save is refused, the field is marked, and the message names the path. -- **Rich text that round-trips.** Stored as html, written by Payload's own converters and read back by a parser kept in step with them. -- **Plain values for the site.** No tree-walking in your templates. +**A form instead of a textarea.** Thirteen kinds, nested as deep as you like, drawn with Payload's own inputs — the upload field picks from your Media collection, rich text is the lexical editor your project configured, a list is a list of cards you drag. + +**A builder, in the admin.** Behind the gear: a sidebar of sections, a canvas of the one you are on, and a palette of kinds on the right. Pick a kind, name the key, set it required, give it a minimum. A new field reaches every row of a list that already has rows, and no deploy is involved. + +![The builder](https://raw.githubusercontent.com/focusreactive/payload-plugins/main/packages/payload-plugin-json-form-builder/docs/builder.png) + +**Sections of your own.** A section is a named group at the root of the value, and it is what a template reads: `settings.hero.title`. Add one, drag it, hide it from the site without deleting it. + +**Shared sections.** Build a section once and point many documents at it. Each keeps its own values; the shape stays in one place and every document follows it — add a field there and it appears in all of them. See [`library`](#library). + +**A JSON view, always.** The same value as code, for reading, diffing and the occasional hand fix. Editable by whoever may build. + +**Validation that behaves like Payload's.** `required`, `min`/`max`, `minRows`/`maxRows` are judged on the server, the save is refused, the field is marked, and the message names the path. + +**Rich text that round-trips.** Stored as html, written by Payload's own converters and read back by a parser kept in step with them. + +**Plain values for the site.** No tree-walking in your templates. ## Requirements @@ -103,11 +118,14 @@ That is all. Styles travel with the components; there is nothing to import. jsonFormPlugin({ build: true, global: { slug: 'json-form', fields: [] }, + library: true, richText: { editor: lexicalEditor(), holds: { headings: true, quotes: true, rules: true } }, uploads: 'media', }) ``` +Most projects set two of these: `richText`, because a rich text node needs an editor to be drawn with, and `library`, when sections are worth sharing. The rest have defaults that fit. + ### `build` Who may open the builder and edit the JSON by hand. Everyone, by default — the field already sits behind the document's own update access, so this is a second gate, not the first one. @@ -136,6 +154,36 @@ global: false // no global — and An empty global is `admin.hidden`, since the only field in it is one nobody can see. Put fields in it and it appears; `admin`, `access`, `label` and `hooks` are passed straight through if you want to say otherwise. `false` leaves the config without the global, which also leaves rich text nowhere to anchor. +### `library` + +Shared sections: one json field holding sections that documents elsewhere follow. + +```ts +library: false // the default: no shared sections +library: true // the field goes into the plugin's own global +library: { name: 'blocks', label: 'Blocks', description: 'Built once, used everywhere' } +``` + +Or mount it yourself, wherever it belongs — beside the field it shares with, in a global of your own: + +```ts +import { jsonLibraryField } from '@focus-reactive/payload-plugin-json-form-builder' + +fields: [ + { type: 'group', label: 'Components', fields: [jsonField({ name: 'components' })] }, + { type: 'group', label: 'Shared sections', fields: [jsonLibraryField()] }, +] +``` + +A field offers the library when it asks to: `jsonField({ shares: true })`. Open its builder and the sidebar grows two groups under *Add section* — **Attached sections**, each with a `×` that detaches it, and **Shared sections**, each with a `+` that attaches it. In the form, an attached section carries a `global` chip. + +- **values are the document's.** Changing them in the library later changes nothing anywhere; +- **the shape is the library's.** A field added there appears in every document that follows the section, one removed disappears, one retyped is retyped — read on the way out, so nothing is stale; +- **keys are frozen.** Once a section exists in the library, its keys and its fields' keys cannot be renamed, only added to or removed. Renaming would leave every document that followed it pointing at nothing; +- **removing a section from the library takes nothing away.** Every document keeps it, with its content, and simply stops following: the chip goes and the shape is theirs to edit again. + +One caveat worth knowing: a document stores its own copy of the shape and rewrites it when it is saved. So after changing a shape in the library, save the documents that follow it — until they are saved, their copy is the one from last time, and that is the shape they fall back to if they ever stop following. + ### `richText` ```ts diff --git a/packages/payload-plugin-json-form-builder/docs/builder.png b/packages/payload-plugin-json-form-builder/docs/builder.png new file mode 100644 index 00000000..045fc68d Binary files /dev/null and b/packages/payload-plugin-json-form-builder/docs/builder.png differ diff --git a/packages/payload-plugin-json-form-builder/docs/form.png b/packages/payload-plugin-json-form-builder/docs/form.png new file mode 100644 index 00000000..9f73acc0 Binary files /dev/null and b/packages/payload-plugin-json-form-builder/docs/form.png differ diff --git a/packages/payload-plugin-json-form-builder/src/components/JsonArray.tsx b/packages/payload-plugin-json-form-builder/src/components/JsonArray.tsx index 0b8252a5..8da67066 100644 --- a/packages/payload-plugin-json-form-builder/src/components/JsonArray.tsx +++ b/packages/payload-plugin-json-form-builder/src/components/JsonArray.tsx @@ -32,9 +32,8 @@ const rowValue = (row: TypedNode[]) => .map((field) => ("value" in field && field.type === "text" ? String(field.value ?? "") : "")) .find(Boolean); -// An array laid out as Payload's array field. Each row gets an id that travels with it: an id per -// position would make a dropped row animate twice. export const JsonArray = ({ + badge, faults, drag, node, @@ -46,6 +45,7 @@ export const JsonArray = ({ hidden, readOnly, }: { + badge?: string; faults?: boolean; drag?: Drag; node: ArrayNode; @@ -63,16 +63,10 @@ export const JsonArray = ({ if (rowIds.length !== rows.length) setIds(rows.map(newId)); const [open, setOpen] = useState([]); const singular = formatLabels(node.name ?? label).singular || t("general:row"); - // What Payload's own array field does with the two: the button goes at the ceiling, and the last - // rows down to the floor cannot be taken away. const atMax = Boolean(node.maxRows && rows.length >= node.maxRows); const atMin = Boolean(node.minRows && rows.length <= node.minRows); - // A list nobody has put fields into yet: a row of it would be an accordion with nothing in it, so - // there is nothing to add until the shape exists. const shapeless = !(node.rows?.[0] ?? node.fields ?? []).length; - // `fields` only exists while the list is empty: it is the shape of the row that left, and the - // next row to arrive takes it away again. const update = (nextIds: string[], nextRows: TypedNode[][]) => { setIds(nextIds); const { fields: _dropped, ...rest } = node; @@ -97,9 +91,7 @@ export const JsonArray = ({ const setRow = (index: number, row: TypedNode[]) => onChange({ ...node, rows: rows.map((entry, i) => (i === index ? row : entry)) }); - // Payload's own row actions, minus the clipboard ones, which need the form state. const rowMenu = (index: number, row: TypedNode[]): MenuItem[] => [ - // A row of one unnamed field is a plain value; a switch inside it would turn it into an object. ...(row.length === 1 && !row[0].name ? [] : [ @@ -230,8 +222,6 @@ export const JsonArray = ({ const header = (!top || rows.length > 0) && (
- {/* Inside a section the accordion above already carries the name, so the heading names - what is below it instead — and the wrap is `space-between`, so it cannot be dropped. */}

{top ? "Items" : label}

@@ -301,7 +291,16 @@ export const JsonArray = ({ actions={menu && } className={cn(hidden && "json-form__hidden")} dragHandleProps={drag} - header={label} + header={ + badge ? ( + <> + {label} + {badge} + + ) : ( + label + ) + } >
{body}
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 c4c15884..f23b75cd 100644 --- a/packages/payload-plugin-json-form-builder/src/components/JsonBuilder.tsx +++ b/packages/payload-plugin-json-form-builder/src/components/JsonBuilder.tsx @@ -9,15 +9,17 @@ import { ConfirmationModal, Drawer, EditIcon, - Pill, TextInput, Tooltip, useDrawerSlug, + useConfig, useModal, XIcon, } from "@payloadcms/ui"; import { IconButton } from "./IconButton.js"; import { JsonBuilderLevel } from "./JsonBuilderLevel.js"; +import { followed, following } from "../library/reconcileSection.js"; +import { isTyped } from "../field/typedJson.js"; import { JsonBuilderSettings } from "./JsonBuilderSettings.js"; import { labelOf } from "./JsonNode.js"; import { schemaErrors } from "../field/checks.js"; @@ -37,44 +39,33 @@ import { useJsonFormConfig } from "../useJsonFormConfig.js"; import { blankNode, TYPES } from "../field/typedJson.js"; import type { NodeType, TypedNode, TypedRoot } from "../field/typedJson.js"; -// A strip of tabs holds tabs and nothing else, and a tab is worth nothing anywhere else — so it is -// offered where it belongs and nowhere besides. -// A strip of tabs holds tabs and nothing else; everywhere else a tab is not a thing to add. And a -// kind this installation has no home for — an upload with no collection, rich text with no editor — -// is not offered, rather than offered and then unable to draw itself. const holds = (node: TypedNode | undefined, missing: readonly NodeType[]): readonly NodeType[] => node?.type === "tabs" ? ["tab"] : TYPES.filter((type) => type !== "tab" && !missing.includes(type)); export const JsonBuilder = ({ - adding, + library, root, onChange, + shares, slug, }: { - adding?: boolean; + library?: boolean; root: TypedRoot; onChange: (root: TypedRoot) => void; + shares?: boolean; slug: string; }) => { - // Everything here edits a copy. The field's own value travels through the form's state, which - // answers a render late, and every edit read back from it arrived behind the one before it. const [draft, setDraft] = useState(root); const [current, setCurrent] = useState(""); - // Where the canvas stands. What it shows is what this spot holds, one level at a time. const [at, setAt] = useState([]); - // Where the node being edited sits, or nothing at all for one that does not exist yet. const [picked, setPicked] = useState(null); - // The drawer works on a copy and writes it back on the button. Editing the tree as it is typed - // renames and re-seats the very node the form is standing on, which takes the cursor with it. const [edited, setEdited] = useState(null); const [key, setKey] = useState(""); const [hovered, setHovered] = useState(""); - // What the drawer is making, if it is making anything. A kind is answered once, here: changing it - // later moves the values into a shape that cannot hold them. const [making, setMaking] = useState<"field" | "section" | null>(null); - const { anchor: richTextAnchor, uploads } = useJsonFormConfig(); + const { anchor: richTextAnchor, library: held, uploads } = useJsonFormConfig(); const missing: NodeType[] = [ ...(uploads ? [] : ["upload" as NodeType]), ...(richTextAnchor ? [] : ["richText" as NodeType]), @@ -83,32 +74,57 @@ export const JsonBuilder = ({ const leaveSlug = useDrawerSlug("json-builder-leave"); const { closeModal, isModalOpen, openModal } = useModal(); - // `body-scroll-lock` restores the page the moment any one modal gives up its lock, with no regard - // for the modals still open, so closing the settings drawer frees the page behind the builder. - // This holds it for as long as the builder itself is open. const showing = isModalOpen(slug); + // `body-scroll-lock` frees the page as soon as any one modal gives up its lock, with no regard + // for the modals still open; this class holds it while the builder is. useEffect(() => { if (!showing) return; document.body.classList.add("json-builder-open"); return () => document.body.classList.remove("json-builder-open"); }, [showing]); - // A copy, taken fresh every time the drawer opens. Keeping an unsaved one across a closing would - // mean telling "built here and not saved" apart from "filled in over there", and both read the - // same: the copy differs from the field. So a closing drops the structure, which is at least - // seen at once — where a kept copy quietly wrote its own stale values back on the next save. const dirty = JSON.stringify(draft) !== JSON.stringify(root); useEffect(() => { if (!showing) return; setDraft(root); - // Opened from "Add section": the form a new section is answered in, rather than the canvas of - // one that already exists. - if (adding) start("section", "collapsible"); // biome-ignore lint/correctness/useExhaustiveDependencies: the stored value is read on opening only }, [showing]); - // Already in order: the root is a list, and a list is kept the way it is written. const sections = draft; + const [shared, setShared] = useState([]); + const { config } = useConfig(); + + useEffect(() => { + if (!(showing && shares && held)) return; + const request = new AbortController(); + fetch(`${config.routes.api}/globals/${held.global}?depth=0`, { + credentials: "include", + signal: request.signal, + }) + .then((answer) => answer.json()) + .then((doc) => { + const value = (doc as Record)?.[held.field]; + setShared(isTyped(value) ? (value as TypedRoot) : []); + }) + .catch(() => setShared([])); + return () => request.abort(); + }, [held, config.routes.api, shares, showing]); + + const take = (node: TypedNode) => setDraft([...draft, following(node, node.name ?? "")]); + + const dropSection = (spot: Spot) => { + remove(spot); + setCurrent(""); + setAt([]); + }; + + const drop = (entry: string) => { + setDraft(draft.filter((node) => (node.name || "") !== entry)); + if (current === entry) setCurrent(""); + }; + + const free = shared.filter((node) => !draft.some((own) => own.name === node.name)); + const kept = draft.filter(followed); const faults = schemaErrors(draft); const section = draft.find((node) => node.name === current); const here: Spot = { section: current, at }; @@ -118,11 +134,7 @@ export const JsonBuilder = ({ const isSection = making === "section" || Boolean(picked && !picked.at.length); - // Who else stands at this level, which is what the key has to be free of. const neighbours = () => { - // Sections are a list, so their names are read off the nodes. A section being renamed does not - // count its own name as taken; one being created counts every name there is, including the one - // standing selected behind the drawer. if (isSection) { const taken = draft.map((node) => node.name ?? ""); return making ? taken : taken.filter((name) => name !== current); @@ -132,7 +144,6 @@ export const JsonBuilder = ({ }; const fault = edited ? keyFault(key, neighbours()) : ""; - // The way back, one step for every door walked through. const trail = at.map((index, depth) => ({ at: at.slice(0, depth + 1), name: @@ -164,8 +175,6 @@ export const JsonBuilder = ({ setEdited(null); }; - // One write for the whole drawer. A section is named by its key in the root, so renaming it moves - // it there rather than giving it a `name` of its own. const save = () => { if (!edited) return shut(); if (making === "section") { @@ -181,7 +190,6 @@ export const JsonBuilder = ({ shut(); }; - // `fields` is an array, so the order it sits in is the order that is stored and drawn. const reorder = (next: TypedNode[]) => setDraft(editAt(draft, here, (node) => withShape(node, next))); @@ -230,16 +238,20 @@ export const JsonBuilder = ({
Sections
{sections.map((held, index) => { const entry = held.name || `#${index + 1}`; + if (followed(held)) return null; return ( - walk(entry)} - pillStyle={entry === current ? "dark" : "light"} + type="button" > {held.label || entry} - {shapeOf(held).length} - + ); })} + + {shares && kept.length > 0 && ( + <> +
Attached sections
+ {kept.map((node, index) => { + const entry = node.name || `#${index + 1}`; + return ( +
+ {node.label || entry} + +
+ ); + })} + + )} + + {shares && free.length > 0 && ( + <> +
Shared sections
+ {free.map((node, index) => { + const entry = node.name || `#${index + 1}`; + return ( +
+ {node.label || entry} + +
+ ); + })} + + )}
- {/* Nothing here writes a fault, so one came in by hand or from a script — and the shape - on screen is not to be trusted until it is gone. */} {faults.length ? {faults.join("; ")} : null} {section ? ( <> @@ -273,7 +327,6 @@ export const JsonBuilder = ({ ))} - {/* They act on where you stand, which is the section itself until you walk into it. */} { + if (!at.length) return dropSection(here); remove(here); - // Standing in what was just removed is standing nowhere. - if (at.length) setAt(at.slice(0, -1)); - else setCurrent(""); + setAt(at.slice(0, -1)); }} > @@ -374,11 +426,11 @@ export const JsonBuilder = ({ > {edited ? (
- {/* Written on the button: a section is its key, so renaming one moves it in the root. */} ) => setKey(event.target.value)} path="builder-key" + readOnly={Boolean(library) && !making} required value={key} /> @@ -403,8 +455,11 @@ export const JsonBuilder = ({ buttonStyle="none" className="json-builder__quiet" onClick={() => { + if (picked && isSection) { + shut(); + return dropSection(picked); + } if (picked) remove(picked); - // Standing inside what was just removed is standing nowhere. if (picked?.at.length && picked.at.length <= at.length) setAt(picked.at.slice(0, -1)); shut(); 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 dec67bde..75a4b109 100644 --- a/packages/payload-plugin-json-form-builder/src/components/JsonBuilderLevel.tsx +++ b/packages/payload-plugin-json-form-builder/src/components/JsonBuilderLevel.tsx @@ -17,7 +17,6 @@ import { KINDS } from "../field/kinds.js"; import { holdsFields } from "../field/typedJson.js"; import type { NodeType, TypedNode } from "../field/typedJson.js"; -// What the node was told to do. Not what kind it is — the tile already says that. const marks = (node: TypedNode) => [ node.required && "required", @@ -34,10 +33,7 @@ type Hands = { onOrder: (fields: TypedNode[]) => void; }; -// One level at a time: a field that holds fields is a door, not a drawer within a drawer. export const JsonBuilderLevel = ({ fields, hands }: { fields: TypedNode[]; hands: Hands }) => { - // `fields` is an array, so its order is the stored order — and a name is unique within a level, - // which makes it an id without a second list to keep in step. const ids = fields.map((node, index) => node.name || `#${index + 1}`); return ( @@ -102,7 +98,6 @@ export const JsonBuilderLevel = ({ fields, hands }: { fields: TypedNode[]; hands > - {/* A mark, not a control: the door is the whole row. */} {holds ? ( 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 59ef003c..afa85898 100644 --- a/packages/payload-plugin-json-form-builder/src/components/JsonBuilderSettings.tsx +++ b/packages/payload-plugin-json-form-builder/src/components/JsonBuilderSettings.tsx @@ -18,8 +18,6 @@ const without = (node: TypedNode, key: string) => { const put = (node: TypedNode, key: string, value: unknown) => ({ ...node, [key]: value }) as TypedNode; -// The date a field starts on, which is simply the date it holds until someone picks another. A date -// is the one kind nobody can type a sensible value into, so it is the one worth answering here. const Starts = ({ node, onChange }: Settings) => ( ( /> ); -// An emptied box takes the key away rather than storing a blank one: no `min` at all is what a -// field with no floor says. const Limit = ({ node, onChange, name }: Settings & { name: string }) => ( ( /> ); -// What a select offers is its schema, so it is written here and the form only picks from it. A -// select with nothing to offer is a broken one, which is why the last row cannot be taken away. const Options = ({ node, onChange }: Settings) => { const options = ("options" in node && node.options) || []; const write = (next: string[]) => onChange(put(node, "options", next)); @@ -76,12 +70,10 @@ const Options = ({ node, onChange }: Settings) => { }; export const JsonBuilderSettings = ({ node, onChange }: Settings) => { - // Filling in is a thing a field does, so a group, a tab or a list is asked neither. const fills = !holdsFields(node); return ( <> - {/* A container wears its name as a heading, so it is worth spelling out in words. */} {fills ? null : ( { onChange(node.required ? without(node, "required") : put(node, "required", true)) } /> - {/* Not for an upload or a rich text: their editors are not asked to be read-only here. */} {node.type === "upload" || node.type === "richText" ? null : ( (value == null ? "" : JSON.stringify(value, null, 2)); -// Payload's own json field keeps the raw text as the value while it does not parse, and that string -// then travels: the form holds it, the server is asked to store it, and a jsonb column is handed a -// sentence. Here the text stays here. The field keeps the last value that parsed, so nothing -// downstream ever sees anything but json — no refused save, and no form left looking saved. export const JsonCode = ({ maxHeight, onChange, @@ -22,8 +18,6 @@ export const JsonCode = ({ }) => { const [text, setText] = useState(() => written(value)); const [fault, setFault] = useState(""); - // What was last sent up, so a value changed elsewhere — the builder, a section deleted — redraws - // the editor, while our own writes do not fight the caret. const sent = useRef(text); if (!readOnly) { diff --git a/packages/payload-plugin-json-form-builder/src/components/JsonFolded.tsx b/packages/payload-plugin-json-form-builder/src/components/JsonFolded.tsx index 1fd8393b..1e9f33ce 100644 --- a/packages/payload-plugin-json-form-builder/src/components/JsonFolded.tsx +++ b/packages/payload-plugin-json-form-builder/src/components/JsonFolded.tsx @@ -9,10 +9,6 @@ type Props = Pick< "actions" | "className" | "dragHandleProps" | "header" | "initCollapsed" > & { children: ReactNode }; -// Payload's Collapsible renders a closed accordion's contents at height 0, so the whole value — -// every section, every row, a Lexical editor per rich text — would mount before anything is opened. -// A section waits for its first opening and then keeps what it mounted; inside it everything is -// eager again, so an accordion nested in an open section has nothing left to load. export const JsonFolded = ({ children, ...props }: Props) => { const [open, setOpen] = useState(false); const [mounted, setMounted] = useState(false); diff --git a/packages/payload-plugin-json-form-builder/src/components/JsonFormClient.tsx b/packages/payload-plugin-json-form-builder/src/components/JsonFormClient.tsx index b8d59872..ed4f13ae 100644 --- a/packages/payload-plugin-json-form-builder/src/components/JsonFormClient.tsx +++ b/packages/payload-plugin-json-form-builder/src/components/JsonFormClient.tsx @@ -10,6 +10,7 @@ import { ConfirmationModal, DraggableSortable, DraggableSortableItem, + FieldDescription, FieldLabel, GearIcon, ListViewIcon, @@ -24,20 +25,20 @@ import { IconButton } from "./IconButton.js"; import { JsonBuilder } from "./JsonBuilder.js"; import { JsonCode } from "./JsonCode.js"; import { hideItem, JsonNode, labelOf } from "./JsonNode.js"; +import { followed } from "../library/reconcileSection.js"; +import { useJsonFormConfig } from "../useJsonFormConfig.js"; import { faults, jsonErrors, schemaErrors } from "../field/checks.js"; import { isObject, isTyped, visible } from "../field/typedJson.js"; import type { TypedNode, TypedRoot } from "../field/typedJson.js"; -type Props = ComponentProps & { mayBuild: boolean }; +type Props = ComponentProps & { + library?: boolean; + mayBuild: boolean; + shares?: boolean; +}; -// A typed json value (src/fields/jsonField/typedJson.ts) laid out as admin fields. The builder owns -// the shape and Code only shows it — except while the json is broken, which is the one thing the -// builder cannot mend. Both gates are decided on the server, in JsonFormField. -export const JsonFormClient = ({ mayBuild, ...props }: Props) => { +export const JsonFormClient = ({ library, mayBuild, shares: offered, ...props }: Props) => { const { path, field } = props; - // The same rule the field's own `validate` runs on the server. It cannot stop a submit — the edit - // view turns client validation off there (`disableValidationOnSubmit`) — but it is what marks the - // field invalid as the value changes, which is what the message below is read from. const validate = useCallback( (next: unknown, options: { required?: boolean }) => jsonErrors(next, options?.required), [] @@ -46,256 +47,241 @@ export const JsonFormClient = ({ mayBuild, ...props }: Props) => { const { openModal } = useModal(); const builderSlug = useDrawerSlug("json-builder"); const dropSlug = useDrawerSlug("json-form-drop"); - // Which section the confirmation is about. One modal serves them all. const [dropping, setDropping] = useState(""); - // Whether the builder opens on its canvas or straight on the form a new section is answered in. - const [adding, setAdding] = useState(false); - const build = (fresh: boolean) => { - setAdding(fresh); - openModal(builderSlug); - }; - // Anything that is not a list of sections opens in Code, to be mended by hand. + const build = () => openModal(builderSlug); const valid = value == null || Array.isArray(value); const [code, setCode] = useState(!valid); const root: TypedRoot = isTyped(value) ? value : []; const broken = schemaErrors(root).length > 0; const asCode = code || broken; - // Code is open to whoever may build, and read-only to everyone else: it is the same value the form - // shows, and the one way to mend json the form cannot draw. const locked = props.readOnly || !mayBuild; + const shares = Boolean(offered) && Boolean(useJsonFormConfig().library) && !library; const set = (name: string, node: TypedNode) => setValue(root.map((entry) => (entry.name === name ? node : entry))); - // Deleting a section is the editor's to make: it is the one piece of the shape that is also a - // piece of the page, and an edition that does not run it has no use for its fields either. const drop = (name: string) => setValue(root.filter((entry) => entry.name !== name)); - // Content that was never converted: it has keys but no kinds, so the form cannot draw it and the - // builder would have nothing to open. const untyped = Boolean(value) && !isTyped(value) && Object.keys(value as object).length > 0; - // A list keeps the order it is written in, so a section sits where it was put. const sections = root.filter((node) => visible(node, root)); - // Nothing built yet: both controls in the strip act on sections, and there are none — so the only - // thing offered is the button that makes the first one. const bare = !sections.length && !untyped && !asCode; - // After a refused save: Payload's own pill is pinned to the top right of the field, which is where - // this one keeps its controls, so the one line naming what is missing is unreadable there. Said - // again here, under the sections it is about, and only once a save has actually been turned down. const unfilled = showError ? faults(root) : []; - // Sections are the keys a template reads, which is the one count worth carrying in the foot. const kept = root.length; return ( -
0 && "json-form--alarmed")}> -
-

- -

- json + <> + {field.admin?.description ? ( +
+ +
+ ) : null} +
0 && "json-form--alarmed")}> +
+

+ +

+ json -
- {mayBuild && !bare && ( - <> - build(false)}> - - - - - )} - {/* Two named ways of looking at one value, so which one is open is readable at a glance — - an icon that toggles only says so once you have guessed what it does. */} - {mayBuild && !bare && ( -
- - -
- )} +
+ {mayBuild && !bare && ( + <> + + + + + + )} + {mayBuild && !bare && ( +
+ + +
+ )} +
-
- {/* Directly under the header, so what is wrong is read before the thing it is wrong in. All of - them, numbered: a count alone sends the editor hunting. A row opens the json. */} - {unfilled.length > 0 ? ( -
    - {unfilled.map((fault, at) => ( -
  1. - - {String(at + 1).padStart(2, "0")} - - {fault.message} - - {fault.path.map((step, depth) => ( - - {depth > 0 ? → : null} - {step} - - ))} + {unfilled.length > 0 ? ( +
      + {unfilled.map((fault, at) => ( +
    1. + + {String(at + 1).padStart(2, "0")} + + {fault.message} + + {fault.path.map((step, depth) => ( + + {depth > 0 ? → : null} + {step} + + ))} + - -
    2. - ))} -
    - ) : null} - {/* The editor fills the card itself; only the form needs room around it. */} -
    - {asCode ? ( - <> - - {broken && !locked ? ( -

    - The form cannot draw this json, so it stays in Code. The builder lists what is - wrong. -

    - ) : null} - - ) : ( - <> - {/* Dragged where they are read, not behind the gear: moving a section is the editor's - work, not a change to the shape. The move is made on the stored list by name rather - than by the seat on screen — a section hidden by a condition is still in the list. */} - {sections.length > 0 && ( - node.name || `#${at + 1}`)} - onDragEnd={({ moveFromIndex, moveToIndex }) => { - const from = root.findIndex( - (node) => node.name === sections[moveFromIndex]?.name - ); - const to = root.findIndex((node) => node.name === sections[moveToIndex]?.name); - if (from < 0 || to < 0) return; - setValue(root.toSpliced(from, 1).toSpliced(to, 0, root[from])); - }} - > - {sections.map((node, at) => { - const key = node.name || `#${at + 1}`; - return ( - - {({ - attributes, - isDragging, - listeners, - setNodeRef, - transform, - transition, - }) => ( -
    -
  2. + ))} +
+ ) : null} +
+ {asCode ? ( + <> + + {broken && !locked ? ( +

+ The form cannot draw this json, so it stays in Code. The builder lists what is + wrong. +

+ ) : null} + + ) : ( + <> + {sections.length > 0 && ( + node.name || `#${at + 1}`)} + onDragEnd={({ moveFromIndex, moveToIndex }) => { + const from = root.findIndex( + (node) => node.name === sections[moveFromIndex]?.name + ); + const to = root.findIndex((node) => node.name === sections[moveToIndex]?.name); + if (from < 0 || to < 0) return; + setValue(root.toSpliced(from, 1).toSpliced(to, 0, root[from])); + }} + > + {sections.map((node, at) => { + const key = node.name || `#${at + 1}`; + return ( + + {({ + attributes, + isDragging, + listeners, + setNodeRef, + transform, + transition, + }) => ( +
+
- )} -
- ); - })} -
- )} - {untyped && ( -

- This json has no field types yet — it is converted by the typed-json script. -

- )} - {!sections.length && !untyped && !mayBuild && ( -

- No fields yet — someone with the builder adds them. -

- )} - {/* What a list and a blocks field offer under their rows, so adding a section is where - the hand already goes looking. */} - {mayBuild && !untyped && ( - + ]} + node={node} + onChange={(next) => set(key, next)} + top + /> +
+ )} + + ); + })} + + )} + {untyped && ( +

+ This json has no field types yet — it is converted by the typed-json script. +

+ )} + {!sections.length && !untyped && !mayBuild && ( +

+ No fields yet — someone with the builder adds them. +

+ )} + {mayBuild && !untyped && ( + + )} + + )} +
+
+ + > + {broken + ? "invalid" + : unfilled.length + ? `${unfilled.length} ${unfilled.length === 1 ? "error" : "errors"}` + : "valid"} + + {`${kept} ${kept === 1 ? "section" : "sections"}`} + {asCode && locked ? ( + read only — the gear builds the sections + ) : null} +
+ {mayBuild && ( + )} + node.name === dropping), + dropping + )}" and everything filled in under it.`} + confirmLabel="Delete" + heading="Delete section" + modalSlug={dropSlug} + onConfirm={() => drop(dropping)} + />
-
- - {broken - ? "invalid" - : unfilled.length - ? `${unfilled.length} ${unfilled.length === 1 ? "error" : "errors"}` - : "valid"} - - {`${kept} ${kept === 1 ? "section" : "sections"}`} - {asCode && locked ? ( - read only — the gear builds the sections - ) : null} -
- {mayBuild && ( - - )} - node.name === dropping), - dropping - )}" and everything filled in under it.`} - confirmLabel="Delete" - heading="Delete section" - modalSlug={dropSlug} - onConfirm={() => drop(dropping)} - /> -
+ ); }; diff --git a/packages/payload-plugin-json-form-builder/src/components/JsonFormField.tsx b/packages/payload-plugin-json-form-builder/src/components/JsonFormField.tsx index f600afd5..6bb51486 100644 --- a/packages/payload-plugin-json-form-builder/src/components/JsonFormField.tsx +++ b/packages/payload-plugin-json-form-builder/src/components/JsonFormField.tsx @@ -4,12 +4,16 @@ import { allows } from "../field/index.js"; import type { BuildGate } from "../field/index.js"; import { JsonFormClient } from "./JsonFormClient.js"; -type Props = ComponentProps & { build?: BuildGate }; +type Props = ComponentProps & { + build?: BuildGate; + library?: boolean; + shares?: boolean; +}; -// A server component for one reason: the gate is answered where the user is. A predicate cannot -// cross to the browser, so it is read here and only its answer travels. export const JsonFormField = ({ build, + library, + shares, clientField, path, permissions, @@ -19,7 +23,9 @@ export const JsonFormField = ({ }: Props) => ( { if (kind === "number") { - // A number written as text is shown rather than blanked — the box holds it, the checks read it - // the same way, and the first edit writes it back as a number. Only a value no reading makes a - // number stays out, and then it is said: an empty box over a value nobody can see is the worse - // of the two. const held = numberOf(value); const shown = Number.isFinite(held) ? held : ""; const unread = diff --git a/packages/payload-plugin-json-form-builder/src/components/JsonMediaInput.tsx b/packages/payload-plugin-json-form-builder/src/components/JsonMediaInput.tsx index c4e99996..49a069b7 100644 --- a/packages/payload-plugin-json-form-builder/src/components/JsonMediaInput.tsx +++ b/packages/payload-plugin-json-form-builder/src/components/JsonMediaInput.tsx @@ -42,14 +42,10 @@ export const JsonMediaInput = ({ required?: boolean; }) => { const { config } = useConfig(); - // Which collection the file is looked for in. With none, every url is simply a link we did not - // put there — which is the branch below that already knows what to do with one. const { uploads } = useJsonFormConfig(); // Relative, like UploadInput's own requests: `serverURL` is production's even on a preview. const api = config.routes.api; const filename = fileOf(url); - // The write-back happens after a request, and by then the form may have moved on: the callback of - // the last render rebuilds the value from what is in it now, the captured one from what was. const latest = useRef(onChange); latest.current = onChange; const [doc, setDoc] = useState(filename ? undefined : null); @@ -63,17 +59,12 @@ export const JsonMediaInput = ({ ) .then((res) => res.json()) .then(({ docs }) => setDoc(docs?.[0]?.id ?? null)) - // Not left waiting: an empty upload field can still be filled in, a shimmer cannot. .catch((error) => error?.name !== "AbortError" && setDoc(null)); return () => request.abort(); }, [api, filename, uploads]); - // A url Media has no document for is two different situations, and they need different answers: - // the file may still be where it points — the document was renamed, or the link was never ours — - // or there may be nothing there at all. Only the image can say which, so it is asked. const stray = Boolean(url) && doc === null; const [reachable, setReachable] = useState(); - // Read off the same image, so what the card says about it is measured rather than claimed. const [measured, setMeasured] = useState<{ height: number; width: number } | null>(null); useEffect(() => { @@ -108,8 +99,6 @@ export const JsonMediaInput = ({ else setDoc(before); }; - // The same wrapper, label and 62px UploadInput itself shows while it loads a document, so the - // accordion opening onto it animates to the height it ends at. if (doc === undefined || (stray && reachable === undefined)) return (
@@ -118,12 +107,6 @@ export const JsonMediaInput = ({
); - // The card UploadInput draws for a file that has been chosen, less the one action with nothing to - // act on: Edit opens a document, and there is none. Payload's own classes rather than a copy of - // its look — `RelationshipContent` is not in the root barrel, and by subpath it is a second copy - // of the module graph, which is where its config and translation hooks throw. What it would put - // in the meta line is the mime type and the byte size, neither of which exists without a - // document; the size the image reports is measured, so that is what stands there. if (stray && reachable) return (
@@ -181,8 +164,6 @@ export const JsonMediaInput = ({ serverURL={config.serverURL} value={doc ?? undefined} /> - {/* Nothing in Media by that name and nothing at the url either. The value is left where it - is rather than quietly dropped — said plainly instead, and a file picked replaces it. */} {stray ? (

{`No file at this link, and none in ${uploads} by that name. It is still stored — pick one to replace it.`} 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 eecc332d..78a15c36 100644 --- a/packages/payload-plugin-json-form-builder/src/components/JsonNode.tsx +++ b/packages/payload-plugin-json-form-builder/src/components/JsonNode.tsx @@ -26,6 +26,7 @@ import type { TypedNode } from "../field/typedJson.js"; export type Drag = ComponentProps["dragHandleProps"]; type Fold = Pick, "isCollapsed" | "onToggle">; export type NodeProps = Fold & { + badge?: string; node: TypedNode; id: string; label: string; @@ -38,7 +39,6 @@ export type NodeProps = Fold & { faults?: boolean; }; -// A written `label` wins; otherwise the name is read as words, and a section falls back to its key. export const labelOf = (node?: TypedNode, key = "") => node?.label || toWords(node?.name || key); export const isRowHidden = (row: TypedNode[]) => @@ -48,8 +48,6 @@ export const toggleRowHidden = (row: TypedNode[]): TypedNode[] => ? row.filter((field) => field.name !== HIDDEN) : [...row, { name: HIDDEN, type: "checkbox", value: true }]; -// `flatten` leaves a hidden node out of what the site gets, so the switch lives on the node itself -// and the name — the key a template reads — is never touched by hiding. export const hideItem = (node: TypedNode, onChange: (node: TypedNode) => void): MenuItem => ({ icon: , label: node.hidden ? "Show" : "Hide", @@ -75,8 +73,6 @@ export const JsonFields = ({ {fields.map((field, index) => { const set = (next: TypedNode) => onChange(fields.map((entry, i) => (i === index ? next : entry))); - // A row's own switch, which its card's menu owns: it is not a field to fill in. Elsewhere a - // field of that name is an ordinary one and is drawn. if (row && field.name === HIDDEN) return null; if (!visible(field, fields)) return null; return ( @@ -95,8 +91,8 @@ export const JsonFields = ({

); -// One typed value as an admin field, drawn from its `type` alone. export const JsonNode = ({ + badge, node, id, label, @@ -110,19 +106,24 @@ export const JsonNode = ({ faults, readOnly: parentReadOnly, }: NodeProps) => { - // A section set read-only takes everything under it with it. const readOnly = parentReadOnly || node.readOnly; const actions = menu && !readOnly && ; - // Only a section waits to be opened; below it accordions mount with it. const Fold = top ? JsonFolded : Collapsible; - // Said under the field it is about, and only once a save has been turned down — before that an - // unfilled field is simply one nobody has got to yet. const fault = faults ? nodeFault(node) : ""; + const head = badge ? ( + <> + {label} + {badge} + + ) : ( + label + ); if (node.type === "array") return (