@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/analytics/CHANGELOG.md +10 -0
- package/corpus/templates/assets/CHANGELOG.md +8 -0
- package/corpus/templates/calendar/CHANGELOG.md +18 -0
- package/corpus/templates/clips/CHANGELOG.md +19 -0
- package/corpus/templates/content/CHANGELOG.md +16 -0
- package/corpus/templates/content/actions/_database-property-setup.ts +33 -2
- package/corpus/templates/content/actions/_database-setup-discovery.ts +4 -4
- package/corpus/templates/content/actions/_property-utils.ts +9 -1
- package/corpus/templates/content/actions/_relation-values.ts +291 -0
- package/corpus/templates/content/actions/configure-document-property.ts +53 -1
- package/corpus/templates/content/actions/search-content-database-rows.ts +124 -0
- package/corpus/templates/content/actions/set-document-property.ts +21 -1
- package/corpus/templates/content/agent-native.config.ts +5 -0
- package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +892 -189
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +7 -2
- package/corpus/templates/content/app/components/editor/database/navigation-state.ts +4 -1
- package/corpus/templates/content/app/hooks/use-content-database.ts +16 -2
- package/corpus/templates/content/app/hooks/use-document-properties.ts +65 -5
- package/corpus/templates/content/app/i18n-data.ts +28 -0
- package/corpus/templates/content/changelog/2026-09-28-link-rows-across-collections-with-relation-properties.md +6 -0
- package/corpus/templates/content/changelog/2026-09-29-related-pages-can-be-loaded-past-the-first-25-results.md +6 -0
- package/corpus/templates/content/parity/matrix.md +1 -1
- package/corpus/templates/content/parity/matrix.ts +1 -0
- package/corpus/templates/content/shared/api.ts +21 -0
- package/corpus/templates/content/shared/properties.ts +19 -0
- package/corpus/templates/crm/CHANGELOG.md +8 -0
- package/corpus/templates/design/CHANGELOG.md +33 -0
- package/corpus/templates/design/actions/clone-creative-context-design-native.ts +12 -3
- package/corpus/templates/design/actions/generate-design.ts +2 -29
- package/corpus/templates/design/actions/update-design.ts +33 -8
- package/corpus/templates/design/app/components/design/design-canvas/LocalNetworkAccessPrompt.tsx +2 -2
- package/corpus/templates/design/changelog/2026-09-29-design-tweak-controls-stay-intact-when-an-edit-contains-inva.md +6 -0
- package/corpus/templates/design/changelog/2026-09-29-the-chrome-fallback-guide-shows-the-local-network-allow-opti.md +6 -0
- package/corpus/templates/design/shared/tweak-definition-schema.ts +30 -0
- package/corpus/templates/dispatch/CHANGELOG.md +4 -0
- package/corpus/templates/mail/CHANGELOG.md +34 -0
- package/corpus/templates/mail/app/components/email/EmailList.tsx +29 -11
- package/corpus/templates/mail/app/components/layout/AppLayout.tsx +4 -4
- package/corpus/templates/mail/app/pages/InboxPage.tsx +34 -10
- package/corpus/templates/mail/changelog/2026-09-29-mail-loading-layout.md +6 -0
- package/corpus/templates/slides/CHANGELOG.md +19 -0
- package/dist/automation/index.d.ts +1 -1
- package/dist/client/AppShellSkeleton.js +97 -54
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/routes.d.ts +7 -7
- package/dist/secrets/routes.d.ts +6 -6
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/agent-engine-ollama-models-route.d.ts +1 -1
- package/dist/triggers/actions/manage-automation.d.ts +2 -2
- package/docs/content/versioning-and-stability.mdx +3 -2
- 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
|
|
421
|
-
if (
|
|
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
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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.
|
|
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
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
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(
|
|
3542
|
-
|
|
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:
|
|
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
|
-
|
|
3654
|
-
<div className="
|
|
3655
|
-
<
|
|
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
|
-
|
|
3684
|
-
|
|
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
|
-
<
|
|
3687
|
-
<span className="flex-1">{connectSourceLabel}</span>
|
|
4249
|
+
<IconArrowLeft className="size-4" />
|
|
3688
4250
|
</button>
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
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
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
className="
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
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
|
-
{
|
|
3753
|
-
? t("editor.properties.federated")
|
|
3754
|
-
: t("editor.properties.source")}
|
|
4307
|
+
{t("editor.properties.thisDatabase")}
|
|
3755
4308
|
</span>
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
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
|
-
|
|
3773
|
-
|
|
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
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
}
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
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
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
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
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
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
|
-
|
|
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,
|