Skip to content

Commit 76230d2

Browse files
feat: Table in confirm modal to see all workflows using nodes before updating / uninstalling (#17488)
1 parent d924d82 commit 76230d2

File tree

12 files changed

+564
-30
lines changed

12 files changed

+564
-30
lines changed

packages/@n8n/db/src/repositories/workflow.repository.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
FolderWithWorkflowAndSubFolderCount,
2020
ListQuery,
2121
} from '../entities/types-db';
22+
import { buildWorkflowsByNodesQuery } from '../utils/build-workflows-by-nodes-query';
2223
import { isStringArray } from '../utils/is-string-array';
2324
import { TimedQuery } from '../utils/timed-query';
2425

@@ -712,4 +713,22 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
712713
{ parentFolder: toFolderId === PROJECT_ROOT ? null : { id: toFolderId } },
713714
);
714715
}
716+
717+
async findWorkflowsWithNodeType(nodeTypes: string[]) {
718+
if (!nodeTypes?.length) return [];
719+
720+
const qb = this.createQueryBuilder('workflow');
721+
722+
const { whereClause, parameters } = buildWorkflowsByNodesQuery(
723+
nodeTypes,
724+
this.globalConfig.database.type,
725+
);
726+
727+
const workflows: Array<{ id: string; name: string; active: boolean }> = await qb
728+
.select(['workflow.id', 'workflow.name', 'workflow.active'])
729+
.where(whereClause, parameters)
730+
.getMany();
731+
732+
return workflows;
733+
}
715734
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { buildWorkflowsByNodesQuery } from '../build-workflows-by-nodes-query';
2+
3+
describe('WorkflowRepository', () => {
4+
describe('filterWorkflowsByNodesConstructWhereClause', () => {
5+
it('should return the correct WHERE clause and parameters for sqlite', () => {
6+
const nodeTypes = ['HTTP Request', 'Set'];
7+
const expectedInQuery =
8+
"FROM json_each(workflow.nodes) WHERE json_extract(json_each.value, '$.type')";
9+
const expectedParameters = {
10+
nodeType0: 'HTTP Request',
11+
nodeType1: 'Set',
12+
nodeTypes,
13+
};
14+
15+
const { whereClause, parameters } = buildWorkflowsByNodesQuery(nodeTypes, 'sqlite');
16+
17+
expect(whereClause).toContain(expectedInQuery);
18+
expect(parameters).toEqual(expectedParameters);
19+
});
20+
21+
it('should return the correct WHERE clause and parameters for postgresdb', () => {
22+
const nodeTypes = ['HTTP Request', 'Set'];
23+
const expectedInQuery = 'FROM jsonb_array_elements(workflow.nodes::jsonb) AS node';
24+
const expectedParameters = { nodeTypes };
25+
26+
const { whereClause, parameters } = buildWorkflowsByNodesQuery(nodeTypes, 'postgresdb');
27+
28+
expect(whereClause).toContain(expectedInQuery);
29+
expect(parameters).toEqual(expectedParameters);
30+
});
31+
32+
it('should return the correct WHERE clause and parameters for mysqldb', () => {
33+
const nodeTypes = ['HTTP Request', 'Set'];
34+
const expectedWhereClause =
35+
"(JSON_SEARCH(JSON_EXTRACT(workflow.nodes, '$[*].type'), 'one', :nodeType0) IS NOT NULL OR JSON_SEARCH(JSON_EXTRACT(workflow.nodes, '$[*].type'), 'one', :nodeType1) IS NOT NULL)";
36+
const expectedParameters = {
37+
nodeType0: 'HTTP Request',
38+
nodeType1: 'Set',
39+
nodeTypes,
40+
};
41+
42+
const { whereClause, parameters } = buildWorkflowsByNodesQuery(nodeTypes, 'mysqldb');
43+
44+
expect(whereClause).toEqual(expectedWhereClause);
45+
expect(parameters).toEqual(expectedParameters);
46+
});
47+
});
48+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Builds the WHERE clause and parameters for a query to find workflows by node types
3+
*/
4+
export function buildWorkflowsByNodesQuery(
5+
nodeTypes: string[],
6+
dbType: 'postgresdb' | 'mysqldb' | 'mariadb' | 'sqlite',
7+
) {
8+
let whereClause: string;
9+
10+
const parameters: Record<string, string | string[]> = { nodeTypes };
11+
12+
switch (dbType) {
13+
case 'postgresdb':
14+
whereClause = `EXISTS (
15+
SELECT 1
16+
FROM jsonb_array_elements(workflow.nodes::jsonb) AS node
17+
WHERE node->>'type' = ANY(:nodeTypes)
18+
)`;
19+
break;
20+
case 'mysqldb':
21+
case 'mariadb': {
22+
const conditions = nodeTypes
23+
.map(
24+
(_, i) =>
25+
`JSON_SEARCH(JSON_EXTRACT(workflow.nodes, '$[*].type'), 'one', :nodeType${i}) IS NOT NULL`,
26+
)
27+
.join(' OR ');
28+
29+
whereClause = `(${conditions})`;
30+
31+
nodeTypes.forEach((nodeType, index) => {
32+
parameters[`nodeType${index}`] = nodeType;
33+
});
34+
break;
35+
}
36+
case 'sqlite': {
37+
const conditions = nodeTypes
38+
.map(
39+
(_, i) =>
40+
`EXISTS (SELECT 1 FROM json_each(workflow.nodes) WHERE json_extract(json_each.value, '$.type') = :nodeType${i})`,
41+
)
42+
.join(' OR ');
43+
44+
whereClause = `(${conditions})`;
45+
46+
nodeTypes.forEach((nodeType, index) => {
47+
parameters[`nodeType${index}`] = nodeType;
48+
});
49+
break;
50+
}
51+
default:
52+
throw new Error('Unsupported database type');
53+
}
54+
55+
return { whereClause, parameters };
56+
}

packages/cli/src/workflows/workflow.service.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,4 +557,25 @@ export class WorkflowService {
557557
})),
558558
);
559559
}
560+
561+
async getWorkflowsWithNodesIncluded(user: User, nodeTypes: string[]) {
562+
const foundWorkflows = await this.workflowRepository.findWorkflowsWithNodeType(nodeTypes);
563+
564+
let { workflows } = await this.workflowRepository.getManyAndCount(
565+
foundWorkflows.map((w) => w.id),
566+
);
567+
568+
if (hasSharing(workflows)) {
569+
workflows = await this.processSharedWorkflows(workflows);
570+
}
571+
572+
workflows = await this.addUserScopes(workflows, user);
573+
574+
this.cleanupSharedField(workflows);
575+
576+
return workflows.map((workflow) => ({
577+
resourceType: 'workflow',
578+
...workflow,
579+
}));
580+
}
560581
}

