mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +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,
|
||||
ListQuery,
|
||||
} from '../entities/types-db';
|
||||
import { buildWorkflowsByNodesQuery } from '../utils/build-workflows-by-nodes-query';
|
||||
import { isStringArray } from '../utils/is-string-array';
|
||||
import { TimedQuery } from '../utils/timed-query';
|
||||
|
||||
@@ -712,4 +713,22 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
|
||||
{ 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 {
|
||||
ImportWorkflowFromUrlDto,
|
||||
ManualRunQueryDto,
|
||||
ROLE,
|
||||
TransferWorkflowBodyDto,
|
||||
} from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
@@ -559,4 +560,31 @@ export class WorkflowsController {
|
||||
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.message": "{packageName} updated to version {version}",
|
||||
"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.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.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.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.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.buttonLabel": "Update package",
|
||||
"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": "Confirm update",
|
||||
"settings.communityNodes.confirmModal.update.buttonLoadingLabel": "Updating...",
|
||||
"settings.communityNodes.confirmModal.cancel": "Cancel",
|
||||
"settings.goBack": "Go back",
|
||||
"settings.personal": "Personal",
|
||||
"settings.personal.basicInformation": "Basic Information",
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
IWorkflowDb,
|
||||
NewWorkflowResponse,
|
||||
WorkflowListResource,
|
||||
WorkflowResource,
|
||||
} from '@/Interface';
|
||||
import type { IRestApiContext } from '@n8n/rest-api-client';
|
||||
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(
|
||||
context: IRestApiContext,
|
||||
filter?: object,
|
||||
|
||||
@@ -9,6 +9,13 @@ import { createTestingPinia } from '@pinia/testing';
|
||||
import { STORES } from '@n8n/stores';
|
||||
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, {
|
||||
data() {
|
||||
return {
|
||||
@@ -28,6 +35,7 @@ const renderComponent = createComponentRenderer(CommunityPackageManageConfirmMod
|
||||
packageName: 'n8n-nodes-test',
|
||||
installedVersion: '1.0.0',
|
||||
updateAvailable: '2.0.0',
|
||||
installedNodes: [{ name: 'TestNode' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -103,4 +111,93 @@ describe('CommunityPackageManageConfirmModal', () => {
|
||||
const testId = getByTestId('communityPackageManageConfirmModal-warning');
|
||||
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 { useSettingsStore } from '@/stores/settings.store';
|
||||
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';
|
||||
|
||||
@@ -25,6 +29,7 @@ const props = defineProps<Props>();
|
||||
const communityNodesStore = useCommunityNodesStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
|
||||
const modalBus = createEventBus();
|
||||
|
||||
@@ -34,6 +39,8 @@ const telemetry = useTelemetry();
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const workflowsWithPackageNodes = ref<WorkflowResource[]>([]);
|
||||
|
||||
const isUsingVerifiedAndUnverifiedPackages =
|
||||
settingsStore.isCommunityNodesFeatureEnabled && settingsStore.isUnverifiedPackagesEnabled;
|
||||
const isUsingVerifiedPackagesOnly =
|
||||
@@ -53,15 +60,22 @@ const isLatestPackageVerified = ref<boolean>(true);
|
||||
|
||||
const packageVersion = ref<string>(communityStorePackage.value.updateAvailable ?? '');
|
||||
|
||||
const includedNodes = computed(() => {
|
||||
return communityStorePackage.value.installedNodes.map((node) => node.name).join(', ');
|
||||
});
|
||||
|
||||
const getModalContent = computed(() => {
|
||||
if (props.mode === COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL) {
|
||||
return {
|
||||
title: i18n.baseText('settings.communityNodes.confirmModal.uninstall.title'),
|
||||
message: i18n.baseText('settings.communityNodes.confirmModal.uninstall.message', {
|
||||
message: i18n.baseText('settings.communityNodes.confirmModal.includedNodes', {
|
||||
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'),
|
||||
buttonLoadingLabel: i18n.baseText(
|
||||
'settings.communityNodes.confirmModal.uninstall.buttonLoadingLabel',
|
||||
@@ -69,19 +83,16 @@ const getModalContent = computed(() => {
|
||||
};
|
||||
}
|
||||
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: {
|
||||
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'),
|
||||
message: i18n.baseText('settings.communityNodes.confirmModal.update.message', {
|
||||
interpolate: {
|
||||
packageName: props.activePackageName,
|
||||
version: packageVersion.value,
|
||||
},
|
||||
}),
|
||||
buttonLabel: i18n.baseText('settings.communityNodes.confirmModal.update.buttonLabel'),
|
||||
buttonLoadingLabel: i18n.baseText(
|
||||
'settings.communityNodes.confirmModal.update.buttonLoadingLabel',
|
||||
@@ -200,11 +211,21 @@ function setPackageVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
const onClick = async () => {
|
||||
useUIStore().closeModal(COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (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();
|
||||
setPackageVersion();
|
||||
});
|
||||
@@ -212,7 +233,7 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
width="540px"
|
||||
width="640px"
|
||||
:name="COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY"
|
||||
:title="getModalContent.title"
|
||||
:event-bus="modalBus"
|
||||
@@ -221,22 +242,32 @@ onMounted(async () => {
|
||||
:before-close="onModalClose"
|
||||
>
|
||||
<template #content>
|
||||
<n8n-text>{{ getModalContent.message }}</n8n-text>
|
||||
<div
|
||||
v-if="mode === COMMUNITY_PACKAGE_MANAGE_ACTIONS.UPDATE"
|
||||
:class="$style.descriptionContainer"
|
||||
>
|
||||
<n8n-info-tip theme="info" type="note" :bold="false">
|
||||
<span v-text="getModalContent.description"></span>
|
||||
</n8n-info-tip>
|
||||
<n8n-notice
|
||||
v-if="!isLatestPackageVerified"
|
||||
data-test-id="communityPackageManageConfirmModal-warning"
|
||||
:content="getModalContent.warning"
|
||||
/>
|
||||
<N8nText color="text-dark" :bold="true">{{ getModalContent.message }}</N8nText>
|
||||
<n8n-notice
|
||||
v-if="!isLatestPackageVerified"
|
||||
data-test-id="communityPackageManageConfirmModal-warning"
|
||||
:content="getModalContent.warning"
|
||||
/>
|
||||
<div :class="$style.descriptionContainer">
|
||||
<N8nText size="medium" color="text-base">
|
||||
{{ getModalContent.description }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<NodesInWorkflowTable
|
||||
v-if="workflowsWithPackageNodes?.length"
|
||||
:data="workflowsWithPackageNodes"
|
||||
/>
|
||||
</template>
|
||||
<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
|
||||
:loading="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;
|
||||
}
|
||||
|
||||
async function fetchWorkflowsWithNodesIncluded(nodeTypes: string[]) {
|
||||
return await workflowsApi.getWorkflowsWithNodesIncluded(rootStore.restApiContext, nodeTypes);
|
||||
}
|
||||
|
||||
async function getNewWorkflowData(
|
||||
name?: string,
|
||||
projectId?: string,
|
||||
@@ -2013,6 +2017,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||
fetchAllWorkflows,
|
||||
fetchWorkflowsPage,
|
||||
fetchWorkflow,
|
||||
fetchWorkflowsWithNodesIncluded,
|
||||
getNewWorkflowData,
|
||||
makeNewWorkflowShareable,
|
||||
resetWorkflow,
|
||||
|
||||
Reference in New Issue
Block a user