Skip to content

Commit 7f26dd0

Browse files
authored
ENG-1855 Add Roam shared-node import discovery (#1214)
* [ENG-1855] Add Roam shared-node import discovery * [ENG-1855] Keep discovery dialog open * [ENG-1855] Fix discovery dialog dismissing on keyboard launch Roam's command palette runs commands on Enter keyup, which landed as a click on the dialog's autofocused close button and dismissed it instantly. Disable autoFocus/enforceFocus like AdvancedSearchDialog and restore outside-click close. Also from review: use Tailwind classes instead of inline styles, drop the unused ValidSharedSpace.id field, and unexport the file-internal getImportedSourceRids. * [ENG-1855] Use getAllPages for ResourceAccess pagination Replaces the hand-rolled paging loop with the existing @repo/database/lib/pagination helper, matching how syncDgNodesToSupabase already reads ResourceAccess. * Avoid racing Supabase context initialization * [ENG-1855] Address shared-node discovery review feedback * [ENG-1855] Match UTC parsing with Obsidian * [ENG-1855] Defer shared discovery refactor to ENG-2019 * [ENG-1855] Exclude relations from shared-node discovery
1 parent 04706ec commit 7f26dd0

5 files changed

Lines changed: 761 additions & 0 deletions

File tree

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import {
2+
Button,
3+
Callout,
4+
Classes,
5+
Dialog,
6+
HTMLTable,
7+
InputGroup,
8+
Intent,
9+
NonIdealState,
10+
Spinner,
11+
Tag,
12+
Tooltip,
13+
} from "@blueprintjs/core";
14+
import React, { useCallback, useEffect, useMemo, useState } from "react";
15+
import createOverlayRender from "roamjs-components/util/createOverlayRender";
16+
import {
17+
discoverSharedNodes,
18+
type DiscoveredSharedNode,
19+
} from "~/utils/discoverSharedNodes";
20+
import internalError from "~/utils/internalError";
21+
import { getLoggedInClient, getSupabaseContext } from "~/utils/supabaseContext";
22+
23+
const formatModifiedAt = (modifiedAt: string): string =>
24+
new Date(modifiedAt).toLocaleString();
25+
26+
const SharedNodeRow = ({ node }: { node: DiscoveredSharedNode }) => (
27+
<tr>
28+
<td>
29+
<Tag minimal>{node.sourceApp}</Tag>
30+
</td>
31+
<td>
32+
<div className="max-w-52 font-medium [overflow-wrap:anywhere]">
33+
{node.sourceSpaceName}
34+
</div>
35+
<div
36+
className={[
37+
Classes.MONOSPACE_TEXT,
38+
Classes.TEXT_MUTED,
39+
"max-w-52 truncate text-xs",
40+
].join(" ")}
41+
title={node.sourceSpaceId}
42+
>
43+
{node.sourceSpaceId}
44+
</div>
45+
</td>
46+
<td>
47+
<div className="max-w-72 font-medium [overflow-wrap:anywhere]">
48+
{node.title}
49+
</div>
50+
</td>
51+
<td>
52+
{node.sourceNodeId ? (
53+
<div
54+
className={[Classes.MONOSPACE_TEXT, "max-w-44 truncate text-xs"].join(
55+
" ",
56+
)}
57+
title={node.sourceNodeRid}
58+
>
59+
{node.sourceNodeId}
60+
</div>
61+
) : (
62+
<span className={Classes.TEXT_MUTED}>Not provided</span>
63+
)}
64+
</td>
65+
<td className="whitespace-nowrap" title={node.modifiedAt}>
66+
{formatModifiedAt(node.modifiedAt)}
67+
</td>
68+
<td>
69+
{node.alreadyImported ? (
70+
<Tag intent={Intent.SUCCESS} minimal>
71+
Imported
72+
</Tag>
73+
) : (
74+
<Tag minimal>Available</Tag>
75+
)}
76+
</td>
77+
</tr>
78+
);
79+
80+
const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => {
81+
const [nodes, setNodes] = useState<DiscoveredSharedNode[]>([]);
82+
const [loading, setLoading] = useState(true);
83+
const [error, setError] = useState("");
84+
const [searchTerm, setSearchTerm] = useState("");
85+
86+
const loadNodes = useCallback(async (): Promise<void> => {
87+
setLoading(true);
88+
setError("");
89+
try {
90+
const context = await getSupabaseContext();
91+
if (!context) throw new Error("Could not connect to shared persistence.");
92+
const client = await getLoggedInClient();
93+
if (!client) throw new Error("Could not connect to shared persistence.");
94+
setNodes(
95+
await discoverSharedNodes({
96+
client,
97+
currentSpaceId: context.spaceId,
98+
}),
99+
);
100+
} catch (loadError) {
101+
internalError({
102+
error: loadError,
103+
type: "Shared node discovery failed",
104+
context: { operation: "load-shared-nodes" },
105+
sendEmail: false,
106+
});
107+
setError(
108+
loadError instanceof Error
109+
? loadError.message
110+
: "Could not load shared nodes.",
111+
);
112+
} finally {
113+
setLoading(false);
114+
}
115+
}, []);
116+
117+
useEffect(() => {
118+
void loadNodes();
119+
}, [loadNodes]);
120+
121+
const visibleNodes = useMemo(() => {
122+
const normalizedSearch = searchTerm.trim().toLocaleLowerCase();
123+
if (!normalizedSearch) return nodes;
124+
return nodes.filter((node) =>
125+
[
126+
node.sourceApp,
127+
node.sourceSpaceName,
128+
node.sourceSpaceId,
129+
node.title,
130+
node.sourceNodeId,
131+
].some((value) => value?.toLocaleLowerCase().includes(normalizedSearch)),
132+
);
133+
}, [nodes, searchTerm]);
134+
135+
return (
136+
<Dialog
137+
autoFocus={false}
138+
canEscapeKeyClose
139+
canOutsideClickClose
140+
enforceFocus={false}
141+
style={{ width: "min(68rem, calc(100vw - 2rem))" }}
142+
isOpen
143+
onClose={onClose}
144+
title="Discover shared nodes"
145+
>
146+
<div
147+
className={[Classes.DIALOG_BODY, "flex min-h-72 flex-col gap-3"].join(
148+
" ",
149+
)}
150+
>
151+
<div className="flex items-center gap-2">
152+
<InputGroup
153+
className="min-w-0 flex-1"
154+
leftIcon="search"
155+
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
156+
setSearchTerm(event.target.value)
157+
}
158+
placeholder="Search shared nodes"
159+
value={searchTerm}
160+
/>
161+
<Tooltip content="Reload shared nodes">
162+
<Button
163+
aria-label="Reload shared nodes"
164+
disabled={loading}
165+
icon="refresh"
166+
minimal
167+
onClick={() => void loadNodes()}
168+
/>
169+
</Tooltip>
170+
</div>
171+
172+
{loading ? (
173+
<div className="flex min-h-52 items-center justify-center">
174+
<Spinner />
175+
</div>
176+
) : error ? (
177+
<Callout intent={Intent.DANGER} title="Could not load shared nodes">
178+
<div className="mb-3">{error}</div>
179+
<Button icon="refresh" onClick={() => void loadNodes()}>
180+
Try again
181+
</Button>
182+
</Callout>
183+
) : visibleNodes.length === 0 ? (
184+
<div className="flex min-h-52 items-center justify-center">
185+
<NonIdealState
186+
icon="search"
187+
title={
188+
searchTerm ? "No matching shared nodes" : "No shared nodes"
189+
}
190+
/>
191+
</div>
192+
) : (
193+
<div className="min-h-0 overflow-auto">
194+
<HTMLTable striped className="w-full">
195+
<thead>
196+
<tr>
197+
<th>Source app</th>
198+
<th>Source space</th>
199+
<th>Title</th>
200+
<th>Source ID</th>
201+
<th>Modified</th>
202+
<th>Status</th>
203+
</tr>
204+
</thead>
205+
<tbody>
206+
{visibleNodes.map((node) => (
207+
<SharedNodeRow key={node.sourceNodeRid} node={node} />
208+
))}
209+
</tbody>
210+
</HTMLTable>
211+
</div>
212+
)}
213+
</div>
214+
<div className={Classes.DIALOG_FOOTER}>
215+
<div className="flex items-center justify-between">
216+
<span className={[Classes.TEXT_MUTED, "text-xs"].join(" ")}>
217+
{loading || error
218+
? ""
219+
: `${visibleNodes.length} of ${nodes.length} nodes`}
220+
</span>
221+
<Button onClick={onClose}>Close</Button>
222+
</div>
223+
</div>
224+
</Dialog>
225+
);
226+
};
227+
228+
type Props = Record<string, never>;
229+
230+
export const renderDiscoverSharedNodesDialog = createOverlayRender<Props>(
231+
"discourse-discover-shared-nodes",
232+
DiscoverSharedNodesDialog,
233+
);
234+
235+
export default DiscoverSharedNodesDialog;

0 commit comments

Comments
 (0)