Add on-page editor for the go-graph-cms
Injectable React editor that is side-loaded onto the statically rendered page and edits through the CMS HTTP API (POST /api/edit). - Shadow-root UI over server HTML; patches morphed in, undo/redo from server inverses (reuses @k6n/cms-editor EditClient/morph/markdown editor) - Sign-in via go-auth device grant as the CMS client (aud matches), token refresh + 401 retry; live preview swapped in on load and on tab focus - Schema-driven property form (arrays, maps, variants, $ref, validation) - Every string property editable where it is rendered (value-bound inline fields and attribute chips), markdown WYSIWYG for marked fields - Block palette + drag & drop with ghost drop targets, insertion slots, sort snapping between siblings (Alt to nest) - x-style annotations: on-page padding/margin/gap handles, Style panel, wrap-in-layout; x-graph label/group for titles and grouping - Dev workflow: Vite proxy to the live site (token injected server-side) or an offline in-memory mock CMS; jsdom test suite Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
1af9ec3172
67 files changed
+12322
No files matched your search
@@ -0,0 +1,183 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import type { JSONSchema } from '../api'
|
||||
import { Icon } from '../ui/Icon'
|
||||
import { useForm } from './context'
|
||||
import { JsonArea, Widget } from './Form'
|
||||
import { defaultFor, resolveSchema, variantsOf } from './resolve'
|
||||
|
||||
let uid = 0
|
||||
const newKey = () => `k${++uid}`
|
||||
|
||||
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`
|
||||
|
||||
/** Short label for a collapsed object card: x-label, else the first useful string. */
|
||||
function summarise(root: JSONSchema, itemSchema: JSONSchema, item: unknown, index: number): string {
|
||||
if (typeof item === 'string' || typeof item === 'number') return String(item)
|
||||
if (item && typeof item === 'object') {
|
||||
const o = item as Record<string, unknown>
|
||||
const label = itemSchema['x-label'] ?? itemSchema['x-graph']?.label
|
||||
const prefer = [label, 'title', 'name', 'label', 'text', 'heading', 'href', 'id'].filter(Boolean) as string[]
|
||||
for (const k of prefer) if (typeof o[k] === 'string' && o[k]) return String(o[k]).slice(0, 48)
|
||||
for (const v of Object.values(o)) if (typeof v === 'string' && v) return v.slice(0, 48)
|
||||
}
|
||||
return `Item ${index + 1}`
|
||||
}
|
||||
|
||||
export function ArrayField({ schema: s, value, onChange, path }: { schema: JSONSchema; value: unknown; onChange(v: unknown): void; path: string }) {
|
||||
const { root, errors } = useForm()
|
||||
const items = Array.isArray(value) ? value : []
|
||||
const it = resolveSchema(root, Array.isArray(s.items) ? undefined : s.items)
|
||||
const itemSchema = it.schema
|
||||
|
||||
// Tuples / untyped arrays have no per-item form: raw JSON is the honest editor.
|
||||
if (Array.isArray(s.items) || s.prefixItems) return <JsonArea value={value} onChange={onChange} />
|
||||
|
||||
// uniqueItems + enum items: a checkbox set (the natural UI for "pick several").
|
||||
if (Array.isArray(itemSchema.enum) && s.uniqueItems) {
|
||||
return (
|
||||
<div className="cms-checks">
|
||||
{itemSchema.enum.map((o: unknown) => (
|
||||
<label key={String(o)} className="cms-inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="cms-check"
|
||||
checked={items.includes(o)}
|
||||
onChange={(e) => onChange(e.target.checked ? [...items, o] : items.filter((x) => x !== o))}
|
||||
/>{' '}
|
||||
{String(o)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const t = Array.isArray(itemSchema.type) ? itemSchema.type[0] : itemSchema.type
|
||||
const variants = variantsOf(itemSchema)
|
||||
const compound = t === 'object' || t === 'array' || (!t && !!itemSchema.properties) || !!variants?.some((v) => resolveSchema(root, v).schema.type === 'object')
|
||||
return <ArrayList schema={s} itemSchema={itemSchema} items={items} onChange={onChange} path={path} compound={compound} err={errors[path]} />
|
||||
}
|
||||
|
||||
function ArrayList({ schema: s, itemSchema, items, onChange, path, compound }: { schema: JSONSchema; itemSchema: JSONSchema; items: unknown[]; onChange(v: unknown): void; path: string; compound: boolean; err?: string }) {
|
||||
const { root } = useForm()
|
||||
// Stable keys survive reorders, so open/closed state and focus stay with the item.
|
||||
const keys = useRef<string[]>([])
|
||||
while (keys.current.length < items.length) keys.current.push(newKey())
|
||||
if (keys.current.length > items.length) keys.current.length = items.length
|
||||
const [open, setOpen] = useState<Record<string, boolean>>({})
|
||||
const [dragFrom, setDragFrom] = useState<number | null>(null)
|
||||
const [dragOver, setDragOver] = useState<number | null>(null)
|
||||
const min = s.minItems ?? 0
|
||||
const max = s.maxItems ?? Infinity
|
||||
const isOpen = (k: string) => open[k] ?? items.length <= 2
|
||||
|
||||
const commit = (next: unknown[]) => onChange(next)
|
||||
const move = (from: number, to: number) => {
|
||||
if (to < 0 || to >= items.length || from === to) return
|
||||
const next = [...items]
|
||||
next.splice(to, 0, ...next.splice(from, 1))
|
||||
keys.current.splice(to, 0, ...keys.current.splice(from, 1))
|
||||
commit(next)
|
||||
}
|
||||
const remove = (i: number) => {
|
||||
keys.current.splice(i, 1)
|
||||
commit(items.filter((_, j) => j !== i))
|
||||
}
|
||||
const duplicate = (i: number) => {
|
||||
const next = [...items]
|
||||
next.splice(i + 1, 0, structuredClone(items[i]))
|
||||
keys.current.splice(i + 1, 0, newKey())
|
||||
commit(next)
|
||||
}
|
||||
const add = () => {
|
||||
if (items.length >= max) return
|
||||
const k = newKey()
|
||||
keys.current.push(k)
|
||||
setOpen((o) => ({ ...o, [k]: true }))
|
||||
commit([...items, defaultFor(root, itemSchema)])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cms-array" data-count={items.length}>
|
||||
<ol className="cms-array__list">
|
||||
{items.map((item, i) => {
|
||||
const k = keys.current[i]
|
||||
const expanded = !compound || isOpen(k)
|
||||
return (
|
||||
<li
|
||||
key={k}
|
||||
className={`cms-item${dragOver === i && dragFrom !== null && dragFrom !== i ? ' is-over' : ''}`}
|
||||
onDragOver={(e) => {
|
||||
if (dragFrom === null) return
|
||||
e.preventDefault()
|
||||
setDragOver(i)
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
if (dragFrom !== null) move(dragFrom, i)
|
||||
setDragFrom(null)
|
||||
setDragOver(null)
|
||||
}}
|
||||
>
|
||||
<div className="cms-item__head">
|
||||
<span
|
||||
className="cms-grip"
|
||||
draggable
|
||||
title="Drag to reorder"
|
||||
aria-hidden="true"
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', String(i))
|
||||
setDragFrom(i)
|
||||
}}
|
||||
onDragEnd={() => (setDragFrom(null), setDragOver(null))}
|
||||
>
|
||||
<Icon name="grip" size={18} />
|
||||
</span>
|
||||
{compound ? (
|
||||
<button type="button" className="cms-item__title" aria-expanded={expanded} onClick={() => setOpen((o) => ({ ...o, [k]: !expanded }))}>
|
||||
<Icon name="chevron" size={16} className={`cms-chev${expanded ? ' is-open' : ''}`} /> {summarise(root, itemSchema, item, i)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="cms-item__value">
|
||||
<Widget schema={itemSchema} value={item} onChange={(v) => commit(items.map((x, j) => (j === i ? (v === undefined ? defaultFor(root, itemSchema) : v) : x)))} path={`${path}.${i}`} required />
|
||||
</div>
|
||||
)}
|
||||
<span className="cms-item__actions">
|
||||
<button type="button" className="cms-ico" aria-label={`Move item ${i + 1} up`} title="Move up" disabled={i === 0} onClick={() => move(i, i - 1)}>
|
||||
<Icon name="up" size={16} />
|
||||
</button>
|
||||
<button type="button" className="cms-ico" aria-label={`Move item ${i + 1} down`} title="Move down" disabled={i === items.length - 1} onClick={() => move(i, i + 1)}>
|
||||
<Icon name="down" size={16} />
|
||||
</button>
|
||||
{compound && (
|
||||
<button type="button" className="cms-ico" aria-label={`Duplicate item ${i + 1}`} title="Duplicate" disabled={items.length >= max} onClick={() => duplicate(i)}>
|
||||
<Icon name="duplicate" size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="cms-ico cms-ico--danger" aria-label={`Remove item ${i + 1}`} title="Remove" disabled={items.length <= min} onClick={() => remove(i)}>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{compound && expanded && (
|
||||
<div className="cms-item__body">
|
||||
<Widget schema={itemSchema} value={item} onChange={(v) => commit(items.map((x, j) => (j === i ? (v ?? {}) : x)))} path={`${path}.${i}`} required bare />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
<div className="cms-array__foot">
|
||||
<button type="button" className="cms-btn" aria-label="Add item" onClick={add} disabled={items.length >= max}>
|
||||
<Icon name="add" size={16} /> Add {itemSchema.title?.toLowerCase() ?? 'item'}
|
||||
</button>
|
||||
<span className="cms-muted">
|
||||
{plural(items.length, 'item')}
|
||||
{s.minItems ? ` · min ${s.minItems}` : ''}
|
||||
{s.maxItems ? ` · max ${s.maxItems}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { CmsNode, JSONSchema } from '../api'
|
||||
import type { Controller } from '../controller'
|
||||
import { fieldEditorFor } from '../registry'
|
||||
import { labelOf, READONLY_KEYS } from '../schema'
|
||||
import { Summary } from '../ui/Summary'
|
||||
import { ArrayField } from './ArrayField'
|
||||
import { FormContext, useForm } from './context'
|
||||
import { MapField } from './MapField'
|
||||
import { resolveSchema, variantsOf } from './resolve'
|
||||
import { validate } from './validate'
|
||||
import { VariantField } from './VariantField'
|
||||
|
||||
export { validate } from './validate'
|
||||
export type { Errors } from './validate'
|
||||
|
||||
interface FormProps {
|
||||
schema: JSONSchema
|
||||
value: Record<string, any>
|
||||
onChange(v: Record<string, any>): void
|
||||
node: CmsNode
|
||||
ctrl: Controller
|
||||
}
|
||||
|
||||
/** The whole properties form. Reports validity through `useFormErrors` callers via `validate()`. */
|
||||
export function SchemaForm({ schema, value, onChange, node, ctrl }: FormProps) {
|
||||
const [advanced, setAdvanced] = useState(false)
|
||||
const errors = useMemo(() => validate(schema, schema, value), [schema, value])
|
||||
const hasAdvanced = useMemo(() => JSON.stringify(schema).includes('"x-advanced"'), [schema])
|
||||
return (
|
||||
<FormContext.Provider value={{ root: schema, node, ctrl, errors, advanced }}>
|
||||
{hasAdvanced && (
|
||||
<label className="cms-inline">
|
||||
<input type="checkbox" className="cms-check" checked={advanced} onChange={(e) => setAdvanced(e.target.checked)} /> Show advanced fields
|
||||
</label>
|
||||
)}
|
||||
<ObjectFields schema={resolveSchema(schema, schema).schema} value={value} onChange={(v) => onChange(v ?? {})} path="" />
|
||||
</FormContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// --- containers ----------------------------------------------------------------
|
||||
|
||||
const isPlainObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
/** Should a cleared value remove its key (optional fields never round-trip as '' / [] / {})? */
|
||||
const dropsKey = (v: unknown) => v === undefined || v === '' || (Array.isArray(v) && v.length === 0) || (isPlainObject(v) && Object.keys(v).length === 0)
|
||||
|
||||
export function ObjectFields({ schema: s, value, onChange, path }: { schema: JSONSchema; value: Record<string, any>; onChange(v: Record<string, any> | undefined): void; path: string }) {
|
||||
const { root, advanced } = useForm()
|
||||
const required = new Set<string>(s.required ?? [])
|
||||
const entries = Object.entries<JSONSchema>(s.properties ?? {}).filter(([k, raw]) => {
|
||||
if (READONLY_KEYS.has(k) && path === '') return false
|
||||
const p = resolveSchema(root, raw).schema
|
||||
if (p['x-editor'] === 'hidden') return false
|
||||
if ('const' in p) return false // discriminators are set by the variant picker, not typed
|
||||
if (!advanced && (p['x-advanced'] || p['x-graph']?.advanced)) return false
|
||||
return true
|
||||
})
|
||||
const extra = Object.keys(value).filter((k) => !(k in (s.properties ?? {})) && !(path === '' && READONLY_KEYS.has(k)))
|
||||
const set = (k: string, v: unknown) => {
|
||||
const next = { ...value }
|
||||
if (!required.has(k) && dropsKey(v)) delete next[k]
|
||||
else next[k] = v
|
||||
onChange(next)
|
||||
}
|
||||
const at = (k: string) => (path ? `${path}.${k}` : k)
|
||||
|
||||
// x-group: collapsible sections, in order of first appearance.
|
||||
const meta = (s['x-groups'] ?? {}) as Record<string, { title?: string; description?: string; collapsedByDefault?: boolean; advancedOnly?: boolean }>
|
||||
const sections: Array<{ group: string | null; items: Array<[string, JSONSchema]> }> = []
|
||||
for (const e of entries) {
|
||||
const g = resolveSchema(root, e[1]).schema['x-group'] as string | undefined
|
||||
const existing = g ? sections.find((x) => x.group === g) : undefined
|
||||
if (existing) existing.items.push(e)
|
||||
else sections.push({ group: g ?? null, items: [e] })
|
||||
}
|
||||
|
||||
const row = ([k, raw]: [string, JSONSchema]) => (
|
||||
<FieldRow key={k} name={k} schema={raw} required={required.has(k)} path={at(k)}>
|
||||
<Widget schema={raw} value={value[k]} onChange={(v) => set(k, v)} path={at(k)} required={required.has(k)} />
|
||||
</FieldRow>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
{sections.map((sec) =>
|
||||
sec.group === null ? (
|
||||
row(sec.items[0])
|
||||
) : meta[sec.group]?.advancedOnly && !advanced ? null : (
|
||||
<details key={sec.group} className="cms-group" open={!(meta[sec.group]?.collapsedByDefault ?? true)}>
|
||||
<Summary>
|
||||
{meta[sec.group]?.title ?? labelOf(sec.group, {})} <span className="cms-muted">({sec.items.length})</span>
|
||||
</Summary>
|
||||
{meta[sec.group]?.description && <p className="cms-muted">{meta[sec.group]!.description}</p>}
|
||||
{sec.items.map(row)}
|
||||
</details>
|
||||
),
|
||||
)}
|
||||
{extra.length > 0 && (
|
||||
<details className="cms-extra">
|
||||
<Summary>{extra.length} field(s) not in the schema</Summary>
|
||||
{extra.map((k) => (
|
||||
<FieldRow key={k} name={k} schema={{}} path={at(k)} block>
|
||||
<Widget schema={s.additionalProperties && typeof s.additionalProperties === 'object' ? s.additionalProperties : {}} value={value[k]} onChange={(v) => set(k, v)} path={at(k)} />
|
||||
</FieldRow>
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// --- field chrome ------------------------------------------------------------------
|
||||
|
||||
/** Containers get a heading `div`; scalars get a wrapping `label` (click-to-focus). */
|
||||
export function isBlock(root: JSONSchema, raw: JSONSchema): boolean {
|
||||
const s = resolveSchema(root, raw).schema
|
||||
if (s['x-editor'] && ['json', 'dictionary'].includes(s['x-editor'])) return true
|
||||
if (variantsOf(s)) return true
|
||||
const t = Array.isArray(s.type) ? s.type[0] : s.type
|
||||
return t === 'array' || t === 'object' || (!t && !!s.properties)
|
||||
}
|
||||
|
||||
export function FieldRow({ name, schema, required, path, children, block }: { name: string; schema: JSONSchema; required?: boolean; path: string; children: ReactNode; block?: boolean }) {
|
||||
const { root, errors } = useForm()
|
||||
const { schema: s } = resolveSchema(root, schema)
|
||||
const err = errors[path]
|
||||
const Tag = block ?? isBlock(root, schema) ? 'div' : 'label'
|
||||
return (
|
||||
<Tag className={`cms-field${err ? ' has-error' : ''}`} data-path={path}>
|
||||
<span className="cms-field__label">
|
||||
{s.title ?? labelOf(name, s)}
|
||||
{required && <b title="required"> *</b>}
|
||||
</span>
|
||||
{s.description && <span className="cms-field__hint">{s.description}</span>}
|
||||
{children}
|
||||
{err && (
|
||||
<span className="cms-field__error" role="alert">
|
||||
{err}
|
||||
</span>
|
||||
)}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
// --- widget dispatch -----------------------------------------------------------------
|
||||
|
||||
export interface WidgetProps {
|
||||
schema: JSONSchema
|
||||
value: unknown
|
||||
onChange(v: unknown): void
|
||||
path: string
|
||||
required?: boolean
|
||||
/** Object editors drop their own border (used inside array cards). */
|
||||
bare?: boolean
|
||||
}
|
||||
|
||||
export function Widget({ schema: raw, value, onChange, path, required, bare }: WidgetProps) {
|
||||
const { root, node, ctrl } = useForm()
|
||||
const { schema: s } = resolveSchema(root, raw)
|
||||
const x = s['x-editor'] as string | undefined
|
||||
if (x === 'hidden') return null
|
||||
|
||||
const Custom = fieldEditorFor(s, path, node)?.component
|
||||
if (Custom) return <Custom schema={s} value={value} onChange={onChange} path={path} node={node} ctrl={ctrl} />
|
||||
|
||||
if (s.readOnly || x === 'readonly') return <ReadOnly value={value} />
|
||||
if (variantsOf(s)) return <VariantField schema={s} value={value} onChange={onChange} path={path} />
|
||||
if (x === 'json') return <JsonArea value={value} onChange={onChange} />
|
||||
|
||||
const t = Array.isArray(s.type) ? s.type.find((y: string) => y !== 'null') : s.type
|
||||
if (s.enum) return <EnumSelect options={s.enum} value={value} onChange={onChange} required={required} />
|
||||
if (t === 'array') return <ArrayField schema={s} value={value} onChange={onChange} path={path} />
|
||||
if (t === 'object' || (!t && (s.properties || s.additionalProperties))) {
|
||||
if (x === 'dictionary' || s['x-dict-keys'] || !s.properties) return <MapField schema={s} value={value} onChange={onChange} path={path} />
|
||||
const obj = isPlainObject(value) ? value : {}
|
||||
const fields = <ObjectFields schema={s} value={obj} onChange={(v) => onChange(v)} path={path} />
|
||||
return bare ? <div className="cms-object">{fields}</div> : <fieldset className="cms-nested">{fields}</fieldset>
|
||||
}
|
||||
if (t === 'boolean') return <input type="checkbox" className="cms-check" checked={!!value} onChange={(e) => onChange(e.target.checked)} />
|
||||
if (t === 'integer' || t === 'number') return <NumberInput schema={s} value={value} onChange={onChange} />
|
||||
if (t === 'string') return <StringInput schema={s} value={value} onChange={onChange} />
|
||||
// Untyped ("any"): decide by what is actually stored.
|
||||
switch (typeof value) {
|
||||
case 'string':
|
||||
return <StringInput schema={s} value={value} onChange={onChange} />
|
||||
case 'number':
|
||||
return <NumberInput schema={s} value={value} onChange={onChange} />
|
||||
case 'boolean':
|
||||
return <input type="checkbox" className="cms-check" checked={value} onChange={(e) => onChange(e.target.checked)} />
|
||||
case 'undefined':
|
||||
return <StringInput schema={s} value={value} onChange={onChange} />
|
||||
default:
|
||||
return <JsonArea value={value} onChange={onChange} />
|
||||
}
|
||||
}
|
||||
|
||||
// --- scalar inputs ----------------------------------------------------------------------
|
||||
|
||||
const looksLikeImage = (v: unknown) => typeof v === 'string' && /\.(jpe?g|gif|png|webp|svg|avif)(\?|$)/i.test(v)
|
||||
|
||||
export function StringInput({ schema: s, value, onChange }: { schema: JSONSchema; value: unknown; onChange(v: unknown): void }) {
|
||||
const x = s['x-editor'] as string | undefined
|
||||
const text = typeof value === 'string' ? value : value == null ? '' : String(value)
|
||||
if (x && ['textarea', 'markdown', 'monaco', 'html', 'richtext'].includes(x) || (s.maxLength ?? 0) > 200) {
|
||||
const mono = x === 'monaco' || x === 'html'
|
||||
return <textarea className={`cms-input cms-input--area${mono ? ' cms-mono' : ''}`} rows={x === 'markdown' || x === 'richtext' ? 8 : 5} value={text} onChange={(e) => onChange(e.target.value)} spellCheck={!mono} />
|
||||
}
|
||||
const type = ({ email: 'email', uri: 'url', url: 'url', date: 'date', time: 'time' } as Record<string, string>)[s.format] ?? 'text'
|
||||
return (
|
||||
<>
|
||||
<input className={`cms-input${x === 'node' ? ' cms-mono' : ''}`} type={type} value={text} placeholder={x === 'node' ? 'node id' : s.default ?? s.examples?.[0] ?? ''} onChange={(e) => onChange(e.target.value)} />
|
||||
{x === 'asset' && looksLikeImage(text) && <img className="cms-thumb" src={text} alt="" />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function NumberInput({ schema: s, value, onChange }: { schema: JSONSchema; value: unknown; onChange(v: unknown): void }) {
|
||||
return (
|
||||
<input
|
||||
className="cms-input"
|
||||
type="number"
|
||||
value={typeof value === 'number' ? value : ''}
|
||||
min={s.minimum}
|
||||
max={s.maximum}
|
||||
step={s.type === 'integer' ? 1 : (s.multipleOf ?? 'any')}
|
||||
onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function EnumSelect({ options, value, onChange, required }: { options: unknown[]; value: unknown; onChange(v: unknown): void; required?: boolean }) {
|
||||
const known = options.some((o) => o === value)
|
||||
return (
|
||||
<select
|
||||
className="cms-input"
|
||||
value={value === undefined || value === null ? '' : String(value)}
|
||||
onChange={(e) => onChange(e.target.value === '' ? undefined : (options.find((o) => String(o) === e.target.value) ?? e.target.value))}
|
||||
>
|
||||
{!required || value === undefined ? <option value="">—</option> : null}
|
||||
{!known && value !== undefined && value !== null && <option value={String(value)}>{String(value)} (not in schema)</option>}
|
||||
{options.map((o) => (
|
||||
<option key={String(o)} value={String(o)}>
|
||||
{String(o)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReadOnly({ value }: { value: unknown }) {
|
||||
return <pre className="cms-readonly">{typeof value === 'string' ? value : JSON.stringify(value, null, 2) ?? '—'}</pre>
|
||||
}
|
||||
|
||||
/** Free-form JSON. Keeps invalid text locally until it parses; a parse error is shown, never lost. */
|
||||
export function JsonArea({ value, onChange }: { value: unknown; onChange(v: unknown): void }) {
|
||||
const [text, setText] = useState(() => (value === undefined ? '' : JSON.stringify(value, null, 2)))
|
||||
const [bad, setBad] = useState<string | null>(null)
|
||||
return (
|
||||
<>
|
||||
<textarea
|
||||
className={`cms-input cms-input--area cms-mono${bad ? ' cms-input--bad' : ''}`}
|
||||
rows={5}
|
||||
value={text}
|
||||
spellCheck={false}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value)
|
||||
if (e.target.value.trim() === '') {
|
||||
setBad(null)
|
||||
return onChange(undefined)
|
||||
}
|
||||
try {
|
||||
onChange(JSON.parse(e.target.value))
|
||||
setBad(null)
|
||||
} catch (err) {
|
||||
setBad((err as Error).message)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{bad && <span className="cms-field__error">Invalid JSON: {bad}</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState } from 'react'
|
||||
import type { JSONSchema } from '../api'
|
||||
import { Icon } from '../ui/Icon'
|
||||
import { useForm } from './context'
|
||||
import { Widget } from './Form'
|
||||
import { defaultFor, resolveSchema } from './resolve'
|
||||
|
||||
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
/**
|
||||
* Key → value editor: `additionalProperties` schemas, `x-editor: dictionary`, objects without a
|
||||
* declared shape. With `x-dict-keys` the keys are a fixed vocabulary (one row each, blank removes);
|
||||
* otherwise keys are free-form (rename, add, remove).
|
||||
*/
|
||||
export function MapField({ schema: s, value, onChange, path }: { schema: JSONSchema; value: unknown; onChange(v: unknown): void; path: string }) {
|
||||
const { root } = useForm()
|
||||
const obj = isObj(value) ? value : {}
|
||||
const ap = s.additionalProperties
|
||||
const valueSchema: JSONSchema = ap && typeof ap === 'object' ? ap : {}
|
||||
const fixed = Array.isArray(s['x-dict-keys']) ? (s['x-dict-keys'] as string[]) : null
|
||||
const [draftKey, setDraftKey] = useState('')
|
||||
const [dupe, setDupe] = useState(false)
|
||||
|
||||
const setVal = (k: string, v: unknown) => {
|
||||
const next = { ...obj }
|
||||
if (fixed && (v === undefined || v === '')) delete next[k]
|
||||
else next[k] = v === undefined ? defaultFor(root, valueSchema) : v
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
if (fixed) {
|
||||
const rest = Object.keys(obj).filter((k) => !fixed.includes(k))
|
||||
return (
|
||||
<div className="cms-map">
|
||||
{[...fixed, ...rest].map((k) => (
|
||||
<div key={k} className="cms-map__row">
|
||||
<span className="cms-map__key">{k}</span>
|
||||
<div className="cms-map__value">
|
||||
<Widget schema={valueSchema} value={obj[k]} onChange={(v) => setVal(k, v)} path={`${path}.${k}`} />
|
||||
</div>
|
||||
{k in obj && (
|
||||
<button type="button" className="cms-ico cms-ico--danger" aria-label={`Clear ${k}`} onClick={() => setVal(k, undefined)}>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rename = (from: string, to: string) => {
|
||||
to = to.trim()
|
||||
if (!to || to === from) return false
|
||||
if (to in obj) return setDupe(true), false
|
||||
// Rebuild to keep the row's position.
|
||||
onChange(Object.fromEntries(Object.entries(obj).map(([k, v]) => [k === from ? to : k, v])))
|
||||
return true
|
||||
}
|
||||
const add = () => {
|
||||
const k = draftKey.trim()
|
||||
if (!k) return
|
||||
if (k in obj) return setDupe(true)
|
||||
onChange({ ...obj, [k]: defaultFor(root, valueSchema) })
|
||||
setDraftKey('')
|
||||
setDupe(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cms-map">
|
||||
{Object.entries(obj).map(([k, v]) => (
|
||||
<div key={k} className="cms-map__row">
|
||||
<KeyInput name={k} onCommit={(to) => rename(k, to)} />
|
||||
<div className="cms-map__value">
|
||||
<Widget schema={valueSchema} value={v} onChange={(nv) => setVal(k, nv)} path={`${path}.${k}`} required />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="cms-ico cms-ico--danger"
|
||||
aria-label={`Remove ${k}`}
|
||||
onClick={() => {
|
||||
const { [k]: _gone, ...rest } = obj
|
||||
onChange(rest)
|
||||
}}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="cms-map__add">
|
||||
<input
|
||||
className={`cms-input${dupe ? ' cms-input--bad' : ''}`}
|
||||
placeholder="new key"
|
||||
aria-label="New key"
|
||||
value={draftKey}
|
||||
onChange={(e) => (setDraftKey(e.target.value), setDupe(false))}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), add())}
|
||||
/>
|
||||
<button type="button" className="cms-btn" aria-label="Add entry" onClick={add} disabled={!draftKey.trim()}>
|
||||
<Icon name="add" size={16} /> Add
|
||||
</button>
|
||||
</div>
|
||||
{dupe && <span className="cms-field__error">That key already exists</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Key text is local until blur/Enter: renaming rebuilds the object, which would otherwise steal focus per keystroke. */
|
||||
function KeyInput({ name, onCommit }: { name: string; onCommit(to: string): boolean }) {
|
||||
const [text, setText] = useState(name)
|
||||
const [seen, setSeen] = useState(name)
|
||||
if (seen !== name) {
|
||||
setSeen(name)
|
||||
setText(name)
|
||||
}
|
||||
const commit = () => (onCommit(text) ? undefined : setText(name))
|
||||
return <input className="cms-input cms-map__key cms-mono" aria-label={`Key ${name}`} value={text} onChange={(e) => setText(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), commit())} />
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { JSONSchema } from '../api'
|
||||
import { useForm } from './context'
|
||||
import { Widget } from './Form'
|
||||
import { defaultFor, discriminators, pickVariant, resolveSchema, variantLabel, variantsOf } from './resolve'
|
||||
|
||||
/** oneOf / anyOf: choose a branch, then edit it with that branch's own form. */
|
||||
export function VariantField({ schema: s, value, onChange, path }: { schema: JSONSchema; value: unknown; onChange(v: unknown): void; path: string }) {
|
||||
const { root } = useForm()
|
||||
const branches = variantsOf(s) ?? []
|
||||
const current = pickVariant(root, branches, value)
|
||||
const choose = (i: number) => {
|
||||
if (i < 0) return onChange(undefined)
|
||||
const b = branches[i]
|
||||
const base = defaultFor(root, b)
|
||||
const consts = discriminators(root, b)
|
||||
// Keep what the old branch and the new one share, so switching does not wipe the user's input.
|
||||
const carried = value && typeof value === 'object' && base && typeof base === 'object' ? Object.fromEntries(Object.entries(value as object).filter(([k]) => k in (resolveSchema(root, b).schema.properties ?? {}))) : {}
|
||||
onChange(base && typeof base === 'object' && !Array.isArray(base) ? { ...base, ...carried, ...consts } : base)
|
||||
}
|
||||
return (
|
||||
<div className="cms-variant">
|
||||
<select className="cms-input" aria-label="Variant" value={current} onChange={(e) => choose(Number(e.target.value))}>
|
||||
<option value={-1}>Choose…</option>
|
||||
{branches.map((b, i) => (
|
||||
<option key={i} value={i}>
|
||||
{variantLabel(root, b, i)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{current >= 0 && <Widget schema={{ ...branches[current] }} value={value} onChange={onChange} path={path} required bare />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import type { CmsNode, JSONSchema } from '../api'
|
||||
import type { Controller } from '../controller'
|
||||
import type { Errors } from './validate'
|
||||
|
||||
export interface FormCtx {
|
||||
root: JSONSchema
|
||||
node: CmsNode
|
||||
ctrl: Controller
|
||||
errors: Errors
|
||||
advanced: boolean
|
||||
}
|
||||
export const FormContext = createContext<FormCtx>(null as never)
|
||||
export const useForm = () => useContext(FormContext)
|
||||
@@ -0,0 +1,152 @@
|
||||
// Schema normalisation for the form: $ref, allOf, nullable unions and
|
||||
// oneOf/anyOf variants. Everything the widgets need to know about "what is this
|
||||
// field" is decided here, so widgets stay dumb.
|
||||
import type { JSONSchema } from '../api'
|
||||
|
||||
export interface Resolved {
|
||||
schema: JSONSchema
|
||||
/** `type: [x, 'null']` or `anyOf: [x, {type:'null'}]` */
|
||||
nullable: boolean
|
||||
}
|
||||
|
||||
const isNullBranch = (s: JSONSchema) => s?.type === 'null' || (s?.enum?.length === 1 && s.enum[0] === null)
|
||||
|
||||
function lookup(root: JSONSchema, ref: string): JSONSchema | undefined {
|
||||
if (!ref.startsWith('#')) return undefined // remote refs are not fetched
|
||||
let t: any = root
|
||||
for (const part of ref.replace(/^#\/?/, '').split('/').filter(Boolean)) t = t?.[decodeURIComponent(part.replace(/~1/g, '/').replace(/~0/g, '~'))]
|
||||
return t
|
||||
}
|
||||
|
||||
/** Merge allOf branches into one schema (properties/required are combined, later wins elsewhere). */
|
||||
function mergeAllOf(root: JSONSchema, s: JSONSchema, depth: number): JSONSchema {
|
||||
if (!s.allOf) return s
|
||||
const { allOf, ...rest } = s
|
||||
let out: JSONSchema = { ...rest }
|
||||
for (const part of allOf as JSONSchema[]) {
|
||||
const p = resolveSchema(root, part, depth + 1).schema
|
||||
out = {
|
||||
...out,
|
||||
...p,
|
||||
properties: { ...(out.properties ?? {}), ...(p.properties ?? {}) },
|
||||
required: [...new Set([...(out.required ?? []), ...(p.required ?? [])])],
|
||||
}
|
||||
if (!Object.keys(out.properties).length) delete out.properties
|
||||
if (!out.required.length) delete out.required
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function resolveSchema(root: JSONSchema, s: JSONSchema | undefined | boolean, depth = 0): Resolved {
|
||||
if (!s || s === true || depth > 12) return { schema: {}, nullable: false }
|
||||
let cur: JSONSchema = s
|
||||
let nullable = false
|
||||
for (let i = 0; i < 12 && typeof cur.$ref === 'string'; i++) {
|
||||
const { $ref, ...siblings } = cur
|
||||
cur = { ...(lookup(root, $ref) ?? {}), ...siblings }
|
||||
}
|
||||
cur = mergeAllOf(root, cur, depth)
|
||||
if (Array.isArray(cur.type) && cur.type.includes('null')) {
|
||||
nullable = true
|
||||
const rest = cur.type.filter((t: string) => t !== 'null')
|
||||
cur = { ...cur, type: rest.length === 1 ? rest[0] : rest }
|
||||
}
|
||||
for (const key of ['anyOf', 'oneOf'] as const) {
|
||||
const branches = cur[key] as JSONSchema[] | undefined
|
||||
if (!branches) continue
|
||||
const real = branches.filter((b) => !isNullBranch(resolveSchema(root, b, depth + 1).schema))
|
||||
if (real.length !== branches.length) nullable = true
|
||||
if (real.length === 1) {
|
||||
const { [key]: _drop, ...rest } = cur
|
||||
const inner = resolveSchema(root, { ...rest, ...real[0] }, depth + 1)
|
||||
return { schema: inner.schema, nullable: nullable || inner.nullable }
|
||||
}
|
||||
if (real.length !== branches.length) cur = { ...cur, [key]: real }
|
||||
}
|
||||
return { schema: cur, nullable }
|
||||
}
|
||||
|
||||
export const variantsOf = (s: JSONSchema): JSONSchema[] | undefined => (s.oneOf ?? s.anyOf) as JSONSchema[] | undefined
|
||||
|
||||
/** Const / single-value-enum properties of an object branch: its discriminator(s). */
|
||||
export function discriminators(root: JSONSchema, branch: JSONSchema): Record<string, unknown> {
|
||||
const b = resolveSchema(root, branch).schema
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [k, raw] of Object.entries<JSONSchema>(b.properties ?? {})) {
|
||||
const p = resolveSchema(root, raw).schema
|
||||
if ('const' in p) out[k] = p.const
|
||||
else if (Array.isArray(p.enum) && p.enum.length === 1) out[k] = p.enum[0]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const typeOf = (v: unknown) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v === 'number' && Number.isInteger(v) ? 'integer' : typeof v)
|
||||
|
||||
/** Which branch of a oneOf/anyOf does `value` belong to? -1 when undecidable (e.g. no value). */
|
||||
export function pickVariant(root: JSONSchema, branches: JSONSchema[], value: unknown): number {
|
||||
if (value === undefined || value === null) return -1
|
||||
const scored = branches.map((raw, i) => {
|
||||
const b = resolveSchema(root, raw).schema
|
||||
const t = typeOf(value)
|
||||
const bt = Array.isArray(b.type) ? b.type : b.type ? [b.type] : []
|
||||
let score = 0
|
||||
if (bt.length && !(bt.includes(t) || (t === 'integer' && bt.includes('number')))) return { i, score: -1 }
|
||||
if (bt.length) score += 1
|
||||
if (t === 'object') {
|
||||
const d = discriminators(root, b)
|
||||
for (const [k, v] of Object.entries(d)) score += (value as any)[k] === v ? 10 : -10
|
||||
for (const k of b.required ?? []) score += k in (value as object) ? 1 : -1
|
||||
}
|
||||
if (Array.isArray(b.enum)) score += b.enum.includes(value) ? 5 : -5
|
||||
if ('const' in b) score += b.const === value ? 5 : -5
|
||||
return { i, score }
|
||||
})
|
||||
const best = scored.sort((a, b) => b.score - a.score)[0]
|
||||
return best && best.score >= 0 ? best.i : -1
|
||||
}
|
||||
|
||||
export function variantLabel(root: JSONSchema, raw: JSONSchema, i: number): string {
|
||||
const b = resolveSchema(root, raw).schema
|
||||
if (b.title) return b.title
|
||||
const d = Object.values(discriminators(root, b))
|
||||
if (d.length) return d.map(String).join(' / ')
|
||||
if ('const' in b) return String(b.const)
|
||||
return typeof b.type === 'string' ? `${b.type} ${i + 1}` : `Option ${i + 1}`
|
||||
}
|
||||
|
||||
/** A sensible starting value for a new array item / newly chosen variant. */
|
||||
export function defaultFor(root: JSONSchema, raw: JSONSchema | undefined, depth = 0): unknown {
|
||||
const { schema: s } = resolveSchema(root, raw)
|
||||
if (s.default !== undefined) return structuredClone(s.default)
|
||||
if ('const' in s) return s.const
|
||||
if (Array.isArray(s.enum)) return s.enum[0]
|
||||
const variants = variantsOf(s)
|
||||
if (variants?.length && depth < 6) return defaultFor(root, variants[0], depth + 1)
|
||||
const t = Array.isArray(s.type) ? s.type[0] : s.type
|
||||
switch (t) {
|
||||
case 'string':
|
||||
return ''
|
||||
case 'integer':
|
||||
case 'number':
|
||||
return s.minimum ?? 0
|
||||
case 'boolean':
|
||||
return false
|
||||
case 'array':
|
||||
return []
|
||||
case 'object':
|
||||
default: {
|
||||
if (t !== 'object' && !s.properties) return ''
|
||||
const out: Record<string, unknown> = {}
|
||||
if (depth < 6) {
|
||||
for (const k of s.required ?? []) {
|
||||
const v = defaultFor(root, s.properties?.[k], depth + 1)
|
||||
if (v !== undefined) out[k] = v
|
||||
}
|
||||
for (const [k, p] of Object.entries<JSONSchema>(s.properties ?? {})) {
|
||||
if (!(k in out) && (resolveSchema(root, p).schema as JSONSchema).const !== undefined) out[k] = defaultFor(root, p, depth + 1)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Client-side validation: early feedback only. The server's schema validation
|
||||
// stays authoritative (400 on /api/edit), this just stops obvious mistakes and
|
||||
// powers the inline messages / disabled Save. Keys are form paths (`links.0.text`).
|
||||
import type { JSONSchema } from '../api'
|
||||
import { pickVariant, resolveSchema, variantsOf } from './resolve'
|
||||
|
||||
export type Errors = Record<string, string>
|
||||
|
||||
const empty = (v: unknown) => v === undefined || v === null || v === ''
|
||||
|
||||
export function validate(root: JSONSchema, rawSchema: JSONSchema, value: unknown, path = '', errors: Errors = {}, depth = 0): Errors {
|
||||
if (depth > 20) return errors
|
||||
const { schema: s } = resolveSchema(root, rawSchema)
|
||||
if (s['x-editor'] === 'hidden' || s['x-editor'] === 'readonly' || s.readOnly) return errors
|
||||
const at = (msg: string) => void (errors[path] ??= msg)
|
||||
if (value === undefined || value === null) return errors
|
||||
|
||||
const variants = variantsOf(s)
|
||||
if (variants) {
|
||||
const i = pickVariant(root, variants, value)
|
||||
if (i >= 0) validate(root, variants[i], value, path, errors, depth + 1)
|
||||
return errors
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
if (s.minLength !== undefined && value.length < s.minLength && value !== '') at(`At least ${s.minLength} characters`)
|
||||
if (s.maxLength !== undefined && value.length > s.maxLength) at(`At most ${s.maxLength} characters`)
|
||||
if (s.pattern) {
|
||||
try {
|
||||
if (value !== '' && !new RegExp(s.pattern).test(value)) at(s['x-pattern-message'] ?? `Must match ${s.pattern}`)
|
||||
} catch {
|
||||
/* bad pattern in schema: ignore */
|
||||
}
|
||||
}
|
||||
if (s.format === 'email' && value !== '' && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) at('Not a valid email address')
|
||||
} else if (typeof value === 'number') {
|
||||
if (s.minimum !== undefined && value < s.minimum) at(`Must be at least ${s.minimum}`)
|
||||
if (s.maximum !== undefined && value > s.maximum) at(`Must be at most ${s.maximum}`)
|
||||
if (s.type === 'integer' && !Number.isInteger(value)) at('Must be a whole number')
|
||||
} else if (Array.isArray(value)) {
|
||||
if (s.minItems !== undefined && value.length < s.minItems) at(`Add at least ${s.minItems} item${s.minItems === 1 ? '' : 's'}`)
|
||||
if (s.maxItems !== undefined && value.length > s.maxItems) at(`At most ${s.maxItems} items`)
|
||||
value.forEach((it, i) => validate(root, s.items ?? {}, it, path ? `${path}.${i}` : String(i), errors, depth + 1))
|
||||
} else if (typeof value === 'object') {
|
||||
const obj = value as Record<string, unknown>
|
||||
for (const k of s.required ?? []) {
|
||||
const p = resolveSchema(root, s.properties?.[k]).schema
|
||||
if (empty(obj[k]) && !p.readOnly && p['x-editor'] !== 'hidden') errors[path ? `${path}.${k}` : k] ??= 'Required'
|
||||
}
|
||||
for (const [k, p] of Object.entries<JSONSchema>(s.properties ?? {})) validate(root, p, obj[k], path ? `${path}.${k}` : k, errors, depth + 1)
|
||||
const ap = s.additionalProperties
|
||||
if (ap && typeof ap === 'object') {
|
||||
for (const [k, v] of Object.entries(obj)) if (!(k in (s.properties ?? {}))) validate(root, ap, v, path ? `${path}.${k}` : k, errors, depth + 1)
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
Reference in new issue
Block a user