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

@@ -1,6 +1,10 @@
import { createResource } from '../composables/create';
import { setCredentialValues } from '../composables/modals/credential-modal';
import { clickCreateNewCredential, selectResourceLocatorItem } from '../composables/ndv';
import {
clickCreateNewCredential,
getNdvContainer,
selectResourceLocatorItem,
} from '../composables/ndv';
import * as projects from '../composables/projects';
import {
EDIT_FIELDS_SET_NODE_NAME,
@@ -302,7 +306,8 @@ describe('Projects', { disableAutoLogin: true }, () => {
});
selectResourceLocatorItem('workflowId', 0, 'Create a');
// Need to wait for the trigger node to auto-open after a delay
getNdvContainer().should('be.visible');
cy.get('body').type('{esc}');
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
clickCreateNewCredential();

View File

@@ -3,8 +3,9 @@ import {
getCloseSaveChangesButton,
getSaveChangesModal,
} from '../composables/modals/save-changes-modal';
import { getNdvContainer } from '../composables/ndv';
import { getHomeButton } from '../composables/projects';
import { addNodeToCanvas } from '../composables/workflow';
import { addNodeToCanvas, saveWorkflowOnButtonClick } from '../composables/workflow';
import {
getCreateWorkflowButton,
getNewWorkflowCardButton,
@@ -12,6 +13,7 @@ import {
visitWorkflowsPage,
} from '../composables/workflowsPage';
import { EDIT_FIELDS_SET_NODE_NAME } from '../constants';
import { warningToast } from '../pages/notifications';
describe('Workflows', () => {
beforeEach(() => {
@@ -42,11 +44,11 @@ describe('Workflows', () => {
getCreateWorkflowButton().click();
cy.createFixtureWorkflow('Test_workflow_1.json', 'Empty State Card Workflow');
addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true);
// Here we go back via browser rather than the home button
// As this already updates the route
cy.go(-1);
cy.go(-2);
cy.url().should('include', getWorkflowsPageUrl());
@@ -56,4 +58,79 @@ describe('Workflows', () => {
// Confirm the url is back to the workflow
cy.url().should('include', '/workflow/');
});
it('should correct route when opening and closing NDV via browser button', () => {
getCreateWorkflowButton().click();
saveWorkflowOnButtonClick();
cy.url().then((startUrl) => {
cy.createFixtureWorkflow('Test_workflow_1.json', 'Empty State Card Workflow');
cy.url().should('equal', startUrl);
addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true);
// Getting the generated nodeId is awkward, so we just ensure the URL changed
cy.url().should('not.equal', startUrl);
// Here we go back via browser rather than the home button
// As this already updates the route
cy.go(-1);
cy.url().should('equal', startUrl);
});
});
it('should correct route when opening and closing NDV via browser button', () => {
getCreateWorkflowButton().click();
saveWorkflowOnButtonClick();
cy.url().then((startUrl) => {
cy.createFixtureWorkflow('Test_workflow_1.json', 'Empty State Card Workflow');
cy.url().should('equal', startUrl);
addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true);
// Getting the generated nodeId is awkward, so we just ensure the URL changed
cy.url().should('not.equal', startUrl);
cy.get('body').type('{esc}');
cy.url().should('equal', startUrl);
});
});
it('should open ndv via URL', () => {
getCreateWorkflowButton().click();
saveWorkflowOnButtonClick();
cy.createFixtureWorkflow('Test_workflow_1.json', 'Empty State Card Workflow');
addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true);
cy.url().then((ndvUrl) => {
cy.get('body').type('{esc}');
saveWorkflowOnButtonClick();
getNdvContainer().should('not.be.visible');
cy.visit(ndvUrl);
getNdvContainer().should('be.visible');
});
});
it('should open show warning and drop nodeId from URL if it contained an unknown nodeId', () => {
getCreateWorkflowButton().click();
saveWorkflowOnButtonClick();
cy.createFixtureWorkflow('Test_workflow_1.json', 'Empty State Card Workflow');
addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true);
cy.url().then((ndvUrl) => {
cy.get('body').type('{esc}');
saveWorkflowOnButtonClick();
getNdvContainer().should('not.be.visible');
cy.visit(ndvUrl + 'thisMessesUpTheNodeId');
warningToast().should('be.visible');
cy.url().should('equal', ndvUrl.split('/').slice(0, -1).join('/'));
});
});
});

View File

@@ -7,8 +7,10 @@ export async function loadSubWorkflowInputs(
const { fields, subworkflowInfo, dataMode } = await loadWorkflowInputMappings.bind(this)();
let emptyFieldsNotice: string | undefined;
if (fields.length === 0) {
const subworkflowLink = subworkflowInfo?.id
? `<a href="/workflow/${subworkflowInfo?.id}" target="_blank">sub-workflows trigger</a>`
const { triggerId, workflowId } = subworkflowInfo ?? {};
const path = (workflowId ?? '') + (triggerId ? `/${triggerId.slice(0, 6)}` : '');
const subworkflowLink = workflowId
? `<a href="/workflow/${path}" target="_blank">sub-workflows trigger</a>`
: 'sub-workflows trigger';
switch (dataMode) {

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();
});

View File

@@ -8,8 +8,10 @@ export async function loadSubWorkflowInputs(
const { fields, dataMode, subworkflowInfo } = await loadWorkflowInputMappings.bind(this)();
let emptyFieldsNotice: string | undefined;
if (fields.length === 0) {
const subworkflowLink = subworkflowInfo?.id
? `<a href="/workflow/${subworkflowInfo?.id}" target="_blank">sub-workflows trigger</a>`
const { triggerId, workflowId } = subworkflowInfo ?? {};
const path = (workflowId ?? '') + (triggerId ? `/${triggerId.slice(0, 6)}` : '');
const subworkflowLink = workflowId
? `<a href="/workflow/${path}" target="_blank">sub-workflows trigger</a>`
: 'sub-workflows trigger';
switch (dataMode) {

View File

@@ -96,7 +96,12 @@ export function getFieldEntries(context: IWorkflowNodeContext): {
if (Array.isArray(result)) {
const dataMode = String(inputSource);
const workflow = context.getWorkflow();
return { fields: result, dataMode, subworkflowInfo: { id: workflow.id } };
const node = context.getNode();
return {
fields: result,
dataMode,
subworkflowInfo: { workflowId: workflow.id, triggerId: node.id },
};
}
throw new NodeOperationError(context.getNode(), result);
}
@@ -153,7 +158,7 @@ export async function loadWorkflowInputMappings(
const nodeLoadContext = await this.getWorkflowNodeContext(EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE);
let fields: ResourceMapperField[] = [];
let dataMode: string = PASSTHROUGH;
let subworkflowInfo: { id?: string } | undefined;
let subworkflowInfo: { workflowId?: string; triggerId?: string } | undefined;
if (nodeLoadContext) {
const fieldValues = getFieldEntries(nodeLoadContext);

View File

@@ -2658,7 +2658,7 @@ export interface ResourceMapperFields {
export interface WorkflowInputsData {
fields: ResourceMapperField[];
dataMode: string;
subworkflowInfo?: { id?: string };
subworkflowInfo?: { workflowId?: string; triggerId?: string };
}
export interface ResourceMapperField {