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"
    />
  );
}