packages/cli/src/workflows/workflows.controller.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
ImportWorkflowFromUrlDto,
33
ManualRunQueryDto,
4+
ROLE,
45
TransferWorkflowBodyDto,
56
} from '@n8n/api-types';
67
import { Logger } from '@n8n/backend-common';
@@ -559,4 +560,31 @@ export class WorkflowsController {
559560
body.destinationParentFolderId,
560561
);
561562
}
563+
564+
@Post('/with-node-types')
565+
async getWorkflowsWithNodesIncluded(req: AuthenticatedRequest, res: express.Response) {
566+
try {
567+
const hasPermission = req.user.role === ROLE.Owner || req.user.role === ROLE.Admin;
568+
569+
if (!hasPermission) {
570+
res.json({ data: [], count: 0 });
571+
return;
572+
}
573+
574+
const { nodeTypes } = req.body as { nodeTypes: string[] };
575+
const workflows = await this.workflowService.getWorkflowsWithNodesIncluded(
576+
req.user,
577+
nodeTypes,
578+
);
579+
580+
res.json({
581+
data: workflows,
582+
count: workflows.length,
583+
});
584+
} catch (maybeError) {
585+
const error = utils.toError(maybeError);
586+
ResponseHelper.reportError(error);
587+
ResponseHelper.sendErrorResponse(res, error);
588+
}
589+
}
562590
}

