Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions apps/roam/src/components/DiscoverSharedNodesDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,14 @@ const ImportedNodeLink = ({
const ImportResultsSummary = ({
results,
relationsFailed,
hasRelationFailures,
retryingRelations,
onOpenInMainWindow,
onRetryRelations,
}: {
results: SharedNodeImportItem[];
relationsFailed: boolean;
hasRelationFailures: boolean;
retryingRelations: boolean;
onOpenInMainWindow: () => void;
onRetryRelations: () => void;
Expand All @@ -179,7 +181,10 @@ const ImportResultsSummary = ({
<Callout
className="flex-none"
intent={
failedImports.length > 0 || warningCount > 0 || relationsFailed
failedImports.length > 0 ||
warningCount > 0 ||
relationsFailed ||
hasRelationFailures
? Intent.WARNING
: Intent.SUCCESS
}
Expand Down Expand Up @@ -220,6 +225,21 @@ const ImportResultsSummary = ({
);
};

const RelationFailuresSummary = ({ failures }: { failures: string[] }) => (
<Callout
className="flex-none"
intent={Intent.WARNING}
title={`${failures.length} shared relation${failures.length === 1 ? "" : "s"} not imported`}
>
<div>These relations are retried on the next import.</div>
<ul className="mb-0 mt-2 list-disc pl-5">
{failures.map((failure) => (
<li key={failure}>{failure}</li>
))}
</ul>
</Callout>
);

const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => {
const [nodes, setNodes] = useState<SharedNode[]>([]);
const [importedRids, setImportedRids] = useState<Set<string>>(new Set());
Expand All @@ -238,13 +258,15 @@ const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => {
>(null);
const [relationsFailed, setRelationsFailed] = useState(false);
const [retryingRelations, setRetryingRelations] = useState(false);
const [relationFailures, setRelationFailures] = useState<string[]>([]);
const importing = importProgress !== null;

const loadNodes = useCallback(async (): Promise<void> => {
setLoading(true);
setError("");
setSelectedRids(new Set());
setImportResults(null);
setRelationFailures([]);
try {
const context = await getSupabaseContext();
if (!context) throw new Error(CONNECTION_ERROR_MESSAGE);
Expand Down Expand Up @@ -326,7 +348,11 @@ const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => {

const importRelations = async (client: DGSupabaseClient): Promise<void> => {
try {
await importSharedRelations(client, spaceId);
const { failures, skipped } = await importSharedRelations(
client,
spaceId,
);
setRelationFailures([...skipped, ...failures]);
setRelationsFailed(false);
} catch (relationsError) {
setRelationsFailed(true);
Expand Down Expand Up @@ -363,6 +389,7 @@ const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => {
const selectedNodes = nodes.filter((node) => selectedRids.has(node.rid));

setImportResults(null);
setRelationFailures([]);
setImportProgress({ current: 0, total: selectedNodes.length });
try {
const client = await getLoggedInClient();
Expand All @@ -381,6 +408,7 @@ const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => {
newlyImportedRids.forEach((rid) => next.add(rid));
return next;
});
// Shown before importing relations, so a relation import error keeps the node summary.
setImportResults(results);
const failedImports = results.filter(isFailedSharedNodeImport);
setSelectedRids(
Expand Down Expand Up @@ -472,6 +500,7 @@ const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => {
) : (
importResults && (
<ImportResultsSummary
hasRelationFailures={relationFailures.length > 0}
onOpenInMainWindow={onClose}
onRetryRelations={() => void retryRelations()}
relationsFailed={relationsFailed}
Expand All @@ -480,6 +509,9 @@ const DiscoverSharedNodesDialog = ({ onClose }: { onClose: () => void }) => {
/>
)
)}
{relationFailures.length > 0 && (
<RelationFailuresSummary failures={relationFailures} />
)}

{loading ? (
<div className="flex min-h-52 items-center justify-center">
Expand Down
53 changes: 53 additions & 0 deletions apps/roam/src/utils/__tests__/discoverSharedRelations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from "vitest";
import type { DGSupabaseClient } from "@repo/database/lib/client";
import { discoverSharedRelations } from "~/utils/discoverSharedRelations";

vi.mock("~/utils/importedSourceIdentity", () => ({
getImportedSourceRids: () =>
Promise.resolve(
new Set(["orn:obsidian.note:vault-a/x", "orn:obsidian.note:vault-a/y"]),
),
}));
vi.mock("@repo/database/lib/dbToCrossAppConverters", () => ({
getSpaceMap: () => Promise.resolve({ 20: "obsidian:vault-a" }),
}));

// Each `from` call answers with the next queued result, whatever the filters.
const fakeClient = (results: unknown[][]): DGSupabaseClient => {
const from = () => {
const data = results.shift();
const query: Record<string, unknown> = {};
for (const method of ["select", "neq", "eq", "in", "overlaps"])
query[method] = () => query;
query.then = (resolve: (value: unknown) => unknown) =>
resolve({ data, error: null });
return query;
};
return { from } as unknown as DGSupabaseClient;
};

describe("discoverSharedRelations", () => {
it("skips a relation whose schema is not visible and reports it", async () => {
const relation = {
id: 300,
space_id: 20,
source_local_id: "rel-1",
schema_id: 99,
is_schema: false,
is_relation: true,
reference_content: { source: 1, destination: 2 },
concepts_of_relation: [
{ id: 1, space_id: 20, source_local_id: "x", schema_id: 10 },
{ id: 2, space_id: 20, source_local_id: "y", schema_id: 11 },
],
};
const client = fakeClient([[relation], []]);

const result = await discoverSharedRelations(client, 7);

expect(result.relations).toEqual([]);
expect(result.skippedRelations).toEqual([
"orn:obsidian.relation:vault-a/rel-1: its relation type is not visible",
]);
});
});
Loading
Loading