"use client"; /** * CellEditor — per-type inline editors for record property cells. * * Behavior contract (PRODUCT-SPEC §9 / frontend-architecture §6 "Interaction * model"): click cell → editor opens in place; Enter or blur commits; Esc * cancels. Commits are optimistic via useUpdateRecord (If-Match version) and * roll back + toast on failure. * * Types: * text / rich_text / email / phone / url → input (url adds a label field) * number / progress → number input (progress: slider) * select / status → dropdown from property options * multi_select → multi dropdown (checklist) * date / date_range → input[type=date|datetime-local] * checkbox → toggle * person → searchable members list * files → upload via @/lib/files (presigned) * formula / rollup / created_* / last_* / unique_id → read-only (formula * offers "edit formula" → ) * relation → search + add/remove linked records */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Check, ExternalLink, FilePlus2, Loader2, Pencil, Plus, Search, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiFetch } from "@/lib/api"; import { toast } from "@/stores/ui-store"; /** Toast adapter: call sites use variant:"destructive"; core store uses kind. */ const notify = (t: { title: string; description?: string; variant?: string }) => toast(t.variant === "destructive" ? "error" : "success", t.title, t.description); import { DatabaseProperty, RecordDto, RecordValue, SelectOption, findOption, useRelations, useUpdateRecord, valueToText } from "@/hooks/use-database"; import { FormulaEditor } from "@/components/database/formula-editor"; import { UserAvatar } from "@/components/database/cell-renderer"; // Presigned upload flow exported by the core API client (@/lib/api): // uploadFile(file, onProgress?) → Promise. import { uploadFile } from "@/lib/api"; export interface CellEditorProps { record: RecordDto; property: DatabaseProperty; /** Called after a successful commit (or cancel) so the parent can unmount. */ onDone: () => void; className?: string; } interface MemberRef { id: string; name: string; avatarUrl?: string | null; email?: string; } function useMembers(workspaceId: string | undefined) { return useQuery({ queryKey: ["workspace-members", workspaceId ?? "none"], enabled: !!workspaceId, queryFn: async () => { const b = await apiFetch(`/workspaces/${workspaceId}/members?limit=100`); const d = (b as { data?: unknown }).data ?? b; const arr = Array.isArray(d) ? (d as { user: MemberRef }[]) : []; return arr.map((m) => m.user).filter((u) => u && u.id); }, staleTime: 5 * 60 * 1000 }); } const inputCls = "w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"; /** Build the stored RecordValue for each editable type. */ export function buildValue( property: DatabaseProperty, input: string, extra?: { optionId?: string; optionIds?: string[]; userIds?: string[]; recordIds?: string[]; fileIds?: string[]; urlTitle?: string } ): RecordValue | null { switch (property.type) { case "title": case "text": case "rich_text": return input ? { text: input, marks: [] } : null; case "email": return input ? { value: input } : null; case "phone": return input ? { value: input } : null; case "url": return input ? { value: input, title: extra?.urlTitle || undefined } : null; case "number": if (input === "") return null; const n = Number(input); return Number.isFinite(n) ? { value: property.config.format === "percent" ? n / 100 : n } : null; case "select": case "status": return extra?.optionId ? { optionId: extra.optionId } : null; case "multi_select": return extra?.optionIds && extra.optionIds.length ? { optionIds: extra.optionIds } : null; case "date": if (!input) return null; const d = new Date(input); return Number.isNaN(d.getTime()) ? null : { start: d.toISOString(), end: null }; case "date_range": // input is "start|end" const [s, e] = input.split("|"); if (!s) return null; const sd = new Date(s); if (Number.isNaN(sd.getTime())) return null; const ed = e ? new Date(e) : null; return { start: sd.toISOString(), end: ed && !Number.isNaN(ed.getTime()) ? ed.toISOString() : null }; case "checkbox": return { value: input === "true" }; case "person": return extra?.userIds && extra.userIds.length ? { userIds: extra.userIds } : null; case "relation": return extra?.recordIds ? { recordIds: extra.recordIds } : { recordIds: [] }; case "files": return extra?.fileIds ? { fileIds: extra.fileIds } : { fileIds: [] }; default: return null; } } /** * Render a date/date_range stored value as the editor's `text` state: * `` compatible, `start` (or `start|end`). * Shared by the state initializer and the no-op guard so both sides compare * the SAME canonical string (an empty date is `""`, not `valueToText`'s raw * JSON — the old mismatch is what made every blur look like a change). */ export function dateToInput(property: DatabaseProperty, value: RecordValue | undefined | null): string { const isDT = !!property.config.includeTime; const s = value?.start ? (isDT ? String(value.start).slice(0, 16) : String(value.start).slice(0, 10)) : ""; const e = property.type === "date_range" && value?.end ? String(value.end).slice(0, 10) : ""; return property.type === "date_range" ? `${s}|${e}` : s; } // --------------------------------------------------------------------------- export function CellEditor({ record, property, onDone, className }: CellEditorProps) { const updateRecord = useUpdateRecord(record.databaseId); const ref = useRef(null); // Date-family editors keep their value in the `` string // format because that state drives BOTH the displayed value and the committed // value (valueFromInputs → buildValue reads `text`). `valueToText` is NOT // suitable for dates: for an empty `{start:null,end:null}` it returns the raw // JSON string, and for a set date a date-only prefix — neither matches the // input format. Seeding the wrong string meant every blur saw "changed" and // committed garbage; that no-op write is what made the Date field re-commit // `{start:null,end:null}` on every re-focus (see onDateCommit below). const [text, setText] = useState(() => property.type === "date" || property.type === "date_range" ? dateToInput(property, record.properties[property.id]) : valueToText(record.properties[property.id], property) ); // Stored option refs are canonical option ids (the API guarantees ids on // read). A legacy option NAME can still arrive via optimistic merges or // realtime payloads — resolve it to the real id (id-or-name match) so the // preselect and the committed value both use the canonical id. const [optionId, setOptionId] = useState( () => findOption(property.config.options, record.properties[property.id]?.optionId)?.id ); const [optionIds, setOptionIds] = useState( () => { const raw = (record.properties[property.id]?.optionIds as string[] | undefined) ?? []; const ids = raw .map((x) => findOption(property.config.options, x)?.id) .filter((x): x is string => Boolean(x)); return [...new Set(ids)]; } ); const [userIds, setUserIds] = useState( (record.properties[property.id]?.userIds as string[] | undefined) ?? [] ); const [recordIds, setRecordIds] = useState( (record.properties[property.id]?.recordIds as string[] | undefined) ?? [] ); const [fileIds, setFileIds] = useState( (record.properties[property.id]?.fileIds as string[] | undefined) ?? [] ); const [urlTitle, setUrlTitle] = useState( String(record.properties[property.id]?.title ?? "") ); // Select-family editors open with the dropdown ALREADY OPEN (Notion // behavior): one click on the cell shows the choices; the button toggles // it closed and the first click never "just focuses". const [open, setOpen] = useState( () => property.type === "select" || property.type === "status" || property.type === "multi_select" ); const [uploading, setUploading] = useState(false); const [editingFormula, setEditingFormula] = useState(false); // ---- commit / cancel ----------------------------------------------------- const commit = useCallback( (value: RecordValue | null) => { const payload: Record = value == null ? { [property.id]: { text: "" } } : { [property.id]: value }; if (value == null) { // Clearing: send the type-appropriate empty value. switch (property.type) { case "title": case "text": case "rich_text": payload[property.id] = { text: "" }; break; case "number": payload[property.id] = { value: null as unknown as number }; break; case "select": case "status": payload[property.id] = { optionId: null as unknown as string }; break; case "multi_select": payload[property.id] = { optionIds: [] }; break; case "date": case "date_range": payload[property.id] = { start: null, end: null }; break; case "checkbox": payload[property.id] = { value: false }; break; case "person": payload[property.id] = { userIds: [] }; break; case "relation": payload[property.id] = { recordIds: [] }; break; case "files": payload[property.id] = { fileIds: [] }; break; default: payload[property.id] = { value: null as unknown as number }; } } // The title property is NOT writable via `properties` — the API rejects // it (`title_via_title_field`, query.service.ts) and requires the value // in the top-level `title` field (docs/api/databases.md §7.2). Route it // there; `titlePropertyId` lets the hook's optimistic merge update the // title property's cached `{ text }` value (the read side synthesizes // that value from the page title, so it must stay in sync client-side). const isTitleProp = property.isTitle || property.type === "title"; if (isTitleProp) { const text = value?.text ?? ""; updateRecord.mutate( { recordId: record.id, version: record.version, title: text, titlePropertyId: property.id, properties: {} }, { onSuccess: () => onDone(), onError: (e) => { notify({ title: "Update failed", description: (e as Error).message, variant: "destructive" }); } } ); return; } updateRecord.mutate( { recordId: record.id, version: record.version, properties: payload }, { onSuccess: () => onDone(), onError: (e) => { notify({ title: "Update failed", description: (e as Error).message, variant: "destructive" }); } } ); }, [property, record, updateRecord, onDone] ); const valueFromInputs = useCallback( () => buildValue(property, text, { optionId, optionIds, userIds, recordIds, fileIds, urlTitle }), [property, text, optionId, optionIds, userIds, recordIds, fileIds, urlTitle] ); // Focus on mount only. Split from the Escape handler (below) because the // panel passes an inline `onDone`, and a `[onDone]`-dependent effect that // re-focuses on every re-render re-blurs the previously focused control — // which, for the date editor, fired a blur-commit on every refetch (the // infinite `{start:null,end:null}` PATCH loop). useEffect(() => { const el = ref.current; if (el) el.querySelector("input,select,textarea,button[data-autofocus]")?.focus(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Esc-to-cancel (re-bound when onDone identity changes). useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); onDone(); } }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [onDone]); if (editingFormula) { return ( setEditingFormula(false)} onSaved={() => { setEditingFormula(false); onDone(); }} /> ); } const options = property.config.options ?? []; const onTextCommit = () => { if (text === valueToText(record.properties[property.id], property)) { onDone(); return; } commit(valueFromInputs()); }; // Date/date-range counterpart of onTextCommit. `text` is seeded from and // compared against `dateToInput(property, current)` (see useState above) — // the SAME `` string the field displays. // So "did the user change it?" is an exact string compare: it's correct for // both plain dates and includeTime datetimes (we compare the editor's own // display string to itself, never a re-parsed instant), and it's the no-op // guard that stops an unchanged blur (e.g. the record panel re-focusing // after a refetch) from committing `{start:null,end:null}` — the write → // invalidate → refetch → re-render → re-focus → blur loop that flooded the // API and kept the board from settling (a status change "not reflecting" // in the column). const onDateCommit = () => { if (text === dateToInput(property, record.properties[property.id])) { onDone(); return; } commit(valueFromInputs()); }; // ---- per-type UI --------------------------------------------------------- let body: React.ReactNode = null; switch (property.type) { case "title": case "text": case "rich_text": case "email": case "phone": body = ( setText(e.target.value)} onBlur={onTextCommit} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); onTextCommit(); } }} inputMode={property.type === "email" ? "email" : property.type === "phone" ? "tel" : "text"} /> ); break; case "url": { // The cell itself no longer opens the link (its click opens the // editor instead) — keep the affordance here. const openHref = /^https?:\/\//i.test(text) ? text : null; body = (
e.currentTarget.contains(e.relatedTarget as Node) || onTextCommit()}>
setText(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onTextCommit()} /> {openHref && ( )}
setUrlTitle(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onTextCommit()} />
); break; } case "number": { if (property.config.format === "percent") { const pct = typeof record.properties[property.id]?.value === "number" ? record.properties[property.id].value * 100 : 0; body = ( setText(e.target.value)} onPointerUp={() => commit(valueFromInputs())} onKeyDown={(e) => e.key === "Enter" && commit(valueFromInputs())} data-autofocus /> ); } else { body = ( setText(e.target.value)} onBlur={onTextCommit} onKeyDown={(e) => e.key === "Enter" && onTextCommit()} /> ); } break; } case "select": case "status": body = (
e.currentTarget.contains(e.relatedTarget as Node) || onDone()}> {open && (
  • {options.map((o: SelectOption) => (
  • ))}
)}
); break; case "multi_select": body = (
e.currentTarget.contains(e.relatedTarget as Node) || onDone()}> {open && (
    {options.map((o: SelectOption) => (
  • ))}
)}
); break; case "date": case "date_range": { // `text` is the single source of truth, kept in `start` (or `start|end`) // form via dateToInput / toInput; deriving the fields here keeps the // displayed values in sync without a second state. // `text.split("|")[0]` is always a string (a split yields ≥1 element), so // the start segment is never `undefined` at runtime; the `= ""` default // only satisfies noUncheckedIndexedAccess for `toInput(s, …)` below. const [s = "", e] = text.split("|"); const toInput = (ns: string, ne: string) => (property.type === "date_range" ? `${ns}|${ne}` : ns); body = (
setText(toInput(ev.target.value, e ?? ""))} onBlur={onDateCommit} onKeyDown={(ev) => ev.key === "Enter" && onDateCommit()} data-autofocus /> {property.type === "date_range" && ( setText(toInput(s, ev.target.value))} onBlur={onDateCommit} onKeyDown={(ev) => ev.key === "Enter" && onDateCommit()} /> )}
); break; } case "checkbox": body = ( ); break; case "person": body = ; break; case "files": body = (
{fileIds.map((id) => ( {id.slice(-6)} ))}
); break; case "relation": body = ( ); break; case "formula": body = (
{valueToText(record.properties[property.id], property) || "—"}
); break; default: body = ( Read-only — {property.type} values are maintained server-side. ); break; } return
{body}
; } // --------------------------------------------------------------------------- // Person picker (searchable members list) // --------------------------------------------------------------------------- function PersonPicker({ workspaceId, selected, onChange, onDone, onCommit }: { workspaceId: string; selected: string[]; onChange: (ids: string[]) => void; onDone: () => void; onCommit: (v: RecordValue | null) => void; }) { const [q, setQ] = useState(""); const [open, setOpen] = useState(true); const { data: members = [] } = useMembers(workspaceId); const filtered = useMemo( () => members.filter( (m) => !q || m.name.toLowerCase().includes(q.toLowerCase()) || (m.email ?? "").toLowerCase().includes(q.toLowerCase()) ), [members, q] ); return (
e.currentTarget.contains(e.relatedTarget as Node) || onDone()}>
{ setQ(e.target.value); setOpen(true); }} />
{open && (
    {selected.length > 0 && (
  • )} {filtered.map((m) => (
  • ))} {filtered.length === 0 && (
  • No matching members
  • )}
)}
); } // --------------------------------------------------------------------------- // Relation picker (search target database records, add/remove) // --------------------------------------------------------------------------- function RelationPicker({ record, property, selected, onChange, onDone, onCommit }: { record: RecordDto; property: DatabaseProperty; selected: string[]; onChange: (ids: string[]) => void; onDone: () => void; onCommit: (v: RecordValue | null) => void; }) { const targetDbId = property.config.targetDatabaseId; const [q, setQ] = useState(""); const [open, setOpen] = useState(true); const { data: resolved } = useRelations(targetDbId, targetDbId, selected); // Search the target database (first page; the documented records listing). const searchQuery = useQuery({ queryKey: ["relation-search", targetDbId, q], enabled: !!targetDbId && q.trim().length >= 1, queryFn: async () => { const b = await apiFetch( `/databases/${targetDbId}/records?pageSize=10&filter=${encodeURIComponent( JSON.stringify({ op: "or", conditions: [ { propertyId: "title", op: "contains", value: { text: q } } ] }) )}` ); const d = (b as { data?: { records?: { id: string; properties?: Record }[] } }).data; return d?.records ?? []; } }); return (
e.currentTarget.contains(e.relatedTarget as Node) || onDone()}>
{selected.map((id) => { const r = resolved?.find((x) => x.recordId === id)?.record; return ( {r ? (r.title ?? r.properties?.[Object.keys(r.properties)[0]]?.text ?? "record") : "…"} ); })} { setQ(e.target.value); setOpen(true); }} />
{open && (
    {searchQuery.data?.map((r) => (
  • ))} {q && (searchQuery.data?.length ?? 0) === 0 && (
  • No matches
  • )}
)}
); }