mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-20 03:12:15 +00:00
feat: Table in confirm modal to see all workflows using nodes before updating / uninstalling (#17488)
This commit is contained in:
@@ -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