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