packages/frontend/@n8n/i18n/src/locales/en.json

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1949,16 +1949,20 @@
19491949
"settings.communityNodes.messages.update.success.title": "Package updated",
19501950
"settings.communityNodes.messages.update.success.message": "{packageName} updated to version {version}",
19511951
"settings.communityNodes.messages.update.error.title": "Problem updating package",
1952-
"settings.communityNodes.confirmModal.uninstall.title": "Uninstall package?",
1952+
"settings.communityNodes.confirmModal.uninstall.title": "Uninstall node package",
19531953
"settings.communityNodes.confirmModal.uninstall.message": "Any workflows that use nodes from the {packageName} package won't be able to run. Are you sure?",
1954-
"settings.communityNodes.confirmModal.uninstall.buttonLabel": "Uninstall package",
1954+
"settings.communityNodes.confirmModal.uninstall.description": "Uninstalling the package will remove every instance of nodes included in this package. The following workflows will be effected:",
1955+
"settings.communityNodes.confirmModal.noWorkflowsUsingNodes": "Nodes from this package are not used in any workflows",
1956+
"settings.communityNodes.confirmModal.uninstall.buttonLabel": "Confirm uninstall",
19551957
"settings.communityNodes.confirmModal.uninstall.buttonLoadingLabel": "Uninstalling",
1956-
"settings.communityNodes.confirmModal.update.title": "Update community node package?",
1958+
"settings.communityNodes.confirmModal.update.title": "Update node package",
19571959
"settings.communityNodes.confirmModal.update.message": "You are about to update {packageName} to version {version}",
1960+
"settings.communityNodes.confirmModal.includedNodes": "Package includes: {nodes}",
19581961
"settings.communityNodes.confirmModal.update.warning": "This version has not been verified by n8n and may contain breaking changes or bugs.",
1959-
"settings.communityNodes.confirmModal.update.description": "We recommend you deactivate workflows that use any of the package's nodes and reactivate them once the update is completed",
1960-
"settings.communityNodes.confirmModal.update.buttonLabel": "Update package",
1962+
"settings.communityNodes.confirmModal.update.description": "Updating to the latest version will update every instance of these nodes. The following workflows will be effected:",
1963+
"settings.communityNodes.confirmModal.update.buttonLabel": "Confirm update",
19611964
"settings.communityNodes.confirmModal.update.buttonLoadingLabel": "Updating...",
1965+
"settings.communityNodes.confirmModal.cancel": "Cancel",
19621966
"settings.goBack": "Go back",
19631967
"settings.personal": "Personal",
19641968
"settings.personal.basicInformation": "Basic Information",

packages/frontend/editor-ui/src/api/workflows.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
IWorkflowDb,
99
NewWorkflowResponse,
1010
WorkflowListResource,
11+
WorkflowResource,
1112
} from '@/Interface';
1213
import type { IRestApiContext } from '@n8n/rest-api-client';
1314
import type {
@@ -43,6 +44,17 @@ export async function getWorkflows(context: IRestApiContext, filter?: object, op
4344
});
4445
}
4546

