feat(editor): Include NodeDetailsView in URL (#14349)

This commit is contained in:
Charlie Kolb
2025-04-25 10:57:22 +02:00
committed by GitHub
parent 40aadbf880
commit 5ff073bd7b
14 changed files with 225 additions and 16 deletions

View File

@@ -21,7 +21,7 @@ import { useWorkflowResourcesLocator } from './useWorkflowResourcesLocator';
import { useProjectsStore } from '@/stores/projects.store';
import { useTelemetry } from '@/composables/useTelemetry';
import { VIEWS } from '@/constants';
import { SAMPLE_SUBWORKFLOW_WORKFLOW } from '@/constants.workflows';
import { SAMPLE_SUBWORKFLOW_TRIGGER_ID, SAMPLE_SUBWORKFLOW_WORKFLOW } from '@/constants.workflows';
import type { IWorkflowDataCreate } from '@/Interface';
import { useDocumentVisibility } from '@/composables/useDocumentVisibility';
@@ -260,7 +260,10 @@ const onAddResourceClicked = async () => {
telemetry.track('User clicked create new sub-workflow button', {}, { withPostHog: true });
const newWorkflow = await workflowsStore.createNewWorkflow(workflow);
const { href } = router.resolve({ name: VIEWS.WORKFLOW, params: { name: newWorkflow.id } });
const { href } = router.resolve({
name: VIEWS.WORKFLOW,
params: { name: newWorkflow.id, nodeId: SAMPLE_SUBWORKFLOW_TRIGGER_ID },
});
await reloadWorkflows();
onInputChange(newWorkflow.id);
hideDropdown();

View File

@@ -1,11 +1,12 @@
import { NodeConnectionTypes } from 'n8n-workflow';
import type { INodeUi, IWorkflowDataCreate } from './Interface';
export const SAMPLE_SUBWORKFLOW_TRIGGER_ID = 'c055762a-8fe7-4141-a639-df2372f30060';
export const SAMPLE_SUBWORKFLOW_WORKFLOW: IWorkflowDataCreate = {
name: 'My Sub-Workflow',
nodes: [
{
id: 'c055762a-8fe7-4141-a639-df2372f30060',
id: SAMPLE_SUBWORKFLOW_TRIGGER_ID,
typeVersion: 1.1,
name: 'When Executed by Another Workflow',
type: 'n8n-nodes-base.executeWorkflowTrigger',

View File

@@ -1463,6 +1463,8 @@
"nodeView.showMessage.debug.content": "You can make edits and re-execute. Once you're done, unpin the the first node.",
"nodeView.showMessage.debug.missingNodes.title": "Some execution data wasn't imported",
"nodeView.showMessage.debug.missingNodes.content": "Some nodes have been deleted or renamed or added to the workflow since the execution ran.",
"nodeView.showMessage.ndvUrl.missingNodes.title": "Node not found",
"nodeView.showMessage.ndvUrl.missingNodes.content": "URL contained a reference to an unknown node. Maybe the node was deleted?",
"nodeView.stopCurrentExecution": "Stop current execution",
"nodeView.stopWaitingForWebhookCall": "Stop waiting for webhook call",
"nodeView.stoppingCurrentExecution": "Stopping current execution",

View File

@@ -45,6 +45,8 @@ describe('router', () => {
['/workflow', VIEWS.NEW_WORKFLOW],
['/workflow/new', VIEWS.NEW_WORKFLOW],
['/workflow/R9JFXwkUCL1jZBuw', VIEWS.WORKFLOW],
['/workflow/R9JFXwkUCL1jZBuw/myNodeId', VIEWS.WORKFLOW],
['/workflow/R9JFXwkUCL1jZBuw/398-1ewq213', VIEWS.WORKFLOW],
['/workflow/R9JFXwkUCL1jZBuw/executions/29021', VIEWS.EXECUTION_PREVIEW],
['/workflows/templates/R9JFXwkUCL1jZBuw', VIEWS.TEMPLATE_IMPORT],
['/workflows/demo', VIEWS.DEMO],

View File

@@ -389,7 +389,7 @@ export const routes: RouteRecordRaw[] = [
},
},
{
path: '/workflow/:name',
path: '/workflow/:name/:nodeId?',
name: VIEWS.WORKFLOW,
components: {
default: NodeView,

View File

@@ -815,6 +815,44 @@ describe('useWorkflowsStore', () => {
);
},
);
describe('findNodeByPartialId', () => {
test.each([
[[], 'D', undefined],
[['A', 'B', 'C'], 'D', undefined],
[['A', 'B', 'C'], 'B', 1],
[['AA', 'BB', 'CC'], 'B', 1],
[['AA', 'BB', 'BC'], 'B', 1],
[['AA', 'BB', 'BC'], 'BC', 2],
] as Array<[string[], string, number | undefined]>)(
'with input %s , %s returns node with index %s',
(ids, id, expectedIndex) => {
workflowsStore.workflow.nodes = ids.map((x) => ({ id: x }) as never);
expect(workflowsStore.findNodeByPartialId(id)).toBe(
workflowsStore.workflow.nodes[expectedIndex ?? -1],
);
},
);
});
describe('getPartialIdForNode', () => {
test.each([
[[], 'Alphabet', 'Alphabet'],
[['Alphabet'], 'Alphabet', 'Alphab'],
[['Alphabet', 'Alphabeta'], 'Alphabeta', 'Alphabeta'],
[['Alphabet', 'Alphabeta', 'Alphabetagamma'], 'Alphabet', 'Alphabet'],
[['Alphabet', 'Alphabeta', 'Alphabetagamma'], 'Alphabeta', 'Alphabeta'],
[['Alphabet', 'Alphabeta', 'Alphabetagamma'], 'Alphabetagamma', 'Alphabetag'],
] as Array<[string[], string, string]>)(
'with input %s , %s returns %s',
(ids, id, expected) => {
workflowsStore.workflow.nodes = ids.map((x) => ({ id: x }) as never);
expect(workflowsStore.getPartialIdForNode(id)).toBe(expected);
},
);
});
});
function getMockEditFieldsNode() {

View File

@@ -343,6 +343,22 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
return workflow.value.nodes.find((node) => node.id === nodeId);
}
// Finds the full id for a given partial id for a node, relying on order for uniqueness in edge cases
function findNodeByPartialId(partialId: string): INodeUi | undefined {
return workflow.value.nodes.find((node) => node.id.startsWith(partialId));
}
// Finds a uniquely identifying partial id for a node, relying on order for uniqueness in edge cases
function getPartialIdForNode(fullId: string): string {
for (let length = 6; length < fullId.length; ++length) {
const partialId = fullId.slice(0, length);
if (workflow.value.nodes.filter((x) => x.id.startsWith(partialId)).length === 1) {
return partialId;
}
}
return fullId;
}
function getNodesByIds(nodeIds: string[]): INodeUi[] {
return nodeIds.map(getNodeById).filter(isPresent);
}
@@ -1875,6 +1891,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
setNodes,
setConnections,
markExecutionAsStopped,
findNodeByPartialId,
getPartialIdForNode,
totalWorkflowCount,
};
});

View File

@@ -234,6 +234,7 @@ const workflowId = computed(() => {
? undefined
: workflowIdParam;
});
const routeNodeId = computed(() => route.params.nodeId as string | undefined);
const isNewWorkflowRoute = computed(() => route.name === VIEWS.NEW_WORKFLOW || !workflowId.value);
const isWorkflowRoute = computed(() => !!route?.meta?.nodeView || isDemoRoute.value);
@@ -1606,6 +1607,23 @@ function showAddFirstStepIfEnabled() {
* Routing
*/
function updateNodeRoute(nodeId: string) {
const nodeUi = workflowsStore.findNodeByPartialId(nodeId);
if (nodeUi) {
setNodeActive(nodeUi.id);
} else {
toast.showToast({
title: i18n.baseText('nodeView.showMessage.ndvUrl.missingNodes.title'),
message: i18n.baseText('nodeView.showMessage.ndvUrl.missingNodes.content'),
type: 'warning',
});
void router.replace({
name: route.name,
params: { name: workflowId.value },
});
}
}
watch(
() => route.name,
async (newRouteName, oldRouteName) => {
@@ -1617,6 +1635,35 @@ watch(
},
);
// This keeps the selected node in sync if the URL is updated
watch(
() => route.params.nodeId,
async (newId) => {
if (typeof newId !== 'string' || newId === '') ndvStore.activeNodeName = null;
else {
updateNodeRoute(newId);
}
},
);
// This keeps URL in sync if the activeNode is changed
watch(
() => ndvStore.activeNode,
async (val) => {
// This is just out of caution
if (!([VIEWS.WORKFLOW] as string[]).includes(String(route.name))) return;
// Route params default to '' instead of undefined if not present
const nodeId = val?.id ? workflowsStore.getPartialIdForNode(val?.id) : '';
if (nodeId !== route.params.nodeId) {
await router.push({
name: route.name,
params: { name: workflowId.value, nodeId },
});
}
},
);
onBeforeRouteLeave(async (to, from, next) => {
const toNodeViewTab = getNodeViewTab(to);
@@ -1689,6 +1736,13 @@ onMounted(() => {
void externalHooks.run('nodeView.mount').catch(() => {});
// A delay here makes opening the NDV a bit less jarring
setTimeout(() => {
if (routeNodeId.value) {
updateNodeRoute(routeNodeId.value);
}
}, 500);
emitPostMessageReady();
});