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