import { useEffect, useRef, type ReactNode } from 'react';
import {
editableValue,
formatCell,
isComputed,
isNumericColumn,
type CellValue,
type ColumnMeta,
type Row,
type TableMeta,
} from '@wellformed/shared';
import { LINK_OPTION_LIMIT } from '../api/hooks';
import { useI18n } from '../i18n';
import { KeyIcon, selectColors } from './Icons';
import { LinkPicker } from './LinkPicker';
export interface EditorState {
rowId: string;
col: string;
draft: string;
/** The edit was started by typing, so the draft is the first character. */
seeded?: boolean;
/** A link editor opens searching for this — the typing that opened it. */
search?: string;
}
/** Where the cursor goes once a cell has been committed from the keyboard. */
export type Move = 'down' | 'right' | 'left';
interface CellProps {
row: Row;
col: ColumnMeta;
selected: boolean;
/** Part of the block selection: a chosen row and a chosen column overlap here. */
inBlock: boolean;
editing: EditorState | null;
/** Link targets, so a link cell can offer the rows it may point at. */
linkOptions: Row[] | undefined;
linkTable: TableMeta | undefined;
/** The workspace's currency, for the columns that hold money. */
currency: string;
error: string | undefined;
readOnly: boolean;
/** The row-label column stays visible while the grid scrolls sideways. */
frozen: boolean;
onSelect(): void;
onBeginEdit(seed?: string): void;
/** A double-click that read-only swallowed — the grid may want to say why. */
onRefused?(): void;
onDraft(value: string): void;
onCommit(value: string, move?: Move): void;
/** `byKey` when Escape closed it, so the grid can take the keys back. */
onCancel(byKey: boolean): void;
onContextMenu(event: React.MouseEvent): void;
/** Follow a link cell to the row it points at. */
onOpenLink(rowId: string): void;
}
export function GridCell({
row,
col,
selected,
inBlock,
editing,
linkOptions,
linkTable,
currency,
error,
readOnly,
frozen,
onSelect,
onBeginEdit,
onRefused,
onDraft,
onCommit,
onCancel,
onContextMenu,
onOpenLink,
}: CellProps): ReactNode {
const computed = isComputed(col.type);
const numeric = isNumericColumn(col);
const value: CellValue = row.values[col.name] ?? null;
const label = row.labels[col.name];
const isEditing = editing?.rowId === row.id && editing.col === col.name;
// A checkbox has two states and no text, so it has nothing to open an editor
// for: the box itself is the control.
const toggle =
col.type === 'checkbox' && !computed && !readOnly
? () => onCommit(value ? 'false' : 'true')
: undefined;
const className = [
'cell',
numeric ? 'num' : '',
col.type === 'date' ? 'datev' : '',
computed ? 'computed' : '',
computed && error ? 'errored' : '',
]
.filter(Boolean)
.join(' ');
return (
);
}
function CellValueView({
col,
value,
label,
currency,
error,
onToggle,
onOpenLink,
}: {
onOpenLink(rowId: string): void;
/** Set for a writable checkbox: the box is its own editor. */
onToggle: (() => void) | undefined;
col: ColumnMeta;
value: CellValue;
label: string | null | undefined;
currency: string;
error: string | undefined;
}): ReactNode {
const { t, locale } = useI18n();
if (isComputed(col.type) && error) return {t('grid.cellError')};
if (col.type === 'link') {
if (!label) return <>{' '}>;
return (
// A relation you can only look at is half a relation. Click follows it;
// the surrounding cell still selects on mousedown as it always did.
);
}
if (col.type === 'checkbox') {
return (
onToggle?.()}
/>
);
}
if (col.type === 'select') {
const text = value === null || value === '' ? '' : String(value);
if (!text) return <>{' '}>;
const [bg, fg] = selectColors(text);
return (
{text}
);
}
const text = formatCell(value, col, { label, currency, locale });
return {text || ' '};
}
function CellEditor({
col,
draft,
seeded,
search,
linkOptions,
linkTable,
onDraft,
onCommit,
onCancel,
}: {
col: ColumnMeta;
draft: string;
seeded: boolean;
/** What a link picker opens searching for. */
search: string;
linkOptions: Row[] | undefined;
linkTable: TableMeta | undefined;
onDraft(value: string): void;
onCommit(value: string, move?: Move): void;
onCancel(byKey: boolean): void;
}): ReactNode {
const inputRef = useRef(null);
const selectRef = useRef(null);
/**
* Set once a key has decided what happens to this edit.
*
* Handing the keys back to the grid blurs this input, and blur means "commit
* what is in the box" — which would write the value a second time after
* Enter, and write the abandoned typing after Escape.
*/
const settled = useRef(false);
/** Set once a key has moved a dropdown's highlight, so change means "browsing". */
const browsing = useRef(false);
/** What the cell held when the editor opened, to tell a pick from a no-op. */
const initial = useRef(draft);
useEffect(() => {
const input = inputRef.current;
input?.focus();
// Selecting the text is right when the edit opened on what is already
// there. It is wrong when the edit was started by typing: the seed is that
// first character, and selecting it means the next keystroke eats it.
if (input && seeded && input.type === 'text') {
input.setSelectionRange(input.value.length, input.value.length);
} else {
input?.select();
}
selectRef.current?.focus();
}, [seeded]);
if (col.type === 'link') {
return (
({
value: r.id,
label: linkTable ? editableValue(r.values[linkTable.display] ?? null) || r.id : r.id,
}))}
capped={(linkOptions?.length ?? 0) >= LINK_OPTION_LIMIT}
onCommit={onCommit}
onCancel={onCancel}
/>
);
}
if (col.type === 'select') {
const options = (col.options ?? []).map((o) => ({ value: o, label: o }));
return (
);
}
return (
onDraft(e.target.value)}
onBlur={() => {
if (!settled.current) onCommit(draft);
}}
onKeyDown={(e) => {
// The editor owns these keys; the grid's navigation must not also act,
// so where the cursor goes next travels with the value instead.
if (e.key === 'Enter') {
e.stopPropagation();
e.preventDefault();
settled.current = true;
onCommit(draft, 'down');
} else if (e.key === 'Escape') {
e.stopPropagation();
e.preventDefault();
settled.current = true;
onCancel(true);
} else if (e.key === 'Tab') {
e.stopPropagation();
e.preventDefault();
settled.current = true;
onCommit(draft, e.shiftKey ? 'left' : 'right');
} else {
e.stopPropagation();
}
}}
/>
);
}