@agent-native/core 0.197.0-nightly-20260929180759 → 0.197.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/CHANGELOG.md +10 -0
  3. package/corpus/templates/assets/CHANGELOG.md +8 -0
  4. package/corpus/templates/calendar/CHANGELOG.md +18 -0
  5. package/corpus/templates/clips/CHANGELOG.md +19 -0
  6. package/corpus/templates/content/CHANGELOG.md +16 -0
  7. package/corpus/templates/content/actions/_database-property-setup.ts +33 -2
  8. package/corpus/templates/content/actions/_database-setup-discovery.ts +4 -4
  9. package/corpus/templates/content/actions/_property-utils.ts +9 -1
  10. package/corpus/templates/content/actions/_relation-values.ts +291 -0
  11. package/corpus/templates/content/actions/configure-document-property.ts +53 -1
  12. package/corpus/templates/content/actions/search-content-database-rows.ts +124 -0
  13. package/corpus/templates/content/actions/set-document-property.ts +21 -1
  14. package/corpus/templates/content/agent-native.config.ts +5 -0
  15. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +892 -189
  16. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +7 -2
  17. package/corpus/templates/content/app/components/editor/database/navigation-state.ts +4 -1
  18. package/corpus/templates/content/app/hooks/use-content-database.ts +16 -2
  19. package/corpus/templates/content/app/hooks/use-document-properties.ts +65 -5
  20. package/corpus/templates/content/app/i18n-data.ts +28 -0
  21. package/corpus/templates/content/changelog/2026-09-28-link-rows-across-collections-with-relation-properties.md +6 -0
  22. package/corpus/templates/content/changelog/2026-09-29-related-pages-can-be-loaded-past-the-first-25-results.md +6 -0
  23. package/corpus/templates/content/parity/matrix.md +1 -1
  24. package/corpus/templates/content/parity/matrix.ts +1 -0
  25. package/corpus/templates/content/shared/api.ts +21 -0
  26. package/corpus/templates/content/shared/properties.ts +19 -0
  27. package/corpus/templates/crm/CHANGELOG.md +8 -0
  28. package/corpus/templates/design/CHANGELOG.md +33 -0
  29. package/corpus/templates/design/actions/clone-creative-context-design-native.ts +12 -3
  30. package/corpus/templates/design/actions/generate-design.ts +2 -29
  31. package/corpus/templates/design/actions/update-design.ts +33 -8
  32. package/corpus/templates/design/app/components/design/design-canvas/LocalNetworkAccessPrompt.tsx +2 -2
  33. package/corpus/templates/design/changelog/2026-09-29-design-tweak-controls-stay-intact-when-an-edit-contains-inva.md +6 -0
  34. package/corpus/templates/design/changelog/2026-09-29-the-chrome-fallback-guide-shows-the-local-network-allow-opti.md +6 -0
  35. package/corpus/templates/design/shared/tweak-definition-schema.ts +30 -0
  36. package/corpus/templates/dispatch/CHANGELOG.md +4 -0
  37. package/corpus/templates/mail/CHANGELOG.md +34 -0
  38. package/corpus/templates/mail/app/components/email/EmailList.tsx +29 -11
  39. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +4 -4
  40. package/corpus/templates/mail/app/pages/InboxPage.tsx +34 -10
  41. package/corpus/templates/mail/changelog/2026-09-29-mail-loading-layout.md +6 -0
  42. package/corpus/templates/slides/CHANGELOG.md +19 -0
  43. package/dist/automation/index.d.ts +1 -1
  44. package/dist/client/AppShellSkeleton.js +97 -54
  45. package/dist/collab/awareness.d.ts +2 -2
  46. package/dist/collab/struct-routes.d.ts +1 -1
  47. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  48. package/dist/notifications/routes.d.ts +2 -2
  49. package/dist/observability/routes.d.ts +7 -7
  50. package/dist/secrets/routes.d.ts +6 -6
  51. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  52. package/dist/server/agent-engine-ollama-models-route.d.ts +1 -1
  53. package/dist/triggers/actions/manage-automation.d.ts +2 -2
  54. package/docs/content/versioning-and-stability.mdx +3 -2
  55. package/package.json +4 -4
@@ -23,7 +23,9 @@ import type {
23
23
  BindContentDatabaseSourceFieldRequest,
24
24
  ContentDatabaseResponse,
25
25
  ContentDatabaseSource,
26
+ ContentDatabaseSummary,
26
27
  DocumentProperty,
28
+ DocumentPropertyRelationTarget,
27
29
  } from "@shared/api";
