mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
feat: Table in confirm modal to see all workflows using nodes before updating / uninstalling (#17488)
This commit is contained in:
@@ -19,6 +19,7 @@ import type {
|
|||||||
FolderWithWorkflowAndSubFolderCount,
|
FolderWithWorkflowAndSubFolderCount,
|
||||||
ListQuery,
|
ListQuery,
|
||||||
} from '../entities/types-db';
|
} from '../entities/types-db';
|
||||||
|
import { buildWorkflowsByNodesQuery } from '../utils/build-workflows-by-nodes-query';
|
||||||
import { isStringArray } from '../utils/is-string-array';
|
import { isStringArray } from '../utils/is-string-array';
|
||||||
import { TimedQuery } from '../utils/timed-query';
|
import { TimedQuery } from '../utils/timed-query';
|
||||||
|
|
||||||
@@ -712,4 +713,22 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
|
|||||||
{ parentFolder: toFolderId === PROJECT_ROOT ? null : { id: toFolderId } },
|
{ parentFolder: toFolderId === PROJECT_ROOT ? null : { id: toFolderId } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findWorkflowsWithNodeType(nodeTypes: string[]) {
|
||||||
|
if (!nodeTypes?.length) return [];
|
||||||
|
|
||||||
|
const qb = this.createQueryBuilder('workflow');
|
||||||
|
|
||||||
|
const { whereClause, parameters } = buildWorkflowsByNodesQuery(
|
||||||
|
nodeTypes,
|
||||||
|
this.globalConfig.database.type,
|
||||||
|
);
|
||||||
|
|
||||||
|
const workflows: Array<{ id: string; name: string; active: boolean }> = await qb
|
||||||
|
.select(['workflow.id', 'workflow.name', 'workflow.active'])
|
||||||
|
.where(whereClause, parameters)
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
return workflows;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { buildWorkflowsByNodesQuery } from '../build-workflows-by-nodes-query';
|
||||||
|
|
||||||
|
describe('WorkflowRepository', () => {
|
||||||
|
describe('filterWorkflowsByNodesConstructWhereClause', () => {
|
||||||
|
it('should return the correct WHERE clause and parameters for sqlite', () => {
|
||||||
|
const nodeTypes = ['HTTP Request', 'Set'];
|
||||||
|
const expectedInQuery =
|
||||||
|
"FROM json_each(workflow.nodes) WHERE json_extract(json_each.value, '$.type')";
|
||||||
|
const expectedParameters = {
|
||||||
|
nodeType0: 'HTTP Request',
|
||||||
|
nodeType1: 'Set',
|
||||||
|
nodeTypes,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { whereClause, parameters } = buildWorkflowsByNodesQuery(nodeTypes, 'sqlite');
|
||||||
|
|
||||||
|
expect(whereClause).toContain(expectedInQuery);
|
||||||
|
expect(parameters).toEqual(expectedParameters);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the correct WHERE clause and parameters for postgresdb', () => {
|
||||||
|
const nodeTypes = ['HTTP Request', 'Set'];
|
||||||
|
const expectedInQuery = 'FROM jsonb_array_elements(workflow.nodes::jsonb) AS node';
|
||||||
|
const expectedParameters = { nodeTypes };
|
||||||
|
|
||||||
|
const { whereClause, parameters } = buildWorkflowsByNodesQuery(nodeTypes, 'postgresdb');
|
||||||
|
|
||||||
|
expect(whereClause).toContain(expectedInQuery);
|
||||||
|
expect(parameters).toEqual(expectedParameters);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the correct WHERE clause and parameters for mysqldb', () => {
|
||||||
|
const nodeTypes = ['HTTP Request', 'Set'];
|
||||||
|
const expectedWhereClause =
|
||||||
|
"(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)";
|
||||||
|
const expectedParameters = {
|
||||||
|
nodeType0: 'HTTP Request',
|
||||||
|
nodeType1: 'Set',
|
||||||
|
nodeTypes,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { whereClause, parameters } = buildWorkflowsByNodesQuery(nodeTypes, 'mysqldb');
|
||||||
|
|
||||||
|
expect(whereClause).toEqual(expectedWhereClause);
|
||||||
|
expect(parameters).toEqual(expectedParameters);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
56
packages/@n8n/db/src/utils/build-workflows-by-nodes-query.ts
Normal file
56
packages/@n8n/db/src/utils/build-workflows-by-nodes-query.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Builds the WHERE clause and parameters for a query to find workflows by node types
|
||||||
|
*/
|
||||||
|
export function buildWorkflowsByNodesQuery(
|
||||||
|
nodeTypes: string[],
|
||||||
|
dbType: 'postgresdb' | 'mysqldb' | 'mariadb' | 'sqlite',
|
||||||
|
) {
|
||||||
|
let whereClause: string;
|
||||||
|
|
||||||
|
const parameters: Record<string, string | string[]> = { nodeTypes };
|
||||||
|
|
||||||
|
switch (dbType) {
|
||||||
|
case 'postgresdb':
|
||||||
|
whereClause = `EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM jsonb_array_elements(workflow.nodes::jsonb) AS node
|
||||||
|
WHERE node->>'type' = ANY(:nodeTypes)
|
||||||
|
)`;
|
||||||
|
break;
|
||||||
|
case 'mysqldb':
|
||||||
|
case 'mariadb': {
|
||||||
|
const conditions = nodeTypes
|
||||||
|
.map(
|
||||||
|
(_, i) =>
|
||||||
|
`JSON_SEARCH(JSON_EXTRACT(workflow.nodes, '$[*].type'), 'one', :nodeType${i}) IS NOT NULL`,
|
||||||
|
)
|
||||||
|
.join(' OR ');
|
||||||
|
|
||||||
|
whereClause = `(${conditions})`;
|
||||||
|
|
||||||
|
nodeTypes.forEach((nodeType, index) => {
|
||||||
|
parameters[`nodeType${index}`] = nodeType;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'sqlite': {
|
||||||
|
const conditions = nodeTypes
|
||||||
|
.map(
|
||||||
|
(_, i) =>
|
||||||
|
`EXISTS (SELECT 1 FROM json_each(workflow.nodes) WHERE json_extract(json_each.value, '$.type') = :nodeType${i})`,
|
||||||
|
)
|
||||||
|
.join(' OR ');
|
||||||
|
|
||||||
|
whereClause = `(${conditions})`;
|
||||||
|
|
||||||
|
nodeTypes.forEach((nodeType, index) => {
|
||||||
|
parameters[`nodeType${index}`] = nodeType;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error('Unsupported database type');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { whereClause, parameters };
|
||||||
|
}
|
||||||
@@ -557,4 +557,25 @@ export class WorkflowService {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getWorkflowsWithNodesIncluded(user: User, nodeTypes: string[]) {
|
||||||
|
const foundWorkflows = await this.workflowRepository.findWorkflowsWithNodeType(nodeTypes);
|
||||||
|
|
||||||
|
let { workflows } = await this.workflowRepository.getManyAndCount(
|
||||||
|
foundWorkflows.map((w) => w.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasSharing(workflows)) {
|
||||||
|
workflows = await this.processSharedWorkflows(workflows);
|
||||||
|
}
|
||||||
|
|
||||||
|
workflows = await this.addUserScopes(workflows, user);
|
||||||
|
|
||||||
|
this.cleanupSharedField(workflows);
|
||||||
|
|
||||||
|
return workflows.map((workflow) => ({
|
||||||
|
resourceType: 'workflow',
|
||||||
|
...workflow,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ImportWorkflowFromUrlDto,
|
ImportWorkflowFromUrlDto,
|
||||||
ManualRunQueryDto,
|
ManualRunQueryDto,
|
||||||
|
ROLE,
|
||||||
TransferWorkflowBodyDto,
|
TransferWorkflowBodyDto,
|
||||||
} from '@n8n/api-types';
|
} from '@n8n/api-types';
|
||||||
import { Logger } from '@n8n/backend-common';
|
import { Logger } from '@n8n/backend-common';
|
||||||
@@ -559,4 +560,31 @@ export class WorkflowsController {
|
|||||||
body.destinationParentFolderId,
|
body.destinationParentFolderId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('/with-node-types')
|
||||||
|
async getWorkflowsWithNodesIncluded(req: AuthenticatedRequest, res: express.Response) {
|
||||||
|
try {
|
||||||
|
const hasPermission = req.user.role === ROLE.Owner || req.user.role === ROLE.Admin;
|
||||||
|
|
||||||
|
if (!hasPermission) {
|
||||||
|
res.json({ data: [], count: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { nodeTypes } = req.body as { nodeTypes: string[] };
|
||||||
|
const workflows = await this.workflowService.getWorkflowsWithNodesIncluded(
|
||||||
|
req.user,
|
||||||
|
nodeTypes,
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
data: workflows,
|
||||||
|
count: workflows.length,
|
||||||
|
});
|
||||||
|
} catch (maybeError) {
|
||||||
|
const error = utils.toError(maybeError);
|
||||||
|
ResponseHelper.reportError(error);
|
||||||
|
ResponseHelper.sendErrorResponse(res, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1949,16 +1949,20 @@
|
|||||||
"settings.communityNodes.messages.update.success.title": "Package updated",
|
"settings.communityNodes.messages.update.success.title": "Package updated",
|
||||||
"settings.communityNodes.messages.update.success.message": "{packageName} updated to version {version}",
|
"settings.communityNodes.messages.update.success.message": "{packageName} updated to version {version}",
|
||||||
"settings.communityNodes.messages.update.error.title": "Problem updating package",
|
"settings.communityNodes.messages.update.error.title": "Problem updating package",
|
||||||
"settings.communityNodes.confirmModal.uninstall.title": "Uninstall package?",
|
"settings.communityNodes.confirmModal.uninstall.title": "Uninstall node package",
|
||||||
"settings.communityNodes.confirmModal.uninstall.message": "Any workflows that use nodes from the {packageName} package won't be able to run. Are you sure?",
|
"settings.communityNodes.confirmModal.uninstall.message": "Any workflows that use nodes from the {packageName} package won't be able to run. Are you sure?",
|
||||||
"settings.communityNodes.confirmModal.uninstall.buttonLabel": "Uninstall package",
|
"settings.communityNodes.confirmModal.uninstall.description": "Uninstalling the package will remove every instance of nodes included in this package. The following workflows will be effected:",
|
||||||
|
"settings.communityNodes.confirmModal.noWorkflowsUsingNodes": "Nodes from this package are not used in any workflows",
|
||||||
|
"settings.communityNodes.confirmModal.uninstall.buttonLabel": "Confirm uninstall",
|
||||||
"settings.communityNodes.confirmModal.uninstall.buttonLoadingLabel": "Uninstalling",
|
"settings.communityNodes.confirmModal.uninstall.buttonLoadingLabel": "Uninstalling",
|
||||||
"settings.communityNodes.confirmModal.update.title": "Update community node package?",
|
"settings.communityNodes.confirmModal.update.title": "Update node package",
|
||||||
"settings.communityNodes.confirmModal.update.message": "You are about to update {packageName} to version {version}",
|
"settings.communityNodes.confirmModal.update.message": "You are about to update {packageName} to version {version}",
|
||||||
|
"settings.communityNodes.confirmModal.includedNodes": "Package includes: {nodes}",
|
||||||
"settings.communityNodes.confirmModal.update.warning": "This version has not been verified by n8n and may contain breaking changes or bugs.",
|
"settings.communityNodes.confirmModal.update.warning": "This version has not been verified by n8n and may contain breaking changes or bugs.",
|
||||||
"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",
|
"settings.communityNodes.confirmModal.update.description": "Updating to the latest version will update every instance of these nodes. The following workflows will be effected:",
|
||||||
"settings.communityNodes.confirmModal.update.buttonLabel": "Update package",
|
"settings.communityNodes.confirmModal.update.buttonLabel": "Confirm update",
|
||||||
"settings.communityNodes.confirmModal.update.buttonLoadingLabel": "Updating...",
|
"settings.communityNodes.confirmModal.update.buttonLoadingLabel": "Updating...",
|
||||||
|
"settings.communityNodes.confirmModal.cancel": "Cancel",
|
||||||
"settings.goBack": "Go back",
|
"settings.goBack": "Go back",
|
||||||
"settings.personal": "Personal",
|
"settings.personal": "Personal",
|
||||||
"settings.personal.basicInformation": "Basic Information",
|
"settings.personal.basicInformation": "Basic Information",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
IWorkflowDb,
|
IWorkflowDb,
|
||||||
NewWorkflowResponse,
|
NewWorkflowResponse,
|
||||||
WorkflowListResource,
|
WorkflowListResource,
|
||||||
|
WorkflowResource,
|
||||||
} from '@/Interface';
|
} from '@/Interface';
|
||||||
import type { IRestApiContext } from '@n8n/rest-api-client';
|
import type { IRestApiContext } from '@n8n/rest-api-client';
|
||||||
import type {
|
import type {
|
||||||
@@ -43,6 +44,17 @@ export async function getWorkflows(context: IRestApiContext, filter?: object, op
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getWorkflowsWithNodesIncluded(context: IRestApiContext, nodeTypes: string[]) {
|
||||||
|
return await getFullApiResponse<WorkflowResource[]>(
|
||||||
|
context,
|
||||||
|
'POST',
|
||||||
|
'/workflows/with-node-types',
|
||||||
|
{
|
||||||
|
nodeTypes,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getWorkflowsAndFolders(
|
export async function getWorkflowsAndFolders(
|
||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
filter?: object,
|
filter?: object,
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ import { createTestingPinia } from '@pinia/testing';
|
|||||||
import { STORES } from '@n8n/stores';
|
import { STORES } from '@n8n/stores';
|
||||||
import { COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY } from '@/constants';
|
import { COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY } from '@/constants';
|
||||||
|
|
||||||
|
const fetchWorkflowsWithNodesIncluded = vi.fn();
|
||||||
|
vi.mock('@/stores/workflows.store', () => ({
|
||||||
|
useWorkflowsStore: vi.fn(() => ({
|
||||||
|
fetchWorkflowsWithNodesIncluded,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(CommunityPackageManageConfirmModal, {
|
const renderComponent = createComponentRenderer(CommunityPackageManageConfirmModal, {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -28,6 +35,7 @@ const renderComponent = createComponentRenderer(CommunityPackageManageConfirmMod
|
|||||||
packageName: 'n8n-nodes-test',
|
packageName: 'n8n-nodes-test',
|
||||||
installedVersion: '1.0.0',
|
installedVersion: '1.0.0',
|
||||||
updateAvailable: '2.0.0',
|
updateAvailable: '2.0.0',
|
||||||
|
installedNodes: [{ name: 'TestNode' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -103,4 +111,93 @@ describe('CommunityPackageManageConfirmModal', () => {
|
|||||||
const testId = getByTestId('communityPackageManageConfirmModal-warning');
|
const testId = getByTestId('communityPackageManageConfirmModal-warning');
|
||||||
expect(testId).toBeInTheDocument();
|
expect(testId).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should include table with affected workflows', async () => {
|
||||||
|
useSettingsStore().setSettings({ ...defaultSettings, communityNodesEnabled: true });
|
||||||
|
|
||||||
|
nodeTypesStore.loadNodeTypesIfNotLoaded = vi.fn().mockResolvedValue(undefined);
|
||||||
|
nodeTypesStore.getCommunityNodeAttributes = vi.fn().mockResolvedValue({ npmVersion: '1.5.0' });
|
||||||
|
|
||||||
|
fetchWorkflowsWithNodesIncluded.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'workflow-1',
|
||||||
|
name: 'Test Workflow 1',
|
||||||
|
resourceType: 'workflow',
|
||||||
|
active: true,
|
||||||
|
createdAt: '2023-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2023-01-01T00:00:00.000Z',
|
||||||
|
homeProject: {
|
||||||
|
id: 'project-1',
|
||||||
|
name: 'Test Project 1',
|
||||||
|
icon: { type: 'emoji', value: 'test' },
|
||||||
|
type: 'personal',
|
||||||
|
createdAt: '2023-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2023-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
isArchived: false,
|
||||||
|
readOnly: false,
|
||||||
|
scopes: [],
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const screen = renderComponent({
|
||||||
|
props: {
|
||||||
|
modalName: 'test-modal',
|
||||||
|
activePackageName: 'n8n-nodes-test',
|
||||||
|
mode: 'update',
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
'router-link': {
|
||||||
|
template: '<a><slot /></a>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [createTestingPinia()],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const testId = screen.getByTestId('communityPackageManageConfirmModal-warning');
|
||||||
|
expect(testId).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Test Workflow 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Test Project 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Active')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Confirm update')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Package includes: TestNode')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should notinclude table with affected workflows', async () => {
|
||||||
|
useSettingsStore().setSettings({ ...defaultSettings, communityNodesEnabled: true });
|
||||||
|
|
||||||
|
nodeTypesStore.loadNodeTypesIfNotLoaded = vi.fn().mockResolvedValue(undefined);
|
||||||
|
nodeTypesStore.getCommunityNodeAttributes = vi.fn().mockResolvedValue({ npmVersion: '1.5.0' });
|
||||||
|
|
||||||
|
fetchWorkflowsWithNodesIncluded.mockResolvedValue({
|
||||||
|
data: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const screen = renderComponent({
|
||||||
|
props: {
|
||||||
|
modalName: 'test-modal',
|
||||||
|
activePackageName: 'n8n-nodes-test',
|
||||||
|
mode: 'update',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const testId = screen.getByTestId('communityPackageManageConfirmModal-warning');
|
||||||
|
expect(testId).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(screen.getByText('Package includes: TestNode')).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText('Nodes from this package are not used in any workflows'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
|||||||
import type { CommunityNodeType } from '@n8n/api-types';
|
import type { CommunityNodeType } from '@n8n/api-types';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import semver from 'semver';
|
import semver from 'semver';
|
||||||
|
import { N8nText } from '@n8n/design-system';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import type { WorkflowResource } from '@/Interface';
|
||||||
|
|
||||||
export type CommunityPackageManageMode = 'uninstall' | 'update' | 'view-documentation';
|
export type CommunityPackageManageMode = 'uninstall' | 'update' | 'view-documentation';
|
||||||
|
|
||||||
@@ -25,6 +29,7 @@ const props = defineProps<Props>();
|
|||||||
const communityNodesStore = useCommunityNodesStore();
|
const communityNodesStore = useCommunityNodesStore();
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
|
||||||
const modalBus = createEventBus();
|
const modalBus = createEventBus();
|
||||||
|
|
||||||
@@ -34,6 +39,8 @@ const telemetry = useTelemetry();
|
|||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const workflowsWithPackageNodes = ref<WorkflowResource[]>([]);
|
||||||
|
|
||||||
const isUsingVerifiedAndUnverifiedPackages =
|
const isUsingVerifiedAndUnverifiedPackages =
|
||||||
settingsStore.isCommunityNodesFeatureEnabled && settingsStore.isUnverifiedPackagesEnabled;
|
settingsStore.isCommunityNodesFeatureEnabled && settingsStore.isUnverifiedPackagesEnabled;
|
||||||
const isUsingVerifiedPackagesOnly =
|
const isUsingVerifiedPackagesOnly =
|
||||||
@@ -53,15 +60,22 @@ const isLatestPackageVerified = ref<boolean>(true);
|
|||||||
|
|
||||||
const packageVersion = ref<string>(communityStorePackage.value.updateAvailable ?? '');
|
const packageVersion = ref<string>(communityStorePackage.value.updateAvailable ?? '');
|
||||||
|
|
||||||
|
const includedNodes = computed(() => {
|
||||||
|
return communityStorePackage.value.installedNodes.map((node) => node.name).join(', ');
|
||||||
|
});
|
||||||
|
|
||||||
const getModalContent = computed(() => {
|
const getModalContent = computed(() => {
|
||||||
if (props.mode === COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL) {
|
if (props.mode === COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL) {
|
||||||
return {
|
return {
|
||||||
title: i18n.baseText('settings.communityNodes.confirmModal.uninstall.title'),
|
title: i18n.baseText('settings.communityNodes.confirmModal.uninstall.title'),
|
||||||
message: i18n.baseText('settings.communityNodes.confirmModal.uninstall.message', {
|
message: i18n.baseText('settings.communityNodes.confirmModal.includedNodes', {
|
||||||
interpolate: {
|
interpolate: {
|
||||||
packageName: props.activePackageName,
|
nodes: includedNodes.value,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
description: workflowsWithPackageNodes.value.length
|
||||||
|
? i18n.baseText('settings.communityNodes.confirmModal.uninstall.description')
|
||||||
|
: i18n.baseText('settings.communityNodes.confirmModal.noWorkflowsUsingNodes'),
|
||||||
buttonLabel: i18n.baseText('settings.communityNodes.confirmModal.uninstall.buttonLabel'),
|
buttonLabel: i18n.baseText('settings.communityNodes.confirmModal.uninstall.buttonLabel'),
|
||||||
buttonLoadingLabel: i18n.baseText(
|
buttonLoadingLabel: i18n.baseText(
|
||||||
'settings.communityNodes.confirmModal.uninstall.buttonLoadingLabel',
|
'settings.communityNodes.confirmModal.uninstall.buttonLoadingLabel',
|
||||||
@@ -69,19 +83,16 @@ const getModalContent = computed(() => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title: i18n.baseText('settings.communityNodes.confirmModal.update.title', {
|
title: i18n.baseText('settings.communityNodes.confirmModal.update.title'),
|
||||||
|
message: i18n.baseText('settings.communityNodes.confirmModal.includedNodes', {
|
||||||
interpolate: {
|
interpolate: {
|
||||||
packageName: props.activePackageName,
|
nodes: includedNodes.value,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
description: i18n.baseText('settings.communityNodes.confirmModal.update.description'),
|
description: workflowsWithPackageNodes.value.length
|
||||||
|
? i18n.baseText('settings.communityNodes.confirmModal.update.description')
|
||||||
|
: i18n.baseText('settings.communityNodes.confirmModal.noWorkflowsUsingNodes'),
|
||||||
warning: i18n.baseText('settings.communityNodes.confirmModal.update.warning'),
|
warning: i18n.baseText('settings.communityNodes.confirmModal.update.warning'),
|
||||||
message: i18n.baseText('settings.communityNodes.confirmModal.update.message', {
|
|
||||||
interpolate: {
|
|
||||||
packageName: props.activePackageName,
|
|
||||||
version: packageVersion.value,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
buttonLabel: i18n.baseText('settings.communityNodes.confirmModal.update.buttonLabel'),
|
buttonLabel: i18n.baseText('settings.communityNodes.confirmModal.update.buttonLabel'),
|
||||||
buttonLoadingLabel: i18n.baseText(
|
buttonLoadingLabel: i18n.baseText(
|
||||||
'settings.communityNodes.confirmModal.update.buttonLoadingLabel',
|
'settings.communityNodes.confirmModal.update.buttonLoadingLabel',
|
||||||
@@ -200,11 +211,21 @@ function setPackageVersion() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onClick = async () => {
|
||||||
|
useUIStore().closeModal(COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (props.activePackageName) {
|
if (props.activePackageName) {
|
||||||
await fetchPackageInfo(props.activePackageName);
|
await fetchPackageInfo(props.activePackageName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (communityStorePackage.value?.installedNodes.length) {
|
||||||
|
const nodeTypes = communityStorePackage.value.installedNodes.map((node) => node.type);
|
||||||
|
const response = await workflowsStore.fetchWorkflowsWithNodesIncluded(nodeTypes);
|
||||||
|
workflowsWithPackageNodes.value = response?.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
setIsVerifiedLatestPackage();
|
setIsVerifiedLatestPackage();
|
||||||
setPackageVersion();
|
setPackageVersion();
|
||||||
});
|
});
|
||||||
@@ -212,7 +233,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal
|
<Modal
|
||||||
width="540px"
|
width="640px"
|
||||||
:name="COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY"
|
:name="COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY"
|
||||||
:title="getModalContent.title"
|
:title="getModalContent.title"
|
||||||
:event-bus="modalBus"
|
:event-bus="modalBus"
|
||||||
@@ -221,22 +242,32 @@ onMounted(async () => {
|
|||||||
:before-close="onModalClose"
|
:before-close="onModalClose"
|
||||||
>
|
>
|
||||||
<template #content>
|
<template #content>
|
||||||
<n8n-text>{{ getModalContent.message }}</n8n-text>
|
<N8nText color="text-dark" :bold="true">{{ getModalContent.message }}</N8nText>
|
||||||
<div
|
<n8n-notice
|
||||||
v-if="mode === COMMUNITY_PACKAGE_MANAGE_ACTIONS.UPDATE"
|
v-if="!isLatestPackageVerified"
|
||||||
:class="$style.descriptionContainer"
|
data-test-id="communityPackageManageConfirmModal-warning"
|
||||||
>
|
:content="getModalContent.warning"
|
||||||
<n8n-info-tip theme="info" type="note" :bold="false">
|
/>
|
||||||
<span v-text="getModalContent.description"></span>
|
<div :class="$style.descriptionContainer">
|
||||||
</n8n-info-tip>
|
<N8nText size="medium" color="text-base">
|
||||||
<n8n-notice
|
{{ getModalContent.description }}
|
||||||
v-if="!isLatestPackageVerified"
|
</N8nText>
|
||||||
data-test-id="communityPackageManageConfirmModal-warning"
|
|
||||||
:content="getModalContent.warning"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NodesInWorkflowTable
|
||||||
|
v-if="workflowsWithPackageNodes?.length"
|
||||||
|
:data="workflowsWithPackageNodes"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
<n8n-button
|
||||||
|
:label="i18n.baseText('settings.communityNodes.confirmModal.cancel')"
|
||||||
|
size="large"
|
||||||
|
float="left"
|
||||||
|
type="secondary"
|
||||||
|
data-test-id="close-button"
|
||||||
|
@click="onClick"
|
||||||
|
/>
|
||||||
<n8n-button
|
<n8n-button
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import NodesInWorkflowTable from '@/components/NodesInWorkflowTable.vue';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { screen } from '@testing-library/vue';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import type { WorkflowResource } from '@/Interface';
|
||||||
|
|
||||||
|
const mockWorkflows: WorkflowResource[] = [
|
||||||
|
{
|
||||||
|
id: 'workflow-1',
|
||||||
|
name: 'Test Workflow 1',
|
||||||
|
resourceType: 'workflow',
|
||||||
|
active: true,
|
||||||
|
createdAt: '2023-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2023-01-01T00:00:00.000Z',
|
||||||
|
homeProject: {
|
||||||
|
id: 'project-1',
|
||||||
|
name: 'Test Project 1',
|
||||||
|
icon: { type: 'emoji', value: 'test' },
|
||||||
|
type: 'personal',
|
||||||
|
createdAt: '2023-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2023-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
isArchived: false,
|
||||||
|
readOnly: false,
|
||||||
|
scopes: [],
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'workflow-2',
|
||||||
|
name: 'Test Workflow 2',
|
||||||
|
resourceType: 'workflow',
|
||||||
|
active: false,
|
||||||
|
createdAt: '2023-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2023-01-01T00:00:00.000Z',
|
||||||
|
homeProject: {
|
||||||
|
id: 'project-2',
|
||||||
|
name: 'Test Project 2',
|
||||||
|
icon: { type: 'emoji', value: 'test' },
|
||||||
|
type: 'personal',
|
||||||
|
createdAt: '2023-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2023-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
isArchived: false,
|
||||||
|
readOnly: false,
|
||||||
|
scopes: [],
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('NodesInWorkflowTable', () => {
|
||||||
|
it('should render workflow data in table rows', () => {
|
||||||
|
const renderComponent = createComponentRenderer(NodesInWorkflowTable, {
|
||||||
|
props: {
|
||||||
|
data: mockWorkflows,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
'router-link': {
|
||||||
|
template: '<a><slot /></a>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [createTestingPinia()],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Workflow 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Test Workflow 2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Test Project 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Test Project 2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Active')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Inactive')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import type { TableHeader } from '@n8n/design-system/components/N8nDataTableServer';
|
||||||
|
import type { RouteLocationRaw } from 'vue-router';
|
||||||
|
import { VIEWS } from '@/constants';
|
||||||
|
import type { WorkflowResource } from '@/Interface';
|
||||||
|
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
|
||||||
|
import { useI18n } from '@n8n/i18n';
|
||||||
|
import { ResourceType } from '@/utils/projects.utils';
|
||||||
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
|
|
||||||
|
type WorkflowData = WorkflowResource[];
|
||||||
|
|
||||||
|
type WorkflowDataItem = WorkflowData[number];
|
||||||
|
|
||||||
|
type WorkflowDataKeys = keyof WorkflowDataItem;
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: WorkflowData;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const sortBy = defineModel<Array<{ id: WorkflowDataKeys; desc: boolean }>>('sortBy');
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const projectsStore = useProjectsStore();
|
||||||
|
|
||||||
|
const headers = ref<Array<TableHeader<WorkflowDataItem>>>([
|
||||||
|
{
|
||||||
|
title: 'Workflow',
|
||||||
|
key: 'name',
|
||||||
|
width: 250,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Owner',
|
||||||
|
key: 'homeProject.name',
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
key: 'active',
|
||||||
|
width: 30,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sortedItems = computed(() => {
|
||||||
|
if (!sortBy?.value?.length) return props.data;
|
||||||
|
|
||||||
|
const [{ id, desc }] = sortBy.value;
|
||||||
|
|
||||||
|
return [...props.data].sort((a, b) => {
|
||||||
|
if (!a[id] || !b[id]) return 0;
|
||||||
|
if (a[id] < b[id]) return desc ? 1 : -1;
|
||||||
|
if (a[id] > b[id]) return desc ? -1 : 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const getWorkflowLink = (workflowId: string): RouteLocationRaw => ({
|
||||||
|
name: VIEWS.WORKFLOW,
|
||||||
|
params: {
|
||||||
|
name: workflowId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<N8nDataTableServer
|
||||||
|
v-if="sortedItems?.length"
|
||||||
|
v-model:sort-by="sortBy"
|
||||||
|
:headers="headers"
|
||||||
|
:items="sortedItems"
|
||||||
|
:items-length="sortedItems.length"
|
||||||
|
:page-sizes="[sortedItems.length + 1]"
|
||||||
|
>
|
||||||
|
<template #[`item.name`]="{ item }">
|
||||||
|
<router-link :to="getWorkflowLink(item.id)" target="_blank">
|
||||||
|
<N8nText class="ellipsis" style="color: var(--color-text-base)">{{ item.name }}</N8nText>
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
<template #[`item.homeProject.name`]="{ item }">
|
||||||
|
<div>
|
||||||
|
<ProjectCardBadge
|
||||||
|
class="cardBadge"
|
||||||
|
:resource="item"
|
||||||
|
:resource-type="ResourceType.Workflow"
|
||||||
|
:resource-type-label="i18n.baseText('generic.workflow').toLowerCase()"
|
||||||
|
:personal-project="projectsStore.personalProject"
|
||||||
|
:show-badge-border="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #[`item.active`]="{ item }">
|
||||||
|
<span v-if="item.active" class="status active">Active</span>
|
||||||
|
<span v-else class="status inactive">Inactive</span>
|
||||||
|
</template>
|
||||||
|
</N8nDataTableServer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Use deep selector to access internal scroll container in N8nDataTableServer */
|
||||||
|
:deep(.n8n-data-table-server-wrapper .table-scroll) {
|
||||||
|
max-height: 275px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
border-style: solid;
|
||||||
|
border-width: var(--border-width-base);
|
||||||
|
padding: var(--spacing-4xs);
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inactive {
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ellipsis {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.2;
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardBadge {
|
||||||
|
margin-right: auto;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -648,6 +648,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
return workflowData;
|
return workflowData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchWorkflowsWithNodesIncluded(nodeTypes: string[]) {
|
||||||
|
return await workflowsApi.getWorkflowsWithNodesIncluded(rootStore.restApiContext, nodeTypes);
|
||||||
|
}
|
||||||
|
|
||||||
async function getNewWorkflowData(
|
async function getNewWorkflowData(
|
||||||
name?: string,
|
name?: string,
|
||||||
projectId?: string,
|
projectId?: string,
|
||||||
@@ -2013,6 +2017,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
fetchAllWorkflows,
|
fetchAllWorkflows,
|
||||||
fetchWorkflowsPage,
|
fetchWorkflowsPage,
|
||||||
fetchWorkflow,
|
fetchWorkflow,
|
||||||
|
fetchWorkflowsWithNodesIncluded,
|
||||||
getNewWorkflowData,
|
getNewWorkflowData,
|
||||||
makeNewWorkflowShareable,
|
makeNewWorkflowShareable,
|
||||||
resetWorkflow,
|
resetWorkflow,
|
||||||
|
|||||||
Reference in New Issue
Block a user