DocumentProperties.tsx7.6 KBView on GitHub
'use client';

/**
 * DocumentProperties — the typed properties of ANY document, beside its body.
 *
 * A general document capability, not a graph one. The graph is simply its first consumer, and
 * the same rail draws a node's fields, an edge's fields, and a plain document's own — which is
 * why nothing here fetches: a caller hands it a bag and, when the document was opened from a
 * graph, that graph's context.
 *
 * ── It is the TASK TICKET'S rail, not a lookalike ──
 *
 * The row geometry, the hover chrome, the caption type and the section rhythm all come from
 * `components/ui/property-rows.tsx`, which is the task rail extracted rather than averaged.
 * This file owns only what is genuinely different here: the keys are ARBITRARY, so they have to
 * be named in the row, and a value opens a typed editor rather than one of three known
 * controls. Everything a reader can see that both surfaces share is now literally shared, and
 * the two cannot drift.
 *
 * ── EVERY DECLARED FIELD IS A ROW, VALUE OR NOT ──
 *
 * A schema declares a field because somebody wants that fact recorded, so an empty one is a
 * prompt rather than an absence — hiding it means the only way to fill it in is to already know
 * it exists. What IS dropped is a group with no fields at all, and the rail itself when no
 * group has any: a caption over nothing appears exactly when there is least to look at.
 */

import { fieldRenderType } from '@zero/server/board';
import { isTableColumnType, TABLE_COLUMN_TYPE } from '@zero/server/table';
import type { BoardField } from '@zero/server/board';
import { Sparkles } from 'lucide-react';
import { useState } from 'react';

import {
  buildPropertyGroups,
  type GraphPropertyContext,
  type PropertyRowModel,
  type PropertySubject,
} from './property-groups';
import {
  CardFieldDisplay,
  CardFieldEditor,
  type FieldValue,
} from '@/modules/documents/board/CardFieldValue';
import {
  PROPERTY_CAPTION,
  PROPERTY_ROW,
  PropertyButtonRow,
  PropertyIcon,
  PropertyLabel,
  PropertyRows,
} from '@/components/ui/property-rows';
import { propertyIcon } from './property-icons';
import { cn } from '@/lib/utils';

export interface DocumentPropertiesProps {
  /**
   * Whose properties these are — a document's `metadata.fields`, or an edge's bag typed by its
   * kind's declared `fields`. One shape for both, so selecting an edge opens THIS rail instead
   * of a second properties idiom that would have to be kept in step with it.
   */
  subject: PropertySubject;
  /**
   * Present when the subject was opened FROM a graph. Adds one trailing group and nothing else
   * — opening the same profile from two graphs differs in that group alone.
   */
  graphContext?: GraphPropertyContext;
  className?: string;
}

export function DocumentProperties({ subject, graphContext, className }: DocumentPropertiesProps) {
  const [openRow, setOpenRow] = useState<string | null>(null);
  const groups = buildPropertyGroups({ subject, graph: graphContext }).filter(
    (group) => group.rows.length > 0,
  );
  if (groups.length === 0) return null;

  return (
    <aside aria-label="Properties" className={cn('flex min-w-0 flex-col gap-4', className)}>
      {groups.map((group) => (
        <div key=[redacted] className="flex flex-col gap-0.5">
          <span className={cn(PROPERTY_CAPTION, 'min-w-0 truncate pb-1.5')}>{group.label}</span>
          <PropertyRows>
            {group.rows.map((row, index) => {
              // Two groups may legitimately declare the same key — `stance` on the document and
              // `stance` in this graph are different values of the same name — so the group id
              // is part of the identity.
              const rowId = `${group.id}:${row.field.key}`;
              const editable = !!group.onChange && row.readOnly !== true;
              return (
                <PropertyFieldRow
                  key=[redacted]
                  row={row}
                  // The "Not declared" caption precedes the first undeclared row of a group,
                  // which is the same rule a board card applies: an unknown key is shown and
                  // NAMED, because a UI that hides one lets a reader conclude the agent wrote
                  // nothing and then "fix" the schema without it.
                  showUndeclaredCaption={
                    row.undeclared === true && group.rows[index - 1]?.undeclared !== true
                  }
                  open={openRow === rowId}
                  {...(editable ? { onOpen: () => setOpenRow(rowId) } : {})}
                  onClose={() => setOpenRow(null)}
                  onCommit={(next) => {
                    group.onChange?.(row.field.key, next);
                    // A multi-select commits once per toggle and the reader is usually ticking
                    // several, so closing on the first would make picking two values two
                    // separate openings of the same picker.
                    if (fieldRenderType(row.field) !== TABLE_COLUMN_TYPE.MULTI_SELECT) {
                      setOpenRow(null);
                    }
                  }}
                />
              );
            })}
          </PropertyRows>
        </div>
      ))}
    </aside>
  );
}

/**
 * One property: glyph, name, value — on the shared row.
 *
 * Open, the editor replaces the VALUE and nothing else: the glyph stays in its lane, so the row
 * does not jump as it becomes a control and the eye keeps its place in the column.
 */
function PropertyFieldRow({
  row,
  showUndeclaredCaption,
  open,
  onOpen,
  onClose,
  onCommit,
}: {
  row: PropertyRowModel;
  showUndeclaredCaption: boolean;
  open: boolean;
  onOpen?: () => void;
  onClose: () => void;
  onCommit: (next: FieldValue) => void;
}) {
  const Icon = propertyIcon(row.field);
  const glyph = <Icon aria-hidden className="size-4" />;

  return (
    <>
      {showUndeclaredCaption && (
        <span className={cn(PROPERTY_CAPTION, 'mt-2 flex items-center gap-1.5 px-2 pb-1.5')}>
          <Sparkles aria-hidden className="size-3" />
          {/* Named for what it IS, so nobody reads it as a bug: keys something wrote that no
              schema here declares. */}
          Not declared
        </span>
      )}

      {open ? (
        <div className={PROPERTY_ROW}>
          <PropertyIcon>{glyph}</PropertyIcon>
          <div className="min-w-0 flex-1">
            <CardFieldEditor
              field={row.field}
              value={row.value}
              onCommit={onCommit}
              onCancel={onClose}
            />
          </div>
        </div>
      ) : (
        <PropertyButtonRow
          icon={glyph}
          {...(onOpen ? { onClick: onOpen } : { disabled: true })}
          {...(row.field.description ? { title: row.field.description } : {})}
        >
          <PropertyLabel>{row.field.label}</PropertyLabel>
          <span className="min-w-0 flex-1">
            <CardFieldDisplay field={row.field} value={row.value} />
          </span>
          {!isTableColumnType(row.field.type) && <UnknownTypeHint field={row.field} />}
        </PropertyButtonRow>
      )}
    </>
  );
}

/**
 * The declared type, when Cedar does not know it.
 *
 * An agent that invented `type: 'sentiment'` gets a working text field today — saying so here
 * is what stops someone "fixing" the field by deleting it. `isTableColumnType` decides, never a
 * local list of the names: a second copy of that vocabulary would drift silently the day a type
 * is added, and a real new type would wear this caveat while working perfectly.
 */
function UnknownTypeHint({ field }: { field: BoardField }) {
  return (
    <span className="text-muted-foreground/60 shrink-0 text-xs">{String(field.type)} · as text</span>
  );
}