Files
cms-onpageeditor/src/form/MapField.tsx
T
matsandClaude Sonnet 5.5 1af9ec3172 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>
2026-10-31 22:34:48 +01:00

119 lines
4.6 KiB
TypeScript

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())} />
}