diff --git a/apps/roam/src/components/CreateRelationDialog.tsx b/apps/roam/src/components/CreateRelationDialog.tsx index c1d08b701..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"; @@ -8,6 +9,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 +293,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, @@ -387,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; @@ -405,7 +408,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); }} /> ); diff --git a/apps/roam/src/components/SuggestionsBody.tsx b/apps/roam/src/components/SuggestionsBody.tsx index c81d4dc00..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, @@ -21,6 +22,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 +234,13 @@ const SuggestionsBody = ({ () => findDiscourseNode({ uid: tagUid }), [tagUid], ); - const allRelations = useMemo(() => getDiscourseRelations(), []); + 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(), []); 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..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 { @@ -350,7 +353,9 @@ export const createAllRelationShapeTools = ( override onEnter = () => { this.didTimeout = false; - const selectedRelations = discourseContext.relations[name] || []; + const selectedRelations = ( + discourseContext.relations[name] || [] + ).filter(isAcceptedRelationSchema); const hasIncompleteSelectedRelation = selectedRelations.some( (relation) => !isRelationComplete(relation), ); @@ -384,7 +389,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 +397,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..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,16 +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]; + const relationsWithLabel = includeProvisional + ? discourseContext.relations[label] + : discourseContext.relations[label]?.filter(isAcceptedRelationSchema); if (!relationsWithLabel) { return { isDirect: false, isReverse: false, matchingRelation: null }; } @@ -1787,7 +1799,9 @@ export class BaseDiscourseRelationUtil extends ShapeUtil } getValidTargetTypes(label: string, sourceNodeType: string): string[] { - const relationsWithLabel = discourseContext.relations[label]; + const relationsWithLabel = discourseContext.relations[label]?.filter( + isAcceptedRelationSchema, + ); if (!relationsWithLabel) return []; const targets = new Set(); @@ -1807,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 505833d02..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, @@ -120,6 +124,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 +138,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,10 +148,15 @@ export type DiscourseContextType = { export const discourseContext: DiscourseContextType = { nodes: {}, relations: {}, + provisionalRelationIds: new Set(), lastAppEvent: "", lastActions: [], }; +export const isAcceptedRelationSchema = (relation: { id: string }): boolean => + !discourseContext.provisionalRelationIds.has(relation.id) && + !isRelationSchemaDeleted(relation.id); + let activeCanvasPageUid: string | null = null; let activeCanvasEditor: Editor | null = null; @@ -766,6 +779,7 @@ const TldrawCanvasShared = ({ }, {} as Record, ); + return relations; }, []); const allRelationsById = useMemo(() => { @@ -777,9 +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(() => { - return Object.keys(discourseContext.relations); - }, []); + 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( @@ -1025,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/canvas/canvasUtils.ts b/apps/roam/src/components/canvas/canvasUtils.ts index 5bb71dbbc..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,8 +19,10 @@ export const isDiscourseNodeShape = ( } }; -export const getAllRelations = () => - Object.values(discourseContext.relations).flat(); +export const getCreatableRelations = () => + Object.values(discourseContext.relations) + .flat() + .filter(isAcceptedRelationSchema); export const checkConnectionType = ( relation: { source: string; destination: string }, @@ -36,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 968bf4f7d..3a5a5d1b9 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, @@ -21,6 +22,7 @@ import React, { useCallback, useEffect, useMemo, + useReducer, useRef, useState, } from "react"; @@ -65,6 +67,16 @@ 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"; +import { ROAM_URL_PREFIX } from "~/utils/canonicalRoamUrl"; +import { deleteRelationSchema } from "~/utils/deleteRelationSchema"; +import internalError from "~/utils/internalError"; const DEFAULT_SELECTED_RELATION = { display: "none", @@ -976,6 +988,14 @@ type Relation = { source: string | undefined; destination: string | undefined; }; +type ImportedRelation = Relation & { importMeta: RelationSchemaImportMeta }; + +const formatImportedSource = (sourceNodeRid: string): string => { + const { spaceUri } = ridToSpaceUriAndLocalId(sourceNodeRid); + return spaceUri.startsWith(ROAM_URL_PREFIX) + ? spaceUri.slice(ROAM_URL_PREFIX.length) + : spaceUri; +}; const DiscourseRelationConfigPanel = ({ uid, parentUid, @@ -1037,6 +1057,17 @@ const DiscourseRelationConfigPanel = ({ : visibleRelations, [nodes, sort, visibleRelations], ); + // 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, @@ -1069,15 +1100,52 @@ 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(() => { + 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.`, + }); + setDeleteConfirmation(null); + return; + } + return handleDelete(rel); + }) + .catch((error: unknown) => { + internalError({ + error, + type: "Discourse Relation: Delete imported failed", + userMessage: "Could not delete the imported relation.", + }); + }); }; const handleDuplicate = (rel: Relation) => { const text = rel.text; @@ -1183,7 +1251,7 @@ const DiscourseRelationConfigPanel = ({ - {sortedRelations.map((rel) => ( + {localRelations.map((rel) => ( handleEdit(rel)}> {nodes[rel.source || ""]?.label} @@ -1222,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" : "" @@ -1243,6 +1317,104 @@ const DiscourseRelationConfigPanel = ({ ))} + {importedRelations.length > 0 && ( + <> +

Imported relations

+

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

+ + + + {renderSortableHeader("Source", "source")} + {renderSortableHeader("Relation", "relation")} + {renderSortableHeader("Destination", "destination")} + From + Status + Actions + + + + {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 + + )} + + +
+ {deleteConfirmation === rel.uid ? ( + <> + + + + ) : ( + <> + {isProvisional && ( + +
+ + + ); + })} + +
+ + )} ); }; 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__/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/__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/__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/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/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/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(); }; diff --git a/apps/roam/src/utils/importedSourceIdentity.ts b/apps/roam/src/utils/importedSourceIdentity.ts index 64588918a..ca55ff499 100644 --- a/apps/roam/src/utils/importedSourceIdentity.ts +++ b/apps/roam/src/utils/importedSourceIdentity.ts @@ -12,10 +12,10 @@ 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 = ( +export const parseImportedSourceIdentity = ( props: Record, ): ImportedSourceIdentity | undefined => { const discourseGraphProps = props[DISCOURSE_GRAPH_PROP_NAME]; 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..7b8079659 --- /dev/null +++ b/apps/roam/src/utils/relationSchemaAcceptance.ts @@ -0,0 +1,69 @@ +import { + isRelationSchemaDeleted, + notifyRelationSchemaChange, +} from "./relationSchemaChanges"; +import { DISCOURSE_GRAPH_PROP_NAME } from "./createReifiedBlock"; +import getBlockProps from "./getBlockProps"; +import { setBlockPropsAsync } from "./setBlockProps"; +import { + isJsonObject, + parseImportedSourceIdentity, + type ImportedSourceIdentity, +} from "./importedSourceIdentity"; + +type ImportStatus = "provisional" | "accepted"; + +export const RELATION_SCHEMA_STATUS_PROP_KEY = "status"; +const ACCEPTED_STATUS: ImportStatus = "accepted"; + +export type RelationSchemaImportMeta = { + importedFrom: ImportedSourceIdentity; + status: ImportStatus; +}; + +// 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 props = getBlockProps(relationSchemaUid); + const importedFrom = parseImportedSourceIdentity(props); + if (importedFrom === undefined) return undefined; + const discourseGraphProps = props[DISCOURSE_GRAPH_PROP_NAME]; + const status = + isJsonObject(discourseGraphProps) && + discourseGraphProps[RELATION_SCHEMA_STATUS_PROP_KEY] === ACCEPTED_STATUS + ? ACCEPTED_STATUS + : "provisional"; + return { importedFrom, status }; +}; + +export const isProvisionalRelationSchema = ( + relationSchemaUid: string, +): boolean => + readRelationSchemaImportMeta(relationSchemaUid)?.status === "provisional"; + +export const excludeProvisionalRelationSchemas = ( + relations: T[], +): T[] => + relations.filter( + (relation) => + !isRelationSchemaDeleted(relation.id) && + !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_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; +};