Skip to content
Open
235 changes: 235 additions & 0 deletions apps/roam/src/components/DiscoverSharedNodesDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import {
Button,
Callout,
Classes,
Dialog,
HTMLTable,
InputGroup,
Intent,
NonIdealState,
Spinner,
Tag,
Tooltip,
} from "@blueprintjs/core";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import createOverlayRender from "roamjs-components/util/createOverlayRender";
import {
discoverSharedNodes,
type DiscoveredSharedNode,
} from "~/utils/discoverSharedNodes";
import internalError from "~/utils/internalError";
import { getLoggedInClient, getSupabaseContext } from "~/utils/supabaseContext";

const formatModifiedAt = (modifiedAt: string): string =>
new Date(modifiedAt).toLocaleString();

const SharedNodeRow = ({ node }: { node: DiscoveredSharedNode }) => (
<tr>
<td>
<Tag minimal>{node.sourceApp}</Tag>
</td>
<td>
<div className="max-w-52 font-medium [overflow-wrap:anywhere]">
{node.sourceSpaceName}
</div>
<div
className={[
Classes.MONOSPACE_TEXT,
Classes.TEXT_MUTED,
"max-w-52 truncate text-xs",
].join(" ")}
title={node.sourceSpaceId}
>
{node.sourceSpaceId}
</div>
</td>
<td>
<div className="max-w-72 font-medium [overflow-wrap:anywhere]">
{node.title}
</div>
</td>
<td>
{node.sourceNodeId ? (
<div
className={[Classes.MONOSPACE_TEXT, "max-w-44 truncate text-xs"].join(
" ",
)}
title={node.sourceNodeRid}
>
{node.sourceNodeId}
</div>
) : (
<span className={Classes.TEXT_MUTED}>Not provided</span>
)}
</td>
<td className="whitespace-nowrap" title={node.modifiedAt}>
{formatModifiedAt(node.modifiedAt)}
</td>
<td>
{node.alreadyImported ? (
<Tag intent={Intent.SUCCESS} minimal>
Imported
</Tag>
) : (
<Tag minimal>Available</Tag>
)}
</td>
</tr>
);

const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => {
Comment thread
sid597 marked this conversation as resolved.
const [nodes, setNodes] = useState<DiscoveredSharedNode[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [searchTerm, setSearchTerm] = useState("");

const loadNodes = useCallback(async (): Promise<void> => {
setLoading(true);
setError("");
try {
const context = await getSupabaseContext();
if (!context) throw new Error("Could not connect to shared persistence.");
Comment thread
sid597 marked this conversation as resolved.
const client = await getLoggedInClient();
if (!client) throw new Error("Could not connect to shared persistence.");
setNodes(
await discoverSharedNodes({
client,
currentSpaceId: context.spaceId,
}),
);
} catch (loadError) {
internalError({
error: loadError,
type: "Shared node discovery failed",
context: { operation: "load-shared-nodes" },
sendEmail: false,
});
setError(
loadError instanceof Error
? loadError.message
: "Could not load shared nodes.",
);
} finally {
setLoading(false);
}
}, []);

useEffect(() => {
void loadNodes();
}, [loadNodes]);

const visibleNodes = useMemo(() => {
const normalizedSearch = searchTerm.trim().toLocaleLowerCase();
if (!normalizedSearch) return nodes;
return nodes.filter((node) =>
[
node.sourceApp,
node.sourceSpaceName,
node.sourceSpaceId,
node.title,
node.sourceNodeId,
].some((value) => value?.toLocaleLowerCase().includes(normalizedSearch)),
);
}, [nodes, searchTerm]);

return (
<Dialog
autoFocus={false}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Roam's command palette dispatches commands on Enter keyup. Without autoFocus={false} that keyup lands as a click on the freshly-focused close button and dismisses the dialog immediately. Same props as AdvancedSearchDialog.

canEscapeKeyClose
canOutsideClickClose
enforceFocus={false}
style={{ width: "min(68rem, calc(100vw - 2rem))" }}
isOpen
onClose={onClose}
title="Discover shared nodes"
>
<div
className={[Classes.DIALOG_BODY, "flex min-h-72 flex-col gap-3"].join(
" ",
)}
>
<div className="flex items-center gap-2">
<InputGroup
className="min-w-0 flex-1"
leftIcon="search"
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setSearchTerm(event.target.value)
}
placeholder="Search shared nodes"
value={searchTerm}
/>
<Tooltip content="Reload shared nodes">
<Button
aria-label="Reload shared nodes"
disabled={loading}
icon="refresh"
minimal
onClick={() => void loadNodes()}
/>
</Tooltip>
</div>

{loading ? (
<div className="flex min-h-52 items-center justify-center">
<Spinner />
</div>
) : error ? (
<Callout intent={Intent.DANGER} title="Could not load shared nodes">
<div className="mb-3">{error}</div>
<Button icon="refresh" onClick={() => void loadNodes()}>
Try again
</Button>
</Callout>
) : visibleNodes.length === 0 ? (
<div className="flex min-h-52 items-center justify-center">
<NonIdealState
icon="search"
title={
searchTerm ? "No matching shared nodes" : "No shared nodes"
}
/>
</div>
) : (
<div className="min-h-0 overflow-auto">
<HTMLTable striped className="w-full">
<thead>
<tr>
<th>Source app</th>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This stays intentionally read-only: these columns match ENG-1855's pre-import metadata, while materialization remains in ENG-1859.

<th>Source space</th>
<th>Title</th>
<th>Source ID</th>
<th>Modified</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{visibleNodes.map((node) => (
<SharedNodeRow key={node.sourceNodeRid} node={node} />
))}
</tbody>
</HTMLTable>
</div>
)}
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className="flex items-center justify-between">
<span className={[Classes.TEXT_MUTED, "text-xs"].join(" ")}>
{loading || error
? ""
: `${visibleNodes.length} of ${nodes.length} nodes`}
</span>
<Button onClick={onClose}>Close</Button>
</div>
</div>
</Dialog>
);
};

type Props = Record<string, never>;

export const renderDiscoverSharedNodesDialog = createOverlayRender<Props>(
"discourse-discover-shared-nodes",
DiscoverSharedNodesDialog,
);

export default DiscoverSharedNodesDialog;
Loading