From d545c52b43f5d36c062ed4ea7f24221a29b316a1 Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 31 Aug 2026 17:14:24 +0530 Subject: [PATCH 1/7] ENG-2131 Review and accept imported relation types and triples in Roam --- .../src/components/CreateRelationDialog.tsx | 3 +- apps/roam/src/components/SuggestionsBody.tsx | 6 +- .../DiscourseRelationTool.tsx | 8 +- .../DiscourseRelationUtil.tsx | 8 +- apps/roam/src/components/canvas/Tldraw.tsx | 18 ++- .../roam/src/components/canvas/canvasUtils.ts | 6 +- .../settings/DiscourseRelationConfigPanel.tsx | 146 +++++++++++++++++- .../relationSchemaAcceptance.test.ts | 132 ++++++++++++++++ apps/roam/src/utils/importedSourceIdentity.ts | 2 +- apps/roam/src/utils/publishNodesToGroups.ts | 7 +- .../src/utils/relationSchemaAcceptance.ts | 59 +++++++ 11 files changed, 383 insertions(+), 12 deletions(-) create mode 100644 apps/roam/src/utils/__tests__/relationSchemaAcceptance.test.ts create mode 100644 apps/roam/src/utils/relationSchemaAcceptance.ts diff --git a/apps/roam/src/components/CreateRelationDialog.tsx b/apps/roam/src/components/CreateRelationDialog.tsx index c1d08b701..b283161f8 100644 --- a/apps/roam/src/components/CreateRelationDialog.tsx +++ b/apps/roam/src/components/CreateRelationDialog.tsx @@ -8,6 +8,7 @@ import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageU import getDiscourseRelations, { type DiscourseRelation, } from "~/utils/getDiscourseRelations"; +import { excludeProvisionalRelationSchemas } from "~/utils/relationSchemaAcceptance"; import { createReifiedRelation } from "~/utils/createReifiedBlock"; import { getStoredRelationsEnabled } from "~/utils/storedRelations"; import findDiscourseNode from "~/utils/findDiscourseNode"; @@ -291,7 +292,7 @@ const prepareRelData = ( ): RelWithDirection[] => { nodeTitle = nodeTitle || getPageTitleByPageUid(targetNodeUid).trim(); const discourseNodeSchemas = getDiscourseNodes(); - const relations = getDiscourseRelations(); + const relations = excludeProvisionalRelationSchemas(getDiscourseRelations()); const nodeSchema = findDiscourseNode({ uid: targetNodeUid, title: nodeTitle, diff --git a/apps/roam/src/components/SuggestionsBody.tsx b/apps/roam/src/components/SuggestionsBody.tsx index c81d4dc00..bcfe68ac7 100644 --- a/apps/roam/src/components/SuggestionsBody.tsx +++ b/apps/roam/src/components/SuggestionsBody.tsx @@ -21,6 +21,7 @@ import getDiscourseContextResults from "~/utils/getDiscourseContextResults"; import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; import findDiscourseNode from "~/utils/findDiscourseNode"; import getDiscourseRelations from "~/utils/getDiscourseRelations"; +import { excludeProvisionalRelationSchemas } from "~/utils/relationSchemaAcceptance"; import getDiscourseNodes from "~/utils/getDiscourseNodes"; import normalizePageTitle from "roamjs-components/queries/normalizePageTitle"; import { type RelationDetails } from "~/utils/hyde"; @@ -232,7 +233,10 @@ const SuggestionsBody = ({ () => findDiscourseNode({ uid: tagUid }), [tagUid], ); - const allRelations = useMemo(() => getDiscourseRelations(), []); + const allRelations = useMemo( + () => excludeProvisionalRelationSchemas(getDiscourseRelations()), + [], + ); const allNodes = useMemo(() => getDiscourseNodes(), []); const validRelations = useMemo(() => { diff --git a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx index 3669a6202..44d11ea5d 100644 --- a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx +++ b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx @@ -350,7 +350,9 @@ export const createAllRelationShapeTools = ( override onEnter = () => { this.didTimeout = false; - const selectedRelations = discourseContext.relations[name] || []; + const selectedRelations = ( + discourseContext.relations[name] || [] + ).filter((r) => !discourseContext.provisionalRelationIds.has(r.id)); const hasIncompleteSelectedRelation = selectedRelations.some( (relation) => !isRelationComplete(relation), ); @@ -384,7 +386,7 @@ export const createAllRelationShapeTools = ( target && isDiscourseNodeShape(target) ? getDiscourseNodeTypeId({ shape: target }) : undefined; - const relation = discourseContext.relations[name].find( + const relation = selectedRelations.find( (r) => r.source === targetNodeTypeId || r.destination === targetNodeTypeId, @@ -392,7 +394,7 @@ export const createAllRelationShapeTools = ( if (relation) { this.shapeType = relation.id; } else { - const acceptableTypes = discourseContext.relations[name] + const acceptableTypes = selectedRelations .flatMap((r) => [ discourseContext.nodes[r.source]?.text, discourseContext.nodes[r.destination]?.text, diff --git a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx index a44668b73..f3ba2e3c1 100644 --- a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx +++ b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx @@ -1767,7 +1767,9 @@ export class BaseDiscourseRelationUtil extends ShapeUtil isReverse: boolean; matchingRelation: DiscourseRelation | null; } { - const relationsWithLabel = discourseContext.relations[label]; + const relationsWithLabel = discourseContext.relations[label]?.filter( + (r) => !discourseContext.provisionalRelationIds.has(r.id), + ); if (!relationsWithLabel) { return { isDirect: false, isReverse: false, matchingRelation: null }; } @@ -1787,7 +1789,9 @@ export class BaseDiscourseRelationUtil extends ShapeUtil } getValidTargetTypes(label: string, sourceNodeType: string): string[] { - const relationsWithLabel = discourseContext.relations[label]; + const relationsWithLabel = discourseContext.relations[label]?.filter( + (r) => !discourseContext.provisionalRelationIds.has(r.id), + ); if (!relationsWithLabel) return []; const targets = new Set(); diff --git a/apps/roam/src/components/canvas/Tldraw.tsx b/apps/roam/src/components/canvas/Tldraw.tsx index 505833d02..954887087 100644 --- a/apps/roam/src/components/canvas/Tldraw.tsx +++ b/apps/roam/src/components/canvas/Tldraw.tsx @@ -120,6 +120,7 @@ import posthog from "posthog-js"; import { getPersonalSetting } from "~/components/settings/utils/accessors"; import { PERSONAL_KEYS } from "~/components/settings/utils/settingKeys"; import { json, normalizeProps } from "~/utils/getBlockProps"; +import { isProvisionalRelationSchema } from "~/utils/relationSchemaAcceptance"; import { onPageRefObserverChange } from "~/utils/pageRefObserverHandlers"; declare global { @@ -133,6 +134,9 @@ export type DiscourseContextType = { nodes: Record; // { [Relation.Label] => DiscourseRelation[] } relations: Record; + // Imported, not-yet-accepted relation schemas; excluded from relation + // creation but kept in `relations` so existing shapes still render. + provisionalRelationIds: Set; lastAppEvent: string; lastActions: HistoryEntry[]; }; @@ -140,6 +144,7 @@ export type DiscourseContextType = { export const discourseContext: DiscourseContextType = { nodes: {}, relations: {}, + provisionalRelationIds: new Set(), lastAppEvent: "", lastActions: [], }; @@ -766,6 +771,11 @@ const TldrawCanvasShared = ({ }, {} as Record, ); + discourseContext.provisionalRelationIds = new Set( + relations + .filter((r) => isProvisionalRelationSchema(r.id)) + .map((r) => r.id), + ); return relations; }, []); const allRelationsById = useMemo(() => { @@ -778,7 +788,13 @@ const TldrawCanvasShared = ({ return Object.keys(allRelationsById); }, [allRelationsById]); const allRelationNames = useMemo(() => { - return Object.keys(discourseContext.relations); + return Object.entries(discourseContext.relations) + .filter(([, relations]) => + relations.some( + (r) => !discourseContext.provisionalRelationIds.has(r.id), + ), + ) + .map(([name]) => name); }, []); const allNodes = useMemo(() => { const allNodes = getDiscourseNodes(); diff --git a/apps/roam/src/components/canvas/canvasUtils.ts b/apps/roam/src/components/canvas/canvasUtils.ts index 5bb71dbbc..6b065e7fc 100644 --- a/apps/roam/src/components/canvas/canvasUtils.ts +++ b/apps/roam/src/components/canvas/canvasUtils.ts @@ -16,8 +16,12 @@ export const isDiscourseNodeShape = ( } }; +// Creation-facing list: provisional imported relation schemas are excluded so +// they cannot be used to create new relations on the canvas. export const getAllRelations = () => - Object.values(discourseContext.relations).flat(); + Object.values(discourseContext.relations) + .flat() + .filter((r) => !discourseContext.provisionalRelationIds.has(r.id)); export const checkConnectionType = ( relation: { source: string; destination: string }, diff --git a/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx b/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx index 968bf4f7d..e58681341 100644 --- a/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx +++ b/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx @@ -10,6 +10,7 @@ import { SpinnerSize, Tab, Tabs, + Tag, Tooltip, HTMLTable, ControlGroup, @@ -65,6 +66,13 @@ import { type RelationSort, type RelationSortColumn, } from "~/utils/sortRelations"; +import { + acceptImportedRelationSchema, + readRelationSchemaImportMeta, + type RelationSchemaImportMeta, +} from "~/utils/relationSchemaAcceptance"; +import { getReifiedRelations } from "~/utils/createReifiedBlock"; +import { ridToSpaceUriAndLocalId } from "@repo/database/lib/rid"; const DEFAULT_SELECTED_RELATION = { display: "none", @@ -976,6 +984,16 @@ type Relation = { source: string | undefined; destination: string | undefined; }; +type ImportedRelation = Relation & { importMeta: RelationSchemaImportMeta }; + +const ROAM_SPACE_URI_PREFIX = "https://roamresearch.com/#/app/"; + +const formatImportedSource = (sourceNodeRid: string): string => { + const { spaceUri } = ridToSpaceUriAndLocalId(sourceNodeRid); + return spaceUri.startsWith(ROAM_SPACE_URI_PREFIX) + ? spaceUri.slice(ROAM_SPACE_URI_PREFIX.length) + : spaceUri; +}; const DiscourseRelationConfigPanel = ({ uid, parentUid, @@ -1037,6 +1055,16 @@ const DiscourseRelationConfigPanel = ({ : visibleRelations, [nodes, sort, visibleRelations], ); + const { localRelations, importedRelations } = useMemo(() => { + const local: Relation[] = []; + const imported: ImportedRelation[] = []; + for (const rel of sortedRelations) { + const importMeta = readRelationSchemaImportMeta(rel.uid); + if (importMeta) imported.push({ ...rel, importMeta }); + else local.push(rel); + } + return { localRelations: local, importedRelations: imported }; + }, [sortedRelations]); const editingRelationInfo = useMemo( () => editingRelation ? getFullTreeByParentUid(editingRelation) : undefined, @@ -1079,6 +1107,30 @@ const DiscourseRelationConfigPanel = ({ }, 50); }); }; + const handleAcceptImported = (rel: Relation) => { + void acceptImportedRelationSchema(rel.uid).then(() => { + setRelations(refreshRelations()); + }); + }; + const handleDeleteImported = (rel: Relation) => { + void getReifiedRelations().then((reifiedRelations) => { + const inUseCount = reifiedRelations.filter( + (r) => r.hasSchema === rel.uid, + ).length; + if (inUseCount > 0) { + renderToast({ + id: "discourse-relation-delete-blocked", + intent: Intent.WARNING, + content: `Cannot delete this imported relation: ${inUseCount} relation ${ + inUseCount === 1 ? "instance uses" : "instances use" + } it in this graph.`, + }); + setDeleteConfirmation(null); + return; + } + handleDelete(rel); + }); + }; const handleDuplicate = (rel: Relation) => { const text = rel.text; const copyTree = getBasicTreeByParentUid(rel.uid); @@ -1183,7 +1235,7 @@ const DiscourseRelationConfigPanel = ({ - {sortedRelations.map((rel) => ( + {localRelations.map((rel) => ( handleEdit(rel)}> {nodes[rel.source || ""]?.label} @@ -1243,6 +1295,98 @@ const DiscourseRelationConfigPanel = ({ ))} + {importedRelations.length > 0 && ( + <> +

Imported relations

+

+ Imported relations are read-only. Accept a relation to enable it for + local use. +

+ + + + Source + Relation + Destination + From + Status + Actions + + + + {importedRelations.map((rel) => ( + + + {nodes[rel.source || ""]?.label} + + {rel.text} + + {nodes[rel.destination || ""]?.label} + + + {formatImportedSource( + rel.importMeta.importedFrom.sourceNodeRid, + )} + + + {rel.importMeta.status === "provisional" ? ( + + Provisional + + ) : ( + + Accepted + + )} + + + {rel.importMeta.status === "provisional" && ( + + + + + + ))} + + + + )} ); }; diff --git a/apps/roam/src/utils/__tests__/relationSchemaAcceptance.test.ts b/apps/roam/src/utils/__tests__/relationSchemaAcceptance.test.ts new file mode 100644 index 000000000..e45710ca9 --- /dev/null +++ b/apps/roam/src/utils/__tests__/relationSchemaAcceptance.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DISCOURSE_GRAPH_PROP_NAME } from "~/utils/createReifiedBlock"; +import { IMPORTED_FROM_PROP_KEY } from "~/utils/importedSourceIdentity"; +import { + acceptImportedRelationSchema, + excludeProvisionalRelationSchemas, + isProvisionalRelationSchema, + readRelationSchemaImportMeta, + RELATION_SCHEMA_STATUS_PROP_KEY, +} from "~/utils/relationSchemaAcceptance"; +import type { json } from "~/utils/getBlockProps"; + +const SOURCE_NODE_RID = "orn:obsidian.schema:vault-a/relation-type-1"; +const SOURCE_MODIFIED_AT = "2026-08-01T12:00:00.000Z"; +const SCHEMA_UID = "relation-schema-uid"; + +const importedFromProps = { + [IMPORTED_FROM_PROP_KEY]: { + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }, +}; + +const propsByUid = new Map>(); + +const setRoamAlphaApi = (): void => { + (globalThis as { window: unknown }).window = { + roamAlphaAPI: { + data: { + block: { + update: vi.fn( + ({ + block, + }: { + block: { props: Record; uid: string }; + }) => { + propsByUid.set(block.uid, block.props); + return Promise.resolve(); + }, + ), + }, + }, + pull: (_pattern: string, [, uid]: [string, string]) => ({ + ":block/props": propsByUid.get(uid) ?? {}, + }), + }, + }; +}; + +beforeEach(() => { + propsByUid.clear(); + setRoamAlphaApi(); +}); + +describe("relation schema import meta", () => { + it("returns undefined for local schemas without imported provenance", () => { + expect(readRelationSchemaImportMeta(SCHEMA_UID)).toBeUndefined(); + expect(isProvisionalRelationSchema(SCHEMA_UID)).toBe(false); + }); + + it("treats imported schemas without a status as provisional", () => { + propsByUid.set(SCHEMA_UID, { + [DISCOURSE_GRAPH_PROP_NAME]: importedFromProps, + }); + + expect(readRelationSchemaImportMeta(SCHEMA_UID)).toEqual({ + importedFrom: { + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }, + status: "provisional", + }); + expect(isProvisionalRelationSchema(SCHEMA_UID)).toBe(true); + }); + + it("treats imported schemas with an accepted status as accepted", () => { + propsByUid.set(SCHEMA_UID, { + [DISCOURSE_GRAPH_PROP_NAME]: { + ...importedFromProps, + [RELATION_SCHEMA_STATUS_PROP_KEY]: "accepted", + }, + }); + + expect(readRelationSchemaImportMeta(SCHEMA_UID)?.status).toBe("accepted"); + expect(isProvisionalRelationSchema(SCHEMA_UID)).toBe(false); + }); +}); + +describe("acceptImportedRelationSchema", () => { + it("marks the schema accepted while preserving imported provenance", async () => { + propsByUid.set(SCHEMA_UID, { + [DISCOURSE_GRAPH_PROP_NAME]: importedFromProps, + "other-extension": { enabled: true }, + }); + + await acceptImportedRelationSchema(SCHEMA_UID); + + expect(propsByUid.get(SCHEMA_UID)).toEqual({ + [DISCOURSE_GRAPH_PROP_NAME]: { + ...importedFromProps, + [RELATION_SCHEMA_STATUS_PROP_KEY]: "accepted", + }, + "other-extension": { enabled: true }, + }); + expect(readRelationSchemaImportMeta(SCHEMA_UID)?.status).toBe("accepted"); + }); +}); + +describe("excludeProvisionalRelationSchemas", () => { + it("filters provisional schemas but keeps local and accepted ones", () => { + propsByUid.set("provisional-uid", { + [DISCOURSE_GRAPH_PROP_NAME]: importedFromProps, + }); + propsByUid.set("accepted-uid", { + [DISCOURSE_GRAPH_PROP_NAME]: { + ...importedFromProps, + [RELATION_SCHEMA_STATUS_PROP_KEY]: "accepted", + }, + }); + + const relations = [ + { id: "local-uid" }, + { id: "provisional-uid" }, + { id: "accepted-uid" }, + ]; + + expect(excludeProvisionalRelationSchemas(relations)).toEqual([ + { id: "local-uid" }, + { id: "accepted-uid" }, + ]); + }); +}); diff --git a/apps/roam/src/utils/importedSourceIdentity.ts b/apps/roam/src/utils/importedSourceIdentity.ts index 64588918a..6420273a5 100644 --- a/apps/roam/src/utils/importedSourceIdentity.ts +++ b/apps/roam/src/utils/importedSourceIdentity.ts @@ -12,7 +12,7 @@ export const IMPORTED_FROM_PROP_KEY = "importedFrom"; const SOURCE_NODE_RID_KEY = "sourceNodeRid"; const SOURCE_MODIFIED_AT_KEY = "sourceModifiedAt"; -const isJsonObject = (value: json): value is Record => +export const isJsonObject = (value: json): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); const parseImportedSourceIdentity = ( diff --git a/apps/roam/src/utils/publishNodesToGroups.ts b/apps/roam/src/utils/publishNodesToGroups.ts index 8b60547ce..025f35ba4 100644 --- a/apps/roam/src/utils/publishNodesToGroups.ts +++ b/apps/roam/src/utils/publishNodesToGroups.ts @@ -26,6 +26,7 @@ import getDiscourseNodes from "./getDiscourseNodes"; import { difference, intersection } from "@repo/utils/setOperations"; import internalError from "./internalError"; import { readImportedSourceIdentity } from "./importedSourceIdentity"; +import { excludeProvisionalRelationSchemas } from "./relationSchemaAcceptance"; export type NodeUidWithType = { uid: string; @@ -111,7 +112,11 @@ export const gatherCorrespondingRelations = async ({ relationTripleSchemas: CrossAppRelationTripleSchema[]; relevantRelationIdsPerGroupId: Record; }> => { - const allRelationsSchemas = getDiscourseRelations(); + // Excluding provisional schemas here also drops their relation instances: + // relationSchemaIds below only keeps instances whose schema is in this map. + const allRelationsSchemas = excludeProvisionalRelationSchemas( + getDiscourseRelations(), + ); const allRelationSchemasById = Object.fromEntries( allRelationsSchemas.map((s) => [s.id, s]), ); diff --git a/apps/roam/src/utils/relationSchemaAcceptance.ts b/apps/roam/src/utils/relationSchemaAcceptance.ts new file mode 100644 index 000000000..7c7144545 --- /dev/null +++ b/apps/roam/src/utils/relationSchemaAcceptance.ts @@ -0,0 +1,59 @@ +import { DISCOURSE_GRAPH_PROP_NAME } from "./createReifiedBlock"; +import getBlockProps from "./getBlockProps"; +import { setBlockPropsAsync } from "./setBlockProps"; +import { + isJsonObject, + readImportedSourceIdentity, + type ImportedSourceIdentity, +} from "./importedSourceIdentity"; + +export type RelationSchemaImportStatus = "provisional" | "accepted"; + +export const RELATION_SCHEMA_STATUS_PROP_KEY = "status"; + +export type RelationSchemaImportMeta = { + importedFrom: ImportedSourceIdentity; + status: RelationSchemaImportStatus; +}; + +// Origin (importedFrom) and acceptance (status) are stored as separate props so +// accepting never erases provenance; a schema with origin but no accepted +// status is provisional, which also covers schemas imported before acceptance +// existed. +export const readRelationSchemaImportMeta = ( + relationSchemaUid: string, +): RelationSchemaImportMeta | undefined => { + const importedFrom = readImportedSourceIdentity(relationSchemaUid); + if (importedFrom === undefined) return undefined; + const discourseGraphProps = + getBlockProps(relationSchemaUid)[DISCOURSE_GRAPH_PROP_NAME]; + const status = + isJsonObject(discourseGraphProps) && + discourseGraphProps[RELATION_SCHEMA_STATUS_PROP_KEY] === "accepted" + ? "accepted" + : "provisional"; + return { importedFrom, status }; +}; + +export const isProvisionalRelationSchema = ( + relationSchemaUid: string, +): boolean => + readRelationSchemaImportMeta(relationSchemaUid)?.status === "provisional"; + +export const excludeProvisionalRelationSchemas = ( + relations: T[], +): T[] => + relations.filter((relation) => !isProvisionalRelationSchema(relation.id)); + +export const acceptImportedRelationSchema = async ( + relationSchemaUid: string, +): Promise => { + const existing = getBlockProps(relationSchemaUid)[DISCOURSE_GRAPH_PROP_NAME]; + const discourseGraphProps = isJsonObject(existing) ? existing : {}; + await setBlockPropsAsync(relationSchemaUid, { + [DISCOURSE_GRAPH_PROP_NAME]: { + ...discourseGraphProps, + [RELATION_SCHEMA_STATUS_PROP_KEY]: "accepted", + }, + }); +}; From 9c05b1970c513857cbd6cd369f60fe014e04c9b3 Mon Sep 17 00:00:00 2001 From: sid597 Date: Thu, 3 Sep 2026 00:49:13 +0530 Subject: [PATCH 2/7] ENG-2131 Address pre-PR review findings --- .../DiscourseRelationTool.tsx | 7 +- .../DiscourseRelationUtil.tsx | 55 +++-- apps/roam/src/components/canvas/Tldraw.tsx | 9 +- .../roam/src/components/canvas/canvasUtils.ts | 13 +- .../canvas/overlays/relationCreation.ts | 8 +- .../src/components/canvas/uiOverrides.tsx | 6 +- .../settings/DiscourseRelationConfigPanel.tsx | 228 ++++++++++-------- apps/roam/src/utils/canonicalRoamUrl.ts | 2 +- apps/roam/src/utils/importedSourceIdentity.ts | 2 +- .../src/utils/relationSchemaAcceptance.ts | 19 +- 10 files changed, 199 insertions(+), 150 deletions(-) diff --git a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx index 44d11ea5d..997c8c4d4 100644 --- a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx +++ b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx @@ -8,7 +8,10 @@ import { DiscourseRelationShape, getRelationColor, } from "./DiscourseRelationUtil"; -import { discourseContext } from "~/components/canvas/Tldraw"; +import { + discourseContext, + isAcceptedRelationSchema, +} from "~/components/canvas/Tldraw"; import { dispatchToastEvent } from "~/components/canvas/ToastListener"; import { isRelationComplete } from "~/utils/isRelationComplete"; import { @@ -352,7 +355,7 @@ export const createAllRelationShapeTools = ( const selectedRelations = ( discourseContext.relations[name] || [] - ).filter((r) => !discourseContext.provisionalRelationIds.has(r.id)); + ).filter(isAcceptedRelationSchema); const hasIncompleteSelectedRelation = selectedRelations.some( (relation) => !isRelationComplete(relation), ); diff --git a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx index f3ba2e3c1..981c91d5e 100644 --- a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx +++ b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx @@ -66,7 +66,11 @@ import { import { createReifiedRelation } from "~/utils/createReifiedBlock"; import { getStoredRelationsEnabled } from "~/utils/storedRelations"; import type { DiscourseRelation } from "~/utils/getDiscourseRelations"; -import { discourseContext, isPageUid } from "~/components/canvas/Tldraw"; +import { + discourseContext, + isAcceptedRelationSchema, + isPageUid, +} from "~/components/canvas/Tldraw"; import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; /** @@ -659,11 +663,11 @@ export const createAllRelationShapeUtils = ( isDirect, isReverse, matchingRelation: foundRelation, - } = this.checkConnectionTypeAcrossLabel( - relation.label, + } = this.checkConnectionTypeAcrossLabel({ + label: relation.label, sourceNodeType, targetNodeType, - ); + }); const matchingRelation = foundRelation ?? relation; if (!isDirect && !isReverse) { @@ -1036,11 +1040,11 @@ export const createAllRelationShapeUtils = ( const endNodeType = getDiscourseNodeTypeId({ shape: endNode }); const { isReverse, matchingRelation } = - this.checkConnectionTypeAcrossLabel( - relation.label, - startNodeType, - endNodeType, - ); + this.checkConnectionTypeAcrossLabel({ + label: relation.label, + sourceNodeType: startNodeType, + targetNodeType: endNodeType, + }); const effectiveRelation = matchingRelation ?? relation; @@ -1758,18 +1762,24 @@ export class BaseDiscourseRelationUtil extends ShapeUtil return checkConnectionType(relation, sourceNodeType, targetNodeType); } - checkConnectionTypeAcrossLabel( - label: string, - sourceNodeType: string, - targetNodeType: string, - ): { + checkConnectionTypeAcrossLabel({ + label, + sourceNodeType, + targetNodeType, + includeProvisional, + }: { + label: string; + sourceNodeType: string; + targetNodeType: string; + includeProvisional?: boolean; + }): { isDirect: boolean; isReverse: boolean; matchingRelation: DiscourseRelation | null; } { - const relationsWithLabel = discourseContext.relations[label]?.filter( - (r) => !discourseContext.provisionalRelationIds.has(r.id), - ); + const relationsWithLabel = includeProvisional + ? discourseContext.relations[label] + : discourseContext.relations[label]?.filter(isAcceptedRelationSchema); if (!relationsWithLabel) { return { isDirect: false, isReverse: false, matchingRelation: null }; } @@ -1790,7 +1800,7 @@ export class BaseDiscourseRelationUtil extends ShapeUtil getValidTargetTypes(label: string, sourceNodeType: string): string[] { const relationsWithLabel = discourseContext.relations[label]?.filter( - (r) => !discourseContext.provisionalRelationIds.has(r.id), + isAcceptedRelationSchema, ); if (!relationsWithLabel) return []; @@ -1811,11 +1821,14 @@ export class BaseDiscourseRelationUtil extends ShapeUtil const relation = relations.find((r) => r.id === relationId); if (!relation) return false; - const { isDirect, isReverse } = this.checkConnectionTypeAcrossLabel( - relation.label, + // Validates handle drags of arrows that already exist, so provisional + // relations stay re-bindable; only creation paths filter them out. + const { isDirect, isReverse } = this.checkConnectionTypeAcrossLabel({ + label: relation.label, sourceNodeType, targetNodeType, - ); + includeProvisional: true, + }); return isDirect || isReverse; } diff --git a/apps/roam/src/components/canvas/Tldraw.tsx b/apps/roam/src/components/canvas/Tldraw.tsx index 954887087..7849df75a 100644 --- a/apps/roam/src/components/canvas/Tldraw.tsx +++ b/apps/roam/src/components/canvas/Tldraw.tsx @@ -149,6 +149,9 @@ export const discourseContext: DiscourseContextType = { lastActions: [], }; +export const isAcceptedRelationSchema = (relation: { id: string }): boolean => + !discourseContext.provisionalRelationIds.has(relation.id); + let activeCanvasPageUid: string | null = null; let activeCanvasEditor: Editor | null = null; @@ -789,11 +792,7 @@ const TldrawCanvasShared = ({ }, [allRelationsById]); const allRelationNames = useMemo(() => { return Object.entries(discourseContext.relations) - .filter(([, relations]) => - relations.some( - (r) => !discourseContext.provisionalRelationIds.has(r.id), - ), - ) + .filter(([, relations]) => relations.some(isAcceptedRelationSchema)) .map(([name]) => name); }, []); const allNodes = useMemo(() => { diff --git a/apps/roam/src/components/canvas/canvasUtils.ts b/apps/roam/src/components/canvas/canvasUtils.ts index 6b065e7fc..2e8058deb 100644 --- a/apps/roam/src/components/canvas/canvasUtils.ts +++ b/apps/roam/src/components/canvas/canvasUtils.ts @@ -3,7 +3,10 @@ import { DiscourseNodeUtil, DiscourseNodeShape, } from "~/components/canvas/DiscourseNodeUtil"; -import { discourseContext } from "~/components/canvas/Tldraw"; +import { + discourseContext, + isAcceptedRelationSchema, +} from "~/components/canvas/Tldraw"; export const isDiscourseNodeShape = ( editor: Editor, @@ -16,12 +19,10 @@ export const isDiscourseNodeShape = ( } }; -// Creation-facing list: provisional imported relation schemas are excluded so -// they cannot be used to create new relations on the canvas. -export const getAllRelations = () => +export const getCreatableRelations = () => Object.values(discourseContext.relations) .flat() - .filter((r) => !discourseContext.provisionalRelationIds.has(r.id)); + .filter(isAcceptedRelationSchema); export const checkConnectionType = ( relation: { source: string; destination: string }, @@ -40,7 +41,7 @@ export const hasValidRelationTypes = ( sourceNodeType: string, targetNodeType: string, ): boolean => - getAllRelations().some( + getCreatableRelations().some( (r) => (r.source === sourceNodeType && r.destination === targetNodeType) || (r.source === targetNodeType && r.destination === sourceNodeType), diff --git a/apps/roam/src/components/canvas/overlays/relationCreation.ts b/apps/roam/src/components/canvas/overlays/relationCreation.ts index ede2fc6af..0daeefd06 100644 --- a/apps/roam/src/components/canvas/overlays/relationCreation.ts +++ b/apps/roam/src/components/canvas/overlays/relationCreation.ts @@ -13,7 +13,7 @@ import { createOrUpdateArrowBinding } from "~/components/canvas/DiscourseRelatio import { getDiscourseNodeTypeId } from "~/components/canvas/DiscourseNodeUtil"; import { checkConnectionType, - getAllRelations, + getCreatableRelations, isDiscourseNodeShape, } from "~/components/canvas/canvasUtils"; import type { DiscourseRelation } from "~/utils/getDiscourseRelations"; @@ -93,7 +93,7 @@ export const getValidRelationTypesBetween = ( const validTypes: RelationTypeOption[] = []; const seenLabels = new Set(); - for (const relation of getAllRelations()) { + for (const relation of getCreatableRelations()) { if (!isRelationComplete(relation)) continue; const { isDirect, isReverse } = checkConnectionType( relation, @@ -129,7 +129,9 @@ export const createDefaultRelationBetweenNodes = async ({ sourceId: TLShapeId; targetId: TLShapeId; }): Promise => { - const selectedRelation = getAllRelations().find((r) => r.id === relationId); + const selectedRelation = getCreatableRelations().find( + (r) => r.id === relationId, + ); if (!selectedRelation) return null; const sourceNode = editor.getShape(sourceId); diff --git a/apps/roam/src/components/canvas/uiOverrides.tsx b/apps/roam/src/components/canvas/uiOverrides.tsx index 111ebae41..774002378 100644 --- a/apps/roam/src/components/canvas/uiOverrides.tsx +++ b/apps/roam/src/components/canvas/uiOverrides.tsx @@ -66,7 +66,7 @@ import { getValidRelationTypesBetween, persistRelationArrow, } from "./overlays/relationCreation"; -import { getAllRelations } from "./canvasUtils"; +import { getCreatableRelations } from "./canvasUtils"; import { createOrUpdateArrowBinding } from "./DiscourseRelationShape/helpers"; import DiscourseGraphPanel from "./DiscourseToolPanel"; import type { CanvasNodeShortcuts } from "~/components/settings/utils/zodSchema"; @@ -299,7 +299,9 @@ const convertArrowToRelation = async ({ const boundNodes = getArrowBoundNodeInfo(editor, arrow); if (!boundNodes) return null; - const selectedRelation = getAllRelations().find((r) => r.id === relationId); + const selectedRelation = getCreatableRelations().find( + (r) => r.id === relationId, + ); if (!selectedRelation) return null; const sourceNode = editor.getShape(boundNodes.startId); diff --git a/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx b/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx index e58681341..7b12697ba 100644 --- a/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx +++ b/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx @@ -22,6 +22,7 @@ import React, { useCallback, useEffect, useMemo, + useReducer, useRef, useState, } from "react"; @@ -73,6 +74,9 @@ import { } from "~/utils/relationSchemaAcceptance"; import { getReifiedRelations } from "~/utils/createReifiedBlock"; import { ridToSpaceUriAndLocalId } from "@repo/database/lib/rid"; +import { ROAM_URL_PREFIX } from "~/utils/canonicalRoamUrl"; +import { discourseContext } from "~/components/canvas/Tldraw"; +import internalError from "~/utils/internalError"; const DEFAULT_SELECTED_RELATION = { display: "none", @@ -986,12 +990,10 @@ type Relation = { }; type ImportedRelation = Relation & { importMeta: RelationSchemaImportMeta }; -const ROAM_SPACE_URI_PREFIX = "https://roamresearch.com/#/app/"; - const formatImportedSource = (sourceNodeRid: string): string => { const { spaceUri } = ridToSpaceUriAndLocalId(sourceNodeRid); - return spaceUri.startsWith(ROAM_SPACE_URI_PREFIX) - ? spaceUri.slice(ROAM_SPACE_URI_PREFIX.length) + return spaceUri.startsWith(ROAM_URL_PREFIX) + ? spaceUri.slice(ROAM_URL_PREFIX.length) : spaceUri; }; const DiscourseRelationConfigPanel = ({ @@ -1055,16 +1057,17 @@ const DiscourseRelationConfigPanel = ({ : visibleRelations, [nodes, sort, visibleRelations], ); - const { localRelations, importedRelations } = useMemo(() => { - const local: Relation[] = []; - const imported: ImportedRelation[] = []; - for (const rel of sortedRelations) { - const importMeta = readRelationSchemaImportMeta(rel.uid); - if (importMeta) imported.push({ ...rel, importMeta }); - else local.push(rel); - } - return { localRelations: local, importedRelations: imported }; - }, [sortedRelations]); + // Acceptance lives in block props, not in the relations state, so the split + // below is recomputed on every render and accepting bumps this reducer to + // trigger one. + const [, refreshImportMeta] = useReducer((version: number) => version + 1, 0); + const localRelations: Relation[] = []; + const importedRelations: ImportedRelation[] = []; + for (const rel of sortedRelations) { + const importMeta = readRelationSchemaImportMeta(rel.uid); + if (importMeta) importedRelations.push({ ...rel, importMeta }); + else localRelations.push(rel); + } const editingRelationInfo = useMemo( () => editingRelation ? getFullTreeByParentUid(editingRelation) : undefined, @@ -1108,28 +1111,50 @@ const DiscourseRelationConfigPanel = ({ }); }; const handleAcceptImported = (rel: Relation) => { - void acceptImportedRelationSchema(rel.uid).then(() => { - setRelations(refreshRelations()); - }); + void acceptImportedRelationSchema(rel.uid) + .then(() => { + // Make the acceptance visible to canvases that are already mounted. + discourseContext.provisionalRelationIds.delete(rel.uid); + posthog.capture("Discourse Relation: Accepted", { + relationUid: rel.uid, + }); + refreshImportMeta(); + }) + .catch((error: unknown) => { + internalError({ + error, + type: "Discourse Relation: Accept failed", + userMessage: "Could not accept the imported relation.", + }); + }); }; const handleDeleteImported = (rel: Relation) => { - void getReifiedRelations().then((reifiedRelations) => { - const inUseCount = reifiedRelations.filter( - (r) => r.hasSchema === rel.uid, - ).length; - if (inUseCount > 0) { - renderToast({ - id: "discourse-relation-delete-blocked", - intent: Intent.WARNING, - content: `Cannot delete this imported relation: ${inUseCount} relation ${ - inUseCount === 1 ? "instance uses" : "instances use" - } it in this graph.`, + void getReifiedRelations() + .then((reifiedRelations) => { + const inUseCount = reifiedRelations.filter( + (r) => r.hasSchema === rel.uid, + ).length; + if (inUseCount > 0) { + renderToast({ + id: "discourse-relation-delete-blocked", + intent: Intent.WARNING, + content: `Cannot delete this imported relation: ${inUseCount} relation ${ + inUseCount === 1 ? "instance uses" : "instances use" + } it in this graph.`, + }); + setDeleteConfirmation(null); + return; + } + handleDelete(rel); + }) + .catch((error: unknown) => { + internalError({ + error, + type: "Discourse Relation: Delete imported check failed", + userMessage: + "Could not check whether this imported relation is in use.", }); - setDeleteConfirmation(null); - return; - } - handleDelete(rel); - }); + }); }; const handleDuplicate = (rel: Relation) => { const text = rel.text; @@ -1305,84 +1330,87 @@ const DiscourseRelationConfigPanel = ({ - Source - Relation - Destination + {renderSortableHeader("Source", "source")} + {renderSortableHeader("Relation", "relation")} + {renderSortableHeader("Destination", "destination")} From Status Actions - {importedRelations.map((rel) => ( - - - {nodes[rel.source || ""]?.label} - - {rel.text} - - {nodes[rel.destination || ""]?.label} - - - {formatImportedSource( - rel.importMeta.importedFrom.sourceNodeRid, - )} - - - {rel.importMeta.status === "provisional" ? ( - - Provisional - - ) : ( - - Accepted - - )} - - - {rel.importMeta.status === "provisional" && ( - + {importedRelations.map((rel) => { + const isProvisional = rel.importMeta.status === "provisional"; + return ( + + + {nodes[rel.source || ""]?.label} + + {rel.text} + + {nodes[rel.destination || ""]?.label} + + + {formatImportedSource( + rel.importMeta.importedFrom.sourceNodeRid, + )} + + + {isProvisional ? ( + + Provisional + + ) : ( + + Accepted + + )} + + + {isProvisional && ( + + - - - - ))} + intent={Intent.DANGER} + onClick={() => handleDeleteImported(rel)} + className={`mx-1 ${ + deleteConfirmation !== rel.uid ? "invisible" : "" + }`} + > + Confirm + + + + + ); + })} diff --git a/apps/roam/src/utils/canonicalRoamUrl.ts b/apps/roam/src/utils/canonicalRoamUrl.ts index bb73c860c..07f4f6e63 100644 --- a/apps/roam/src/utils/canonicalRoamUrl.ts +++ b/apps/roam/src/utils/canonicalRoamUrl.ts @@ -1,4 +1,4 @@ -const ROAM_URL_PREFIX = "https://roamresearch.com/#/app/"; +export const ROAM_URL_PREFIX = "https://roamresearch.com/#/app/"; const canonicalRoamUrl = (graphName = window.roamAlphaAPI.graph.name) => ROAM_URL_PREFIX + graphName; export default canonicalRoamUrl; diff --git a/apps/roam/src/utils/importedSourceIdentity.ts b/apps/roam/src/utils/importedSourceIdentity.ts index 6420273a5..ca55ff499 100644 --- a/apps/roam/src/utils/importedSourceIdentity.ts +++ b/apps/roam/src/utils/importedSourceIdentity.ts @@ -15,7 +15,7 @@ const SOURCE_MODIFIED_AT_KEY = "sourceModifiedAt"; export const isJsonObject = (value: json): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); -const parseImportedSourceIdentity = ( +export const parseImportedSourceIdentity = ( props: Record, ): ImportedSourceIdentity | undefined => { const discourseGraphProps = props[DISCOURSE_GRAPH_PROP_NAME]; diff --git a/apps/roam/src/utils/relationSchemaAcceptance.ts b/apps/roam/src/utils/relationSchemaAcceptance.ts index 7c7144545..4bcb0f5ef 100644 --- a/apps/roam/src/utils/relationSchemaAcceptance.ts +++ b/apps/roam/src/utils/relationSchemaAcceptance.ts @@ -3,17 +3,18 @@ import getBlockProps from "./getBlockProps"; import { setBlockPropsAsync } from "./setBlockProps"; import { isJsonObject, - readImportedSourceIdentity, + parseImportedSourceIdentity, type ImportedSourceIdentity, } from "./importedSourceIdentity"; -export type RelationSchemaImportStatus = "provisional" | "accepted"; +type ImportStatus = "provisional" | "accepted"; export const RELATION_SCHEMA_STATUS_PROP_KEY = "status"; +const ACCEPTED_STATUS: ImportStatus = "accepted"; export type RelationSchemaImportMeta = { importedFrom: ImportedSourceIdentity; - status: RelationSchemaImportStatus; + status: ImportStatus; }; // Origin (importedFrom) and acceptance (status) are stored as separate props so @@ -23,14 +24,14 @@ export type RelationSchemaImportMeta = { export const readRelationSchemaImportMeta = ( relationSchemaUid: string, ): RelationSchemaImportMeta | undefined => { - const importedFrom = readImportedSourceIdentity(relationSchemaUid); + const props = getBlockProps(relationSchemaUid); + const importedFrom = parseImportedSourceIdentity(props); if (importedFrom === undefined) return undefined; - const discourseGraphProps = - getBlockProps(relationSchemaUid)[DISCOURSE_GRAPH_PROP_NAME]; + const discourseGraphProps = props[DISCOURSE_GRAPH_PROP_NAME]; const status = isJsonObject(discourseGraphProps) && - discourseGraphProps[RELATION_SCHEMA_STATUS_PROP_KEY] === "accepted" - ? "accepted" + discourseGraphProps[RELATION_SCHEMA_STATUS_PROP_KEY] === ACCEPTED_STATUS + ? ACCEPTED_STATUS : "provisional"; return { importedFrom, status }; }; @@ -53,7 +54,7 @@ export const acceptImportedRelationSchema = async ( await setBlockPropsAsync(relationSchemaUid, { [DISCOURSE_GRAPH_PROP_NAME]: { ...discourseGraphProps, - [RELATION_SCHEMA_STATUS_PROP_KEY]: "accepted", + [RELATION_SCHEMA_STATUS_PROP_KEY]: ACCEPTED_STATUS, }, }); }; From 0b82df39cc4c4e763501f7ff2988b16cb006aaf2 Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 7 Sep 2026 00:41:31 +0530 Subject: [PATCH 3/7] Fix imported relation matching and refresh the grammar cache --- .../__tests__/importSharedRelations.test.ts | 118 ++++++++++++++++++ apps/roam/src/utils/importSharedRelations.ts | 11 +- 2 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 apps/roam/src/utils/__tests__/importSharedRelations.test.ts diff --git a/apps/roam/src/utils/__tests__/importSharedRelations.test.ts b/apps/roam/src/utils/__tests__/importSharedRelations.test.ts new file mode 100644 index 000000000..50b8725e4 --- /dev/null +++ b/apps/roam/src/utils/__tests__/importSharedRelations.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { DGSupabaseClient } from "@repo/database/lib/client"; +import type { DiscourseRelation } from "~/utils/getDiscourseRelations"; +import { importSharedRelations } from "~/utils/importSharedRelations"; +import refreshConfigTree from "~/utils/refreshConfigTree"; +import { writeImportedSourceIdentity } from "~/utils/importedSourceIdentity"; +import getDiscourseRelations from "~/utils/getDiscourseRelations"; +import { createRelationSchema } from "~/utils/createRelationSchema"; + +vi.hoisted(() => { + vi.stubGlobal("window", { roamAlphaAPI: { graph: { name: "local" } } }); +}); +vi.mock("~/utils/refreshConfigTree", () => ({ default: vi.fn() })); +vi.mock("~/utils/getDiscourseRelations", () => ({ default: vi.fn() })); +vi.mock("~/utils/getDiscourseNodes", () => ({ + default: () => [{ type: "local-claim", text: "Claim" }], +})); +vi.mock("~/utils/importedSourceIdentity", () => ({ + getImportedSourceRids: () => Promise.resolve(new Set()), + findImportedNodeUidBySourceRid: vi.fn(), + writeImportedSourceIdentity: vi.fn(), +})); +vi.mock("~/components/settings/utils/accessors", () => ({ + createDiscourseNodeType: vi.fn(), +})); +vi.mock("~/utils/createRelationSchema", () => ({ + createRelationSchema: vi.fn(), +})); +vi.mock("~/utils/createReifiedBlock", () => ({ + getReifiedRelations: () => Promise.resolve([]), + createReifiedRelation: vi.fn(), +})); +vi.mock("roamjs-components/writes", () => ({ deleteBlock: vi.fn() })); +vi.mock("~/utils/discoverSharedRelations", () => ({ + discoverSharedRelations: () => + Promise.resolve({ + relations: [], + relTypeSchemas: [], + nodeSchemas: [ + { + localId: "claim", + rid: "orn:obsidian.schema:remote/claim", + label: "Claim", + authorId: "author", + createdAt: new Date("2026-09-07"), + }, + ], + relTripleSchemas: [ + { + localId: "supports", + rid: "orn:obsidian.schema:remote/supports", + label: "Supports", + complement: "Supported by", + sourceType: "claim", + destinationType: "claim", + authorId: "author", + createdAt: new Date("2026-09-07"), + }, + ], + }), +})); + +const relation = (id: string): DiscourseRelation => ({ + id, + label: "Supports", + complement: "Supported by", + source: "local-claim", + destination: "local-claim", + triples: [], +}); +const client = {} as DGSupabaseClient; + +beforeEach(() => vi.clearAllMocks()); + +describe("importSharedRelations schema matching", () => { + it("refreshes the grammar after storing a new schema and its provenance", async () => { + vi.mocked(getDiscourseRelations).mockReturnValue([]); + vi.mocked(createRelationSchema).mockResolvedValue("imported-supports"); + await importSharedRelations(client, 7); + expect(writeImportedSourceIdentity).toHaveBeenCalledWith( + expect.objectContaining({ + pageUid: "imported-supports", + sourceNodeRid: "orn:obsidian.schema:remote/supports", + }), + ); + expect(refreshConfigTree).toHaveBeenCalledOnce(); + expect( + vi.mocked(refreshConfigTree).mock.invocationCallOrder[0], + ).toBeGreaterThan( + vi.mocked(writeImportedSourceIdentity).mock.invocationCallOrder[0], + ); + }); + it("reuses one schema when its query patterns produce multiple matches", async () => { + vi.mocked(getDiscourseRelations).mockReturnValue([ + { + ...relation("local-supports"), + triples: [["source", "references", "destination"]], + }, + { + ...relation("local-supports"), + triples: [["source", "is in page", "destination"]], + }, + ]); + await expect(importSharedRelations(client, 7)).resolves.toBeUndefined(); + expect(createRelationSchema).not.toHaveBeenCalled(); + }); + + it("rejects matches to two different schemas", async () => { + vi.mocked(getDiscourseRelations).mockReturnValue([ + relation("supports-one"), + relation("supports-two"), + ]); + await expect(importSharedRelations(client, 7)).rejects.toThrow( + "multiple matches", + ); + expect(createRelationSchema).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/roam/src/utils/importSharedRelations.ts b/apps/roam/src/utils/importSharedRelations.ts index adb7aa5cd..2c7c6d77c 100644 --- a/apps/roam/src/utils/importSharedRelations.ts +++ b/apps/roam/src/utils/importSharedRelations.ts @@ -28,6 +28,7 @@ import { discoverSharedRelations } from "./discoverSharedRelations"; import { DGSupabaseClient } from "@repo/database/lib/client"; import { deleteBlock } from "roamjs-components/writes"; import canonicalRoamUrl from "./canonicalRoamUrl"; +import refreshConfigTree from "./refreshConfigTree"; const matchImportedNodeSchemas = async ( nodeSchemas: CrossAppNodeSchema[], @@ -139,11 +140,13 @@ const matchImportedRelationSchemas = async ( r.source === source && r.destination === destination, ); - if (match.length > 1) { + // Each query pattern can produce a match for the same local schema. + const matchIds = [...new Set(match.map(({ id }) => id))]; + if (matchIds.length > 1) { throw new Error("multiple matches"); } - if (match.length === 1) { - blockUid = match[0].id; + if (matchIds.length === 1) { + blockUid = matchIds[0]; } else { blockUid = await createRelationSchema({ label, @@ -280,4 +283,6 @@ export const importSharedRelations = async ( ); ridToLocalId = { ...ridToLocalId, ...relationSchemaMap }; await importRelations(ridToLocalId, relations); + // Legacy settings read the cached grammar, including newly imported schemas. + refreshConfigTree(); }; From dfc584fb5cdf008a52ac3213f469c323e09de4e6 Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 7 Sep 2026 00:43:44 +0530 Subject: [PATCH 4/7] Read current relation choices when opening the creation dialog --- apps/roam/src/components/CreateRelationDialog.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/roam/src/components/CreateRelationDialog.tsx b/apps/roam/src/components/CreateRelationDialog.tsx index b283161f8..1d2403ca5 100644 --- a/apps/roam/src/components/CreateRelationDialog.tsx +++ b/apps/roam/src/components/CreateRelationDialog.tsx @@ -406,7 +406,8 @@ export const CreateRelationButton = ( text="Add relation" disabled={extProps === null} onClick={() => { - renderCreateRelationDialog(extProps); + // A schema may have been accepted since this button last rendered. + renderCreateRelationDialog(relationProps); }} /> ); From b58f95735f75e2dc4a8bba47dfc663f1cef346ab Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 7 Sep 2026 01:22:16 +0530 Subject: [PATCH 5/7] ENG-2131 Align imported relation actions with row content --- .../settings/DiscourseRelationConfigPanel.tsx | 77 ++++++++++--------- 1 file changed, 40 insertions(+), 37 deletions(-) diff --git a/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx b/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx index 7b12697ba..d228615e5 100644 --- a/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx +++ b/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx @@ -1366,47 +1366,50 @@ const DiscourseRelationConfigPanel = ({ )} - - {isProvisional && ( - + +
+ {isProvisional && ( + + - + {deleteConfirmation === rel.uid && ( + <> + + + + )} +
); From 1a5f2ced878521d909c09023e731e75d8547f63d Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 7 Sep 2026 01:34:03 +0530 Subject: [PATCH 6/7] ENG-2131 Keep delete confirmation within the actions column --- .../settings/DiscourseRelationConfigPanel.tsx | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx b/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx index d228615e5..1166d07b3 100644 --- a/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx +++ b/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx @@ -1368,46 +1368,46 @@ const DiscourseRelationConfigPanel = ({
- {isProvisional && ( - - + ) : ( + <> + {isProvisional && ( + +
From a574c201dfa761a46ab19d98243b91ad017b6daf Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 7 Sep 2026 02:21:20 +0530 Subject: [PATCH 7/7] ENG-2131 Refresh mounted relation creation after schema changes --- .../src/components/CreateRelationDialog.tsx | 2 + apps/roam/src/components/SuggestionsBody.tsx | 6 +- apps/roam/src/components/canvas/Tldraw.tsx | 42 ++++- .../settings/DiscourseRelationConfigPanel.tsx | 31 ++-- .../__tests__/deleteRelationSchema.test.ts | 79 ++++++++ .../relationSchemaCreationRefresh.test.ts | 170 ++++++++++++++++++ apps/roam/src/utils/deleteRelationSchema.ts | 18 ++ .../src/utils/relationSchemaAcceptance.ts | 11 +- apps/roam/src/utils/relationSchemaChanges.ts | 39 ++++ 9 files changed, 371 insertions(+), 27 deletions(-) create mode 100644 apps/roam/src/utils/__tests__/deleteRelationSchema.test.ts create mode 100644 apps/roam/src/utils/__tests__/relationSchemaCreationRefresh.test.ts create mode 100644 apps/roam/src/utils/deleteRelationSchema.ts create mode 100644 apps/roam/src/utils/relationSchemaChanges.ts diff --git a/apps/roam/src/components/CreateRelationDialog.tsx b/apps/roam/src/components/CreateRelationDialog.tsx index 1d2403ca5..fa4b335f0 100644 --- a/apps/roam/src/components/CreateRelationDialog.tsx +++ b/apps/roam/src/components/CreateRelationDialog.tsx @@ -1,3 +1,4 @@ +import { useRelationSchemaRevision } from "~/utils/relationSchemaChanges"; import React, { useState, useMemo } from "react"; import { Dialog, Classes, Label, Button, Callout } from "@blueprintjs/core"; import renderOverlay from "roamjs-components/util/renderOverlay"; @@ -388,6 +389,7 @@ export const renderCreateRelationDialog = ( export const CreateRelationButton = ( props: CreateRelationDialogProps & { fill?: boolean }, ): React.JSX.Element | null => { + useRelationSchemaRevision(); const { fill = false, ...relationProps } = props; const storedRelationsEnabled = getStoredRelationsEnabled(); if (!storedRelationsEnabled) return null; diff --git a/apps/roam/src/components/SuggestionsBody.tsx b/apps/roam/src/components/SuggestionsBody.tsx index bcfe68ac7..ff055b703 100644 --- a/apps/roam/src/components/SuggestionsBody.tsx +++ b/apps/roam/src/components/SuggestionsBody.tsx @@ -1,3 +1,4 @@ +import { useRelationSchemaRevision } from "~/utils/relationSchemaChanges"; import React, { useMemo, useState, useEffect, useCallback } from "react"; import { Button, @@ -233,9 +234,12 @@ const SuggestionsBody = ({ () => findDiscourseNode({ uid: tagUid }), [tagUid], ); + const relationSchemaRevision = useRelationSchemaRevision(); const allRelations = useMemo( () => excludeProvisionalRelationSchemas(getDiscourseRelations()), - [], + // Acceptance and deletion invalidate the relation data stored outside React. + // eslint-disable-next-line react-hooks/exhaustive-deps + [relationSchemaRevision], ); const allNodes = useMemo(() => getDiscourseNodes(), []); diff --git a/apps/roam/src/components/canvas/Tldraw.tsx b/apps/roam/src/components/canvas/Tldraw.tsx index 7849df75a..5c5bb62e5 100644 --- a/apps/roam/src/components/canvas/Tldraw.tsx +++ b/apps/roam/src/components/canvas/Tldraw.tsx @@ -1,3 +1,7 @@ +import { + isRelationSchemaDeleted, + useRelationSchemaRevision, +} from "~/utils/relationSchemaChanges"; import React, { useState, useRef, @@ -150,7 +154,8 @@ export const discourseContext: DiscourseContextType = { }; export const isAcceptedRelationSchema = (relation: { id: string }): boolean => - !discourseContext.provisionalRelationIds.has(relation.id); + !discourseContext.provisionalRelationIds.has(relation.id) && + !isRelationSchemaDeleted(relation.id); let activeCanvasPageUid: string | null = null; let activeCanvasEditor: Editor | null = null; @@ -774,11 +779,7 @@ const TldrawCanvasShared = ({ }, {} as Record, ); - discourseContext.provisionalRelationIds = new Set( - relations - .filter((r) => isProvisionalRelationSchema(r.id)) - .map((r) => r.id), - ); + return relations; }, []); const allRelationsById = useMemo(() => { @@ -790,11 +791,34 @@ const TldrawCanvasShared = ({ const allRelationIds = useMemo(() => { return Object.keys(allRelationsById); }, [allRelationsById]); + const relationSchemaRevision = useRelationSchemaRevision(); + const registeredRelationNames = useMemo( + () => [...new Set(allRelations.map((relation) => relation.label))], + [allRelations], + ); const allRelationNames = useMemo(() => { + discourseContext.provisionalRelationIds = new Set( + allRelations + .filter((r) => isProvisionalRelationSchema(r.id)) + .map((r) => r.id), + ); return Object.entries(discourseContext.relations) .filter(([, relations]) => relations.some(isAcceptedRelationSchema)) .map(([name]) => name); - }, []); + // Acceptance and deletion invalidate the relation data stored outside React. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allRelations, relationSchemaRevision]); + useEffect(() => { + const editor = appRef.current; + if (!editor) return; + const tool = editor.getCurrentToolId(); + if ( + registeredRelationNames.includes(tool) && + !allRelationNames.includes(tool) + ) { + editor.setCurrentTool("select"); + } + }, [allRelationNames, registeredRelationNames]); const allNodes = useMemo(() => { const allNodes = getDiscourseNodes(); discourseContext.nodes = Object.fromEntries( @@ -1040,7 +1064,9 @@ const TldrawCanvasShared = ({ static override isLockable = true; }; const discourseNodeTools = createNodeShapeTools(allNodes); - const discourseRelationTools = createAllRelationShapeTools(allRelationNames); + const discourseRelationTools = createAllRelationShapeTools( + registeredRelationNames, + ); const referencedNodeTools = createAllReferencedNodeTools( allAddReferencedNodeByAction, ); diff --git a/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx b/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx index 1166d07b3..3a5a5d1b9 100644 --- a/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx +++ b/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx @@ -75,7 +75,7 @@ import { import { getReifiedRelations } from "~/utils/createReifiedBlock"; import { ridToSpaceUriAndLocalId } from "@repo/database/lib/rid"; import { ROAM_URL_PREFIX } from "~/utils/canonicalRoamUrl"; -import { discourseContext } from "~/components/canvas/Tldraw"; +import { deleteRelationSchema } from "~/utils/deleteRelationSchema"; import internalError from "~/utils/internalError"; const DEFAULT_SELECTED_RELATION = { @@ -1100,21 +1100,13 @@ const DiscourseRelationConfigPanel = ({ setEditingRelation(rel.uid); }; - const handleDelete = (rel: Relation) => { - void deleteBlock(rel.uid).then(() => { - const { [rel.uid]: _, ...remaining } = getGlobalSettings().Relations; - setGlobalSetting([GLOBAL_KEYS.relations], remaining); - setTimeout(() => { - refreshConfigTree(); - setRelations(refreshRelations()); - }, 50); - }); + const handleDelete = async (rel: Relation): Promise => { + await deleteRelationSchema(rel.uid); + setRelations(refreshRelations()); }; const handleAcceptImported = (rel: Relation) => { void acceptImportedRelationSchema(rel.uid) .then(() => { - // Make the acceptance visible to canvases that are already mounted. - discourseContext.provisionalRelationIds.delete(rel.uid); posthog.capture("Discourse Relation: Accepted", { relationUid: rel.uid, }); @@ -1145,14 +1137,13 @@ const DiscourseRelationConfigPanel = ({ setDeleteConfirmation(null); return; } - handleDelete(rel); + return handleDelete(rel); }) .catch((error: unknown) => { internalError({ error, - type: "Discourse Relation: Delete imported check failed", - userMessage: - "Could not check whether this imported relation is in use.", + type: "Discourse Relation: Delete imported failed", + userMessage: "Could not delete the imported relation.", }); }); }; @@ -1299,7 +1290,13 @@ const DiscourseRelationConfigPanel = ({ intent={Intent.DANGER} onClick={(e) => { e.stopPropagation(); - handleDelete(rel); + void handleDelete(rel).catch((error: unknown) => { + internalError({ + error, + type: "Discourse Relation: Delete failed", + userMessage: "Could not delete the relation.", + }); + }); }} className={`mx-1 ${ deleteConfirmation !== rel.uid ? "opacity-0" : "" diff --git a/apps/roam/src/utils/__tests__/deleteRelationSchema.test.ts b/apps/roam/src/utils/__tests__/deleteRelationSchema.test.ts new file mode 100644 index 000000000..9af73b4b2 --- /dev/null +++ b/apps/roam/src/utils/__tests__/deleteRelationSchema.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { deleteRelationSchema } from "~/utils/deleteRelationSchema"; +import { + isRelationSchemaDeleted, + subscribeToRelationSchemaChanges, +} from "~/utils/relationSchemaChanges"; + +const mocks = vi.hoisted(() => ({ + deleteBlock: vi.fn(), + setSetting: vi.fn(), + refresh: vi.fn(), +})); +vi.mock("roamjs-components/writes/deleteBlock", () => ({ + default: mocks.deleteBlock, +})); +vi.mock("~/components/settings/utils/accessors", () => ({ + getGlobalSettings: () => ({ + Relations: { deleted: { label: "supports" }, kept: { label: "opposes" } }, + }), + setGlobalSetting: mocks.setSetting, +})); +vi.mock("~/utils/refreshConfigTree", () => ({ default: mocks.refresh })); +beforeEach(() => { + vi.resetAllMocks(); + vi.useFakeTimers(); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +it("blocks creation immediately after deletion and waits for configuration refresh", async () => { + const listener = vi.fn(); + const unsubscribe = subscribeToRelationSchemaChanges(listener); + try { + const finished = vi.fn(); + const pending = deleteRelationSchema("deleted").then(finished); + await Promise.resolve(); + expect(isRelationSchemaDeleted("deleted")).toBe(true); + expect(listener).toHaveBeenCalledOnce(); + expect(mocks.setSetting).toHaveBeenCalledWith(["Relations"], { + kept: { label: "opposes" }, + }); + expect(finished).not.toHaveBeenCalled(); + await vi.runAllTimersAsync(); + await pending; + expect(mocks.refresh).toHaveBeenCalledOnce(); + expect(finished).toHaveBeenCalledOnce(); + } finally { + unsubscribe(); + } +}); +it("propagates a failed block deletion without disabling the schema", async () => { + mocks.deleteBlock.mockRejectedValueOnce(new Error("delete failed")); + await expect(deleteRelationSchema("failed-delete")).rejects.toThrow( + "delete failed", + ); + expect(isRelationSchemaDeleted("failed-delete")).toBe(false); + expect(mocks.setSetting).not.toHaveBeenCalled(); +}); +it("propagates setting failures while keeping the deleted schema unavailable", async () => { + mocks.setSetting.mockImplementationOnce(() => { + throw new Error("settings failed"); + }); + await expect(deleteRelationSchema("failed-settings")).rejects.toThrow( + "settings failed", + ); + expect(isRelationSchemaDeleted("failed-settings")).toBe(true); +}); +it("propagates configuration refresh failures to the caller", async () => { + mocks.refresh.mockImplementationOnce(() => { + throw new Error("refresh failed"); + }); + const pending = expect( + deleteRelationSchema("failed-refresh"), + ).rejects.toThrow("refresh failed"); + await vi.runAllTimersAsync(); + await pending; + expect(isRelationSchemaDeleted("failed-refresh")).toBe(true); +}); diff --git a/apps/roam/src/utils/__tests__/relationSchemaCreationRefresh.test.ts b/apps/roam/src/utils/__tests__/relationSchemaCreationRefresh.test.ts new file mode 100644 index 000000000..1977ee7fd --- /dev/null +++ b/apps/roam/src/utils/__tests__/relationSchemaCreationRefresh.test.ts @@ -0,0 +1,170 @@ +// @vitest-environment jsdom +import React from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { act } from "react-dom/test-utils"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { CreateRelationButton } from "~/components/CreateRelationDialog"; +import SuggestionsBody from "~/components/SuggestionsBody"; +import { acceptImportedRelationSchema } from "~/utils/relationSchemaAcceptance"; +import { markRelationSchemaDeleted } from "~/utils/relationSchemaChanges"; +import { DISCOURSE_GRAPH_PROP_NAME } from "~/utils/createReifiedBlock"; +import { IMPORTED_FROM_PROP_KEY } from "~/utils/importedSourceIdentity"; +import type { json } from "~/utils/getBlockProps"; + +const mocks = vi.hoisted(() => ({ + search: vi.fn().mockResolvedValue([]), + props: new Map>(), +})); +vi.mock("~/utils/hyde", () => ({ performHydeSearch: mocks.search })); +vi.mock("~/utils/getDiscourseNodes", () => ({ + default: () => [ + { type: "source", text: "Source", format: "{content}" }, + { type: "target", text: "Target", format: "{content}" }, + ], +})); +vi.mock("~/utils/findDiscourseNode", () => ({ + default: () => ({ type: "source" }), +})); +vi.mock("~/utils/getDiscourseRelations", () => ({ + default: () => [ + { + id: "mounted-schema", + source: "source", + destination: "target", + label: "supports", + complement: "supported by", + triples: [], + }, + ], +})); +vi.mock("~/utils/getDiscourseContextResults", () => ({ + default: vi.fn().mockResolvedValue([]), +})); +vi.mock("~/utils/storedRelations", () => ({ + getStoredRelationsEnabled: () => true, +})); +vi.mock("~/components/settings/utils/accessors", () => ({ + getGlobalSetting: () => [], +})); +vi.mock("~/utils/internalError", () => ({ default: vi.fn() })); +vi.mock("~/utils/notifySuggestiveModeAdoption", () => ({ + notifyBlockSuggestionAdded: vi.fn(), + notifyRelationSuggestionAdded: vi.fn(), +})); +vi.mock("~/utils/discourseContextMutationRefresh", () => ({ + refreshDiscourseContextsForMutatedUids: vi.fn(), +})); +vi.mock("roamjs-components/queries/getAllPageNames", () => ({ + default: () => [], +})); +vi.mock("roamjs-components/queries/getPageTitleByPageUid", () => ({ + default: () => "Source page", +})); +vi.mock("roamjs-components/queries/getPageUidByPageTitle", () => ({ + default: () => "source-page", +})); +vi.mock("roamjs-components/components/AutocompleteInput", () => ({ + default: () => null, +})); +vi.mock("roamjs-components/components/MenuItemSelect", () => ({ + default: () => null, +})); +vi.mock("roamjs-components/util/renderOverlay", () => ({ default: vi.fn() })); +vi.mock("roamjs-components/components/Toast", () => ({ render: vi.fn() })); +vi.mock("posthog-js", () => ({ default: { capture: vi.fn() } })); + +let container: HTMLDivElement; +let root: Root; +beforeEach(() => { + mocks.props.set("mounted-schema", { + [DISCOURSE_GRAPH_PROP_NAME]: { + [IMPORTED_FROM_PROP_KEY]: { + sourceNodeRid: "orn:obsidian.schema:vault/relation", + sourceModifiedAt: "2026-08-01T00:00:00.000Z", + }, + }, + }); + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + Object.assign(window, { + roamAlphaAPI: { + pull: (_pattern: string, [, uid]: [string, string]) => ({ + ":block/props": mocks.props.get(uid) ?? {}, + }), + data: { + block: { + update: vi.fn( + ({ + block, + }: { + block: { uid: string; props: Record }; + }) => { + mocks.props.set(block.uid, block.props); + return Promise.resolve(); + }, + ), + }, + backend: { q: vi.fn().mockResolvedValue([]) }, + }, + }, + }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); +const clickAllPages = async (): Promise => { + const button = [...container.querySelectorAll("button")].find( + (b) => b.textContent === "All Pages", + ); + expect(button).toBeDefined(); + await act(() => { + button?.click(); + return Promise.resolve(); + }); +}; +it("refreshes both mounted creation interfaces after acceptance and deletion", async () => { + await act(() => { + root.render( + React.createElement( + React.Fragment, + null, + React.createElement(CreateRelationButton, { + sourceNodeUid: "source-page", + }), + React.createElement(SuggestionsBody, { + tag: "Source page", + blockUid: "suggestions-block", + }), + ), + ); + return Promise.resolve(); + }); + const addButton = [...container.querySelectorAll("button")].find( + (b) => b.textContent === "Add relation", + ); + expect(addButton?.disabled).toBe(true); + await clickAllPages(); + expect(mocks.search).toHaveBeenLastCalledWith( + expect.objectContaining({ validTypes: [], uniqueRelationTypeTriplets: [] }), + ); + await act(async () => { + await acceptImportedRelationSchema("mounted-schema"); + }); + expect(addButton?.disabled).toBe(false); + await clickAllPages(); + expect(mocks.search).toHaveBeenLastCalledWith( + expect.objectContaining({ validTypes: ["target"] }), + ); + act(() => { + markRelationSchemaDeleted("mounted-schema"); + }); + expect(addButton?.disabled).toBe(true); + await clickAllPages(); + expect(mocks.search).toHaveBeenLastCalledWith( + expect.objectContaining({ validTypes: [], uniqueRelationTypeTriplets: [] }), + ); +}); diff --git a/apps/roam/src/utils/deleteRelationSchema.ts b/apps/roam/src/utils/deleteRelationSchema.ts new file mode 100644 index 000000000..c22e8dac4 --- /dev/null +++ b/apps/roam/src/utils/deleteRelationSchema.ts @@ -0,0 +1,18 @@ +import deleteBlock from "roamjs-components/writes/deleteBlock"; +import { + getGlobalSettings, + setGlobalSetting, +} from "~/components/settings/utils/accessors"; +import { GLOBAL_KEYS } from "~/components/settings/utils/settingKeys"; +import refreshConfigTree from "./refreshConfigTree"; +import { markRelationSchemaDeleted } from "./relationSchemaChanges"; + +export const deleteRelationSchema = async (uid: string): Promise => { + await deleteBlock(uid); + markRelationSchemaDeleted(uid); + const remaining = { ...getGlobalSettings().Relations }; + delete remaining[uid]; + setGlobalSetting([GLOBAL_KEYS.relations], remaining); + await new Promise((resolve) => setTimeout(resolve, 50)); + refreshConfigTree(); +}; diff --git a/apps/roam/src/utils/relationSchemaAcceptance.ts b/apps/roam/src/utils/relationSchemaAcceptance.ts index 4bcb0f5ef..7b8079659 100644 --- a/apps/roam/src/utils/relationSchemaAcceptance.ts +++ b/apps/roam/src/utils/relationSchemaAcceptance.ts @@ -1,3 +1,7 @@ +import { + isRelationSchemaDeleted, + notifyRelationSchemaChange, +} from "./relationSchemaChanges"; import { DISCOURSE_GRAPH_PROP_NAME } from "./createReifiedBlock"; import getBlockProps from "./getBlockProps"; import { setBlockPropsAsync } from "./setBlockProps"; @@ -44,7 +48,11 @@ export const isProvisionalRelationSchema = ( export const excludeProvisionalRelationSchemas = ( relations: T[], ): T[] => - relations.filter((relation) => !isProvisionalRelationSchema(relation.id)); + relations.filter( + (relation) => + !isRelationSchemaDeleted(relation.id) && + !isProvisionalRelationSchema(relation.id), + ); export const acceptImportedRelationSchema = async ( relationSchemaUid: string, @@ -57,4 +65,5 @@ export const acceptImportedRelationSchema = async ( [RELATION_SCHEMA_STATUS_PROP_KEY]: ACCEPTED_STATUS, }, }); + notifyRelationSchemaChange(); }; diff --git a/apps/roam/src/utils/relationSchemaChanges.ts b/apps/roam/src/utils/relationSchemaChanges.ts new file mode 100644 index 000000000..da84c786f --- /dev/null +++ b/apps/roam/src/utils/relationSchemaChanges.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from "react"; + +let revision = 0; +const listeners = new Set<() => void>(); +const deletedSchemaIds = new Set(); + +export const subscribeToRelationSchemaChanges = ( + listener: () => void, +): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +export const notifyRelationSchemaChange = (): void => { + revision += 1; + listeners.forEach((listener) => listener()); +}; + +export const markRelationSchemaDeleted = (uid: string): void => { + // Mounted canvases retain schema metadata to render their existing shapes. + deletedSchemaIds.add(uid); + notifyRelationSchemaChange(); +}; + +export const isRelationSchemaDeleted = (uid: string): boolean => + deletedSchemaIds.has(uid); + +export const useRelationSchemaRevision = (): number => { + const [currentRevision, setCurrentRevision] = useState(revision); + useEffect(() => { + const update = (): void => setCurrentRevision(revision); + const unsubscribe = subscribeToRelationSchemaChanges(update); + update(); + return unsubscribe; + }, []); + return currentRevision; +};