1"use client";
2
3
24import { useCallback, useEffect, useMemo, useRef, useState } from "react";
25import { useQuery } from "@tanstack/react-query";
26import {
27 Check,
28 ExternalLink,
29 FilePlus2,
30 Loader2,
31 Pencil,
32 Plus,
33 Search,
34 X
35} from "lucide-react";
36import { cn } from "@/lib/utils";
37import { apiFetch } from "@/lib/api";
38import { toast } from "@/stores/ui-store";
39
40
41const notify = (t: { title: string; description?: string; variant?: string }) =>
42 toast(t.variant === "destructive" ? "error" : "success", t.title, t.description);
43import {
44 DatabaseProperty,
45 RecordDto,
46 RecordValue,
47 SelectOption,
48 findOption,
49 useRelations,
50 useUpdateRecord,
51 valueToText
52} from "@/hooks/use-database";
53import { FormulaEditor } from "@/components/database/formula-editor";
54import { UserAvatar } from "@/components/database/cell-renderer";
55
56
57
58import { uploadFile } from "@/lib/api";
59
60export interface CellEditorProps {
61 record: RecordDto;
62 property: DatabaseProperty;
63
64 onDone: () => void;
65 className?: string;
66}
67
68interface MemberRef {
69 id: string;
70 name: string;
71 avatarUrl?: string | null;
72 email?: string;
73}
74
75function useMembers(workspaceId: string | undefined) {
76 return useQuery({
77 queryKey: ["workspace-members", workspaceId ?? "none"],
78 enabled: !!workspaceId,
79 queryFn: async () => {
80 const b = await apiFetch(`/workspaces/${workspaceId}/members?limit=100`);
81 const d = (b as { data?: unknown }).data ?? b;
82 const arr = Array.isArray(d) ? (d as { user: MemberRef }[]) : [];
83 return arr.map((m) => m.user).filter((u) => u && u.id);
84 },
85 staleTime: 5 * 60 * 1000
86 });
87}
88
89const inputCls =
90 "w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring";
91
92
93export function buildValue(
94 property: DatabaseProperty,
95 input: string,
96 extra?: { optionId?: string; optionIds?: string[]; userIds?: string[]; recordIds?: string[]; fileIds?: string[]; urlTitle?: string }
97): RecordValue | null {
98 switch (property.type) {
99 case "title":
100 case "text":
101 case "rich_text":
102 return input ? { text: input, marks: [] } : null;
103 case "email":
104 return input ? { value: input } : null;
105 case "phone":
106 return input ? { value: input } : null;
107 case "url":
108 return input ? { value: input, title: extra?.urlTitle || undefined } : null;
109 case "number":
110 if (input === "") return null;
111 const n = Number(input);
112 return Number.isFinite(n)
113 ? { value: property.config.format === "percent" ? n / 100 : n }
114 : null;
115 case "select":
116 case "status":
117 return extra?.optionId ? { optionId: extra.optionId } : null;
118 case "multi_select":
119 return extra?.optionIds && extra.optionIds.length ? { optionIds: extra.optionIds } : null;
120 case "date":
121 if (!input) return null;
122 const d = new Date(input);
123 return Number.isNaN(d.getTime()) ? null : { start: d.toISOString(), end: null };
124 case "date_range":
125
126 const [s, e] = input.split("|");
127 if (!s) return null;
128 const sd = new Date(s);
129 if (Number.isNaN(sd.getTime())) return null;
130 const ed = e ? new Date(e) : null;
131 return {
132 start: sd.toISOString(),
133 end: ed && !Number.isNaN(ed.getTime()) ? ed.toISOString() : null
134 };
135 case "checkbox":
136 return { value: input === "true" };
137 case "person":
138 return extra?.userIds && extra.userIds.length ? { userIds: extra.userIds } : null;
139 case "relation":
140 return extra?.recordIds ? { recordIds: extra.recordIds } : { recordIds: [] };
141 case "files":
142 return extra?.fileIds ? { fileIds: extra.fileIds } : { fileIds: [] };
143 default:
144 return null;
145 }
146}
147
148
155export function dateToInput(property: DatabaseProperty, value: RecordValue | undefined | null): string {
156 const isDT = !!property.config.includeTime;
157 const s = value?.start ? (isDT ? String(value.start).slice(0, 16) : String(value.start).slice(0, 10)) : "";
158 const e = property.type === "date_range" && value?.end ? String(value.end).slice(0, 10) : "";
159 return property.type === "date_range" ? `${s}|${e}` : s;
160}
161
162
163
164export function CellEditor({ record, property, onDone, className }: CellEditorProps) {
165 const updateRecord = useUpdateRecord(record.databaseId);
166 const ref = useRef<HTMLDivElement>(null);
167
168
169
170
171
172
173
174
175
176 const [text, setText] = useState<string>(() =>
177 property.type === "date" || property.type === "date_range"
178 ? dateToInput(property, record.properties[property.id])
179 : valueToText(record.properties[property.id], property)
180 );
181
182
183
184
185 const [optionId, setOptionId] = useState<string | undefined>(
186 () => findOption(property.config.options, record.properties[property.id]?.optionId)?.id
187 );
188 const [optionIds, setOptionIds] = useState<string[]>(
189 () => {
190 const raw = (record.properties[property.id]?.optionIds as string[] | undefined) ?? [];
191 const ids = raw
192 .map((x) => findOption(property.config.options, x)?.id)
193 .filter((x): x is string => Boolean(x));
194 return [...new Set(ids)];
195 }
196 );
197 const [userIds, setUserIds] = useState<string[]>(
198 (record.properties[property.id]?.userIds as string[] | undefined) ?? []
199 );
200 const [recordIds, setRecordIds] = useState<string[]>(
201 (record.properties[property.id]?.recordIds as string[] | undefined) ?? []
202 );
203 const [fileIds, setFileIds] = useState<string[]>(
204 (record.properties[property.id]?.fileIds as string[] | undefined) ?? []
205 );
206 const [urlTitle, setUrlTitle] = useState<string>(
207 String(record.properties[property.id]?.title ?? "")
208 );
209
210
211
212 const [open, setOpen] = useState(
213 () => property.type === "select" || property.type === "status" || property.type === "multi_select"
214 );
215 const [uploading, setUploading] = useState(false);
216 const [editingFormula, setEditingFormula] = useState(false);
217
218
219 const commit = useCallback(
220 (value: RecordValue | null) => {
221 const payload: Record<string, RecordValue> = value == null ? { [property.id]: { text: "" } } : { [property.id]: value };
222 if (value == null) {
223
224 switch (property.type) {
225 case "title":
226 case "text":
227 case "rich_text":
228 payload[property.id] = { text: "" };
229 break;
230 case "number":
231 payload[property.id] = { value: null as unknown as number };
232 break;
233 case "select":
234 case "status":
235 payload[property.id] = { optionId: null as unknown as string };
236 break;
237 case "multi_select":
238 payload[property.id] = { optionIds: [] };
239 break;
240 case "date":
241 case "date_range":
242 payload[property.id] = { start: null, end: null };
243 break;
244 case "checkbox":
245 payload[property.id] = { value: false };
246 break;
247 case "person":
248 payload[property.id] = { userIds: [] };
249 break;
250 case "relation":
251 payload[property.id] = { recordIds: [] };
252 break;
253 case "files":
254 payload[property.id] = { fileIds: [] };
255 break;
256 default:
257 payload[property.id] = { value: null as unknown as number };
258 }
259 }
260
261
262
263
264
265
266 const isTitleProp = property.isTitle || property.type === "title";
267 if (isTitleProp) {
268 const text = value?.text ?? "";
269 updateRecord.mutate(
270 { recordId: record.id, version: record.version, title: text, titlePropertyId: property.id, properties: {} },
271 {
272 onSuccess: () => onDone(),
273 onError: (e) => {
274 notify({ title: "Update failed", description: (e as Error).message, variant: "destructive" });
275 }
276 }
277 );
278 return;
279 }
280 updateRecord.mutate(
281 { recordId: record.id, version: record.version, properties: payload },
282 {
283 onSuccess: () => onDone(),
284 onError: (e) => {
285 notify({ title: "Update failed", description: (e as Error).message, variant: "destructive" });
286 }
287 }
288 );
289 },
290 [property, record, updateRecord, onDone]
291 );
292
293 const valueFromInputs = useCallback(
294 () =>
295 buildValue(property, text, {
296 optionId,
297 optionIds,
298 userIds,
299 recordIds,
300 fileIds,
301 urlTitle
302 }),
303 [property, text, optionId, optionIds, userIds, recordIds, fileIds, urlTitle]
304 );
305
306
307
308
309
310
311 useEffect(() => {
312 const el = ref.current;
313 if (el) el.querySelector<HTMLElement>("input,select,textarea,button[data-autofocus]")?.focus();
314
315 }, []);
316
317
318 useEffect(() => {
319 const onKey = (e: KeyboardEvent) => {
320 if (e.key === "Escape") {
321 e.stopPropagation();
322 onDone();
323 }
324 };
325 document.addEventListener("keydown", onKey);
326 return () => document.removeEventListener("keydown", onKey);
327 }, [onDone]);
328
329 if (editingFormula) {
330 return (
331 <FormulaEditor
332 databaseId={record.databaseId}
333 property={property}
334 initialExpression={property.config.expression ?? ""}
335 sampleRecordIds={[record.id]}
336 onClose={() => setEditingFormula(false)}
337 onSaved={() => {
338 setEditingFormula(false);
339 onDone();
340 }}
341 />
342 );
343 }
344
345 const options = property.config.options ?? [];
346
347 const onTextCommit = () => {
348 if (text === valueToText(record.properties[property.id], property)) {
349 onDone();
350 return;
351 }
352 commit(valueFromInputs());
353 };
354
355
356
357
358
359
360
361
362
363
364
365
366 const onDateCommit = () => {
367 if (text === dateToInput(property, record.properties[property.id])) {
368 onDone();
369 return;
370 }
371 commit(valueFromInputs());
372 };
373
374
375 let body: React.ReactNode = null;
376
377 switch (property.type) {
378 case "title":
379 case "text":
380 case "rich_text":
381 case "email":
382 case "phone":
383 body = (
384 <input
385 className={inputCls}
386 value={text}
387 placeholder={property.type === "title" ? "Add a title…" : "Add a value…"}
388 onChange={(e) => setText(e.target.value)}
389 onBlur={onTextCommit}
390 onKeyDown={(e) => {
391 if (e.key === "Enter") {
392 e.preventDefault();
393 onTextCommit();
394 }
395 }}
396 inputMode={property.type === "email" ? "email" : property.type === "phone" ? "tel" : "text"}
397 />
398 );
399 break;
400 case "url": {
401
402
403 const openHref = /^https?:\/\
404 body = (
405 <div className="flex flex-col gap-1" onBlur={(e) => e.currentTarget.contains(e.relatedTarget as Node) || onTextCommit()}>
406 <div className="flex items-center gap-1">
407 <input
408 className={inputCls}
409 value={text}
410 placeholder="https://…"
411 onChange={(e) => setText(e.target.value)}
412 onKeyDown={(e) => e.key === "Enter" && onTextCommit()}
413 />
414 {openHref && (
415 <button
416 type="button"
417 title="Open link"
418 aria-label="Open link"
419 className="rounded border border-input bg-background p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
420 onClick={() => window.open(openHref, "_blank", "noopener,noreferrer")}
421 >
422 <ExternalLink className="size-3.5" />
423 </button>
424 )}
425 </div>
426 <input
427 className={inputCls}
428 value={urlTitle}
429 placeholder="Label (optional)"
430 onChange={(e) => setUrlTitle(e.target.value)}
431 onKeyDown={(e) => e.key === "Enter" && onTextCommit()}
432 />
433 </div>
434 );
435 break;
436 }
437 case "number": {
438 if (property.config.format === "percent") {
439 const pct = typeof record.properties[property.id]?.value === "number"
440 ? record.properties[property.id].value * 100
441 : 0;
442 body = (
443 <input
444 type="range"
445 min={0}
446 max={100}
447 value={pct}
448 className="w-full"
449 onChange={(e) => setText(e.target.value)}
450 onPointerUp={() => commit(valueFromInputs())}
451 onKeyDown={(e) => e.key === "Enter" && commit(valueFromInputs())}
452 data-autofocus
453 />
454 );
455 } else {
456 body = (
457 <input
458 className={inputCls}
459 type="number"
460 value={text}
461 min={typeof property.config.min === "number" ? property.config.min : undefined}
462 max={typeof property.config.max === "number" ? property.config.max : undefined}
463 step={property.config.format === "percent" ? 1 : "any"}
464 placeholder="0"
465 onChange={(e) => setText(e.target.value)}
466 onBlur={onTextCommit}
467 onKeyDown={(e) => e.key === "Enter" && onTextCommit()}
468 />
469 );
470 }
471 break;
472 }
473 case "select":
474 case "status":
475 body = (
476 <div className="relative" onBlur={(e) => e.currentTarget.contains(e.relatedTarget as Node) || onDone()}>
477 <button
478 type="button"
479 className={cn(inputCls, "flex items-center justify-between text-left")}
480 onClick={() => setOpen((o) => !o)}
481 data-autofocus
482 >
483 {options.find((o) => o.id === optionId)?.name ?? <span className="text-muted-foreground">Select…</span>}
484 </button>
485 {open && (
486 <ul className="absolute z-30 mt-1 max-h-60 w-full overflow-auto rounded-md border bg-popover p-1 shadow-md" role="listbox">
487 <li>
488 <button
489 type="button"
490 className="flex w-full items-center rounded px-2 py-1 text-sm hover:bg-muted"
491 onClick={() => {
492 setOptionId(undefined);
493 commit(null);
494 }}
495 >
496 <X className="size-3.5" aria-hidden /> Clear
497 </button>
498 </li>
499 {options.map((o: SelectOption) => (
500 <li key={o.id}>
501 <button
502 type="button"
503 className="flex w-full items-center gap-2 rounded px-2 py-1 text-sm hover:bg-muted"
504 onClick={() => {
505 setOptionId(o.id);
506 setOpen(false);
507 commit({ optionId: o.id });
508 }}
509 >
510 <span
511 className="size-2.5 rounded-full"
512 style={{ backgroundColor: `var(--factory-color-${o.color}, ${o.color})` }}
513 aria-hidden
514 />
515 {o.name}
516 {o.id === optionId && <Check className="ml-auto size-3.5" aria-hidden />}
517 </button>
518 </li>
519 ))}
520 </ul>
521 )}
522 </div>
523 );
524 break;
525 case "multi_select":
526 body = (
527 <div className="relative" onBlur={(e) => e.currentTarget.contains(e.relatedTarget as Node) || onDone()}>
528 <button
529 type="button"
530 className={cn(inputCls, "flex items-center gap-1 text-left")}
531 onClick={() => setOpen((o) => !o)}
532 data-autofocus
533 >
534 <span className="truncate">
535 {optionIds.length
536 ? optionIds.map((id) => options.find((o) => o.id === id)?.name).filter(Boolean).join(", ")
537 : "Select…"}
538 </span>
539 </button>
540 {open && (
541 <ul className="absolute z-30 mt-1 max-h-60 w-full overflow-auto rounded-md border bg-popover p-1 shadow-md">
542 {options.map((o: SelectOption) => (
543 <li key={o.id}>
544 <button
545 type="button"
546 className="flex w-full items-center gap-2 rounded px-2 py-1 text-sm hover:bg-muted"
547 onClick={() =>
548 setOptionIds((ids) => (ids.includes(o.id) ? ids.filter((x) => x !== o.id) : [...ids, o.id]))
549 }
550 >
551 <span
552 className={cn(
553 "inline-flex size-3.5 items-center justify-center rounded border",
554 optionIds.includes(o.id) && "border-primary bg-primary text-primary-foreground"
555 )}
556 aria-hidden
557 >
558 {optionIds.includes(o.id) && <Check className="size-2.5" />}
559 </span>
560 {o.name}
561 </button>
562 </li>
563 ))}
564 <li className="mt-1 border-t px-1 pt-1">
565 <button type="button" className="rounded px-2 py-1 text-xs text-primary hover:bg-muted" onClick={() => commit(valueFromInputs())}>
566 Done
567 </button>
568 </li>
569 </ul>
570 )}
571 </div>
572 );
573 break;
574 case "date":
575 case "date_range": {
576
577
578
579
580
581
582 const [s = "", e] = text.split("|");
583 const toInput = (ns: string, ne: string) => (property.type === "date_range" ? `${ns}|${ne}` : ns);
584 body = (
585 <div className="flex items-center gap-1">
586 <input
587 type={property.config.includeTime ? "datetime-local" : "date"}
588 className={inputCls}
589 value={s}
590 onChange={(ev) => setText(toInput(ev.target.value, e ?? ""))}
591 onBlur={onDateCommit}
592 onKeyDown={(ev) => ev.key === "Enter" && onDateCommit()}
593 data-autofocus
594 />
595 {property.type === "date_range" && (
596 <input
597 type="date"
598 className={inputCls}
599 value={e}
600 onChange={(ev) => setText(toInput(s, ev.target.value))}
601 onBlur={onDateCommit}
602 onKeyDown={(ev) => ev.key === "Enter" && onDateCommit()}
603 />
604 )}
605 </div>
606 );
607 break;
608 }
609 case "checkbox":
610 body = (
611 <button
612 type="button"
613 data-autofocus
614 className={cn(
615 "inline-flex size-5 items-center justify-center rounded border",
616 text === "true" ? "border-primary bg-primary text-primary-foreground" : "bg-background"
617 )}
618 onClick={() => {
619 const next = text !== "true";
620 setText(next ? "true" : "false");
621 commit({ value: next });
622 }}
623 aria-label="Toggle checkbox"
624 >
625 {text === "true" && <Check className="size-3.5" />}
626 </button>
627 );
628 break;
629 case "person":
630 body = <PersonPicker workspaceId={record.workspaceId} selected={userIds} onChange={setUserIds} onDone={onDone} onCommit={commit} />;
631 break;
632 case "files":
633 body = (
634 <div className="flex items-center gap-2">
635 <label
636 className="inline-flex cursor-pointer items-center gap-1 rounded border px-2 py-1 text-xs hover:bg-muted"
637 data-autofocus
638 >
639 <FilePlus2 className="size-3.5" aria-hidden />
640 {uploading ? <Loader2 className="size-3.5 animate-spin" aria-hidden /> : "Upload"}
641 <input
642 type="file"
643 multiple
644 className="hidden"
645 onChange={async (e) => {
646 const files = Array.from(e.target.files ?? []);
647 if (!files.length) return;
648 setUploading(true);
649 try {
650 const ids = [...fileIds];
651 for (const f of files) {
652 const meta = await uploadFile(f);
653 ids.push(meta.id);
654 }
655 setFileIds(ids);
656 commit({ fileIds: ids });
657 } catch (err) {
658 notify({ title: "Upload failed", description: (err as Error).message, variant: "destructive" });
659 } finally {
660 setUploading(false);
661 }
662 }}
663 />
664 </label>
665 {fileIds.map((id) => (
666 <span key={id} className="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-xs">
667 {id.slice(-6)}
668 <button
669 type="button"
670 aria-label="Remove file"
671 onClick={() => {
672 const next = fileIds.filter((x) => x !== id);
673 setFileIds(next);
674 commit({ fileIds: next });
675 }}
676 >
677 <X className="size-3" />
678 </button>
679 </span>
680 ))}
681 </div>
682 );
683 break;
684 case "relation":
685 body = (
686 <RelationPicker
687 record={record}
688 property={property}
689 selected={recordIds}
690 onChange={setRecordIds}
691 onDone={onDone}
692 onCommit={commit}
693 />
694 );
695 break;
696 case "formula":
697 body = (
698 <div className="flex items-center justify-between gap-2">
699 <span className="truncate text-sm text-muted-foreground">{valueToText(record.properties[property.id], property) || "—"}</span>
700 <button
701 type="button"
702 className="inline-flex items-center gap-1 rounded border px-2 py-0.5 text-xs hover:bg-muted"
703 onClick={() => setEditingFormula(true)}
704 >
705 <Pencil className="size-3" aria-hidden /> Edit formula
706 </button>
707 </div>
708 );
709 break;
710 default:
711 body = (
712 <span className="px-1 text-xs text-muted-foreground">
713 Read-only — {property.type} values are maintained server-side.
714 </span>
715 );
716 break;
717 }
718
719 return <div ref={ref} className={cn("min-w-40 bg-background p-0.5 shadow-md ring-1 ring-ring", className)}>{body}</div>;
720}
721
722
723
724
725
726function PersonPicker({
727 workspaceId,
728 selected,
729 onChange,
730 onDone,
731 onCommit
732}: {
733 workspaceId: string;
734 selected: string[];
735 onChange: (ids: string[]) => void;
736 onDone: () => void;
737 onCommit: (v: RecordValue | null) => void;
738}) {
739 const [q, setQ] = useState("");
740 const [open, setOpen] = useState(true);
741 const { data: members = [] } = useMembers(workspaceId);
742 const filtered = useMemo(
743 () =>
744 members.filter(
745 (m) =>
746 !q ||
747 m.name.toLowerCase().includes(q.toLowerCase()) ||
748 (m.email ?? "").toLowerCase().includes(q.toLowerCase())
749 ),
750 [members, q]
751 );
752
753 return (
754 <div className="relative min-w-44" onBlur={(e) => e.currentTarget.contains(e.relatedTarget as Node) || onDone()}>
755 <div className="flex items-center gap-1 rounded border bg-background px-1.5 py-0.5">
756 <Search className="size-3.5 text-muted-foreground" aria-hidden />
757 <input
758 value={q}
759 data-autofocus
760 placeholder="Search people…"
761 className="w-full bg-transparent py-0.5 text-sm outline-none"
762 onChange={(e) => {
763 setQ(e.target.value);
764 setOpen(true);
765 }}
766 />
767 </div>
768 {open && (
769 <ul className="absolute z-30 mt-1 max-h-52 w-full overflow-auto rounded-md border bg-popover p-1 shadow-md">
770 {selected.length > 0 && (
771 <li>
772 <button
773 type="button"
774 className="w-full rounded px-2 py-1 text-left text-xs text-primary hover:bg-muted"
775 onClick={() => onChange([])}
776 >
777 Clear all ({selected.length})
778 </button>
779 </li>
780 )}
781 {filtered.map((m) => (
782 <li key={m.id}>
783 <button
784 type="button"
785 className="flex w-full items-center gap-2 rounded px-2 py-1 text-sm hover:bg-muted"
786 onClick={() => onChange(selected.includes(m.id) ? selected.filter((x) => x !== m.id) : [...selected, m.id])}
787 >
788 <UserAvatar user={m} size={18} />
789 <span className="truncate">{m.name}</span>
790 {selected.includes(m.id) && <Check className="ml-auto size-3.5" aria-hidden />}
791 </button>
792 </li>
793 ))}
794 {filtered.length === 0 && (
795 <li className="px-2 py-1 text-xs text-muted-foreground">No matching members</li>
796 )}
797 <li className="mt-1 border-t px-1 pt-1">
798 <button type="button" className="rounded px-2 py-1 text-xs text-primary hover:bg-muted" onClick={() => onCommit({ userIds: selected })}>
799 Done
800 </button>
801 </li>
802 </ul>
803 )}
804 </div>
805 );
806}
807
808
809
810
811
812function RelationPicker({
813 record,
814 property,
815 selected,
816 onChange,
817 onDone,
818 onCommit
819}: {
820 record: RecordDto;
821 property: DatabaseProperty;
822 selected: string[];
823 onChange: (ids: string[]) => void;
824 onDone: () => void;
825 onCommit: (v: RecordValue | null) => void;
826}) {
827 const targetDbId = property.config.targetDatabaseId;
828 const [q, setQ] = useState("");
829 const [open, setOpen] = useState(true);
830 const { data: resolved } = useRelations(targetDbId, targetDbId, selected);
831
832
833 const searchQuery = useQuery({
834 queryKey: ["relation-search", targetDbId, q],
835 enabled: !!targetDbId && q.trim().length >= 1,
836 queryFn: async () => {
837 const b = await apiFetch(
838 `/databases/${targetDbId}/records?pageSize=10&filter=${encodeURIComponent(
839 JSON.stringify({
840 op: "or",
841 conditions: [
842 { propertyId: "title", op: "contains", value: { text: q } }
843 ]
844 })
845 )}`
846 );
847 const d = (b as { data?: { records?: { id: string; properties?: Record<string, { text?: string }> }[] } }).data;
848 return d?.records ?? [];
849 }
850 });
851
852 return (
853 <div className="relative min-w-48" onBlur={(e) => e.currentTarget.contains(e.relatedTarget as Node) || onDone()}>
854 <div className="flex flex-wrap items-center gap-1 rounded border bg-background px-1.5 py-0.5">
855 {selected.map((id) => {
856 const r = resolved?.find((x) => x.recordId === id)?.record;
857 return (
858 <span key={id} className="inline-flex items-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-xs">
859 {r
860 ? (r.title ?? r.properties?.[Object.keys(r.properties)[0]]?.text ?? "record")
861 : "…"}
862 <button type="button" aria-label="Unlink" onClick={() => onChange(selected.filter((x) => x !== id))}>
863 <X className="size-3" />
864 </button>
865 </span>
866 );
867 })}
868 <input
869 value={q}
870 data-autofocus
871 placeholder="Link records…"
872 className="min-w-20 flex-1 bg-transparent py-0.5 text-sm outline-none"
873 onChange={(e) => {
874 setQ(e.target.value);
875 setOpen(true);
876 }}
877 />
878 </div>
879 {open && (
880 <ul className="absolute z-30 mt-1 max-h-52 w-full overflow-auto rounded-md border bg-popover p-1 shadow-md">
881 {searchQuery.data?.map((r) => (
882 <li key={r.id}>
883 <button
884 type="button"
885 className="flex w-full items-center gap-2 rounded px-2 py-1 text-sm hover:bg-muted"
886 onClick={() => onChange(selected.includes(r.id) ? selected.filter((x) => x !== r.id) : [...selected, r.id])}
887 >
888 <Plus className="size-3 text-muted-foreground" aria-hidden />
889 {r.properties?.[Object.keys(r.properties)[0]]?.text ?? r.id.slice(-6)}
890 {selected.includes(r.id) && <Check className="ml-auto size-3.5" aria-hidden />}
891 </button>
892 </li>
893 ))}
894 {q && (searchQuery.data?.length ?? 0) === 0 && (
895 <li className="px-2 py-1 text-xs text-muted-foreground">No matches</li>
896 )}
897 <li className="mt-1 border-t px-1 pt-1">
898 <button type="button" className="rounded px-2 py-1 text-xs text-primary hover:bg-muted" onClick={() => onCommit({ recordIds: selected })}>
899 Done
900 </button>
901 </li>
902 </ul>
903 )}
904 </div>
905 );
906}
907