Introduced 1 production defect in 180 days, median 5 days to fix.
'use client';
/**
* What a cell looks like when it is NOT being edited — for every column type.
*
* ── Why display and edit are separate ──
*
* The grid used to render an `<input>` as the display, so that a cell and its editor were the
* same element and mounting the editor produced no shift. That bought pixel-fidelity and cost
* the thing a grid actually needs: a click could not mean "select this cell", because a click
* on an input means "put a caret here". With selection (see `cell-selection.ts`) the cell is a
* plain element until it is opened, and geometry stays identical because the SHELL owns it —
* `CELL_CONTENT_CLASS` is shared by this and by every editor.
*
* ── One component, every type ──
*
* A display has far less to say than an editor: a date shows its text, a select shows pills, a
* number is right-aligned, a checkbox shows a box. Only the checkbox stays interactive here —
* toggling it is a single unambiguous gesture with nothing to type, so requiring an "open" step
* first would be ceremony rather than safety.
*/
import { useMemo } from 'react';
import {
TABLE_CELL_WRAP,
TABLE_COLUMN_TYPE,
TABLE_OUTPUT_KIND,
type TableCellWrap,
type TableColumn,
} from '@zero/server/table';
import { cn } from '@/lib/utils';
import { slackTextToPlain } from '@/modules/conversations/components/timeline/SlackBodyRenderer';
import { isOutputSent, parseCellOutput, summarizeCellOutput } from './cell-output';
import { OutputTargetPill } from './OutputTargetPill';
import { splitCellValue } from './cell-refs';
import { CellSegments } from './CellRefChip';
import { CELL_CONTENT_CLASS, cellWrapClass, MAX_ROW_HEIGHT } from './constants';
import { BoundDateCell, CrmEnumCell, crmEnumFieldForBinding } from './CrmBoundCells';
import { optionColor } from '@/components/ui/option-picker';
import { isCheckedValue, splitMultiSelect } from './cell-values';
export interface CellDisplayProps {
column: TableColumn;
value: string;
/** The column's effective wrap mode, resolved by the caller through `columnWrap`. */
wrap: TableCellWrap;
/**
* Toggle handler for a checkbox column. Absent for a bound or blank-row cell, which leaves
* the box rendered but inert rather than replacing it with a different affordance.
*/
onToggle?: (next: string) => void;
/**
* A derived cell Cedar will not let you edit — muted, because grey is the only thing that
* distinguishes it from the writable bound cell beside it. A derived cell that DOES write
* back is drawn like any other: the column header's link glyph says where it goes, and
* there is nothing about the value itself to qualify.
*/
readOnly?: boolean;
}
export function CellDisplay({ column, value, wrap, onToggle, readOnly }: CellDisplayProps) {
const wrapping = wrap === TABLE_CELL_WRAP.WRAP;
const segments = useMemo(() => splitCellValue(value), [value]);
if (column.type === TABLE_COLUMN_TYPE.CHECKBOX) {
return (
<div className={cn(CELL_CONTENT_CLASS, 'items-center justify-center')}>
<input
type="checkbox"
aria-label={column.label}
checked={isCheckedValue(value)}
disabled={!onToggle}
className="size-3.5 cursor-pointer accent-primary disabled:cursor-default"
onChange={(event) => onToggle?.(event.target.checked ? 'true' : 'false')}
/>
</div>
);
}
if (column.type === TABLE_COLUMN_TYPE.OUTPUT) {
return <OutputCellDisplay column={column} value={value} wrap={wrap} />;
}
// ── Bound CRM fields render as themselves ────────────────────────────────────────────────
// A derived cell holds the raw stored value, and the raw value is not what a deal's stage or
// its last-contact date looks like anywhere else in Cedar. Both branches are ahead of the
// select branch because neither is an options column — the stage vocabulary lives on the
// AOP, and a date has no options at all. See `CrmBoundCells`.
const crmEnumField = crmEnumFieldForBinding(column.binding);
if (crmEnumField) {
return <CrmEnumCell value={value} field={crmEnumField} />;
}
if (column.binding && column.type === TABLE_COLUMN_TYPE.DATE) {
return <BoundDateCell value={value} binding={column.binding} />;
}
const optionColumn =
column.type === TABLE_COLUMN_TYPE.SELECT || column.type === TABLE_COLUMN_TYPE.MULTI_SELECT;
if (optionColumn && (column.options?.length ?? 0) > 0) {
const selected =
column.type === TABLE_COLUMN_TYPE.MULTI_SELECT
? splitMultiSelect(value)
: value
? [value]
: [];
return (
<div
className={cn(
CELL_CONTENT_CLASS,
wrapping ? 'flex-wrap' : 'overflow-hidden',
readOnly && 'text-muted-foreground',
)}
>
{selected.map((option) => (
// The dot carries the same colour the picker gave this option, so a row of chips is
// scannable without reading it — and a value looks like ITSELF in both places.
<span
key=[redacted]
className="bg-sunken flex max-w-full shrink-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-xs leading-4"
>
<span
aria-hidden
className="size-1.5 shrink-0 rounded-full"
style={{ backgroundColor: optionColor(option) }}
/>
<span className="truncate">{option}</span>
</span>
))}
</div>
);
}
const numeric =
column.type === TABLE_COLUMN_TYPE.NUMBER ||
column.type === TABLE_COLUMN_TYPE.CURRENCY ||
column.type === TABLE_COLUMN_TYPE.PERCENT;
// A read-only PROSE cell is still prose. The muted grey earns its place on a short scalar —
// a one-word value beside an editable one, where "can I change this?" is a real question. On
// a paragraph it is not provenance, it is a paragraph greyed out: an enrichment description
// is among the most-read cells in a row and it rendered as the faintest.
const mutedWhenReadOnly = readOnly && column.type !== TABLE_COLUMN_TYPE.LONG_TEXT;
return (
<div
className={cn(
CELL_CONTENT_CLASS,
cellWrapClass(wrap),
numeric && 'justify-end text-right tabular-nums',
mutedWhenReadOnly && 'text-muted-foreground',
)}
// The wrap clamp. A height cap rather than `line-clamp`, which needs `display:-webkit-box`
// and would drop the flex layout the chips and the numeric alignment depend on. It cuts
// exactly at a line boundary because `MAX_ROW_HEIGHT` is built from `CELL_LINE_HEIGHT`
// and `CELL_PADDING_Y` — the same numbers this cell is laid out with.
style={wrapping ? { maxHeight: MAX_ROW_HEIGHT, overflow: 'hidden' } : undefined}
>
<CellSegments segments={segments} wrap={wrapping} />
</div>
);
}
/**
* An OUTPUT cell at rest: where it goes as a pill, with the draft underneath.
*
* The destination leads because across a column of drafts the bodies rhyme — it and the
* sent/unsent state are what tell one row from the next at a glance. `OutputTargetPill` carries
* it (and, for Slack, opens the channel it names); the state stays a quiet word beside it. The
* message follows, in muted text, for when you are reading rather than scanning.
*/
function OutputCellDisplay({
column,
value,
wrap,
}: Pick<CellDisplayProps, 'column' | 'value' | 'wrap'>) {
const output = useMemo(() => parseCellOutput(value), [value]);
const wrapping = wrap === TABLE_CELL_WRAP.WRAP;
if (!output) {
return (
<div className={cn(CELL_CONTENT_CLASS, cellWrapClass(wrap), 'text-muted-foreground/60')}>
{value || (column.outputKind ? 'Not drafted' : 'No output kind set')}
</div>
);
}
const sent = isOutputSent(output);
return (
<div
className={cn(CELL_CONTENT_CLASS, 'flex-col !gap-1')}
style={wrapping ? { maxHeight: MAX_ROW_HEIGHT, overflow: 'hidden' } : undefined}
>
<span className="flex max-w-full shrink-0 items-center gap-1.5">
<OutputTargetPill output={output} fallbackLabel={column.label} />
<span className="shrink-0 text-xs text-muted-foreground">{sent ? 'Sent' : 'Draft'}</span>
</span>
{/* Slack ONLY. The decoder is mrkdwn-specific — it also strips `*bold*`, `_italic_` and
backticks, and rewrites every `<…>` token — so running it over an email subject or a
filename would quietly mangle punctuation that was never Slack syntax. */}
<span className={cn('max-w-full text-muted-foreground', cellWrapClass(wrap))}>
{output.kind === TABLE_OUTPUT_KIND.SLACK
? slackTextToPlain(summarizeCellOutput(output))
: summarizeCellOutput(output)}
</span>
</div>
);
}