From 71f189b235c14f70d459ef824f9aa100897c5037 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 28 Sep 2026 10:22:46 -0400 Subject: [PATCH 1/7] ENG-2258 Create a discourse relation from canvas and associate it with the selected node type pair Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M3M6B6ERYKXVX44A3Z6TZ8CR --- .../components/RelationshipTypeSettings.tsx | 36 +---- .../canvas/CreateRelationTypeModal.tsx | 146 ++++++++++++++++++ .../canvas/overlays/RelationTypeDropdown.tsx | 68 ++++++-- .../canvas/utils/relationTypeUtils.ts | 46 +++++- .../src/components/canvas/utils/toastUtils.ts | 6 +- apps/obsidian/src/utils/typeUtils.ts | 38 +++++ 6 files changed, 293 insertions(+), 47 deletions(-) create mode 100644 apps/obsidian/src/components/canvas/CreateRelationTypeModal.tsx diff --git a/apps/obsidian/src/components/RelationshipTypeSettings.tsx b/apps/obsidian/src/components/RelationshipTypeSettings.tsx index 9f51e81839..1811c9216e 100644 --- a/apps/obsidian/src/components/RelationshipTypeSettings.tsx +++ b/apps/obsidian/src/components/RelationshipTypeSettings.tsx @@ -15,6 +15,7 @@ import { getContrastColor } from "~/utils/colorUtils"; import { getImportInfo, formatImportSource, + getRelationTypeErrors, isProvisionalSchema, } from "~/utils/typeUtils"; import ImportedSchemaMeta from "./ImportedSchemaMeta"; @@ -25,7 +26,11 @@ type ColorPickerProps = { disabled?: boolean; }; -const ColorPicker = ({ value, onChange, disabled }: ColorPickerProps) => { +export const ColorPicker = ({ + value, + onChange, + disabled, +}: ColorPickerProps) => { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); @@ -114,38 +119,11 @@ const RelationshipTypeSettings = () => { >; const saveSettings = (updatedRelationTypes: DiscourseRelationType[]) => { - const newErrors: Record = {}; - - // Validate only complete types (ones with all required fields) + const newErrors = getRelationTypeErrors(updatedRelationTypes); const completeTypes = updatedRelationTypes.filter( (rt) => rt.id && rt.label && rt.complement, ); - // Check for duplicate labels - const seenLabels = new Map(); - for (const rt of completeTypes) { - const idx = updatedRelationTypes.indexOf(rt); - const prev = seenLabels.get(rt.label); - if (prev !== undefined) { - newErrors[idx] = `Duplicate label "${rt.label}"`; - if (!newErrors[prev]) newErrors[prev] = `Duplicate label "${rt.label}"`; - } - seenLabels.set(rt.label, idx); - } - - // Check for duplicate complements - const seenComplements = new Map(); - for (const rt of completeTypes) { - const idx = updatedRelationTypes.indexOf(rt); - const prev = seenComplements.get(rt.complement); - if (prev !== undefined) { - newErrors[idx] = `Duplicate complement "${rt.complement}"`; - if (!newErrors[prev]) - newErrors[prev] = `Duplicate complement "${rt.complement}"`; - } - seenComplements.set(rt.complement, idx); - } - setErrors(newErrors); if (Object.keys(newErrors).length > 0) return; diff --git a/apps/obsidian/src/components/canvas/CreateRelationTypeModal.tsx b/apps/obsidian/src/components/canvas/CreateRelationTypeModal.tsx new file mode 100644 index 0000000000..be732b863f --- /dev/null +++ b/apps/obsidian/src/components/canvas/CreateRelationTypeModal.tsx @@ -0,0 +1,146 @@ +import { App, Modal } from "obsidian"; +import { StrictMode, useState } from "react"; +import { createRoot, Root } from "react-dom/client"; +import { ColorPicker } from "~/components/RelationshipTypeSettings"; +import type { DiscourseRelationType } from "~/types"; +import { + DEFAULT_TLDRAW_COLOR, + type TldrawColorName, +} from "~/utils/tldrawColors"; +import { getRelationTypeErrors } from "~/utils/typeUtils"; + +type RelationTypeFields = { + label: string; + complement: string; + color: TldrawColorName; +}; + +type CreateRelationTypeFormProps = { + relationTypes: DiscourseRelationType[]; + onSubmit: (fields: RelationTypeFields) => Promise; + onClose: () => void; +}; + +const CreateRelationTypeForm = ({ + relationTypes, + onSubmit, + onClose, +}: CreateRelationTypeFormProps) => { + const [label, setLabel] = useState(""); + const [complement, setComplement] = useState(""); + const [color, setColor] = useState(DEFAULT_TLDRAW_COLOR); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async () => { + const candidate = { + id: "new", + label, + complement, + color, + created: 0, + modified: 0, + }; + const nextError = !label + ? "Label is required" + : !complement + ? "Complement is required" + : (getRelationTypeErrors([...relationTypes, candidate])[ + relationTypes.length + ] ?? ""); + setError(nextError); + if (nextError) return; + + setIsSubmitting(true); + try { + await onSubmit({ label, complement, color }); + } catch { + setError("Couldn't save the relation type. Try again."); + setIsSubmitting(false); + return; + } + onClose(); + }; + + const inputClassName = `w-full ${error ? "input-error" : ""}`; + + return ( +
{ + e.preventDefault(); + void handleSubmit(); + }} + > + setLabel(e.target.value)} + autoFocus + /> + setComplement(e.target.value)} + /> + + {error &&
{error}
} +
+ + +
+ + ); +}; + +type CreateRelationTypeModalProps = { + relationTypes: DiscourseRelationType[]; + onSubmit: (fields: RelationTypeFields) => Promise; + onClose: () => void; +}; + +export class CreateRelationTypeModal extends Modal { + private root: Root | null = null; + private props: CreateRelationTypeModalProps; + + constructor(app: App, props: CreateRelationTypeModalProps) { + super(app); + this.props = props; + } + + onOpen() { + this.setTitle("Add relation type"); + // The colour list is absolutely positioned and extends past this short form + this.modalEl.addClass("overflow-visible"); + this.root = createRoot(this.contentEl); + this.root.render( + + this.close()} + /> + , + ); + } + + onClose() { + this.root?.unmount(); + this.root = null; + this.contentEl.empty(); + this.props.onClose(); + } +} diff --git a/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx b/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx index 476de637f1..39f9a512c8 100644 --- a/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx +++ b/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx @@ -14,8 +14,10 @@ import { getArrowBindings, getArrowInfo, } from "~/components/canvas/utils/relationUtils"; +import { CreateRelationTypeModal } from "~/components/canvas/CreateRelationTypeModal"; import { associateRelationTypeWithNodePair, + createRelationTypeForNodePair, getAssociableRelationTypesForNodePair, getDiscourseNodeTypeId, getValidRelationTypesForNodePair, @@ -45,6 +47,8 @@ export const RelationTypeDropdown = ({ const [isAddMenuOpen, setIsAddMenuOpen] = useState(false); const [isPickingExisting, setIsPickingExisting] = useState(false); const [isSaving, setIsSaving] = useState(false); + // The dialog sits outside dropdownRef, so its clicks and Escape must not dismiss the menu + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const arrow = useValue( "dropdownArrow", @@ -157,6 +161,7 @@ export const RelationTypeDropdown = ({ // Handle click outside useEffect(() => { const handlePointerDown = (e: PointerEvent) => { + if (isCreateDialogOpen) return; if ( dropdownRef.current && !dropdownRef.current.contains(e.target as Node) @@ -174,12 +179,12 @@ export const RelationTypeDropdown = ({ clearTimeout(timer); window.removeEventListener("pointerdown", handlePointerDown, true); }; - }, [onDismiss]); + }, [isCreateDialogOpen, onDismiss]); // Handle Escape key: leave the picker or add menu first, then the dropdown useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key !== "Escape") return; + if (e.key !== "Escape" || isCreateDialogOpen) return; if (isPickingExisting) { e.stopPropagation(); setIsPickingExisting(false); @@ -194,7 +199,7 @@ export const RelationTypeDropdown = ({ }; window.addEventListener("keydown", handleKeyDown, true); return () => window.removeEventListener("keydown", handleKeyDown, true); - }, [isAddMenuOpen, isPickingExisting, onDismiss]); + }, [isAddMenuOpen, isCreateDialogOpen, isPickingExisting, onDismiss]); const handleSelect = useCallback( (relationTypeId: string) => { @@ -203,6 +208,23 @@ export const RelationTypeDropdown = ({ [onSelect], ); + const showRelationAddedToast = useCallback( + (relationTypeLabel: string) => { + if (!nodePair) return; + const sourceName = + getNodeTypeById(plugin, nodePair.sourceNodeTypeId)?.name ?? "source"; + const targetName = + getNodeTypeById(plugin, nodePair.targetNodeTypeId)?.name ?? "target"; + showToast({ + severity: "success", + title: "Discourse relation added", + description: `${relationTypeLabel} relation added for ${sourceName} and ${targetName}`, + targetCanvasId: canvasPath, + }); + }, + [nodePair, plugin, canvasPath], + ); + const handleAssociate = useCallback( async (relationType: DiscourseRelationType) => { if (!nodePair || isSaving) return; @@ -224,20 +246,34 @@ export const RelationTypeDropdown = ({ setIsSaving(false); } setIsPickingExisting(false); - const sourceName = - getNodeTypeById(plugin, nodePair.sourceNodeTypeId)?.name ?? "source"; - const targetName = - getNodeTypeById(plugin, nodePair.targetNodeTypeId)?.name ?? "target"; - showToast({ - severity: "success", - title: "Discourse relation added", - description: `${relationType.label} relation added for ${sourceName} and ${targetName}`, - targetCanvasId: canvasPath, - }); + showRelationAddedToast(relationType.label); }, - [nodePair, isSaving, plugin, canvasPath], + [nodePair, isSaving, plugin, canvasPath, showRelationAddedToast], ); + const handleCreateNew = useCallback(() => { + if (!nodePair) return; + setIsAddMenuOpen(false); + setIsCreateDialogOpen(true); + new CreateRelationTypeModal(plugin.app, { + relationTypes: plugin.settings.relationTypes, + onSubmit: async (fields) => { + const relationType = await createRelationTypeForNodePair({ + plugin, + ...fields, + ...nodePair, + }); + showToast({ + severity: "success", + title: "Relation type created", + targetCanvasId: canvasPath, + }); + showRelationAddedToast(relationType.label); + }, + onClose: () => setIsCreateDialogOpen(false), + }).open(); + }, [nodePair, plugin, canvasPath, showRelationAddedToast]); + if (!dropdownPosition || !arrow) return null; const centre = menuSize @@ -262,7 +298,9 @@ export const RelationTypeDropdown = ({ > Add existing… - + ); const hasRelationTypes = validRelationTypes.length > 0; diff --git a/apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts b/apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts index c03d69bcde..45e99f1c9b 100644 --- a/apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts +++ b/apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts @@ -3,7 +3,7 @@ import type { DiscourseNodeShape } from "~/components/canvas/shapes/DiscourseNod import type DiscourseGraphPlugin from "~/index"; import type { DiscourseRelation, DiscourseRelationType } from "~/types"; import generateUid from "~/utils/generateUid"; -import { COLOR_PALETTE } from "~/utils/tldrawColors"; +import { COLOR_PALETTE, type TldrawColorName } from "~/utils/tldrawColors"; import { isAcceptedSchema } from "~/utils/typeUtils"; export const isDiscourseNodeShape = ( @@ -205,6 +205,50 @@ export const associateRelationTypeWithNodePair = async ({ } }; +/** + * Creates a relation type, makes it valid for a source → target node pair and + * saves once. Restores the previous types and relations if the save fails. + */ +export const createRelationTypeForNodePair = async ({ + plugin, + label, + complement, + color, + sourceNodeTypeId, + targetNodeTypeId, +}: { + plugin: DiscourseGraphPlugin; + label: string; + complement: string; + color: TldrawColorName; + sourceNodeTypeId: string; + targetNodeTypeId: string; +}): Promise => { + const now = Date.now(); + const relationType: DiscourseRelationType = { + id: generateUid("rel"), + label, + complement, + color, + created: now, + modified: now, + }; + const previousRelationTypes = plugin.settings.relationTypes; + plugin.settings.relationTypes = [...previousRelationTypes, relationType]; + try { + await associateRelationTypeWithNodePair({ + plugin, + relationTypeId: relationType.id, + sourceNodeTypeId, + targetNodeTypeId, + }); + } catch (error) { + plugin.settings.relationTypes = previousRelationTypes; + throw error; + } + return relationType; +}; + /** * Checks whether a specific relation type can connect the given source and * target node types (in either direction). diff --git a/apps/obsidian/src/components/canvas/utils/toastUtils.ts b/apps/obsidian/src/components/canvas/utils/toastUtils.ts index 8b6c34cb17..d85a1d5c77 100644 --- a/apps/obsidian/src/components/canvas/utils/toastUtils.ts +++ b/apps/obsidian/src/components/canvas/utils/toastUtils.ts @@ -1,5 +1,6 @@ import { TLUiToast } from "tldraw"; import { dispatchToastEvent } from "~/components/canvas/ToastListener"; +import generateUid from "~/utils/generateUid"; export const showToast = ({ severity, @@ -13,11 +14,12 @@ export const showToast = ({ targetCanvasId?: string; }) => { const toast: TLUiToast = { - id: `${severity}-${Date.now()}`, + // tldraw replaces a toast with the same id, so toasts shown together need unique ids + id: generateUid(severity), title, description, severity, keepOpen: false, }; dispatchToastEvent(toast, targetCanvasId); -}; \ No newline at end of file +}; diff --git a/apps/obsidian/src/utils/typeUtils.ts b/apps/obsidian/src/utils/typeUtils.ts index 9540ea81f1..077db83e7c 100644 --- a/apps/obsidian/src/utils/typeUtils.ts +++ b/apps/obsidian/src/utils/typeUtils.ts @@ -95,3 +95,41 @@ export const getUserNameById = ( ): string => { return (plugin.settings.userNames || {})[id] || `user ${id}`; }; + +/** + * Flags relation types whose label or complement repeats another's, keyed by + * index. Types missing a label or complement are skipped. + */ +export const getRelationTypeErrors = ( + relationTypes: DiscourseRelationType[], +): Record => { + const errors: Record = {}; + const completeTypes = relationTypes.filter( + (rt) => rt.id && rt.label && rt.complement, + ); + + const seenLabels = new Map(); + for (const rt of completeTypes) { + const idx = relationTypes.indexOf(rt); + const prev = seenLabels.get(rt.label); + if (prev !== undefined) { + errors[idx] = `Duplicate label "${rt.label}"`; + if (!errors[prev]) errors[prev] = `Duplicate label "${rt.label}"`; + } + seenLabels.set(rt.label, idx); + } + + const seenComplements = new Map(); + for (const rt of completeTypes) { + const idx = relationTypes.indexOf(rt); + const prev = seenComplements.get(rt.complement); + if (prev !== undefined) { + errors[idx] = `Duplicate complement "${rt.complement}"`; + if (!errors[prev]) + errors[prev] = `Duplicate complement "${rt.complement}"`; + } + seenComplements.set(rt.complement, idx); + } + + return errors; +}; From a9665b1bd0112ae8e1a1f9dbe26cad6a8aaa28b9 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 28 Sep 2026 10:22:54 -0400 Subject: [PATCH 2/7] ENG-2258 Add unit tests for relation type creation and validation Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M3M6BDTS4G1NYZCH0QMDPXG8 --- .../utils/__tests__/relationTypeUtils.test.ts | 58 +++++++++++++++ .../src/utils/__tests__/typeUtils.test.ts | 70 +++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 apps/obsidian/src/utils/__tests__/typeUtils.test.ts diff --git a/apps/obsidian/src/components/canvas/utils/__tests__/relationTypeUtils.test.ts b/apps/obsidian/src/components/canvas/utils/__tests__/relationTypeUtils.test.ts index b3b73a76c7..5b00f41e6c 100644 --- a/apps/obsidian/src/components/canvas/utils/__tests__/relationTypeUtils.test.ts +++ b/apps/obsidian/src/components/canvas/utils/__tests__/relationTypeUtils.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type DiscourseGraphPlugin from "~/index"; import { associateRelationTypeWithNodePair, + createRelationTypeForNodePair, getAssociableRelationTypesForNodePair, } from "~/components/canvas/utils/relationTypeUtils"; import type { DiscourseRelation, DiscourseRelationType } from "~/types"; @@ -141,3 +142,60 @@ describe("associateRelationTypeWithNodePair", () => { expect(plugin.settings.discourseRelations).toEqual(existing); }); }); + +describe("createRelationTypeForNodePair", () => { + const setup = (saveSettings: () => Promise) => { + const existingTypes = [relationType("supports")]; + const existingRelations = [relation("supports", "evidence", "claim")]; + const plugin = { + settings: { + relationTypes: existingTypes, + discourseRelations: existingRelations, + }, + saveSettings: vi.fn(saveSettings), + }; + return { + existingTypes, + existingRelations, + plugin, + create: () => + createRelationTypeForNodePair({ + plugin: plugin as unknown as DiscourseGraphPlugin, + label: "informs", + complement: "is informed by", + color: "blue", + sourceNodeTypeId: "question", + targetNodeTypeId: "claim", + }), + }; + }; + + it("saves a local type and its source → target relation once", async () => { + const { plugin, create } = setup(() => Promise.resolve()); + const created = await create(); + + expect(created).toMatchObject({ + label: "informs", + complement: "is informed by", + color: "blue", + }); + expect(created.id).toMatch(/^rel_/); + expect(created).not.toHaveProperty("status"); + expect(plugin.settings.relationTypes.at(-1)).toBe(created); + expect(plugin.settings.discourseRelations.at(-1)).toMatchObject({ + sourceId: "question", + destinationId: "claim", + relationshipTypeId: created.id, + }); + expect(plugin.saveSettings).toHaveBeenCalledTimes(1); + }); + + it("restores the previous types and relations when saving fails", async () => { + const { existingTypes, existingRelations, plugin, create } = setup(() => + Promise.reject(new Error("disk full")), + ); + await expect(create()).rejects.toThrow("disk full"); + expect(plugin.settings.relationTypes).toBe(existingTypes); + expect(plugin.settings.discourseRelations).toBe(existingRelations); + }); +}); diff --git a/apps/obsidian/src/utils/__tests__/typeUtils.test.ts b/apps/obsidian/src/utils/__tests__/typeUtils.test.ts new file mode 100644 index 0000000000..e3a37c5156 --- /dev/null +++ b/apps/obsidian/src/utils/__tests__/typeUtils.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import type { DiscourseRelationType } from "~/types"; +import { getRelationTypeErrors } from "~/utils/typeUtils"; + +const relationType = ( + label: string, + complement: string, +): DiscourseRelationType => ({ + id: `${label}-${complement}`, + label, + complement, + color: "black", + created: 0, + modified: 0, +}); + +describe("getRelationTypeErrors", () => { + it("returns no errors for distinct types", () => { + expect( + getRelationTypeErrors([ + relationType("supports", "is supported by"), + relationType("opposes", "is opposed by"), + ]), + ).toEqual({}); + }); + + it("flags both types sharing a label", () => { + expect( + getRelationTypeErrors([ + relationType("supports", "is supported by"), + relationType("opposes", "is opposed by"), + relationType("supports", "backs"), + ]), + ).toEqual({ + 0: 'Duplicate label "supports"', + 2: 'Duplicate label "supports"', + }); + }); + + it("flags both types sharing a complement", () => { + expect( + getRelationTypeErrors([ + relationType("supports", "is supported by"), + relationType("backs", "is supported by"), + ]), + ).toEqual({ + 0: 'Duplicate complement "is supported by"', + 1: 'Duplicate complement "is supported by"', + }); + }); + + it("skips types missing a label or complement", () => { + expect( + getRelationTypeErrors([ + relationType("supports", "is supported by"), + relationType("supports", ""), + relationType("", "is supported by"), + ]), + ).toEqual({}); + }); + + it("matches labels exactly, without trimming or case folding", () => { + expect( + getRelationTypeErrors([ + relationType("supports", "is supported by"), + relationType("Supports ", "Is supported by"), + ]), + ).toEqual({}); + }); +}); From ac2f41417ddfba651a20ee8f51a1f7731543b116 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 28 Sep 2026 10:31:57 -0400 Subject: [PATCH 3/7] ENG-2258 Share the relation type props and completeness check, reject blank fields Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M3M6VZS14XEE8NRP3SYVAH0K --- .../components/RelationshipTypeSettings.tsx | 5 ++--- .../canvas/CreateRelationTypeModal.tsx | 21 +++++++------------ apps/obsidian/src/utils/typeUtils.ts | 7 ++++--- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/apps/obsidian/src/components/RelationshipTypeSettings.tsx b/apps/obsidian/src/components/RelationshipTypeSettings.tsx index 1811c9216e..a4f7a6065c 100644 --- a/apps/obsidian/src/components/RelationshipTypeSettings.tsx +++ b/apps/obsidian/src/components/RelationshipTypeSettings.tsx @@ -16,6 +16,7 @@ import { getImportInfo, formatImportSource, getRelationTypeErrors, + isCompleteRelationType, isProvisionalSchema, } from "~/utils/typeUtils"; import ImportedSchemaMeta from "./ImportedSchemaMeta"; @@ -120,9 +121,7 @@ const RelationshipTypeSettings = () => { const saveSettings = (updatedRelationTypes: DiscourseRelationType[]) => { const newErrors = getRelationTypeErrors(updatedRelationTypes); - const completeTypes = updatedRelationTypes.filter( - (rt) => rt.id && rt.label && rt.complement, - ); + const completeTypes = updatedRelationTypes.filter(isCompleteRelationType); setErrors(newErrors); if (Object.keys(newErrors).length > 0) return; diff --git a/apps/obsidian/src/components/canvas/CreateRelationTypeModal.tsx b/apps/obsidian/src/components/canvas/CreateRelationTypeModal.tsx index be732b863f..fe00ba3996 100644 --- a/apps/obsidian/src/components/canvas/CreateRelationTypeModal.tsx +++ b/apps/obsidian/src/components/canvas/CreateRelationTypeModal.tsx @@ -15,7 +15,8 @@ type RelationTypeFields = { color: TldrawColorName; }; -type CreateRelationTypeFormProps = { +// Shared by the form and the modal; each closes its own way +type CreateRelationTypeProps = { relationTypes: DiscourseRelationType[]; onSubmit: (fields: RelationTypeFields) => Promise; onClose: () => void; @@ -25,7 +26,7 @@ const CreateRelationTypeForm = ({ relationTypes, onSubmit, onClose, -}: CreateRelationTypeFormProps) => { +}: CreateRelationTypeProps) => { const [label, setLabel] = useState(""); const [complement, setComplement] = useState(""); const [color, setColor] = useState(DEFAULT_TLDRAW_COLOR); @@ -41,9 +42,9 @@ const CreateRelationTypeForm = ({ created: 0, modified: 0, }; - const nextError = !label + const nextError = !label.trim() ? "Label is required" - : !complement + : !complement.trim() ? "Complement is required" : (getRelationTypeErrors([...relationTypes, candidate])[ relationTypes.length @@ -106,24 +107,18 @@ const CreateRelationTypeForm = ({ ); }; -type CreateRelationTypeModalProps = { - relationTypes: DiscourseRelationType[]; - onSubmit: (fields: RelationTypeFields) => Promise; - onClose: () => void; -}; - export class CreateRelationTypeModal extends Modal { private root: Root | null = null; - private props: CreateRelationTypeModalProps; + private props: CreateRelationTypeProps; - constructor(app: App, props: CreateRelationTypeModalProps) { + constructor(app: App, props: CreateRelationTypeProps) { super(app); this.props = props; } onOpen() { this.setTitle("Add relation type"); - // The colour list is absolutely positioned and extends past this short form + // The color list is absolutely positioned and extends past this short form this.modalEl.addClass("overflow-visible"); this.root = createRoot(this.contentEl); this.root.render( diff --git a/apps/obsidian/src/utils/typeUtils.ts b/apps/obsidian/src/utils/typeUtils.ts index 077db83e7c..d9b8fbaa30 100644 --- a/apps/obsidian/src/utils/typeUtils.ts +++ b/apps/obsidian/src/utils/typeUtils.ts @@ -96,6 +96,9 @@ export const getUserNameById = ( return (plugin.settings.userNames || {})[id] || `user ${id}`; }; +export const isCompleteRelationType = (rt: DiscourseRelationType): boolean => + !!rt.id && !!rt.label && !!rt.complement; + /** * Flags relation types whose label or complement repeats another's, keyed by * index. Types missing a label or complement are skipped. @@ -104,9 +107,7 @@ export const getRelationTypeErrors = ( relationTypes: DiscourseRelationType[], ): Record => { const errors: Record = {}; - const completeTypes = relationTypes.filter( - (rt) => rt.id && rt.label && rt.complement, - ); + const completeTypes = relationTypes.filter(isCompleteRelationType); const seenLabels = new Map(); for (const rt of completeTypes) { From 9804c988ca7705d92a313d4a4545c9de69b3eadd Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 28 Sep 2026 10:36:48 -0400 Subject: [PATCH 4/7] ENG-2258 Remove only the created relation type when its save fails Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M3M74W73JE6NCMK1E3EE6WPP --- .../utils/__tests__/relationTypeUtils.test.ts | 22 ++++++++++++++++--- .../canvas/utils/relationTypeUtils.ts | 13 +++++++---- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/apps/obsidian/src/components/canvas/utils/__tests__/relationTypeUtils.test.ts b/apps/obsidian/src/components/canvas/utils/__tests__/relationTypeUtils.test.ts index 5b00f41e6c..856bf13246 100644 --- a/apps/obsidian/src/components/canvas/utils/__tests__/relationTypeUtils.test.ts +++ b/apps/obsidian/src/components/canvas/utils/__tests__/relationTypeUtils.test.ts @@ -190,12 +190,28 @@ describe("createRelationTypeForNodePair", () => { expect(plugin.saveSettings).toHaveBeenCalledTimes(1); }); - it("restores the previous types and relations when saving fails", async () => { + it("removes only the added type and relation when saving fails", async () => { const { existingTypes, existingRelations, plugin, create } = setup(() => Promise.reject(new Error("disk full")), ); await expect(create()).rejects.toThrow("disk full"); - expect(plugin.settings.relationTypes).toBe(existingTypes); - expect(plugin.settings.discourseRelations).toBe(existingRelations); + expect(plugin.settings.relationTypes).toEqual(existingTypes); + expect(plugin.settings.discourseRelations).toEqual(existingRelations); + }); + + it("keeps a type added while its save was pending when saving fails", async () => { + const concurrent = relationType("concurrent"); + const { existingTypes, plugin, create } = setup(() => { + plugin.settings.relationTypes = [ + ...plugin.settings.relationTypes, + concurrent, + ]; + return Promise.reject(new Error("disk full")); + }); + await expect(create()).rejects.toThrow("disk full"); + expect(plugin.settings.relationTypes).toEqual([ + ...existingTypes, + concurrent, + ]); }); }); diff --git a/apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts b/apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts index 45e99f1c9b..2e24af1906 100644 --- a/apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts +++ b/apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts @@ -207,7 +207,7 @@ export const associateRelationTypeWithNodePair = async ({ /** * Creates a relation type, makes it valid for a source → target node pair and - * saves once. Restores the previous types and relations if the save fails. + * saves once. Removes the added type and relation again if the save fails. */ export const createRelationTypeForNodePair = async ({ plugin, @@ -233,8 +233,10 @@ export const createRelationTypeForNodePair = async ({ created: now, modified: now, }; - const previousRelationTypes = plugin.settings.relationTypes; - plugin.settings.relationTypes = [...previousRelationTypes, relationType]; + plugin.settings.relationTypes = [ + ...plugin.settings.relationTypes, + relationType, + ]; try { await associateRelationTypeWithNodePair({ plugin, @@ -243,7 +245,10 @@ export const createRelationTypeForNodePair = async ({ targetNodeTypeId, }); } catch (error) { - plugin.settings.relationTypes = previousRelationTypes; + // Filter by id, as the association helper does, so concurrent saves survive + plugin.settings.relationTypes = plugin.settings.relationTypes.filter( + ({ id }) => id !== relationType.id, + ); throw error; } return relationType; From 8b467b7a1295d6308809d41bf1a7eb83e73f18b3 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 28 Sep 2026 10:37:26 -0400 Subject: [PATCH 5/7] ENG-2258 Drop a restating comment Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M3M7620BA5K31EABR0WR01QD --- apps/obsidian/src/components/canvas/CreateRelationTypeModal.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/obsidian/src/components/canvas/CreateRelationTypeModal.tsx b/apps/obsidian/src/components/canvas/CreateRelationTypeModal.tsx index fe00ba3996..5349808244 100644 --- a/apps/obsidian/src/components/canvas/CreateRelationTypeModal.tsx +++ b/apps/obsidian/src/components/canvas/CreateRelationTypeModal.tsx @@ -15,7 +15,6 @@ type RelationTypeFields = { color: TldrawColorName; }; -// Shared by the form and the modal; each closes its own way type CreateRelationTypeProps = { relationTypes: DiscourseRelationType[]; onSubmit: (fields: RelationTypeFields) => Promise; From 4992cb537163d49cf45018fcacf72c188bb34842 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 28 Sep 2026 10:41:11 -0400 Subject: [PATCH 6/7] ENG-2258 Trim relation type fields before validating and saving Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M3M7CXKYNM6GGRCNG14QVM0J --- .../canvas/CreateRelationTypeModal.tsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/apps/obsidian/src/components/canvas/CreateRelationTypeModal.tsx b/apps/obsidian/src/components/canvas/CreateRelationTypeModal.tsx index 5349808244..3426c7502e 100644 --- a/apps/obsidian/src/components/canvas/CreateRelationTypeModal.tsx +++ b/apps/obsidian/src/components/canvas/CreateRelationTypeModal.tsx @@ -33,17 +33,16 @@ const CreateRelationTypeForm = ({ const [isSubmitting, setIsSubmitting] = useState(false); const handleSubmit = async () => { - const candidate = { - id: "new", - label, - complement, + // Trimmed so "supports " can't pass as a second "supports" + const fields = { + label: label.trim(), + complement: complement.trim(), color, - created: 0, - modified: 0, }; - const nextError = !label.trim() + const candidate = { id: "new", ...fields, created: 0, modified: 0 }; + const nextError = !fields.label ? "Label is required" - : !complement.trim() + : !fields.complement ? "Complement is required" : (getRelationTypeErrors([...relationTypes, candidate])[ relationTypes.length @@ -53,7 +52,7 @@ const CreateRelationTypeForm = ({ setIsSubmitting(true); try { - await onSubmit({ label, complement, color }); + await onSubmit(fields); } catch { setError("Couldn't save the relation type. Try again."); setIsSubmitting(false); From 6a2e3a4c0c7a777f3267d01ca568c3149b190e0b Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 28 Sep 2026 10:49:25 -0400 Subject: [PATCH 7/7] ENG-2258 Validate against current relation types and keep the dialog open while saving Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M3M7VZSCCEHXWPEKJX8709X7 --- .../canvas/CreateRelationTypeModal.tsx | 23 +++++++++++++++---- .../canvas/overlays/RelationTypeDropdown.tsx | 2 +- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/obsidian/src/components/canvas/CreateRelationTypeModal.tsx b/apps/obsidian/src/components/canvas/CreateRelationTypeModal.tsx index 3426c7502e..46f3bea997 100644 --- a/apps/obsidian/src/components/canvas/CreateRelationTypeModal.tsx +++ b/apps/obsidian/src/components/canvas/CreateRelationTypeModal.tsx @@ -16,13 +16,14 @@ type RelationTypeFields = { }; type CreateRelationTypeProps = { - relationTypes: DiscourseRelationType[]; + // Read at submit: another canvas may add a type while the dialog is open + getRelationTypes: () => DiscourseRelationType[]; onSubmit: (fields: RelationTypeFields) => Promise; onClose: () => void; }; const CreateRelationTypeForm = ({ - relationTypes, + getRelationTypes, onSubmit, onClose, }: CreateRelationTypeProps) => { @@ -40,6 +41,7 @@ const CreateRelationTypeForm = ({ color, }; const candidate = { id: "new", ...fields, created: 0, modified: 0 }; + const relationTypes = getRelationTypes(); const nextError = !fields.label ? "Label is required" : !fields.complement @@ -108,12 +110,18 @@ const CreateRelationTypeForm = ({ export class CreateRelationTypeModal extends Modal { private root: Root | null = null; private props: CreateRelationTypeProps; + private isSaving = false; constructor(app: App, props: CreateRelationTypeProps) { super(app); this.props = props; } + // Escape and the backdrop call close(); a pending save must finish first + close() { + if (!this.isSaving) super.close(); + } + onOpen() { this.setTitle("Add relation type"); // The color list is absolutely positioned and extends past this short form @@ -122,8 +130,15 @@ export class CreateRelationTypeModal extends Modal { this.root.render( { + this.isSaving = true; + try { + await this.props.onSubmit(fields); + } finally { + this.isSaving = false; + } + }} onClose={() => this.close()} /> , diff --git a/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx b/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx index 39f9a512c8..290971730e 100644 --- a/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx +++ b/apps/obsidian/src/components/canvas/overlays/RelationTypeDropdown.tsx @@ -256,7 +256,7 @@ export const RelationTypeDropdown = ({ setIsAddMenuOpen(false); setIsCreateDialogOpen(true); new CreateRelationTypeModal(plugin.app, { - relationTypes: plugin.settings.relationTypes, + getRelationTypes: () => plugin.settings.relationTypes, onSubmit: async (fields) => { const relationType = await createRelationTypeForNodePair({ plugin,