diff --git a/apps/obsidian/src/components/RelationshipTypeSettings.tsx b/apps/obsidian/src/components/RelationshipTypeSettings.tsx index 9f51e81839..a4f7a6065c 100644 --- a/apps/obsidian/src/components/RelationshipTypeSettings.tsx +++ b/apps/obsidian/src/components/RelationshipTypeSettings.tsx @@ -15,6 +15,8 @@ import { getContrastColor } from "~/utils/colorUtils"; import { getImportInfo, formatImportSource, + getRelationTypeErrors, + isCompleteRelationType, isProvisionalSchema, } from "~/utils/typeUtils"; import ImportedSchemaMeta from "./ImportedSchemaMeta"; @@ -25,7 +27,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,37 +120,8 @@ const RelationshipTypeSettings = () => { >; const saveSettings = (updatedRelationTypes: DiscourseRelationType[]) => { - const newErrors: Record = {}; - - // Validate only complete types (ones with all required fields) - 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); - } + const newErrors = getRelationTypeErrors(updatedRelationTypes); + 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 new file mode 100644 index 0000000000..46f3bea997 --- /dev/null +++ b/apps/obsidian/src/components/canvas/CreateRelationTypeModal.tsx @@ -0,0 +1,154 @@ +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 CreateRelationTypeProps = { + // Read at submit: another canvas may add a type while the dialog is open + getRelationTypes: () => DiscourseRelationType[]; + onSubmit: (fields: RelationTypeFields) => Promise; + onClose: () => void; +}; + +const CreateRelationTypeForm = ({ + getRelationTypes, + onSubmit, + onClose, +}: CreateRelationTypeProps) => { + 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 () => { + // Trimmed so "supports " can't pass as a second "supports" + const fields = { + label: label.trim(), + complement: complement.trim(), + color, + }; + const candidate = { id: "new", ...fields, created: 0, modified: 0 }; + const relationTypes = getRelationTypes(); + const nextError = !fields.label + ? "Label is required" + : !fields.complement + ? "Complement is required" + : (getRelationTypeErrors([...relationTypes, candidate])[ + relationTypes.length + ] ?? ""); + setError(nextError); + if (nextError) return; + + setIsSubmitting(true); + try { + await onSubmit(fields); + } 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}
} +
+ + +
+ + ); +}; + +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 + this.modalEl.addClass("overflow-visible"); + this.root = createRoot(this.contentEl); + this.root.render( + + { + this.isSaving = true; + try { + await this.props.onSubmit(fields); + } finally { + this.isSaving = false; + } + }} + onClose={() => 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..290971730e 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, { + getRelationTypes: () => 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/__tests__/relationTypeUtils.test.ts b/apps/obsidian/src/components/canvas/utils/__tests__/relationTypeUtils.test.ts index b3b73a76c7..856bf13246 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,76 @@ 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("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).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 c03d69bcde..2e24af1906 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,55 @@ export const associateRelationTypeWithNodePair = async ({ } }; +/** + * Creates a relation type, makes it valid for a source → target node pair and + * saves once. Removes the added type and relation again 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, + }; + plugin.settings.relationTypes = [ + ...plugin.settings.relationTypes, + relationType, + ]; + try { + await associateRelationTypeWithNodePair({ + plugin, + relationTypeId: relationType.id, + sourceNodeTypeId, + targetNodeTypeId, + }); + } catch (error) { + // 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; +}; + /** * 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/__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({}); + }); +}); diff --git a/apps/obsidian/src/utils/typeUtils.ts b/apps/obsidian/src/utils/typeUtils.ts index 9540ea81f1..d9b8fbaa30 100644 --- a/apps/obsidian/src/utils/typeUtils.ts +++ b/apps/obsidian/src/utils/typeUtils.ts @@ -95,3 +95,42 @@ export const getUserNameById = ( ): string => { 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. + */ +export const getRelationTypeErrors = ( + relationTypes: DiscourseRelationType[], +): Record => { + const errors: Record = {}; + const completeTypes = relationTypes.filter(isCompleteRelationType); + + 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; +};