28
30
  import {
29
31
  CREATABLE_DOCUMENT_PROPERTY_TYPES,
@@ -36,6 +38,7 @@ import {
36
38
  isEmptyPropertyValue,
37
39
  isComputedPropertyType,
38
40
  isOnlyBlocksFieldDeletion,
41
+ MAX_RELATION_TARGETS,
39
42
  normalizeDatePropertyValue,
40
43
  type DocumentPropertyDateValue,
41
44
  type DocumentPropertyOption,
@@ -57,6 +60,7 @@ import {
57
60
  IconCircleDotted,
58
61
  IconClockFilled,
59
62
  IconCopy,
63
+ IconDatabase,
60
64
  IconEdit,
61
65
  IconEye,
62
66
  IconEyeOff,
@@ -67,6 +71,7 @@ import {
67
71
  IconLink,
68
72
  IconList,
69
73
  IconMapPin,
74
+ IconMinus,
70
75
  IconNumber,
71
76
  IconNumber123,
72
77
  IconPaperclip,
@@ -92,6 +97,7 @@ import {
92
97
  type MouseEvent as ReactMouseEvent,
93
98
  type ReactNode,
94
99
  } from "react";
100
+ import { Link } from "react-router";
95
101
  import { toast } from "sonner";
96
102
 
97
103
  import { FileStorageStatusGate } from "@/components/editor/FileStorageStatusGate";
@@ -120,6 +126,7 @@ import {
120
126
  import { Input } from "@/components/ui/input";
121
127
  import {
122
128
  Popover,
129
+ PopoverAnchor,
123
130
  PopoverContent,
124
131
  PopoverTrigger,
125
132
  } from "@/components/ui/popover";
@@ -134,10 +141,13 @@ import { useContentActionMutation } from "@/hooks/use-content-action-mutation";
134
141
  import {
135
142
  contentDatabaseConstrainedQueryFilter,
136
143
  useAddContentDatabaseSourceFieldProperty,
144
+ useAddDatabaseItem,
145
+ useContentDatabases,
137
146
  } from "@/hooks/use-content-database";
138
147
  import {
139
148
  documentPropertiesResponseMatchesScope,
140
149
  useConfigureDocumentProperty,
150
+ useContentDatabaseRowSearch,
141
151
  useDeleteDocumentProperty,
142
152
  useDocumentProperties,
143
153
  useDuplicateDocumentProperty,
@@ -350,6 +360,7 @@ export function displayValue(
350
360
  property: DocumentProperty,
351
361
  t?: TFunction,
352
362
  presentation: PropertyValuePresentation = "compact",
363
+ displayOptions: { interactiveRelations?: boolean } = {},
353
364
  ) {
354
365
  const value = property.value;
355
366
  const type = property.definition.type;
@@ -417,23 +428,35 @@ export function displayValue(
417
428
  }
418
429
 
419
430
  if (type === "relation") {
420
- const items = relationItems(value);
421
- if (items.length === 0) {
431
+ const ids = relationItems(value);
432
+ if (ids.length === 0) {
422
433
  return <span className="text-muted-foreground/70">{empty}</span>;
423
434
  }
435
+ const targets = property.relationTargets ?? [];
436
+ const unavailable = ids.length - targets.length;
424
437
  return (
425
- <span className="inline-flex max-w-full items-center gap-1.5 rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-foreground">
426
- <IconLink className="size-3.5 shrink-0 text-muted-foreground" />
427
- <span className="truncate">
428
- {tWithFallback(
429
- t,
430
- items.length === 1
431
- ? "editor.properties.pageCount_one"
432
- : "editor.properties.pageCount_other",
433
- `${items.length} page${items.length === 1 ? "" : "s"}`,
434
- { count: items.length },
435
- )}
436
- </span>
438
+ <span className="inline-flex max-w-full flex-wrap gap-1">
439
+ {targets.map((target) => (
440
+ <RelationPill
441
+ key={target.documentId}
442
+ target={target}
443
+ interactive={!!displayOptions.interactiveRelations}
444
+ t={t}
445
+ />
446
+ ))}
447
+ {unavailable > 0 ? (
448
+ <span className="inline-flex max-w-full items-center gap-1.5 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
449
+ <IconLink className="size-3.5 shrink-0" />
450
+ <span className="truncate">
451
+ {tWithFallback(
452
+ t,
453
+ "editor.properties.unavailablePageCount",
454
+ `${unavailable} unavailable`,
455
+ { count: unavailable },
456
+ )}
457
+ </span>
458
+ </span>
459
+ ) : null}
437
460
  </span>
438
461
  );
439
462
  }
@@ -639,6 +662,65 @@ export function relationItems(value: DocumentProperty["value"]) {
639
662
  : [];
640
663
  }
641
664
 
665
+ export function relationTargetPath(target: DocumentPropertyRelationTarget) {
666
+ if (!target.databaseId || !target.databaseDocumentId) {
667
+ return `/page/${target.documentId}`;
668
+ }
669
+ const search = new URLSearchParams({
670
+ databaseId: target.databaseId,
671
+ databaseDocumentId: target.databaseDocumentId,
672
+ });
673
+ return `/page/${target.documentId}?${search.toString()}`;
674
+ }
675
+
676
+ function RelationPill({
677
+ target,
678
+ interactive,
679
+ t,
680
+ }: {
681
+ target: DocumentPropertyRelationTarget;
682
+ interactive: boolean;
683
+ t?: TFunction;
684
+ }) {
685
+ const content = (
686
+ <>
687
+ {target.icon ? (
688
+ <span className="shrink-0 text-[0.8rem] leading-none">
689
+ {target.icon}
690
+ </span>
691
+ ) : (
692
+ <IconFileText className="size-3.5 shrink-0 text-muted-foreground" />
693
+ )}
694
+ <span className="truncate">{target.title}</span>
695
+ </>
696
+ );
697
+ const className =
698
+ "inline-flex max-w-full items-center gap-1.5 rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-foreground";
699
+ if (!interactive) return <span className={className}>{content}</span>;
700
+ return (
701
+ <Link
702
+ to={relationTargetPath(target)}
703
+ aria-label={tWithFallback(
704
+ t,
705
+ "editor.properties.openPage",
706
+ `Open ${target.title}`,
707
+ { name: target.title },
708
+ )}
709
+ className={cn(
710
+ className,
711
+ "underline-offset-2 hover:bg-accent hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
712
+ )}
713
+ // Keep the click on the link: the surrounding cell opens the value
714
+ // editor, and table rows / cards have their own click handlers.
715
+ onClick={(event) => event.stopPropagation()}
716
+ onPointerDown={(event) => event.stopPropagation()}
717
+ onKeyDown={(event) => event.stopPropagation()}
718
+ >
719
+ {content}
720
+ </Link>
721
+ );
722
+ }
723
+
642
724
  function FilesMediaPill({ value }: { value: string }) {
643
725
  return (
644
726
  <span className="inline-flex max-w-full items-center gap-1.5 rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-foreground">
@@ -1051,7 +1133,7 @@ function PropertyRow({
1051
1133
  const Icon = TYPE_ICONS[property.definition.type];
1052
1134
  const value = (
1053
1135
  <div className="min-w-0 flex-1 whitespace-normal break-words text-left text-sm max-sm:[&_.truncate]:whitespace-normal max-sm:[&_.truncate]:break-words sm:truncate">
1054
- {displayValue(property, t)}
1136
+ {displayValue(property, t, "compact", { interactiveRelations: true })}
1055
1137
  </div>
1056
1138
  );
1057
1139
 
@@ -1730,7 +1812,13 @@ export function PropertyManagementPopover({
1730
1812
  className="z-[310] max-h-80 w-56 overflow-auto"
1731
1813
  container={popoverContainer}
1732
1814
  >
1733
- {CREATABLE_DOCUMENT_PROPERTY_TYPES.map((propertyType) => {
1815
+ {CREATABLE_DOCUMENT_PROPERTY_TYPES.filter(
1816
+ // Converting into a relation needs a target database;
1817
+ // relations are added from "Add property" instead.
1818
+ (propertyType) =>
1819
+ propertyType !== "relation" ||
1820
+ property.definition.type === "relation",
1821
+ ).map((propertyType) => {
1734
1822
  const TypeIcon = TYPE_ICONS[propertyType];
1735
1823
  const selected = property.definition.type === propertyType;
1736
1824
  const disabled = typeIsLocked && !selected;
@@ -2224,19 +2312,48 @@ export function PropertyValuePopover({
2224
2312
  const t = useT();
2225
2313
  const [open, setOpen] = useState(false);
2226
2314
 
2315
+ const editLabel = t("editor.properties.editProperty", {
2316
+ name: property.definition.name,
2317
+ });
2318
+
2227
2319
  return (
2228
2320
  <Popover open={open} onOpenChange={setOpen}>
2229
- <PopoverTrigger asChild>
2230
- <button
2231
- type="button"
2232
- aria-label={t("editor.properties.editProperty", {
2233
- name: property.definition.name,
2234
- })}
2235
- className="flex min-h-6 w-full min-w-0 items-center rounded px-1 text-left hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
2236
- >
2237
- {children}
2238
- </button>
2239
- </PopoverTrigger>
2321
+ {property.definition.type === "relation" ? (
2322
+ // Relation values render links, so the picker gets its own trigger
2323
+ // button beside them rather than wrapping them in button semantics.
2324
+ <PopoverAnchor asChild>
2325
+ <div
2326
+ className="group flex min-h-6 w-full min-w-0 cursor-pointer items-center gap-1 rounded px-1 hover:bg-accent"
2327
+ onClick={(event) => {
2328
+ // Mouse convenience only: keyboard and assistive tech use the
2329
+ // trigger button, and the links keep their own behaviour.
2330
+ if ((event.target as HTMLElement).closest("a, button")) return;
2331
+ setOpen(true);
2332
+ }}
2333
+ >
2334
+ <div className="min-w-0 flex-1">{children}</div>
2335
+ <PopoverTrigger asChild>
2336
+ <button
2337
+ type="button"
2338
+ aria-label={editLabel}
2339
+ className="shrink-0 rounded p-0.5 text-muted-foreground opacity-0 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100 aria-expanded:opacity-100"
2340
+ >
2341
+ <IconEdit className="size-3.5" />
2342
+ </button>
2343
+ </PopoverTrigger>
2344
+ </div>
2345
+ </PopoverAnchor>
2346
+ ) : (
2347
+ <PopoverTrigger asChild>
2348
+ <button
2349
+ type="button"
2350
+ aria-label={editLabel}
2351
+ className="flex min-h-6 w-full min-w-0 items-center rounded px-1 text-left hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
2352
+ >
2353
+ {children}
2354
+ </button>
2355
+ </PopoverTrigger>
2356
+ )}
2240
2357
  <PopoverContent
2241
2358
  align="start"
2242
2359
  portalled={portalled}
@@ -2310,6 +2427,17 @@ function PropertyValueEditor({
2310
2427
  );
2311
2428
  }
2312
2429
 
2430
+ if (type === "relation") {
2431
+ return (
2432
+ <RelationValueEditor
2433
+ property={property}
2434
+ documentId={documentId}
2435
+ databaseDocumentId={databaseDocumentId}
2436
+ onDone={onDone}
2437
+ />
2438
+ );
2439
+ }
2440
+
2313
2441
  if (type === "files_media") {
2314
2442
  return (
2315
2443
  <FilesMediaValueEditor
@@ -2533,6 +2661,431 @@ function PersonValueEditor({
2533
2661
  );
2534
2662
  }
2535
2663
 
2664
+ function RelationValueEditor({
2665
+ property,
2666
+ documentId,
2667
+ databaseDocumentId,
2668
+ onDone,
2669
+ }: {
2670
+ property: DocumentProperty;
2671
+ documentId: string;
2672
+ databaseDocumentId: string;
2673
+ onDone: () => void;
2674
+ }) {
2675
+ const t = useT();
2676
+ const mutation = useSetDocumentProperty(
2677
+ documentId,
2678
+ property.definition.databaseId!,
2679
+ databaseDocumentId,
2680
+ );
2681
+ const targetDatabaseId = property.definition.options.relation?.databaseId;
2682
+ // Every pick and removal saves at once. Saves run one at a time and always
2683
+ // send the latest list, so a slow or failed save can't undo a newer one.
2684
+ // `savedRef` is the last value the server accepted, for rolling back.
2685
+ const [selected, setSelected] = useState(() => relationItems(property.value));
2686
+ const selectedRef = useRef(selected);
2687
+ const savedRef = useRef(selected);
2688
+ const saveQueueRef = useRef<Promise<void>>(Promise.resolve());
2689
+ const changeSeqRef = useRef(0);
2690
+ const savingRef = useRef(false);
2691
+ const [query, setQuery] = useState("");
2692
+ const [debouncedQuery, setDebouncedQuery] = useState("");
2693
+ const [known, setKnown] = useState(
2694
+ () =>
2695
+ new Map(
2696
+ (property.relationTargets ?? []).map((target) => [
2697
+ target.documentId,
2698
+ { title: target.title, icon: target.icon },
2699
+ ]),
2700
+ ),
2701
+ );
2702
+ const knownRef = useRef(known);
2703
+ knownRef.current = known;
2704
+ const inputRef = useRef<HTMLInputElement>(null);
2705
+ const search = useContentDatabaseRowSearch(
2706
+ targetDatabaseId,
2707
+ debouncedQuery,
2708
+ true,
2709
+ );
2710
+ const firstPage = search.data?.pages[0];
2711
+ const rows = useMemo(
2712
+ () => search.data?.pages.flatMap((page) => page.rows) ?? [],
2713
+ [search.data?.pages],
2714
+ );
2715
+ // Results kept from the previous search stay visible but can't be picked.
2716
+ const searchIsCurrent = query === debouncedQuery && !search.isPlaceholderData;
2717
+ const addRow = useAddDatabaseItem(firstPage?.databaseDocumentId ?? "");
2718
+ const rowDragSensors = useSensors(
2719
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
2720
+ useSensor(KeyboardSensor, {
2721
+ coordinateGetter: sortableKeyboardCoordinates,
2722
+ }),
2723
+ );
2724
+
2725
+ useEffect(() => {
2726
+ const frame = requestAnimationFrame(() => inputRef.current?.focus());
2727
+ return () => cancelAnimationFrame(frame);
2728
+ }, []);
2729
+
2730
+ useEffect(() => {
2731
+ const timer = setTimeout(() => setDebouncedQuery(query), 150);
2732
+ return () => clearTimeout(timer);
2733
+ }, [query]);
2734
+
2735
+ useEffect(() => {
2736
+ if (rows.length === 0) return;
2737
+ setKnown((current) => {
2738
+ const next = new Map(current);
2739
+ for (const row of rows) {
2740
+ next.set(row.documentId, { title: row.title, icon: row.icon });
2741
+ }
2742
+ return next;
2743
+ });
2744
+ }, [rows]);
2745
+
2746
+ // Adopt a newer value from the server (polling or another editor) while
2747
+ // no save of ours is waiting to run.
2748
+ const serverValueKey = relationItems(property.value).join("\n");
2749
+ useEffect(() => {
2750
+ if (savingRef.current) return;
2751
+ const serverValue = serverValueKey ? serverValueKey.split("\n") : [];
2752
+ if (serverValue.join("\n") === savedRef.current.join("\n")) return;
2753
+ savedRef.current = serverValue;
2754
+ selectedRef.current = serverValue;
2755
+ setSelected(serverValue);
2756
+ }, [serverValueKey]);
2757
+
2758
+ useEffect(() => {
2759
+ const targets = property.relationTargets ?? [];
2760
+ if (targets.length === 0) return;
2761
+ setKnown((current) => {
2762
+ const changed = targets.some((target) => {
2763
+ const row = current.get(target.documentId);
2764
+ return row?.title !== target.title || row?.icon !== target.icon;
2765
+ });
2766
+ if (!changed) return current;
2767
+ const next = new Map(current);
2768
+ for (const target of targets) {
2769
+ next.set(target.documentId, { title: target.title, icon: target.icon });
2770
+ }
2771
+ return next;
2772
+ });
2773
+ }, [property.relationTargets]);
2774
+
2775
+ function save(value: string[]) {
2776
+ return mutation.mutateAsync({
2777
+ documentId,
2778
+ propertyId: property.definition.id,
2779
+ value: value.length > 0 ? value : null,
2780
+ relationTargets: value.flatMap((id) => {
2781
+ const row = knownRef.current.get(id);
2782
+ return row
2783
+ ? [
2784
+ {
2785
+ documentId: id,
2786
+ title: row.title,
2787
+ icon: row.icon,
2788
+ databaseId: null,
2789
+ databaseDocumentId: null,
2790
+ },
2791
+ ]
2792
+ : [];
2793
+ }),
2794
+ });
2795
+ }
2796
+
2797
+ function commit(next: string[]) {
2798
+ selectedRef.current = next;
2799
+ setSelected(next);
2800
+ const seq = ++changeSeqRef.current;
2801
+ savingRef.current = true;
2802
+ saveQueueRef.current = saveQueueRef.current.then(async () => {
2803
+ // A newer change is queued behind this one and will send the latest list.
2804
+ if (seq !== changeSeqRef.current) return;
2805
+ const value = selectedRef.current;
2806
+ try {
2807
+ await save(value);
2808
+ savedRef.current = value;
2809
+ } catch {
2810
+ // coercion-ok: the mutation hook already shows the error.
2811
+ if (seq === changeSeqRef.current) {
2812
+ selectedRef.current = savedRef.current;
2813
+ setSelected(savedRef.current);
2814
+ }
2815
+ } finally {
2816
+ if (seq === changeSeqRef.current) savingRef.current = false;
2817
+ }
2818
+ });
2819
+ }
2820
+
2821
+ function add(id: string) {
2822
+ const current = selectedRef.current;
2823
+ if (current.includes(id) || current.length >= MAX_RELATION_TARGETS) {
2824
+ return;
2825
+ }
2826
+ commit([...current, id]);
2827
+ }
2828
+
2829
+ function remove(id: string) {
2830
+ commit(selectedRef.current.filter((selectedId) => selectedId !== id));
2831
+ }
2832
+
2833
+ function handleRowDragEnd(event: DragEndEvent) {
2834
+ const { active, over } = event;
2835
+ if (!over || active.id === over.id) return;
2836
+ const current = selectedRef.current;
2837
+ const fromIndex = current.indexOf(String(active.id));
2838
+ const toIndex = current.indexOf(String(over.id));
2839
+ if (fromIndex < 0 || toIndex < 0) return;
2840
+ commit(arrayMove(current, fromIndex, toIndex));
2841
+ }
2842
+
2843
+ async function createRow(title: string) {
2844
+ const rowCreation = firstPage?.rowCreation;
2845
+ if (!rowCreation || addRow.isPending) return;
2846
+ try {
2847
+ const result = await addRow.mutateAsync({
2848
+ target: rowCreation.target,
2849
+ expectedSchemaRevision: rowCreation.schemaRevision,
2850
+ idempotencyKey: `relation-create-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
2851
+ title,
2852
+ });
2853
+ const newId = result.receipt.row.documentId;
2854
+ setKnown((current) => new Map(current).set(newId, { title, icon: null }));
2855
+ setQuery("");
2856
+ // Build on the latest list: other rows may have been picked meanwhile.
2857
+ add(newId);
2858
+ } catch {
2859
+ // coercion-ok: useActionMutation surfaces the failure; keep the typed title.
2860
+ }
2861
+ }
2862
+
2863
+ if (!targetDatabaseId) {
2864
+ return (
2865
+ <div className="px-2 py-3 text-sm text-muted-foreground">
2866
+ {t("editor.properties.noRelatedDatabase")}
2867
+ </div>
2868
+ );
2869
+ }
2870
+
2871
+ const unselectedRows = rows.filter(
2872
+ (row) => !selected.includes(row.documentId),
2873
+ );
2874
+ const needle = query.trim().toLowerCase();
2875
+ const canCreate =
2876
+ searchIsCurrent &&
2877
+ !!firstPage?.rowCreation &&
2878
+ !!needle &&
2879
+ selected.length < MAX_RELATION_TARGETS &&
2880
+ !rows.some((row) => row.title.trim().toLowerCase() === needle);
2881
+ const visibleSelected = selected.filter((id) => {
2882
+ if (!needle) return true;
2883
+ const title = known.get(id)?.title ?? "";
2884
+ return title.toLowerCase().includes(needle);
2885
+ });
2886
+
2887
+ return (
2888
+ <div className="grid gap-1">
2889
+ <div className="flex h-8 items-center gap-1 border-b border-border px-1 pb-1">
2890
+ <IconSearch className="size-3.5 shrink-0 text-muted-foreground" />
2891
+ <input
2892
+ ref={inputRef}
2893
+ aria-label={t("editor.properties.searchPages")}
2894
+ value={query}
2895
+ placeholder={t("editor.properties.linkAPage")}
2896
+ onChange={(event) => setQuery(event.target.value)}
2897
+ onKeyDown={(event) => {
2898
+ if (event.key === "Escape") {
2899
+ event.preventDefault();
2900
+ onDone();
2901
+ }
2902
+ if (!searchIsCurrent && event.key === "Enter") {
2903
+ event.preventDefault();
2904
+ } else if (event.key === "Enter" && unselectedRows[0]) {
2905
+ event.preventDefault();
2906
+ add(unselectedRows[0].documentId);
2907
+ setQuery("");
2908
+ } else if (event.key === "Enter" && canCreate) {
2909
+ event.preventDefault();
2910
+ void createRow(query.trim());
2911
+ }
2912
+ }}
2913
+ className="h-7 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
2914
+ />
2915
+ </div>
2916
+ <div className="max-h-80 overflow-auto">
2917
+ {visibleSelected.length > 0 ? (
2918
+ <>
2919
+ <div className="px-2 pb-1 pt-2 text-xs font-medium text-muted-foreground">
2920
+ {t("editor.properties.selectedCount", {
2921
+ count: selected.length,
2922
+ })}
2923
+ </div>
2924
+ <DndContext
2925
+ sensors={rowDragSensors}
2926
+ collisionDetection={closestCenter}
2927
+ onDragEnd={handleRowDragEnd}
2928
+ >
2929
+ <SortableContext
2930
+ items={visibleSelected}
2931
+ strategy={verticalListSortingStrategy}
2932
+ >
2933
+ {visibleSelected.map((id) => {
2934
+ const row = known.get(id);
2935
+ return (
2936
+ <SortableRelationRow
2937
+ key={id}
2938
+ id={id}
2939
+ title={
2940
+ row?.title ?? t("editor.properties.unavailablePage")
2941
+ }
2942
+ icon={row?.icon ?? null}
2943
+ // Reordering a filtered list would be ambiguous.
2944
+ dragDisabled={!!needle}
2945
+ onRemove={() => remove(id)}
2946
+ />
2947
+ );
2948
+ })}
2949
+ </SortableContext>
2950
+ </DndContext>
2951
+ </>
2952
+ ) : null}
2953
+ {selected.length > 0 &&
2954
+ !needle &&
2955
+ !search.isLoading &&
2956
+ unselectedRows.length === 0 ? null : (
2957
+ <>
2958
+ <div className="px-2 pb-1 pt-2 text-xs font-medium text-muted-foreground">
2959
+ {selected.length > 0
2960
+ ? t("editor.properties.selectMore")
2961
+ : t("editor.properties.selectAPage")}
2962
+ </div>
2963
+ {canCreate ? (
2964
+ <button
2965
+ type="button"
2966
+ className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent disabled:opacity-50"
2967
+ disabled={addRow.isPending}
2968
+ onClick={() => void createRow(query.trim())}
2969
+ >
2970
+ {addRow.isPending ? (
2971
+ <Spinner className="size-4 shrink-0 text-muted-foreground" />
2972
+ ) : (
2973
+ <IconPlus className="size-4 shrink-0 text-muted-foreground" />
2974
+ )}
2975
+ <span className="min-w-0 flex-1 truncate">
2976
+ {t("editor.properties.createPage", { name: query.trim() })}
2977
+ </span>
2978
+ </button>
2979
+ ) : null}
2980
+ {search.isLoading ? (
2981
+ <div className="flex items-center px-2 py-3 text-muted-foreground">
2982
+ <Spinner className="size-4" />
2983
+ </div>
2984
+ ) : unselectedRows.length === 0 ? (
2985
+ canCreate ? null : (
2986
+ <div className="px-2 py-2 text-sm text-muted-foreground">
2987
+ {t("editor.properties.noMatchingPages")}
2988
+ </div>
2989
+ )
2990
+ ) : (
2991
+ unselectedRows.map((row) => (
2992
+ <button
2993
+ type="button"
2994
+ key={row.documentId}
2995
+ className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent disabled:opacity-50"
2996
+ disabled={
2997
+ !searchIsCurrent || selected.length >= MAX_RELATION_TARGETS
2998
+ }
2999
+ onClick={() => add(row.documentId)}
3000
+ >
3001
+ <RelationRowIcon icon={row.icon} />
3002
+ <span className="min-w-0 flex-1 truncate">{row.title}</span>
3003
+ </button>
3004
+ ))
3005
+ )}
3006
+ {search.hasNextPage ? (
3007
+ <button
3008
+ type="button"
3009
+ disabled={!searchIsCurrent || search.isFetchingNextPage}
3010
+ onClick={() => void search.fetchNextPage()}
3011
+ className="flex w-full items-center justify-center gap-2 rounded px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent disabled:opacity-50"
3012
+ >
3013
+ {search.isFetchingNextPage ? (
3014
+ <Spinner className="size-4 shrink-0" />
3015
+ ) : null}
3016
+ {t("sidebar.showMore")}
3017
+ </button>
3018
+ ) : null}
3019
+ </>
3020
+ )}
3021
+ </div>
3022
+ </div>
3023
+ );
3024
+ }
3025
+
3026
+ function SortableRelationRow({
3027
+ id,
3028
+ title,
3029
+ icon,
3030
+ dragDisabled,
3031
+ onRemove,
3032
+ }: {
3033
+ id: string;
3034
+ title: string;
3035
+ icon: string | null;
3036
+ dragDisabled: boolean;
3037
+ onRemove: () => void;
3038
+ }) {
3039
+ const t = useT();
3040
+ const {
3041
+ attributes,
3042
+ listeners,
3043
+ setNodeRef,
3044
+ transform,
3045
+ transition,
3046
+ isDragging,
3047
+ } = useSortable({ id, disabled: dragDisabled });
3048
+ return (
3049
+ <div
3050
+ ref={setNodeRef}
3051
+ style={{ transform: CSS.Transform.toString(transform), transition }}
3052
+ className={cn(
3053
+ "group flex items-center gap-1.5 rounded px-1 py-1 text-sm hover:bg-accent",
3054
+ isDragging && "relative z-10 bg-accent",
3055
+ )}
3056
+ >
3057
+ <button
3058
+ type="button"
3059
+ disabled={dragDisabled}
3060
+ aria-label={t("editor.properties.reorderRelation", { name: title })}
3061
+ className="flex size-5 shrink-0 cursor-grab touch-none items-center justify-center rounded text-muted-foreground/60 hover:text-muted-foreground active:cursor-grabbing disabled:cursor-default disabled:opacity-30"
3062
+ {...attributes}
3063
+ {...listeners}
3064
+ >
3065
+ <IconGripVertical className="size-3.5" />
3066
+ </button>
3067
+ <RelationRowIcon icon={icon} />
3068
+ <span className="min-w-0 flex-1 truncate">{title}</span>
3069
+ <button
3070
+ type="button"
3071
+ aria-label={t("editor.properties.removeRelation", { name: title })}
3072
+ className="flex size-6 shrink-0 items-center justify-center rounded border border-border text-muted-foreground opacity-0 hover:bg-background hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100 max-sm:opacity-100"
3073
+ onClick={onRemove}
3074
+ >
3075
+ <IconMinus className="size-3.5" />
3076
+ </button>
3077
+ </div>
3078
+ );
3079
+ }
3080
+
3081
+ function RelationRowIcon({ icon }: { icon: string | null }) {
3082
+ return icon ? (
3083
+ <span className="w-4 shrink-0 text-center leading-none">{icon}</span>
3084
+ ) : (
3085
+ <IconFileText className="size-4 shrink-0 text-muted-foreground" />
3086
+ );
3087
+ }
3088
+
2536
3089
  function FilesMediaValueEditor({
2537
3090
  property,
2538
3091
  documentId,
@@ -3496,6 +4049,16 @@ export function AddProperty({
3496
4049
  string | null
3497
4050
  >(null);
3498
4051
  const [addPropertyError, setAddPropertyError] = useState<string | null>(null);
4052
+ const [relationStep, setRelationStep] = useState(false);
4053
+ const [relationDatabaseQuery, setRelationDatabaseQuery] = useState("");
4054
+ const relationDatabases = useContentDatabases({
4055
+ enabled: open && relationStep,
4056
+ });
4057
+ const relationDatabaseChoices = relationDatabaseChoicesFor(
4058
+ relationDatabases.data?.databases ?? [],
4059
+ databaseId,
4060
+ relationDatabaseQuery,
4061
+ );
3499
4062
  const isAddingProperty =
3500
4063
  configure.isPending ||
3501
4064
  addSourceFieldProperty.isPending ||
@@ -3526,6 +4089,8 @@ export function AddProperty({
3526
4089
  if (isAddingProperty) return;
3527
4090
  setTypeQuery("");
3528
4091
  setAddPropertyError(null);
4092
+ setRelationStep(false);
4093
+ setRelationDatabaseQuery("");
3529
4094
  setOpen(false);
3530
4095
  }
3531
4096
 
@@ -3538,8 +4103,20 @@ export function AddProperty({
3538
4103
  onConnectSource();
3539
4104
  }
3540
4105
 
3541
- async function add(type: DocumentPropertyType) {
3542
- const label = t(`editor.propertyTypes.${type}`);
4106
+ async function add(
4107
+ type: DocumentPropertyType,
4108
+ relationTarget?: { databaseId: string; title: string },
4109
+ ) {
4110
+ if (type === "relation" && !relationTarget) {
4111
+ // A relation needs its target database before it can be created.
4112
+ setAddPropertyError(null);
4113
+ setRelationDatabaseQuery("");
4114
+ setRelationStep(true);
4115
+ return;
4116
+ }
4117
+ const label = relationTarget
4118
+ ? relationTarget.title
4119
+ : t(`editor.propertyTypes.${type}`);
3543
4120
  setPendingPropertyType(type);
3544
4121
  setPendingSourceFieldId(null);
3545
4122
  setAddPropertyError(null);
@@ -3548,9 +4125,15 @@ export function AddProperty({
3548
4125
  documentId,
3549
4126
  name: label,
3550
4127
  type,
3551
- options: type === "blocks" ? undefined : defaultPropertyOptions(type),
4128
+ options: relationTarget
4129
+ ? { relation: { databaseId: relationTarget.databaseId } }
4130
+ : type === "blocks"
4131
+ ? undefined
4132
+ : defaultPropertyOptions(type),
3552
4133
  });
3553
4134
  setTypeQuery("");
4135
+ setRelationStep(false);
4136
+ setRelationDatabaseQuery("");
3554
4137
  setOpen(false);
3555
4138
  } catch (error) {
3556
4139
  setAddPropertyError(error instanceof Error ? error.message : "");
@@ -3650,183 +4233,303 @@ export function AddProperty({
3650
4233
  "data-[state=closed]:hidden data-[state=closed]:animate-none",
3651
4234
  )}
3652
4235
  >
3653
- <div className="grid gap-2">
3654
- <div className="flex h-8 items-center gap-1 rounded border border-border bg-background px-2">
3655
- <IconSearch className="size-3.5 shrink-0 text-muted-foreground" />
3656
- <Input
3657
- ref={addPropertySearchInputRef}
3658
- autoFocus
3659
- value={typeQuery}
3660
- placeholder={t("editor.properties.searchPropertyTypes")}
3661
- aria-label={t("editor.properties.searchPropertyTypes")}
3662
- onChange={(event) => setTypeQuery(event.target.value)}
3663
- onKeyDown={(event) => {
3664
- if (event.key === "Enter" && firstFilteredPropertyType) {
3665
- event.preventDefault();
3666
- void add(firstFilteredPropertyType);
3667
- } else if (event.key === "Enter" && connectSourceMatches) {
3668
- event.preventDefault();
3669
- connectSource();
3670
- }
3671
- if (event.key === "Escape") {
3672
- event.preventDefault();
3673
- closeAddPropertyPicker();
3674
- }
3675
- }}
3676
- className="h-7 border-0 bg-transparent px-0 text-xs shadow-none focus-visible:ring-0"
3677
- />
3678
- </div>
3679
- <div className="max-h-80 overflow-auto rounded border p-1">
3680
- {connectSourceMatches ? (
4236
+ {relationStep ? (
4237
+ <div className="grid gap-2">
4238
+ <div className="flex items-center gap-1">
3681
4239
  <button
3682
4240
  type="button"
3683
- className="mb-1 flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
3684
- onClick={connectSource}
4241
+ aria-label={t("editor.properties.back")}
4242
+ className="flex size-7 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
4243
+ disabled={isAddingProperty}
4244
+ onClick={() => {
4245
+ setRelationStep(false);
4246
+ setAddPropertyError(null);
4247
+ }}
3685
4248
  >
3686
- <IconPlugConnected className="size-4 shrink-0 text-muted-foreground" />
3687
- <span className="flex-1">{connectSourceLabel}</span>
4249
+ <IconArrowLeft className="size-4" />
3688
4250
  </button>
3689
- ) : null}
3690
- {sourceFieldGroups.map((group) => (
3691
- <div
3692
- key={group.source.id}
3693
- className="mb-1 border-b border-border pb-1"
3694
- >
3695
- <div className="truncate px-2 py-1 text-xs font-medium text-muted-foreground">
3696
- {t("editor.properties.fromSource", {
3697
- name: group.source.sourceName,
3698
- })}
4251
+ <span className="text-sm font-medium">
4252
+ {t("editor.properties.relatedDatabase")}
4253
+ </span>
4254
+ </div>
4255
+ <div className="flex h-8 items-center gap-1 rounded border border-border bg-background px-2">
4256
+ <IconSearch className="size-3.5 shrink-0 text-muted-foreground" />
4257
+ <Input
4258
+ autoFocus
4259
+ value={relationDatabaseQuery}
4260
+ placeholder={t("editor.properties.searchDatabases")}
4261
+ aria-label={t("editor.properties.searchDatabases")}
4262
+ onChange={(event) =>
4263
+ setRelationDatabaseQuery(event.target.value)
4264
+ }
4265
+ onKeyDown={(event) => {
4266
+ const first = relationDatabaseChoices[0];
4267
+ if (event.key === "Enter" && first) {
4268
+ event.preventDefault();
4269
+ void add("relation", first);
4270
+ }
4271
+ if (event.key === "Escape") {
4272
+ event.preventDefault();
4273
+ closeAddPropertyPicker();
4274
+ }
4275
+ }}
4276
+ className="h-7 border-0 bg-transparent px-0 text-xs shadow-none focus-visible:ring-0"
4277
+ />
4278
+ </div>
4279
+ <div className="max-h-80 overflow-auto rounded border p-1">
4280
+ {relationDatabases.isLoading ? (
4281
+ <div className="flex items-center gap-2 px-2 py-3 text-sm text-muted-foreground">
4282
+ <Spinner className="size-4" />
3699
4283
  </div>
3700
- {group.fields.map((field) => {
3701
- const SourceFieldIcon =
3702
- TYPE_ICONS[
3703
- propertyTypeForSourceFieldType(field.sourceFieldType)
3704
- ];
3705
- return (
3706
- <button
3707
- key={field.id}
3708
- type="button"
3709
- aria-label={t("editor.properties.sourceField", {
3710
- name: field.sourceFieldLabel,
3711
- })}
3712
- disabled={isAddingProperty}
3713
- aria-busy={pendingSourceFieldId === field.id}
3714
- className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent disabled:opacity-50"
3715
- onPointerDownCapture={(event) =>
3716
- activateAddPropertyItem(
3717
- event,
3718
- `source:${field.id}`,
3719
- () => {
3720
- void addFromSourceField(field.id);
3721
- },
3722
- )
3723
- }
3724
- onClick={(event) =>
3725
- activateAddPropertyItem(
3726
- event,
3727
- `source:${field.id}`,
3728
- () => {
3729
- void addFromSourceField(field.id);
3730
- },
3731
- )
3732
- }
3733
- onKeyDown={(event) =>
3734
- activateAddPropertyItemFromKeyboard(
3735
- event,
3736
- `source:${field.id}`,
3737
- () => {
3738
- void addFromSourceField(field.id);
3739
- },
3740
- )
3741
- }
3742
- >
3743
- {pendingSourceFieldId === field.id ? (
3744
- <Spinner className="size-4 shrink-0 text-muted-foreground" />
3745
- ) : (
3746
- <SourceFieldIcon className="size-4 shrink-0 text-muted-foreground" />
3747
- )}
3748
- <span className="min-w-0 flex-1 truncate">
3749
- {field.sourceFieldLabel}
3750
- </span>
4284
+ ) : relationDatabaseChoices.length === 0 ? (
4285
+ <div className="px-2 py-3 text-sm text-muted-foreground">
4286
+ {t("editor.properties.noDatabases")}
4287
+ </div>
4288
+ ) : (
4289
+ relationDatabaseChoices.map((choice) => (
4290
+ <button
4291
+ key={choice.databaseId}
4292
+ type="button"
4293
+ className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent disabled:opacity-50"
4294
+ disabled={isAddingProperty}
4295
+ onClick={() => void add("relation", choice)}
4296
+ >
4297
+ {pendingPropertyType === "relation" ? (
4298
+ <Spinner className="size-4 shrink-0 text-muted-foreground" />
4299
+ ) : (
4300
+ <IconDatabase className="size-4 shrink-0 text-muted-foreground" />
4301
+ )}
4302
+ <span className="min-w-0 flex-1 truncate">
4303
+ {choice.title}
4304
+ </span>
4305
+ {choice.isCurrent ? (
3751
4306
  <span className="shrink-0 text-xs text-muted-foreground">
3752
- {group.source.metadata.federation?.role === "secondary"
3753
- ? t("editor.properties.federated")
3754
- : t("editor.properties.source")}
4307
+ {t("editor.properties.thisDatabase")}
3755
4308
  </span>
3756
- </button>
3757
- );
3758
- })}
3759
- </div>
3760
- ))}
3761
- {filteredPropertyTypes.length === 0 && !connectSourceMatches ? (
3762
- <div className="px-2 py-3 text-sm text-muted-foreground">
3763
- {t("editor.properties.noMatchingPropertyTypes")}
3764
- </div>
3765
- ) : null}
3766
- {filteredPropertyTypes.map((type) => {
3767
- const Icon = TYPE_ICONS[type];
3768
- return (
4309
+ ) : null}
4310
+ </button>
4311
+ ))
4312
+ )}
4313
+ {addPropertyError !== null ? (
4314
+ <div
4315
+ role="alert"
4316
+ className="px-2 py-1.5 text-xs text-destructive"
4317
+ >
4318
+ {t("editor.properties.addPropertyFailed")}
4319
+ {addPropertyError ? ` ${addPropertyError}` : null}
4320
+ </div>
4321
+ ) : null}
4322
+ </div>
4323
+ </div>
4324
+ ) : (
4325
+ <div className="grid gap-2">
4326
+ <div className="flex h-8 items-center gap-1 rounded border border-border bg-background px-2">
4327
+ <IconSearch className="size-3.5 shrink-0 text-muted-foreground" />
4328
+ <Input
4329
+ ref={addPropertySearchInputRef}
4330
+ autoFocus
4331
+ value={typeQuery}
4332
+ placeholder={t("editor.properties.searchPropertyTypes")}
4333
+ aria-label={t("editor.properties.searchPropertyTypes")}
4334
+ onChange={(event) => setTypeQuery(event.target.value)}
4335
+ onKeyDown={(event) => {
4336
+ if (event.key === "Enter" && firstFilteredPropertyType) {
4337
+ event.preventDefault();
4338
+ void add(firstFilteredPropertyType);
4339
+ } else if (event.key === "Enter" && connectSourceMatches) {
4340
+ event.preventDefault();
4341
+ connectSource();
4342
+ }
4343
+ if (event.key === "Escape") {
4344
+ event.preventDefault();
4345
+ closeAddPropertyPicker();
4346
+ }
4347
+ }}
4348
+ className="h-7 border-0 bg-transparent px-0 text-xs shadow-none focus-visible:ring-0"
4349
+ />
4350
+ </div>
4351
+ <div className="max-h-80 overflow-auto rounded border p-1">
4352
+ {connectSourceMatches ? (
3769
4353
  <button
3770
- key={type}
3771
4354
  type="button"
3772
- aria-label={t("editor.properties.addPropertyType", {
3773
- type: t(`editor.propertyTypes.${type}`),
4355
+ className="mb-1 flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
4356
+ onClick={connectSource}
4357
+ >
4358
+ <IconPlugConnected className="size-4 shrink-0 text-muted-foreground" />
4359
+ <span className="flex-1">{connectSourceLabel}</span>
4360
+ </button>
4361
+ ) : null}
4362
+ {sourceFieldGroups.map((group) => (
4363
+ <div
4364
+ key={group.source.id}
4365
+ className="mb-1 border-b border-border pb-1"
4366
+ >
4367
+ <div className="truncate px-2 py-1 text-xs font-medium text-muted-foreground">
4368
+ {t("editor.properties.fromSource", {
4369
+ name: group.source.sourceName,
4370
+ })}
4371
+ </div>
4372
+ {group.fields.map((field) => {
4373
+ const SourceFieldIcon =
4374
+ TYPE_ICONS[
4375
+ propertyTypeForSourceFieldType(field.sourceFieldType)
4376
+ ];
4377
+ return (
4378
+ <button
4379
+ key={field.id}
4380
+ type="button"
4381
+ aria-label={t("editor.properties.sourceField", {
4382
+ name: field.sourceFieldLabel,
4383
+ })}
4384
+ disabled={isAddingProperty}
4385
+ aria-busy={pendingSourceFieldId === field.id}
4386
+ className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent disabled:opacity-50"
4387
+ onPointerDownCapture={(event) =>
4388
+ activateAddPropertyItem(
4389
+ event,
4390
+ `source:${field.id}`,
4391
+ () => {
4392
+ void addFromSourceField(field.id);
4393
+ },
4394
+ )
4395
+ }
4396
+ onClick={(event) =>
4397
+ activateAddPropertyItem(
4398
+ event,
4399
+ `source:${field.id}`,
4400
+ () => {
4401
+ void addFromSourceField(field.id);
4402
+ },
4403
+ )
4404
+ }
4405
+ onKeyDown={(event) =>
4406
+ activateAddPropertyItemFromKeyboard(
4407
+ event,
4408
+ `source:${field.id}`,
4409
+ () => {
4410
+ void addFromSourceField(field.id);
4411
+ },
4412
+ )
4413
+ }
4414
+ >
4415
+ {pendingSourceFieldId === field.id ? (
4416
+ <Spinner className="size-4 shrink-0 text-muted-foreground" />
4417
+ ) : (
4418
+ <SourceFieldIcon className="size-4 shrink-0 text-muted-foreground" />
4419
+ )}
4420
+ <span className="min-w-0 flex-1 truncate">
4421
+ {field.sourceFieldLabel}
4422
+ </span>
4423
+ <span className="shrink-0 text-xs text-muted-foreground">
4424
+ {group.source.metadata.federation?.role ===
4425
+ "secondary"
4426
+ ? t("editor.properties.federated")
4427
+ : t("editor.properties.source")}
4428
+ </span>
4429
+ </button>
4430
+ );
3774
4431
  })}
3775
- className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
3776
- disabled={isAddingProperty}
3777
- aria-busy={pendingPropertyType === type}
3778
- onPointerDownCapture={(event) =>
3779
- activateAddPropertyItem(event, `type:${type}`, () => {
3780
- void add(type);
3781
- })
3782
- }
3783
- onClick={(event) =>
3784
- activateAddPropertyItem(event, `type:${type}`, () => {
3785
- void add(type);
3786
- })
3787
- }
3788
- onKeyDown={(event) =>
3789
- activateAddPropertyItemFromKeyboard(
3790
- event,
3791
- `type:${type}`,
3792
- () => {
4432
+ </div>
4433
+ ))}
4434
+ {filteredPropertyTypes.length === 0 && !connectSourceMatches ? (
4435
+ <div className="px-2 py-3 text-sm text-muted-foreground">
4436
+ {t("editor.properties.noMatchingPropertyTypes")}
4437
+ </div>
4438
+ ) : null}
4439
+ {filteredPropertyTypes.map((type) => {
4440
+ const Icon = TYPE_ICONS[type];
4441
+ return (
4442
+ <button
4443
+ key={type}
4444
+ type="button"
4445
+ aria-label={t("editor.properties.addPropertyType", {
4446
+ type: t(`editor.propertyTypes.${type}`),
4447
+ })}
4448
+ className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
4449
+ disabled={isAddingProperty}
4450
+ aria-busy={pendingPropertyType === type}
4451
+ onPointerDownCapture={(event) =>
4452
+ activateAddPropertyItem(event, `type:${type}`, () => {
3793
4453
  void add(type);
3794
- },
3795
- )
3796
- }
3797
- >
3798
- {pendingPropertyType === type ? (
3799
- <Spinner className="size-4 text-muted-foreground" />
3800
- ) : (
3801
- <Icon className="size-4 text-muted-foreground" />
3802
- )}
3803
- <span className="flex-1">
3804
- {t(`editor.propertyTypes.${type}`)}
3805
- </span>
3806
- {isComputedPropertyType(type) ? (
3807
- <span className="text-xs text-muted-foreground">
3808
- {t("editor.properties.computed")}
4454
+ })
4455
+ }
4456
+ onClick={(event) =>
4457
+ activateAddPropertyItem(event, `type:${type}`, () => {
4458
+ void add(type);
4459
+ })
4460
+ }
4461
+ onKeyDown={(event) =>
4462
+ activateAddPropertyItemFromKeyboard(
4463
+ event,
4464
+ `type:${type}`,
4465
+ () => {
4466
+ void add(type);
4467
+ },
4468
+ )
4469
+ }
4470
+ >
4471
+ {pendingPropertyType === type ? (
4472
+ <Spinner className="size-4 text-muted-foreground" />
4473
+ ) : (
4474
+ <Icon className="size-4 text-muted-foreground" />
4475
+ )}
4476
+ <span className="flex-1">
4477
+ {t(`editor.propertyTypes.${type}`)}
3809
4478
  </span>
3810
- ) : null}
3811
- </button>
3812
- );
3813
- })}
3814
- {addPropertyError !== null ? (
3815
- <div
3816
- role="alert"
3817
- className="px-2 py-1.5 text-xs text-destructive"
3818
- >
3819
- {t("editor.properties.addPropertyFailed")}
3820
- {addPropertyError ? ` ${addPropertyError}` : null}
3821
- </div>
3822
- ) : null}
4479
+ {isComputedPropertyType(type) ? (
4480
+ <span className="text-xs text-muted-foreground">
4481
+ {t("editor.properties.computed")}
4482
+ </span>
4483
+ ) : null}
4484
+ </button>
4485
+ );
4486
+ })}
4487
+ {addPropertyError !== null ? (
4488
+ <div
4489
+ role="alert"
4490
+ className="px-2 py-1.5 text-xs text-destructive"
4491
+ >
4492
+ {t("editor.properties.addPropertyFailed")}
4493
+ {addPropertyError ? ` ${addPropertyError}` : null}
4494
+ </div>
4495
+ ) : null}
4496
+ </div>
3823
4497
  </div>
3824
- </div>
4498
+ )}
3825
4499
  </PopoverContent>
3826
4500
  </Popover>
3827
4501
  );
3828
4502
  }
3829
4503
 
4504
+ /**
4505
+ * Databases a new relation property may target: those in the same space as
4506
+ * the current database (the server enforces this too), current one first.
4507
+ */
4508
+ export function relationDatabaseChoicesFor(
4509
+ databases: readonly ContentDatabaseSummary[],
4510
+ currentDatabaseId: string,
4511
+ query: string,
4512
+ ) {
4513
+ const current = databases.find(
4514
+ (database) => database.databaseId === currentDatabaseId,
4515
+ );
4516
+ const needle = query.trim().toLowerCase();
4517
+ return databases
4518
+ .filter(
4519
+ (database) =>
4520
+ !current || (database.spaceId ?? null) === (current.spaceId ?? null),
4521
+ )
4522
+ .filter(
4523
+ (database) => !needle || database.title.toLowerCase().includes(needle),
4524
+ )
4525
+ .map((database) => ({
4526
+ databaseId: database.databaseId,
4527
+ title: database.title || "Untitled",
4528
+ isCurrent: database.databaseId === currentDatabaseId,
4529
+ }))
4530
+ .sort((a, b) => Number(b.isCurrent) - Number(a.isCurrent));
4531
+ }
4532
+
3830
4533
  export function filterDocumentPropertyTypes(
3831
4534
  query: string,
3832
4535
  types: readonly DocumentPropertyType[] = CREATABLE_DOCUMENT_PROPERTY_TYPES,