feat(relation_map): rename relations through context menu (closes #442)

This commit is contained in:
Elian Doran
2026-04-07 18:47:00 +03:00
parent 26cf215150
commit cedce6cf32
5 changed files with 71 additions and 26 deletions

View File

@@ -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):",

View File

@@ -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()) {

View File

@@ -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;

View File

@@ -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<RelationMapApi>) {
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;
}

View File

@@ -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<string | null> {
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
});
}
});
}