property-groups.ts7.3 KBView on GitHub
/**
 * What the properties rail DRAWS, worked out before anything renders.
 *
 * The rail is a general document capability — a graph is only its first consumer — so the
 * ordering rule lives here as a pure function over plain data rather than inside a component
 * that would have to import graph types to obey it.
 *
 * ── The two groups, and why that order ──
 *
 * A document opened FROM a graph shows its OWN properties, then everything this graph says
 * about it. Opening the same profile from two graphs must therefore differ in the SECOND group
 * and nowhere else — the person is the same person, and `stance` on an org chart is a different
 * judgement from `stance` on a deal's buying committee. That is the whole reason a node's
 * fields are per-graph.
 *
 * A value the graph RESOLVED (`from: 'person.title'`) is a row in that same group, marked
 * read-only. It is not a third block: where a value was computed is a fact about the schema,
 * and a caption announcing it makes every reader learn the mechanism before they can read the
 * title.
 *
 * ── Undeclared keys are rows, not silence ──
 *
 * Every group renders the keys its bag carries that its schema does NOT declare, under its own
 * heading — the same rule `CardFieldList` applies to a board card, for the same reason: a UI
 * that hides unknown keys lets a reader conclude the agent wrote nothing, and then "fix" it by
 * rewriting the schema without them. A document with no schema at all is just the degenerate
 * case of that rule, and shows everything it carries.
 */

import type { BoardField } from '@zero/server/board';

import { toFieldValue, type FieldValue } from '@/modules/documents/board/CardFieldValue';

/** One row: a field and its value. */
export interface PropertyRowModel {
  field: BoardField;
  value: FieldValue;
  /**
   * This value resolved from the document rather than being authored here, so the row draws as
   * text even in an editable group. Editing it would write into a bag the next read ignores.
   */
  readOnly?: boolean;
  /** The group's schema does not declare this key. Rendered under the "Not declared" heading. */
  undeclared?: boolean;
}

/** One captioned block of rows. */
export interface PropertyGroupModel {
  /** Stable across renders and unique within the rail. */
  id: string;
  label: string;
  rows: PropertyRowModel[];
  /** Absent = read-only. Every row in the group then renders as text. */
  onChange?: (key=[redacted], value: string) => void;
}

/** A value that resolved elsewhere and is never stored on the thing being drawn. */
export interface BoundPropertyField {
  field: BoardField;
  value: unknown;
}

/**
 * The thing whose properties these are: a document, or an edge.
 *
 * An edge needs no mechanism of its own — its kind's declared `fields` are the schema and its
 * own bag is `fields`, which is exactly what a document with a schema is. One shape, so there
 * is never a second properties idiom to keep in sync with this one.
 */
export interface PropertySubject {
  /** Caption over the first group. An edge passes its kind's label. */
  label?: string;
  /** The declared shape, when there is one. A document with none still renders every key. */
  schema?: readonly BoardField[];
  /** The typed bag — `documents.metadata.fields`, or an edge's `fields`. */
  fields?: Record<string, unknown>;
  /** Absent for a read-only surface (a share page, a teammate's document). */
  onChange?: (key=[redacted], value: string) => void;
}

/** Everything the rail needs from a graph, passed IN — the rail never fetches graph data. */
export interface GraphPropertyContext {
  /** Names the middle group: "In Acme org chart". */
  graphName: string;
  /** This graph's authored node (or edge) field declarations. */
  schema?: readonly BoardField[];
  /** The node's values for them — per-graph, never written onto the document. */
  fields?: Record<string, unknown>;
  onChange?: (key=[redacted], value: string) => void;
  /**
   * Fields resolved on READ from the document and the contact layer. Read-only by
   * construction: a bound field is never written into `fields`, which is the structural
   * version of "a sync must not clobber what a human wrote".
   */
  boundFields?: readonly BoundPropertyField[];
  /**
   * Keys the CALLER has already drawn somewhere else, and which must not come back as
   * "not declared" rows.
   *
   * The undeclared-key rule is open by default on purpose — an agent's invented key is shown
   * and named rather than hidden. But a caller that deliberately lifted a field out of the
   * rail (a photo into an avatar, an identity field into nothing) has not hidden anything, and
   * without this the bag's copy of that key reappears underneath the header that replaced it.
   */
  hiddenKeys?: readonly string[];
}

export interface BuildPropertyGroupsInput {
  subject: PropertySubject;
  graph?: GraphPropertyContext;
}

/** Caption over the group whose values the thing itself owns. */
export const OWN_GROUP_LABEL = 'Properties';

function rowsFor(
  schema: readonly BoardField[] | undefined,
  fields: Record<string, unknown> | undefined,
  /** Keys that belong to another group and must not resurface here as "not declared". */
  claimed?: ReadonlySet<string>,
): PropertyRowModel[] {
  const declared = schema ?? [];
  const bag = fields ?? {};
  const declaredKeys = new Set(declared.map((field) => field.key));

  const rows: PropertyRowModel[] = declared.map((field) => ({
    field,
    value: toFieldValue(bag[field.key]),
  }));

  for (const key of Object.keys(bag)) {
    if (declaredKeys.has(key)) continue;
    if (claimed?.has(key)) continue;
    rows.push({
      // A synthetic field, so the row renders like any other. `text` is the same fallback
      // `fieldRenderType` applies to an unknown DECLARED type — one rule, not two.
      field: { key, label: key, type: 'text' },
      value: toFieldValue(bag[key]),
      undeclared: true,
    });
  }

  return rows;
}

/** The rail's groups, in the one order they are allowed to appear in. */
export function buildPropertyGroups({
  subject,
  graph,
}: BuildPropertyGroupsInput): PropertyGroupModel[] {
  const groups: PropertyGroupModel[] = [
    {
      id: 'own',
      label: subject.label ?? OWN_GROUP_LABEL,
      rows: rowsFor(subject.schema, subject.fields),
      onChange: subject.onChange,
    },
  ];

  if (!graph) return groups;

  // Bound keys are resolved, not authored, so they never count as undeclared keys of the
  // graph's own bag — a cached `derived` copy left in `fields` would otherwise render twice,
  // once editable and once not.
  const boundKeys = new Set([
    ...(graph.boundFields ?? []).map((bound) => bound.field.key),
    ...(graph.hiddenKeys ?? []),
  ]);

  groups.push({
    id: 'graph',
    label: `In ${graph.graphName}`,
    rows: [
      ...rowsFor(graph.schema, graph.fields, boundKeys),
      // Resolved values sit in the SAME group as the authored ones, marked read-only per row
      // rather than split into a third block. A caption over them can only name a mechanism —
      // and a reader who has to be told a value was "resolved elsewhere" is being handed the
      // implementation of a field instead of the field.
      ...(graph.boundFields ?? []).map((bound) => ({
        field: bound.field,
        value: toFieldValue(bound.value),
        readOnly: true,
      })),
    ],
    onChange: graph.onChange,
  });

  return groups;
}