CellDisplay.tsx8.7 KBView on GitHub

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