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;
}