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