GraphSchemaEditor.tsx12.3 KBView on GitHub 'use client';
/**
* How the graph is DRAWN, as a form — the thing that used to be a JSON schema edit.
*
* ── What it owns, and the one line that separates the two halves ──
*
* Three rows change `view`, which is PURELY presentational: repointing the colour field or the
* hierarchy axis re-paints and re-ranks the same nodes and writes not one node and not one
* edge. The fourth control — what shows on a node's face — changes `displayOnCard` on the
* FIELDS, which is a schema write. They sit in one panel because to a reader they are one
* question ("what do I want to see?"), and they are in separate sections because only one of
* them is a fact about the graph rather than about this look at it.
*
* ── Why the colour row is a picker and the other two are selects ──
*
* "Which field paints the background" is the one question you cannot answer from a list of
* words: the useful information is what the colours ARE, and a `<select>` of field labels hides
* exactly that. So each row in that picker carries its field's own palette — the swatches from
* that field's `optionColors`, in its declared option order — and a field that declares no
* colours shows the dashed ring that means "no tint" everywhere else in this module. Choosing
* is then looking rather than remembering.
*
* The other two rows genuinely are short lists of words, so they are `SelectField` —
* crystallized.md puts the filter field (and with it `OptionPicker`) at ~10 options.
*
* ── It emits, it never writes ──
*
* `onSetView` / `onSetFields`, both merge-by-key on the server (`graphs.setView` merges into
* the existing view; `graphs.addFields` merges into the field with that key). So this form
* sends ONLY the keys it owns and never a freshly serialised object: a graph schema is open —
* unknown keys round-trip verbatim — and rebuilding one from form state is the one sure way to
* silently drop whatever the form does not render.
*/
import { useState } from 'react';
import { Eye } from 'lucide-react';
import {
Field,
FieldChips,
FieldPanelFooter,
FieldSection,
FieldSelectTrigger,
SelectField,
} from '@/components/ui/field';
import { OptionPicker, PICKER_SURFACE_CLASS, type PickerOption } from '@/components/ui/option-picker';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { nodeColorFor } from './node-colors';
import type { GraphNodeField, GraphSchema, GraphView } from '@zero/server/graph';
export interface GraphSchemaEditorProps {
schema: GraphSchema;
/** Merged into `schema.view` server-side. Send only the key that changed. */
onSetView: (view: Partial<GraphView>) => void;
/** Merged by `key` server-side. Send only the field that changed. */
onSetFields: (fields: GraphNodeField[]) => void;
}
/**
* "No field at all", as a value a `<select>` can hold.
*
* Underscored so it cannot collide with a field key=[redacted] are lower-snake slugs minted from a
* label, so `__none__` is not one an agent can produce. It is translated back to `undefined`
* on the way out — a view that is not coloured says so by OMITTING the key, which is the same
* state a graph that was never coloured is in, rather than by storing an empty string that
* every reader then has to remember is falsy.
*/
const NO_FIELD = '__none__';
/** The same sentinel for the hierarchy axis, where absent means "every relation ranks". */
const EVERY_RELATION = '__all__';
export function GraphSchemaEditor({ schema, onSetView, onSetFields }: GraphSchemaEditorProps) {
const fields = schema.nodeSchema.fields;
const view = schema.view;
// `|| NO_FIELD` rather than `?? NO_FIELD`: a schema written by hand can carry `colorField: ''`,
// and every reader on the canvas already treats that as "not set".
const colorField = view?.colorField || NO_FIELD;
const tierField = view?.tierField || NO_FIELD;
const layoutEdgeKind = view?.layoutEdgeKind || EVERY_RELATION;
const onCard = fields.filter((field) => field.displayOnCard).map((field) => field.key);
const fieldOptions = fields.map((field) => ({ value: field.key, label: field.label }));
return (
<>
<FieldSection>
{fields.length === 0 ? (
<p className="text-muted-foreground py-1 text-xs leading-5">
This graph declares no node fields yet.
</p>
) : (
<>
<Field label="Background">
<ColorFieldPicker
fields={fields}
value={colorField}
onPick={(next) =>
onSetView({ colorField: next === NO_FIELD ? undefined : next })
}
/>
</Field>
<SelectField
label="Tiers"
value={tierField}
onValueChange={(next) =>
onSetView({ tierField: next === NO_FIELD ? undefined : next })
}
options={[
// Named for what it DOES rather than "None": with no tiers and no layout edges
// a graph lays out in a single row, and that is worth saying before you pick it.
{ value: NO_FIELD, label: 'Nothing — one row' },
...fieldOptions,
...missingFieldOption(viewFieldIsMissing(tierField, fields), tierField),
]}
/>
</>
)}
{/* Hidden when there is no vocabulary to choose from — a select offering exactly one
option is a control that cannot be used, and a graph with no declared relations
still draws every edge it has. An axis pointing at a kind that was since removed is
the exception: it is offered back, so the row can name what the graph is doing. */}
{(schema.edgeKinds.length > 0 || layoutEdgeKind !== EVERY_RELATION) && (
<SelectField
label="Hierarchy"
value={layoutEdgeKind}
onValueChange={(next) =>
onSetView({ layoutEdgeKind: next === EVERY_RELATION ? undefined : next })
}
options={[
{ value: EVERY_RELATION, label: 'Every relation' },
...schema.edgeKinds.map((kind) => ({ value: kind.key, label: kind.label })),
...(layoutEdgeKind !== EVERY_RELATION &&
!schema.edgeKinds.some((kind) => kind.key === layoutEdgeKind)
? [{ value: layoutEdgeKind, label: `${layoutEdgeKind} · no longer a relation` }]
: []),
]}
/>
)}
</FieldSection>
{/* Chips, not a menu: a node schema is a handful of fields, all worth seeing at once, and
a trigger reading "2 shown" is a number you have to open something to understand. */}
<FieldSection title="On the node face">
{fields.length === 0 ? (
<p className="text-muted-foreground py-1 text-xs leading-5">
Nothing to show yet.
</p>
) : (
<FieldChips
options={fieldOptions}
selected={onCard}
onToggle={(key) => {
const field = fields.find((candidate) => candidate.key === key);
if (!field) return;
// The WHOLE field, with one property flipped. `addFields` merges by key, so the
// rest is a no-op — but spreading the field rather than sending `{ key, label,
// type, displayOnCard }` means an unknown property this form never rendered is
// carried across untouched instead of being re-asserted from three defaults.
onSetFields([{ ...field, displayOnCard: !field.displayOnCard }]);
}}
/>
)}
</FieldSection>
<FieldPanelFooter>
{/* Said out loud because it is the surprising part, and because it is what makes these
three rows safe to play with: on most tools, re-ranking a chart is a migration. */}
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
<Eye aria-hidden className="size-3 shrink-0" />
Colours, tiers and hierarchy move no node.
</span>
</FieldPanelFooter>
</>
);
}
/** Is this view key pointing at a field the schema no longer declares? */
function viewFieldIsMissing(key=[redacted], fields: GraphNodeField[]): boolean {
return key !== NO_FIELD && !fields.some((field) => field.key === key);
}
/**
* A field the view still points at but the schema has dropped — an agent removed it, or a
* colleague did — offered back as its own option.
*
* Without it the control matches nothing and renders blank, so the graph is tiered or tinted by
* something the form cannot name, and the first click silently repoints it.
*/
function missingFieldOption(missing: boolean, key=[redacted] {
return missing ? [{ value: key, label: `${key} · no longer a field` }] : [];
}
/**
* Which field paints the node background — a picker whose rows carry the colours themselves.
*
* An `OptionPicker` rather than a `Select` because the rows are not words: the left mark is the
* field's palette, which is the entire thing being chosen between. Everything else about it —
* the tick on the right, the 1–9 ordinals, the keyboard containment — comes with the component.
*/
function ColorFieldPicker({
fields,
value,
onPick,
}: {
fields: GraphNodeField[];
value: string;
onPick: (next: string) => void;
}) {
const [open, setOpen] = useState(false);
const current = fields.find((field) => field.key === value);
const missing = viewFieldIsMissing(value, fields);
const options: PickerOption[] = [
{ value: NO_FIELD, label: 'Nothing — no tint', icon: <NoTintMark /> },
...fields.map((field) => ({
value: field.key,
label: field.label,
icon: <PaletteChip field={field} />,
})),
...missingFieldOption(missing, value).map((option) => ({ ...option, icon: <NoTintMark /> })),
];
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<FieldSelectTrigger>
{current ? current.label : missing ? value : 'Nothing'}
</FieldSelectTrigger>
</PopoverTrigger>
{/* Right-aligned to the trigger and below it: in a row form every control shares one
right edge, so a list that grew rightward would push off the panel (crystallized.md § 5). */}
<PopoverContent align="end" side="bottom" className={PICKER_SURFACE_CLASS}>
<OptionPicker
placeholder="Paint nodes by…"
// A node schema is a handful of fields; a filter field over four rows is ceremony.
searchable={fields.length > 9}
options={options}
selected={[value]}
onPick={(next) => {
onPick(next);
setOpen(false);
}}
onClose={() => setOpen(false)}
/>
</PopoverContent>
</Popover>
);
}
/**
* One field's palette, at 14px: the colours it would paint, in its declared option order.
*
* Capped at four. A chip is read, not counted — past four dots at 6px it is a texture, and the
* question it answers ("is this the field with the red/green scale?") is already answered.
* A colour name Cedar does not know contributes NO dot, the same way it paints no tint: a
* present-but-wrong colour reads as a fact, an absent one reads as "not categorised".
*/
function PaletteChip({ field }: { field: GraphNodeField }) {
const options = Array.isArray(field.options) ? field.options : [];
const swatches = options
.map((option) => nodeColorFor(field.optionColors?.[option])?.swatchClassName)
.filter((swatch): swatch is string => !!swatch)
.slice(0, 4);
if (swatches.length === 0) return <NoTintMark />;
return (
<span
aria-hidden
className="flex size-3.5 flex-wrap content-center items-center justify-center gap-px"
>
{swatches.map((swatch, index) => (
<span
// The same colour can legitimately appear twice (two values, one hue), so the index
// is part of the key rather than the class alone.
key=[redacted]
className={cn('size-1.5 rounded-full', swatch)}
/>
))}
</span>
);
}
/** "No colour here" — an outline, never a grey fill, because `slate` is a real category. */
function NoTintMark() {
return (
<span
aria-hidden
className="size-2.5 shrink-0 rounded-full border border-dashed border-muted-foreground/60"
/>
);
}