421 lines
16 KiB
TypeScript
421 lines
16 KiB
TypeScript
/* ═══════════════════════════════════════════════════════════════
|
|
ADMIN FORM PRIMITIVES
|
|
|
|
Field renders one input from a manifest entry. Repeater renders
|
|
an ordered collection of them, and nests one level for content
|
|
blocks and their items.
|
|
|
|
Ordering is array position — the server writes sort_order from
|
|
the index — so moving a row is a splice, not a number to
|
|
hand-edit. Rows can be dragged by the handle or moved with the
|
|
arrow buttons; the arrows are the keyboard path and stay whether
|
|
or not a pointer is in use.
|
|
|
|
A row added and never filled in is dropped by the server rather
|
|
than rejected, which depends on spec.blank seeding nothing the
|
|
server doesn't also declare as a column default. If you give a
|
|
blank row a starting value here, add the matching default: to
|
|
that column in the server's admin-schema.js or the row will be
|
|
saved as real input.
|
|
|
|
Two options change the box itself rather than what goes in it:
|
|
|
|
prefix fixed text inside the box, left of the cursor. The
|
|
value it decorates is only the part after it, so
|
|
the caller joins the two. Used by composed slugs,
|
|
where the prefix is a fact about another field
|
|
rather than something to retype.
|
|
readOnly shown, selectable, not editable. Deliberately not
|
|
`disabled`: a disabled control reads as switched
|
|
off and drops out of the tab order, whereas an
|
|
immutable id is settled fact you still want to be
|
|
able to read and copy.
|
|
═══════════════════════════════════════════════════════════════ */
|
|
|
|
import { useRef, useState } from "react";
|
|
|
|
const input =
|
|
"w-full rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] " +
|
|
"outline-none transition-colors focus:border-[#138ba0] focus:ring-2 focus:ring-[#138ba0]/25";
|
|
|
|
/* Same box, but lit by the real input nested inside it. */
|
|
const inputShell =
|
|
"flex w-full items-center rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm " +
|
|
"text-[#26454c] transition-colors focus-within:border-[#138ba0] focus-within:ring-2 " +
|
|
"focus-within:ring-[#138ba0]/25";
|
|
|
|
const inputError = "border-[#b3261e] focus:border-[#b3261e] focus:ring-[#b3261e]/20";
|
|
const shellError =
|
|
"border-[#b3261e] focus-within:border-[#b3261e] focus-within:ring-[#b3261e]/20";
|
|
|
|
/* Reads as settled fact rather than as an empty box someone forgot
|
|
to fill in. */
|
|
const inputLocked =
|
|
"w-full rounded-lg border border-[#4a6b72]/20 bg-[#f6fbfc] px-3 py-2 text-sm " +
|
|
"text-[#4a6b72] outline-none cursor-default focus:border-[#4a6b72]/40";
|
|
|
|
/* ── Dotted paths ────────────────────────────────────────────── */
|
|
|
|
export function getPath(object, path) {
|
|
// An entity with no slug has no heading path either, and a missing
|
|
// path should read as "no value" rather than throwing on .split.
|
|
if (!path) return undefined;
|
|
return path.split(".").reduce((value, key) => value?.[key], object);
|
|
}
|
|
|
|
export function setPath(object, path, value) {
|
|
const [head, ...rest] = path.split(".");
|
|
if (rest.length === 0) return { ...object, [head]: value };
|
|
return { ...object, [head]: setPath(object?.[head] ?? {}, rest.join("."), value) };
|
|
}
|
|
|
|
/* ── Field ───────────────────────────────────────────────────── */
|
|
|
|
export function Field({ field, value, row, options, error, onChange }) {
|
|
const id = `f-${field.path.replace(/\./g, "-")}`;
|
|
const widget = field.widget ?? "text";
|
|
const locked = Boolean(field.readOnly);
|
|
|
|
let list = null;
|
|
let orphaned = false;
|
|
|
|
if (widget === "select") {
|
|
list = field.optionsFrom
|
|
? (options?.[field.optionsFrom] ?? []).map((o) => [o.id, o.label, o])
|
|
: (field.options ?? []).map((o) =>
|
|
Array.isArray(o) ? [o[0], o[1]] : [o, o],
|
|
);
|
|
if (field.filterBy && row) {
|
|
list = list.filter(([, , raw]) => !raw || field.filterBy(raw, row));
|
|
}
|
|
|
|
// A stored value with no matching option renders as the blank
|
|
// choice, which reads as "nobody set this" and saves as a
|
|
// deliberate clear. It usually means the row it pointed at was
|
|
// deleted, so keep it on screen and say so.
|
|
orphaned =
|
|
value != null &&
|
|
value !== "" &&
|
|
!list.some(([optionId]) => String(optionId) === String(value));
|
|
}
|
|
|
|
const common = {
|
|
id,
|
|
className: `${input} ${error ? inputError : ""}`,
|
|
value: value ?? "",
|
|
onChange: (e) => onChange(e.target.value),
|
|
};
|
|
|
|
return (
|
|
<div className={field.full ? "sm:col-span-2" : ""}>
|
|
<label htmlFor={id} className="block text-sm font-medium text-[#26454c]">
|
|
{field.label}
|
|
{field.required && !locked && <span className="ml-1 text-[#b3261e]">*</span>}
|
|
</label>
|
|
|
|
<div className="mt-1.5">
|
|
{widget === "checkbox" ? (
|
|
<label className="flex items-center gap-2 text-sm text-[#4a6b72]">
|
|
<input
|
|
id={id}
|
|
type="checkbox"
|
|
checked={value === 1 || value === true}
|
|
disabled={locked}
|
|
onChange={(e) => onChange(e.target.checked ? 1 : 0)}
|
|
className="h-4 w-4 rounded border-[#4a6b72]/40 text-[#138ba0] focus:ring-[#138ba0]/40 disabled:opacity-50"
|
|
/>
|
|
{field.help ?? "Yes"}
|
|
</label>
|
|
) : widget === "textarea" ? (
|
|
<textarea
|
|
{...common}
|
|
rows={4}
|
|
readOnly={locked}
|
|
className={`${locked ? inputLocked : common.className} resize-y`}
|
|
/>
|
|
) : widget === "select" ? (
|
|
// A select has no readOnly, so this one really does have
|
|
// to be disabled — there's no way to keep it focusable
|
|
// and still refuse a new choice.
|
|
<select
|
|
{...common}
|
|
disabled={locked}
|
|
className={`${locked ? inputLocked : common.className} ${
|
|
orphaned && !locked ? inputError : ""
|
|
}`}
|
|
>
|
|
<option value="">{field.blankLabel ?? "— choose —"}</option>
|
|
{orphaned && <option value={value}>{value} — no longer exists</option>}
|
|
{list.map(([id2, label]) => (
|
|
<option key={id2} value={id2}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : widget === "color" ? (
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="color"
|
|
value={/^#[0-9a-f]{6}$/i.test(value ?? "") ? value : "#138ba0"}
|
|
disabled={locked}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className="h-9 w-12 shrink-0 rounded border border-[#4a6b72]/25 bg-white disabled:opacity-50"
|
|
/>
|
|
<input
|
|
{...common}
|
|
readOnly={locked}
|
|
placeholder="#138ba0"
|
|
className={locked ? inputLocked : common.className}
|
|
/>
|
|
</div>
|
|
) : field.prefix && !locked ? (
|
|
// The span is not a form control, so it can't be typed
|
|
// into, tabbed to, or selected by dragging through the
|
|
// field. Clicking it focuses the input, which is what
|
|
// makes the two read as one box.
|
|
<div
|
|
className={`${inputShell} ${error ? shellError : ""}`}
|
|
onClick={() => document.getElementById(id)?.focus()}
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
className={`shrink-0 select-none ${
|
|
field.prefixPending ? "text-[#4a6b72]/45" : "text-[#4a6b72]"
|
|
}`}
|
|
>
|
|
{field.prefix}
|
|
</span>
|
|
<input
|
|
id={id}
|
|
type="text"
|
|
value={value ?? ""}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={field.placeholder}
|
|
className="w-full border-0 bg-transparent p-0 text-[#26454c] outline-none placeholder:text-[#4a6b72]/45"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<input
|
|
type={widget === "number" ? "number" : widget === "date" ? "date" : "text"}
|
|
step={widget === "number" ? "any" : undefined}
|
|
{...common}
|
|
readOnly={locked}
|
|
aria-readonly={locked || undefined}
|
|
className={locked ? inputLocked : common.className}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{error ? (
|
|
<p className="mt-1 text-xs text-[#b3261e]">{error}</p>
|
|
) : orphaned && !locked ? (
|
|
<p className="mt-1 text-xs text-[#b3261e]">
|
|
This points at something that has been deleted. Pick a replacement before saving.
|
|
</p>
|
|
) : (
|
|
field.help &&
|
|
widget !== "checkbox" && (
|
|
<p className="mt-1 text-xs text-[#4a6b72]">{field.help}</p>
|
|
)
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function FieldGrid({ children }) {
|
|
return <div className="grid gap-4 sm:grid-cols-2">{children}</div>;
|
|
}
|
|
|
|
/* ── Repeater ────────────────────────────────────────────────── */
|
|
|
|
export function Repeater({ spec, rows, options, errors, errorPrefix, onChange }) {
|
|
const list = rows ?? [];
|
|
|
|
// Which row is in flight, and which one it's currently over.
|
|
// Both are per-Repeater, which is what keeps a drag inside a
|
|
// nested collection from being accepted by the outer one.
|
|
const [dragIndex, setDragIndex] = useState(null);
|
|
const [overIndex, setOverIndex] = useState(null);
|
|
const rowRefs = useRef([]);
|
|
|
|
const update = (index, next) =>
|
|
onChange(list.map((row, i) => (i === index ? next : row)));
|
|
|
|
const move = (index, delta) => {
|
|
const target = index + delta;
|
|
if (target < 0 || target >= list.length) return;
|
|
const next = [...list];
|
|
[next[index], next[target]] = [next[target], next[index]];
|
|
onChange(next);
|
|
};
|
|
|
|
const relocate = (from, to) => {
|
|
if (from === to || from == null || to == null) return;
|
|
const next = [...list];
|
|
const [moved] = next.splice(from, 1);
|
|
next.splice(to, 0, moved);
|
|
onChange(next);
|
|
};
|
|
|
|
const endDrag = () => {
|
|
setDragIndex(null);
|
|
setOverIndex(null);
|
|
};
|
|
|
|
return (
|
|
<section className="mt-8">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-base font-semibold text-[#26454c]">{spec.label}</h3>
|
|
<button
|
|
type="button"
|
|
onClick={() => onChange([...list, { ...spec.blank }])}
|
|
className="rounded-full border border-[#138ba0] px-3 py-1 text-sm font-medium text-[#138ba0] transition-colors hover:bg-[#eef9fb]"
|
|
>
|
|
{spec.addLabel ?? "Add"}
|
|
</button>
|
|
</div>
|
|
|
|
{spec.note && <p className="mt-1 text-sm text-[#4a6b72]">{spec.note}</p>}
|
|
|
|
{list.length === 0 && <p className="mt-2 text-sm text-[#4a6b72]">None yet.</p>}
|
|
|
|
<div className="mt-3 space-y-3">
|
|
{list.map((row, index) => {
|
|
const dragging = dragIndex === index;
|
|
const over = overIndex === index && dragIndex !== null && !dragging;
|
|
|
|
return (
|
|
<div
|
|
key={index}
|
|
ref={(el) => {
|
|
rowRefs.current[index] = el;
|
|
}}
|
|
onDragOver={(e) => {
|
|
// A null dragIndex means the drag started in some
|
|
// other collection — leave it to whoever owns it.
|
|
if (dragIndex === null) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.dataTransfer.dropEffect = "move";
|
|
if (overIndex !== index) setOverIndex(index);
|
|
}}
|
|
onDrop={(e) => {
|
|
if (dragIndex === null) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
relocate(dragIndex, index);
|
|
endDrag();
|
|
}}
|
|
className={
|
|
"rounded-xl border bg-[#f6fbfc] p-4 transition-colors " +
|
|
(over
|
|
? "border-[#138ba0] ring-2 ring-[#138ba0]/25 "
|
|
: "border-[#4a6b72]/20 ") +
|
|
(dragging ? "opacity-50" : "")
|
|
}
|
|
>
|
|
<div className="mb-3 flex items-center gap-2">
|
|
<span
|
|
draggable
|
|
onDragStart={(e) => {
|
|
e.stopPropagation();
|
|
setDragIndex(index);
|
|
e.dataTransfer.effectAllowed = "move";
|
|
// Firefox won't start a drag without payload.
|
|
e.dataTransfer.setData("text/plain", String(index));
|
|
// Drag the whole row, not just the handle.
|
|
const el = rowRefs.current[index];
|
|
if (el) e.dataTransfer.setDragImage(el, 16, 16);
|
|
}}
|
|
onDragEnd={endDrag}
|
|
title="Drag to reorder"
|
|
aria-hidden="true"
|
|
className="cursor-grab select-none px-1 text-[#4a6b72]/60 active:cursor-grabbing"
|
|
>
|
|
⠿
|
|
</span>
|
|
|
|
<span className="text-sm font-medium text-[#26454c]">
|
|
{spec.title ? spec.title(row, options) : `#${index + 1}`}
|
|
</span>
|
|
|
|
<div className="ml-auto flex gap-1">
|
|
<IconButton
|
|
label="Move up"
|
|
onClick={() => move(index, -1)}
|
|
disabled={index === 0}
|
|
>
|
|
↑
|
|
</IconButton>
|
|
<IconButton
|
|
label="Move down"
|
|
onClick={() => move(index, 1)}
|
|
disabled={index === list.length - 1}
|
|
>
|
|
↓
|
|
</IconButton>
|
|
<IconButton
|
|
label="Remove"
|
|
danger
|
|
onClick={() => onChange(list.filter((_, i) => i !== index))}
|
|
>
|
|
✕
|
|
</IconButton>
|
|
</div>
|
|
</div>
|
|
|
|
<FieldGrid>
|
|
{spec.fields.map((field) => (
|
|
<Field
|
|
key={field.path}
|
|
field={field}
|
|
row={row}
|
|
value={row[field.path]}
|
|
options={options}
|
|
error={errors?.[`${errorPrefix}${index}.${field.path}`]}
|
|
onChange={(value) => update(index, { ...row, [field.path]: value })}
|
|
/>
|
|
))}
|
|
</FieldGrid>
|
|
|
|
{(spec.children ?? []).map((nested) => (
|
|
<div key={nested.key} className="mt-4 border-t border-[#4a6b72]/15 pt-2">
|
|
<Repeater
|
|
spec={nested}
|
|
rows={row[nested.key]}
|
|
options={options}
|
|
errors={errors}
|
|
errorPrefix={`${errorPrefix}${index}.${nested.key}.`}
|
|
onChange={(value) => update(index, { ...row, [nested.key]: value })}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function IconButton({ label, onClick, danger, disabled, children }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
aria-label={label}
|
|
title={label}
|
|
className={
|
|
"h-7 w-7 rounded-full border text-sm leading-none transition-colors " +
|
|
(disabled
|
|
? "cursor-default border-[#4a6b72]/20 text-[#4a6b72]/40"
|
|
: danger
|
|
? "border-[#b3261e]/30 text-[#b3261e] hover:bg-[#fdf3f2]"
|
|
: "border-[#4a6b72]/30 text-[#4a6b72] hover:bg-white")
|
|
}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|