-
-
+
+ {isPickingExisting && (
+
- {hasRelationTypes
- ? validRelationTypes.map((rt) => (
- handleSelect(rt.id)}
- className="flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-2 py-1.5 text-left text-sm text-gray-700 hover:bg-gray-100"
- >
-
- {rt.label}
-
- ))
- : addActions}
+ {isPickingExisting ? pickerContent : listContent}
{/* Outside the scroll container so it isn't clipped, inside dropdownRef so clicks don't dismiss */}
{hasRelationTypes && isAddMenuOpen && (
diff --git a/apps/obsidian/src/components/canvas/utils/__tests__/relationTypeUtils.test.ts b/apps/obsidian/src/components/canvas/utils/__tests__/relationTypeUtils.test.ts
new file mode 100644
index 0000000000..b3b73a76c7
--- /dev/null
+++ b/apps/obsidian/src/components/canvas/utils/__tests__/relationTypeUtils.test.ts
@@ -0,0 +1,143 @@
+import { describe, expect, it, vi } from "vitest";
+import type DiscourseGraphPlugin from "~/index";
+import {
+ associateRelationTypeWithNodePair,
+ getAssociableRelationTypesForNodePair,
+} from "~/components/canvas/utils/relationTypeUtils";
+import type { DiscourseRelation, DiscourseRelationType } from "~/types";
+
+const relationType = (
+ id: string,
+ overrides: Partial
= {},
+): DiscourseRelationType => ({
+ id,
+ label: id,
+ complement: `${id} complement`,
+ color: "black",
+ created: 0,
+ modified: 0,
+ ...overrides,
+});
+
+const relation = (
+ relationshipTypeId: string,
+ sourceId: string,
+ destinationId: string,
+): DiscourseRelation => ({
+ id: `${relationshipTypeId}-${sourceId}-${destinationId}`,
+ relationshipTypeId,
+ sourceId,
+ destinationId,
+ created: 0,
+ modified: 0,
+});
+
+describe("getAssociableRelationTypesForNodePair", () => {
+ const relationTypes = [
+ relationType("supports"),
+ relationType("opposes"),
+ relationType("informs"),
+ relationType("provisional", {
+ importedFromRid: "rid:provisional",
+ status: "provisional",
+ }),
+ relationType("importedAccepted", {
+ importedFromRid: "rid:accepted",
+ status: "accepted",
+ }),
+ ];
+ const discourseRelations = [
+ relation("supports", "evidence", "claim"),
+ relation("opposes", "claim", "evidence"),
+ relation("informs", "evidence", "question"),
+ ];
+
+ const associableIds = (
+ relations: DiscourseRelation[],
+ sourceNodeTypeId: string,
+ targetNodeTypeId: string,
+ ): string[] =>
+ getAssociableRelationTypesForNodePair({
+ settings: { relationTypes, discourseRelations: relations },
+ sourceNodeTypeId,
+ targetNodeTypeId,
+ }).map(({ id }) => id);
+
+ it("excludes types valid for the pair in either direction", () => {
+ expect(associableIds(discourseRelations, "evidence", "claim")).toEqual([
+ "informs",
+ "importedAccepted",
+ ]);
+ });
+
+ it("offers every accepted type for a pair with no relations", () => {
+ expect(associableIds(discourseRelations, "question", "claim")).toEqual([
+ "supports",
+ "opposes",
+ "informs",
+ "importedAccepted",
+ ]);
+ });
+
+ it("excludes a type made valid by a provisional relation", () => {
+ const relations: DiscourseRelation[] = [
+ ...discourseRelations,
+ {
+ ...relation("informs", "claim", "evidence"),
+ importedFromRid: "rid:informs",
+ status: "provisional",
+ },
+ ];
+ expect(associableIds(relations, "evidence", "claim")).toEqual([
+ "importedAccepted",
+ ]);
+ });
+});
+
+describe("associateRelationTypeWithNodePair", () => {
+ const setup = (saveSettings: () => Promise) => {
+ const existing = [relation("supports", "evidence", "claim")];
+ const plugin = {
+ settings: { discourseRelations: existing },
+ saveSettings: vi.fn(saveSettings),
+ };
+ return {
+ existing,
+ plugin,
+ associate: () =>
+ associateRelationTypeWithNodePair({
+ plugin: plugin as unknown as DiscourseGraphPlugin,
+ relationTypeId: "informs",
+ sourceNodeTypeId: "question",
+ targetNodeTypeId: "claim",
+ }),
+ };
+ };
+
+ it("saves a new local source → target relation in a new array", async () => {
+ const { existing, plugin, associate } = setup(() => Promise.resolve());
+ await associate();
+
+ const relations = plugin.settings.discourseRelations;
+ const added = relations.at(-1);
+ expect(relations).not.toBe(existing);
+ expect(relations).toHaveLength(2);
+ expect(added).toMatchObject({
+ sourceId: "question",
+ destinationId: "claim",
+ relationshipTypeId: "informs",
+ });
+ expect(added?.id).toMatch(/^rel3_/);
+ expect(added).not.toHaveProperty("status");
+ expect(added).not.toHaveProperty("importedFromRid");
+ expect(plugin.saveSettings).toHaveBeenCalledTimes(1);
+ });
+
+ it("removes the added relation and rethrows when saving fails", async () => {
+ const { existing, plugin, associate } = setup(() =>
+ Promise.reject(new Error("disk full")),
+ );
+ await expect(associate()).rejects.toThrow("disk full");
+ expect(plugin.settings.discourseRelations).toEqual(existing);
+ });
+});
diff --git a/apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts b/apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts
index b18d81dcdf..c03d69bcde 100644
--- a/apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts
+++ b/apps/obsidian/src/components/canvas/utils/relationTypeUtils.ts
@@ -1,7 +1,10 @@
import type { Editor, TLShape, TLShapeId, VecLike } from "tldraw";
import type { DiscourseNodeShape } from "~/components/canvas/shapes/DiscourseNodeShape";
+import type DiscourseGraphPlugin from "~/index";
import type { DiscourseRelation, DiscourseRelationType } from "~/types";
+import generateUid from "~/utils/generateUid";
import { COLOR_PALETTE } from "~/utils/tldrawColors";
+import { isAcceptedSchema } from "~/utils/typeUtils";
export const isDiscourseNodeShape = (
shape: TLShape | null | undefined,
@@ -140,6 +143,68 @@ export const getValidRelationTypesForNodePair = ({
return validTypes;
};
+/**
+ * Returns the accepted relation types not yet valid for a node pair in either
+ * direction, which the "Add existing" picker offers.
+ */
+export const getAssociableRelationTypesForNodePair = ({
+ settings,
+ sourceNodeTypeId,
+ targetNodeTypeId,
+}: {
+ settings: RelationTypeSettings;
+ sourceNodeTypeId: string;
+ targetNodeTypeId: string;
+}): DiscourseRelationType[] =>
+ settings.relationTypes.filter((relationType) => {
+ if (!isAcceptedSchema(relationType)) return false;
+ const { direct, reverse } = getRelationDirection({
+ discourseRelations: settings.discourseRelations,
+ relationTypeId: relationType.id,
+ sourceNodeTypeId,
+ targetNodeTypeId,
+ });
+ return !direct && !reverse;
+ });
+
+/**
+ * Makes a relation type valid for a source → target node pair and saves it.
+ * Removes the added relation again if the save fails.
+ */
+export const associateRelationTypeWithNodePair = async ({
+ plugin,
+ relationTypeId,
+ sourceNodeTypeId,
+ targetNodeTypeId,
+}: {
+ plugin: DiscourseGraphPlugin;
+ relationTypeId: string;
+ sourceNodeTypeId: string;
+ targetNodeTypeId: string;
+}): Promise => {
+ const now = Date.now();
+ const relation: DiscourseRelation = {
+ id: generateUid("rel3"),
+ sourceId: sourceNodeTypeId,
+ destinationId: targetNodeTypeId,
+ relationshipTypeId: relationTypeId,
+ created: now,
+ modified: now,
+ };
+ plugin.settings.discourseRelations = [
+ ...plugin.settings.discourseRelations,
+ relation,
+ ];
+ try {
+ await plugin.saveSettings();
+ } catch (error) {
+ // Filter by id: another association may have saved while this one was pending
+ plugin.settings.discourseRelations =
+ plugin.settings.discourseRelations.filter(({ id }) => id !== relation.id);
+ throw error;
+ }
+};
+
/**
* Checks whether a specific relation type can connect the given source and
* target node types (in either direction).