BoundCellEditors.tsx7.5 KBView on GitHub
'use client';

/**
 * Editors for a bound cell — the ones the BINDING chooses, not the column type.
 *
 * `CellDisplay` already draws a bound cell by what its field IS: a stage as the coloured
 * badge its AOP defines, a date as a `RelativeDateBadge` (see `CrmBoundCells`). Opening one
 * has to follow the same rule, and the column type cannot decide it — a stage column is
 * declared `text` with `binding: conversation.stage`, so `typedCellFor` would hand it a
 * paragraph editor and the user would type a stage name that matches no option.
 *
 * So the editor is picked off the write target's `kind`:
 *   status / priority → the option picker, over the vocabulary the AOPs define
 *   date             → the same natural-language date picker the conversation row opens
 *   everything else  → nothing here; the column's own editor is right (prose for a status
 *                      overview, a number input for a deal value)
 *
 * Each is a Popover mounted ALREADY OPEN, anchored on the cell, with the display still
 * visible underneath — identical in shape to `TypedCells`' own pickers, so opening a bound
 * cell and opening a plain one are the same gesture.
 */

import { useMemo } from 'react';
import { TABLE_COLUMN_TYPE, type TableCellWrap, type TableColumn } from '@zero/server/table';

import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { DatePickerWithNaturalInput } from '@/components/ui/date-picker-with-natural-input';
import { OptionPicker, PICKER_SURFACE_CLASS } from '@/components/ui/option-picker';
import { getEnumTextColor, sortEnumOptions, type AopMergeableField } from '@/modules/crm/field-enums';
import { mergeEnumOptionsFromAops } from '@/modules/crm/utils/merge-enum-options';
import { getEnumDisplayText } from '@/modules/crm/utils';
import { useAOPs } from '@/modules/aop/hooks/use-aops';
import { useCedarStore } from '@/modules/store';
import { cn } from '@/lib/utils';

import type { EditExit } from './cell-selection';
import { CellDisplay } from './CellDisplay';
import { parseCellDate } from './cell-values';
import { BOUND_DATE_FORMAT } from './use-bound-cell-write';
import type { BoundValueKind, BoundWriteTarget } from './bound-writes';

export interface BoundCellEditorProps {
  column: TableColumn;
  value: string;
  wrap: TableCellWrap;
  target: BoundWriteTarget;
  /** The new cell string. The caller writes it locally and sends it through the binding. */
  onCommit: (next: string) => void;
  onDone: (exit: EditExit) => void;
}

/**
 * The bound editor for this target, or `undefined` when the column's own editor is the right
 * one — which is every scalar the CRM stores as free text (next steps, the status overview, a
 * user-defined field) plus the numeric deal value.
 */
export function boundEditorFor(
  kind: BoundValueKind,
): React.ComponentType<BoundCellEditorProps> | undefined {
  if (kind === 'status' || kind === 'priority') return BoundEnumEditor;
  if (kind === 'date') return BoundDateEditor;
  return undefined;
}

/**
 * How a picked date is STORED, mirroring the server's `formatBindingValue` exactly.
 *
 * Exactly, and not approximately: the cell written here is the value the next recompute
 * compares against, so a different rendering of the same instant would make every edit look
 * like a change and rewrite the cell a second time a moment later.
 */
export function boundDateCellValue(date: Date | null, column: TableColumn): string {
  if (!date) return '';
  const iso = date.toISOString();
  return column.type === TABLE_COLUMN_TYPE.DATE ? iso.slice(0, 10) : iso;
}

function BoundEnumEditor({ column, value, wrap, target, onCommit, onDone }: BoundCellEditorProps) {
  const field: AopMergeableField = target.kind === 'priority' ? 'priority' : 'status';
  // Cached and deduplicated with an hour of staleness — on a screen that has already loaded
  // the AOPs this is a store read. `CrmBoundCells` does the same for the display.
  useAOPs();
  const aopsById = useCedarStore((state) => state.aopsById);

  // Merged across every AOP rather than resolved per row: a stage's label and colour are
  // defined once per playbook, a table can hold rows from several, and the row's own AOP is
  // not in the cell.
  const options = useMemo(
    () => sortEnumOptions(mergeEnumOptionsFromAops(Object.values(aopsById), field)),
    [aopsById, field],
  );

  const pickerOptions = useMemo(
    () =>
      options
        .filter((option): option is typeof option & { value: string } => !!option.value)
        .map((option) => ({
          value: option.value,
          label: getEnumDisplayText(option.value, options),
          // The dot carries the colour the AOP gave this stage, so the list reads the way the
          // column does. `bg-current` inherits it from the text-colour class.
          icon: (
            <span
              aria-hidden
              className={cn(
                'size-1.5 shrink-0 rounded-full bg-current',
                getEnumTextColor(option.value, field, options),
              )}
            />
          ),
        })),
    [options, field],
  );

  return (
    <Popover open onOpenChange={(next) => !next && onDone(null)}>
      {/* The anchor is the cell, and the badge stays visible underneath it. */}
      <PopoverTrigger asChild>
        <div className="h-full w-full">
          <CellDisplay column={column} value={value} wrap={wrap} />
        </div>
      </PopoverTrigger>
      <PopoverContent
        align="start"
        className={PICKER_SURFACE_CLASS}
        // The picker's filter field takes focus itself on mount.
        onOpenAutoFocus={(event) => event.preventDefault()}
      >
        <OptionPicker
          options={pickerOptions}
          selected={value ? [value] : []}
          // Original casing: lowercasing would mangle `ACV` into `acv`.
          placeholder={`Change ${column.label}…`}
          // The vocabulary is the playbook's, so an empty list is a configuration fact rather
          // than a filter result — and the picker's own empty state ("No matching options")
          // would blame the query for it.
          hint={
            pickerOptions.length === 0 ? (
              <span className="text-muted-foreground text-xs">
                No options defined in your playbook
              </span>
            ) : undefined
          }
          onPick={(picked) => {
            // Re-picking the current value clears it — the picker is the only way to empty
            // this cell, so the selected row has to be a toggle rather than a no-op.
            onCommit(picked === value ? '' : picked);
            onDone('down');
          }}
          onClose={() => onDone(null)}
        />
      </PopoverContent>
    </Popover>
  );
}

function BoundDateEditor({ column, value, wrap, onCommit, onDone }: BoundCellEditorProps) {
  const selected = parseCellDate(value, BOUND_DATE_FORMAT);

  return (
    <DatePickerWithNaturalInput
      open
      // Picking closes the popover (`closeOnSelect`), and closing is what ends the edit — so
      // the commit below never has to end it too, and cannot end it twice.
      onOpenChange={(next) => !next && onDone(null)}
      value={selected}
      onChange={(date) => onCommit(boundDateCellValue(date, column))}
      closeOnSelect
      // The cell stores a DAY (the server slices the ISO string), so a time field here would
      // offer a precision the column cannot show back.
      showTimeInput={false}
      trigger={
        <div className="h-full w-full">
          <CellDisplay column={column} value={value} wrap={wrap} />
        </div>
      }
    />
  );
}