diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 2fafb19ed0..cd0523abc8 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1074,6 +1074,7 @@ "edit_title": "Edit title", "rename_note": "Rename note", "enter_new_title": "Enter new note title:", + "rename_relation": "Rename relation", "remove_relation": "Remove relation", "confirm_remove_relation": "Are you sure you want to remove the relation?", "specify_new_relation_name": "Specify new relation name (allowed characters: alphanumeric, colon and underscore):", diff --git a/apps/client/src/widgets/type_widgets/relation_map/RelationMap.tsx b/apps/client/src/widgets/type_widgets/relation_map/RelationMap.tsx index 867fd44d39..8870331512 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/RelationMap.tsx +++ b/apps/client/src/widgets/type_widgets/relation_map/RelationMap.tsx @@ -8,13 +8,11 @@ import { HTMLProps } from "preact/compat"; import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; import FNote from "../../../entities/fnote"; -import attribute_autocomplete from "../../../services/attribute_autocomplete"; import dialog from "../../../services/dialog"; import { isExperimentalFeatureEnabled } from "../../../services/experimental_features"; import { t } from "../../../services/i18n"; import server from "../../../services/server"; import toast from "../../../services/toast"; -import utils from "../../../services/utils"; import ActionButton from "../../react/ActionButton"; import { useEditorSpacedUpdate, useTriliumEvent, useTriliumEvents } from "../../react/hooks"; import { TypeWidgetProps } from "../type_widget"; @@ -23,7 +21,7 @@ import { buildRelationContextMenuHandler } from "./context_menu"; import { JsPlumb } from "./jsplumb"; import { NoteBox } from "./NoteBox"; import setupOverlays, { uniDirectionalOverlays } from "./overlays"; -import { getMousePosition, getZoom, idToNoteId, noteIdToId } from "./utils"; +import { getMousePosition, getZoom, idToNoteId, noteIdToId, promptForRelationName } from "./utils"; const isNewLayout = isExperimentalFeatureEnabled("new-layout"); @@ -415,27 +413,7 @@ function useRelationCreation({ mapApiRef, jsPlumbApiRef }: { mapApiRef: RefObjec // if there's no event, then this has been triggered programmatically if (!originalEvent || !mapApiRef.current) return; - const name = await dialog.prompt({ - message: t("relation_map.specify_new_relation_name"), - shown: ({ $answer }) => { - if (!$answer) { - return; - } - - $answer.on("keyup", () => { - // invalid characters are simply ignored (from user perspective they are not even entered) - const attrName = utils.filterAttributeName($answer.val() as string); - - $answer.val(attrName); - }); - - attribute_autocomplete.initAttributeNameAutocomplete({ - $el: $answer, - attributeType: "relation", - open: true - }); - } - }); + const name = await promptForRelationName(); // Delete the newly created connection if the dialog was dismissed. if (!name || !name.trim()) { diff --git a/apps/client/src/widgets/type_widgets/relation_map/api.ts b/apps/client/src/widgets/type_widgets/relation_map/api.ts index f3e258db6c..10cacff6ed 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/api.ts +++ b/apps/client/src/widgets/type_widgets/relation_map/api.ts @@ -75,6 +75,29 @@ export default class RelationMapApi { this.onDataChange(true); } + async renameRelation(connection: Connection, newName: string) { + newName = utils.filterAttributeName(newName); + const relation = this.relations.find((rel) => rel.attributeId === connection.id); + + if (!relation) return false; + + // Check if a relation with the new name already exists between these notes. + const exists = this.relations.some( + (rel) => rel.sourceNoteId === relation.sourceNoteId && rel.targetNoteId === relation.targetNoteId && rel.name === newName + ); + if (exists) return false; + + await server.remove(`notes/${relation.sourceNoteId}/relations/${relation.name}/to/${relation.targetNoteId}`); + await server.put(`notes/${relation.sourceNoteId}/relations/${newName}/to/${relation.targetNoteId}`); + this.onDataChange(true); + return true; + } + + getRelationName(connection: Connection): string | undefined { + const relation = this.relations.find((rel) => rel.attributeId === connection.id); + return relation?.name; + } + cleanupOtherNotes(noteIds: string[]) { const filteredNotes = this.data.notes.filter((note) => noteIds.includes(note.noteId)); if (filteredNotes.length === this.data.notes.length) return; diff --git a/apps/client/src/widgets/type_widgets/relation_map/context_menu.ts b/apps/client/src/widgets/type_widgets/relation_map/context_menu.ts index 9f38ce8701..71d55ffc78 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/context_menu.ts +++ b/apps/client/src/widgets/type_widgets/relation_map/context_menu.ts @@ -9,6 +9,7 @@ import dialog from "../../../services/dialog"; import { t } from "../../../services/i18n"; import server from "../../../services/server"; import RelationMapApi from "./api"; +import { promptForRelationName } from "./utils"; export function buildNoteContextMenuHandler(note: FNote | null | undefined, mapApiRef: RefObject) { return (e: MouseEvent) => { @@ -73,9 +74,25 @@ export function buildRelationContextMenuHandler(connection: Connection, mapApiRe contextMenu.show({ x: event.pageX, y: event.pageY, - items: [{ title: t("relation_map.remove_relation"), command: "remove", uiIcon: "bx bx-trash" }], + items: [ + { title: t("relation_map.rename_relation"), command: "rename", uiIcon: "bx bx-pencil" }, + { kind: "separator" }, + { title: t("relation_map.remove_relation"), command: "remove", uiIcon: "bx bx-trash" } + ], selectMenuItemHandler: async ({ command }) => { - if (command === "remove") { + if (command === "rename") { + const currentName = mapApiRef.current?.getRelationName(connection) ?? ""; + const newName = await promptForRelationName(currentName); + + if (!newName?.trim() || newName === currentName) { + return; + } + + const result = await mapApiRef.current?.renameRelation(connection, newName); + if (!result) { + await dialog.info(t("relation_map.connection_exists", { name: newName })); + } + } else if (command === "remove") { if (!(await dialog.confirm(t("relation_map.confirm_remove_relation")))) { return; } diff --git a/apps/client/src/widgets/type_widgets/relation_map/utils.ts b/apps/client/src/widgets/type_widgets/relation_map/utils.ts index e52a40fbf2..d21857a398 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/utils.ts +++ b/apps/client/src/widgets/type_widgets/relation_map/utils.ts @@ -1,4 +1,7 @@ +import attribute_autocomplete from "../../../services/attribute_autocomplete"; +import dialog from "../../../services/dialog"; import { t } from "../../../services/i18n"; +import utils from "../../../services/utils"; export function noteIdToId(noteId: string) { return `rel-map-note-${noteId}`; @@ -32,3 +35,26 @@ export function getMousePosition(evt: MouseEvent, container: HTMLDivElement, zoo y: ((evt.clientY ?? 0) - rect.top) / zoom }; } + +export function promptForRelationName(defaultValue?: string): Promise { + return dialog.prompt({ + message: t("relation_map.specify_new_relation_name"), + defaultValue, + shown: ({ $answer }) => { + if (!$answer) { + return; + } + + $answer.on("keyup", () => { + const attrName = utils.filterAttributeName($answer.val() as string); + $answer.val(attrName); + }); + + attribute_autocomplete.initAttributeNameAutocomplete({ + $el: $answer, + attributeType: "relation", + open: true + }); + } + }); +}