47+
export async function getWorkflowsWithNodesIncluded(context: IRestApiContext, nodeTypes: string[]) {
48+
return await getFullApiResponse<WorkflowResource[]>(
49+
context,
50+
'POST',
51+
'/workflows/with-node-types',
52+
{
53+
nodeTypes,
54+
},
55+
);
56+
}
57+
4658
export async function getWorkflowsAndFolders(
4759
context: IRestApiContext,
4860
filter?: object,

packages/frontend/editor-ui/src/components/CommunityPackageManageConfirmModal.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ import { createTestingPinia } from '@pinia/testing';
99
import { STORES } from '@n8n/stores';
1010
import { COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY } from '@/constants';
1111

12+
const fetchWorkflowsWithNodesIncluded = vi.fn();
13+
vi.mock('@/stores/workflows.store', () => ({
14+
useWorkflowsStore: vi.fn(() => ({
15+
fetchWorkflowsWithNodesIncluded,
16+
})),
17+
}));
18+
1219
const renderComponent = createComponentRenderer(CommunityPackageManageConfirmModal, {
1320
data() {
1421
return {
@@ -28,6 +35,7 @@ const renderComponent = createComponentRenderer(CommunityPackageManageConfirmMod
2835
packageName: 'n8n-nodes-test',
2936
installedVersion: '1.0.0',
3037
updateAvailable: '2.0.0',
38+
installedNodes: [{ name: 'TestNode' }],
3139
},
3240
},
3341
},
@@ -103,4 +111,93 @@ describe('CommunityPackageManageConfirmModal', () => {
103111
const testId = getByTestId('communityPackageManageConfirmModal-warning');
104112
expect(testId).toBeInTheDocument();
105113
});
114+
115+
it('should include table with affected workflows', async () => {
116+
useSettingsStore().setSettings({ ...defaultSettings, communityNodesEnabled: true });
117+
118+
nodeTypesStore.loadNodeTypesIfNotLoaded = vi.fn().mockResolvedValue(undefined);
119+
nodeTypesStore.getCommunityNodeAttributes = vi.fn().mockResolvedValue({ npmVersion: '1.5.0' });
120+
121+
fetchWorkflowsWithNodesIncluded.mockResolvedValue({
122+
data: [
123+
{
124+
id: 'workflow-1',
125+
name: 'Test Workflow 1',
126+
resourceType: 'workflow',
127+
active: true,
128+
createdAt: '2023-01-01T00:00:00.000Z',
129+
updatedAt: '2023-01-01T00:00:00.000Z',
130+
homeProject: {
131+
id: 'project-1',
132+
name: 'Test Project 1',
133+
icon: { type: 'emoji', value: 'test' },
134+
type: 'personal',
135+
createdAt: '2023-01-01T00:00:00.000Z',
136+
updatedAt: '2023-01-01T00:00:00.000Z',
137+
},
138+
isArchived: false,
139+
readOnly: false,
140+
scopes: [],
141+
tags: [],
142+
},
143+
],
144+
});
145+
146+
const screen = renderComponent({
147+
props: {
148+
modalName: 'test-modal',
149+
activePackageName: 'n8n-nodes-test',
150+
mode: 'update',
151+
},
152+
global: {
153+
stubs: {
154+
'router-link': {
155+
template: '<a><slot /></a>',
156+
},
157+
},
158+
plugins: [createTestingPinia()],
159+
},
160+
});
161+
162+
await flushPromises();
163+
164+
const testId = screen.getByTestId('communityPackageManageConfirmModal-warning');
165+
expect(testId).toBeInTheDocument();
166+
expect(screen.getByText('Test Workflow 1')).toBeInTheDocument();
167+
expect(screen.getByText('Test Project 1')).toBeInTheDocument();
168+
expect(screen.getByText('Active')).toBeInTheDocument();
169+
expect(screen.getByText('Confirm update')).toBeInTheDocument();
170+
expect(screen.getByText('Cancel')).toBeInTheDocument();
171+
expect(screen.getByText('Package includes: TestNode')).toBeInTheDocument();
172+
});
173+
174+
it('should notinclude table with affected workflows', async () => {
175+
useSettingsStore().setSettings({ ...defaultSettings, communityNodesEnabled: true });
176+
177+
nodeTypesStore.loadNodeTypesIfNotLoaded = vi.fn().mockResolvedValue(undefined);
178+
nodeTypesStore.getCommunityNodeAttributes = vi.fn().mockResolvedValue({ npmVersion: '1.5.0' });
179+
180+
fetchWorkflowsWithNodesIncluded.mockResolvedValue({
181+
data: [],
182+
});
183+
184+
const screen = renderComponent({
185+
props: {
186+
modalName: 'test-modal',
187+
activePackageName: 'n8n-nodes-test',
188+
mode: 'update',
189+
},
190+
});
191+
192+
await flushPromises();
193+
194+
const testId = screen.getByTestId('communityPackageManageConfirmModal-warning');
195+
expect(testId).toBeInTheDocument();
196+
197+
expect(screen.getByText('Package includes: TestNode')).toBeInTheDocument();
198+
199+
expect(
200+
screen.getByText('Nodes from this package are not used in any workflows'),
201+
).toBeInTheDocument();
202+
});
106203
});

0 commit comments

Comments
 (0)