GraphNodeCard.tsx6.6 KBView on GitHub 'use client';
/**
* ONE node component, for every graph there will ever be.
*
* It renders FROM THE SCHEMA — a person header from whatever bindings the graph declares, then
* one row per remaining `displayOnCard` field, typed by the same `TableColumnType` the board
* and the table use. There is deliberately no node component per domain: an org chart's node
* and a buying committee's node differ in what their schema declares, not in code, and the day
* they differ in code is the day "an org chart is configuration" stops being true.
*
* The header is not a special case bolted on for org charts — it is `profile-bindings.ts`
* reading `from`, so a graph gets a face by binding `person.photoUrl` and gets nothing by not
* binding it. A graph of COMPANIES declares none of those and draws exactly as it did before.
*
* ── The three states a node can be in, and why each is visible ──
*
* MISSING DOCUMENT — the pointed-at document no longer resolves. There is no foreign key here
* (a node is a Y.Map entry, not a row), so this rendering is the guard that replaces one. It
* keeps showing the cached values, because a node that went blank tells you nothing about
* what it used to say.
* LINT — a ring, never a panel. A finding is an observation about a graph mid-build, and a
* node that shouted would make the normal state of a half-built graph look broken.
* UNRESOLVED IDENTITY — the node has no title to draw. It shows its document id rather than an
* empty card, because an empty card is indistinguishable from a rendering bug.
*/
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { AlertTriangle, FileQuestion } from 'lucide-react';
import { isTableColumnType } from '@zero/server/table';
import { cn } from '@/lib/utils';
import { CardFieldDisplay, toFieldValue } from '@/modules/documents/board/CardFieldValue';
import { GraphNodeProfile } from './GraphNodeProfile';
import { profileSlotOf, readProfileValues } from './profile-bindings';
import { resolveNodeValue } from './graph-to-flow';
import { nodeColorFor } from './node-colors';
import { NODE_HEIGHT, NODE_WIDTH } from './autoLayout';
import type { GraphFlowNode } from './graph-flow-types';
export function GraphNodeCard({ data, selected }: NodeProps<GraphFlowNode>) {
const profile = readProfileValues(data.nodeSchema.fields, (key) => resolveNodeValue(data, key));
// A field the header drew is not also a row. The title under a person's name and a
// `Title Director of Sales` row beneath it is the same fact twice, and on a 200px card the
// second copy costs a line the card does not have.
const shown = data.nodeSchema.fields.filter(
(field) => field.displayOnCard && !profileSlotOf(field),
);
const hasLint = !!data.lint && data.lint.length > 0;
// The tint, from `view.colorField`. An unrecognised name yields nothing, so the card falls
// back to its ordinary surface rather than to a colour that would read as a category.
const color = nodeColorFor(data.color);
return (
<div
style={{ minWidth: NODE_WIDTH, minHeight: NODE_HEIGHT }}
className={cn(
// One surface per object: the node IS the card, so it carries the border and nothing
// inside it draws a second frame.
'flex cursor-pointer flex-col gap-1 rounded-lg border px-3 py-2 text-left shadow-xs transition-colors',
// The tint REPLACES the card's own surface rather than sitting on top of it — two
// stacked backgrounds is "one surface per object" broken at the smallest scale. It is
// opaque: a washed-out node lets the canvas grid through and stops reading as an object.
color ? color.surfaceClassName : 'bg-raised',
'hover:brightness-95 dark:hover:brightness-110',
// Selection takes the border outright. The tint's own border is dropped rather than
// overridden, because the two carry `dark:` variants tailwind-merge cannot reconcile.
selected
? 'border-primary ring-1 ring-primary'
: (color?.borderClassName ?? 'border-surface-border'),
// A ring, not a panel — see the header.
hasLint && !selected && 'ring-1 ring-amber-500/50',
data.missingDocument && 'border-dashed opacity-80',
)}
>
{/* One dot top, one dot bottom, and each works as BOTH ends of a connection — the canvas
runs in loose connection mode, so a handle is not committed to being a source or a
target. Which side an edge actually uses is decided per-render by `floating-edge.ts`
from where the two cards currently sit, not by which handle it was drawn from. */}
<Handle type="source" position={Position.Top} className="!size-2 !border-0 !bg-muted-foreground/40" />
<div className="flex items-start gap-1.5">
{data.missingDocument && (
<FileQuestion
aria-hidden
className="mt-0.5 size-3.5 shrink-0 text-muted-foreground"
/>
)}
<GraphNodeProfile name={data.title} profile={profile} size="sm" className="flex-1" />
{hasLint && (
<AlertTriangle
aria-hidden
className="mt-0.5 size-3.5 shrink-0 text-amber-500"
// The findings themselves, so hovering the ring says what it is about rather than
// sending the reader to a separate panel to find out.
aria-label={data.lint?.map((l) => l.message).join('\n')}
/>
)}
</div>
{data.missingDocument && (
<span className="text-xs text-muted-foreground">
Its document no longer resolves — showing the last values we cached.
</span>
)}
{shown.map((field) => {
const value = toFieldValue(resolveNodeValue(data, field.key));
if (!value) return null;
return (
<div key=[redacted] className="flex items-center gap-1.5 text-xs">
<span className="shrink-0 text-muted-foreground">{field.label}</span>
<span className="min-w-0 truncate">
{/* The SAME renderer a board card and a table cell use. A second one here would be
a `person` field that draws as a chip on a table and as raw text on a node. An
unknown declared type falls back to text, exactly as it does everywhere else. */}
<CardFieldDisplay
field={isTableColumnType(field.type) ? field : { ...field, type: 'text' }}
value={value}
/>
</span>
</div>
);
})}
<Handle type="source" position={Position.Bottom} className="!size-2 !border-0 !bg-muted-foreground/40" />
</div>
);
}
export const GRAPH_NODE_TYPES = { node: GraphNodeCard };