185 lines
8.4 KiB
TypeScript
185 lines
8.4 KiB
TypeScript
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' : ''}`} />
|
|
<span className="cms-item__text">{summarise(root, itemSchema, item, i)}</span>
|
|
</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>
|
|
)
|
|
}
|