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 = (
);
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()}>
setOpen((o) => !o)}
data-autofocus
>
{options.find((o) => o.id === optionId)?.name ?? Select… }
{open && (
{
setOptionId(undefined);
commit(null);
}}
>
Clear
{options.map((o: SelectOption) => (
{
setOptionId(o.id);
setOpen(false);
commit({ optionId: o.id });
}}
>
{o.name}
{o.id === optionId && }
))}
)}
);
break;
case "multi_select":
body = (
e.currentTarget.contains(e.relatedTarget as Node) || onDone()}>
setOpen((o) => !o)}
data-autofocus
>
{optionIds.length
? optionIds.map((id) => options.find((o) => o.id === id)?.name).filter(Boolean).join(", ")
: "Select…"}
{open && (
{options.map((o: SelectOption) => (
setOptionIds((ids) => (ids.includes(o.id) ? ids.filter((x) => x !== o.id) : [...ids, o.id]))
}
>
{optionIds.includes(o.id) && }
{o.name}
))}
commit(valueFromInputs())}>
Done
)}
);
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 = (
{
const next = text !== "true";
setText(next ? "true" : "false");
commit({ value: next });
}}
aria-label="Toggle checkbox"
>
{text === "true" && }
);
break;
case "person":
body = ;
break;
case "files":
body = (
{uploading ? : "Upload"}
{
const files = Array.from(e.target.files ?? []);
if (!files.length) return;
setUploading(true);
try {
const ids = [...fileIds];
for (const f of files) {
const meta = await uploadFile(f);
ids.push(meta.id);
}
setFileIds(ids);
commit({ fileIds: ids });
} catch (err) {
notify({ title: "Upload failed", description: (err as Error).message, variant: "destructive" });
} finally {
setUploading(false);
}
}}
/>
{fileIds.map((id) => (
{id.slice(-6)}
{
const next = fileIds.filter((x) => x !== id);
setFileIds(next);
commit({ fileIds: next });
}}
>
))}
);
break;
case "relation":
body = (
);
break;
case "formula":
body = (
{valueToText(record.properties[property.id], property) || "—"}
setEditingFormula(true)}
>
Edit formula
);
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 && (
onChange([])}
>
Clear all ({selected.length})
)}
{filtered.map((m) => (
onChange(selected.includes(m.id) ? selected.filter((x) => x !== m.id) : [...selected, m.id])}
>
{m.name}
{selected.includes(m.id) && }
))}
{filtered.length === 0 && (
No matching members
)}
onCommit({ userIds: selected })}>
Done
)}
);
}
// ---------------------------------------------------------------------------
// 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")
: "…"}
onChange(selected.filter((x) => x !== id))}>
);
})}
{
setQ(e.target.value);
setOpen(true);
}}
/>
{open && (
{searchQuery.data?.map((r) => (
onChange(selected.includes(r.id) ? selected.filter((x) => x !== r.id) : [...selected, r.id])}
>
{r.properties?.[Object.keys(r.properties)[0]]?.text ?? r.id.slice(-6)}
{selected.includes(r.id) && }
))}
{q && (searchQuery.data?.length ?? 0) === 0 && (
No matches
)}
onCommit({ recordIds: selected })}>
Done
)}
);
}