mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
fix(core): Allow insights breakdown by workflow to be sorted by workflow name (#17184)
This commit is contained in:
@@ -20,6 +20,26 @@ describe('ListInsightsWorkflowQueryDto', () => {
|
||||
sortBy: 'total:asc',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'valid sortBy workflowName:asc',
|
||||
request: {
|
||||
sortBy: 'workflowName:asc',
|
||||
},
|
||||
parsedResult: {
|
||||
...DEFAULT_PAGINATION,
|
||||
sortBy: 'workflowName:asc',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'valid sortBy workflowName:desc',
|
||||
request: {
|
||||
sortBy: 'workflowName:desc',
|
||||
},
|
||||
parsedResult: {
|
||||
...DEFAULT_PAGINATION,
|
||||
sortBy: 'workflowName:desc',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'valid skip and take',
|
||||
request: {
|
||||
|
||||
@@ -21,6 +21,8 @@ const VALID_SORT_OPTIONS = [
|
||||
'runTime:desc',
|
||||
'averageRunTime:asc',
|
||||
'averageRunTime:desc',
|
||||
'workflowName:asc',
|
||||
'workflowName:desc',
|
||||
] as const;
|
||||
|
||||
// ---------------------
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { InsightsDateRange } from '@n8n/api-types';
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import { mockInstance, createWorkflow, createTeamProject, testDb } from '@n8n/backend-test-utils';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { Telemetry } from '@/telemetry';
|
||||
import { createCompactedInsightsEvent } from '@/modules/insights/database/entities/__tests__/db-utils';
|
||||
|
||||
import { createUser } from '../shared/db/users';
|
||||
import type { SuperAgentTest } from '../shared/types';
|
||||
@@ -116,11 +118,20 @@ describe('GET /insights/by-workflow', () => {
|
||||
features: ['feat:insights:viewSummary', 'feat:insights:viewDashboard'],
|
||||
});
|
||||
});
|
||||
test('Call should work with valid query parameters', async () => {
|
||||
await agents.owner
|
||||
.get('/insights/by-workflow')
|
||||
.query({ skip: '10', take: '20', sortBy: 'total:desc' })
|
||||
.expect(200);
|
||||
|
||||
test.each([
|
||||
{
|
||||
skip: '10',
|
||||
take: '20',
|
||||
sortBy: 'total:desc',
|
||||
},
|
||||
{
|
||||
skip: '1',
|
||||
take: '25',
|
||||
sortBy: 'workflowName:asc',
|
||||
},
|
||||
])('Call should work with valid query parameters: %s', async (queryParams) => {
|
||||
await agents.owner.get('/insights/by-workflow').query(queryParams).expect(200);
|
||||
});
|
||||
|
||||
test.each<{ skip: string; take?: string; sortBy?: string }>([
|
||||
@@ -149,4 +160,168 @@ describe('GET /insights/by-workflow', () => {
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
test.each([
|
||||
'total:asc',
|
||||
'total:desc',
|
||||
'succeeded:asc',
|
||||
'succeeded:desc',
|
||||
'failed:asc',
|
||||
'failed:desc',
|
||||
'failureRate:asc',
|
||||
'failureRate:desc',
|
||||
'timeSaved:asc',
|
||||
'timeSaved:desc',
|
||||
'runTime:asc',
|
||||
'runTime:desc',
|
||||
'averageRunTime:asc',
|
||||
'averageRunTime:desc',
|
||||
'workflowName:asc',
|
||||
'workflowName:desc',
|
||||
])('Call should return 200 with valid sortBy option: %s', async (sortBy) => {
|
||||
const response = await agents.owner.get('/insights/by-workflow').query({ sortBy }).expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('data');
|
||||
expect(response.body.data).toHaveProperty('count');
|
||||
expect(Array.isArray(response.body.data.data)).toBe(true);
|
||||
});
|
||||
|
||||
describe('sorting order verification', () => {
|
||||
afterEach(async () => {
|
||||
await testDb.truncate([
|
||||
'InsightsRaw',
|
||||
'InsightsByPeriod',
|
||||
'InsightsMetadata',
|
||||
'SharedWorkflow',
|
||||
'WorkflowEntity',
|
||||
'Project',
|
||||
]);
|
||||
});
|
||||
|
||||
test('should return workflows sorted by total:desc', async () => {
|
||||
const project = await createTeamProject('Test Project 1');
|
||||
const testData = [
|
||||
{ name: 'Workflow A', total: 10 },
|
||||
{ name: 'Workflow B', total: 5 },
|
||||
{ name: 'Workflow C', total: 15 },
|
||||
];
|
||||
|
||||
const workflows = await Promise.all(
|
||||
testData.map(async (data) => await createWorkflow({ name: data.name }, project)),
|
||||
);
|
||||
|
||||
const periodStart = DateTime.utc().startOf('day');
|
||||
|
||||
await Promise.all(
|
||||
workflows.map(
|
||||
async (workflow, index) =>
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'success',
|
||||
value: testData[index].total,
|
||||
periodUnit: 'day',
|
||||
periodStart,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const response = await agents.owner
|
||||
.get('/insights/by-workflow')
|
||||
.query({ sortBy: 'total:desc' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data.count).toBe(3);
|
||||
expect(response.body.data.data).toHaveLength(3);
|
||||
|
||||
// Verify descending order by total
|
||||
const expectedOrder = [15, 10, 5];
|
||||
response.body.data.data.forEach((item: any, index: number) => {
|
||||
expect(item.total).toBe(expectedOrder[index]);
|
||||
});
|
||||
});
|
||||
|
||||
test('should return workflows sorted by workflowName:asc', async () => {
|
||||
const project = await createTeamProject('Test Project A');
|
||||
const workflowNames = ['Zebra Workflow', 'Alpha Workflow', 'Beta Workflow'];
|
||||
|
||||
const workflows = await Promise.all(
|
||||
workflowNames.map(async (name) => await createWorkflow({ name }, project)),
|
||||
);
|
||||
|
||||
const periodStart = DateTime.utc().startOf('day');
|
||||
|
||||
await Promise.all(
|
||||
workflows.map(
|
||||
async (workflow) =>
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'success',
|
||||
value: 5,
|
||||
periodUnit: 'day',
|
||||
periodStart,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const response = await agents.owner
|
||||
.get('/insights/by-workflow')
|
||||
.query({ sortBy: 'workflowName:asc' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data.count).toBe(3);
|
||||
expect(response.body.data.data).toHaveLength(3);
|
||||
|
||||
// Verify ascending order by workflow name
|
||||
const expectedOrder = ['Alpha Workflow', 'Beta Workflow', 'Zebra Workflow'];
|
||||
response.body.data.data.forEach((item: any, index: number) => {
|
||||
expect(item.workflowName).toBe(expectedOrder[index]);
|
||||
});
|
||||
});
|
||||
|
||||
test('should return workflows sorted by failureRate:asc', async () => {
|
||||
const project = await createTeamProject('Another Test Project');
|
||||
const testData = [
|
||||
{ name: 'Low Failure', success: 9, failure: 1 }, // 10% failure rate
|
||||
{ name: 'High Failure', success: 2, failure: 8 }, // 80% failure rate
|
||||
{ name: 'Medium Failure', success: 5, failure: 5 }, // 50% failure rate
|
||||
];
|
||||
|
||||
const workflows = await Promise.all(
|
||||
testData.map(async (data) => await createWorkflow({ name: data.name }, project)),
|
||||
);
|
||||
|
||||
const periodStart = DateTime.utc().startOf('day');
|
||||
|
||||
// Create insights events for each workflow individually to avoid race conditions
|
||||
for (const [index, workflow] of workflows.entries()) {
|
||||
const data = testData[index];
|
||||
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'success',
|
||||
value: data.success,
|
||||
periodUnit: 'day',
|
||||
periodStart,
|
||||
});
|
||||
|
||||
await createCompactedInsightsEvent(workflow, {
|
||||
type: 'failure',
|
||||
value: data.failure,
|
||||
periodUnit: 'day',
|
||||
periodStart,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await agents.owner
|
||||
.get('/insights/by-workflow')
|
||||
.query({ sortBy: 'failureRate:asc' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data.count).toBe(3);
|
||||
expect(response.body.data.data).toHaveLength(3);
|
||||
|
||||
// Verify ascending order by failure rate
|
||||
const expectedOrder = [0.1, 0.5, 0.8]; // 10%, 50%, 80%
|
||||
response.body.data.data.forEach((item: any, index: number) => {
|
||||
expect(item.failureRate).toBe(expectedOrder[index]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import { defineComponent } from 'vue';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { screen, within } from '@testing-library/vue';
|
||||
import { vi } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { useEmitters } from '@/__tests__/utils';
|
||||
import InsightsTableWorkflows from '@/features/insights/components/tables/InsightsTableWorkflows.vue';
|
||||
import type { InsightsByWorkflow } from '@n8n/api-types';
|
||||
|
||||
const { emitters, addEmitter } = useEmitters<'n8nDataTableServer'>();
|
||||
|
||||
// Mock telemetry
|
||||
const mockTelemetry = {
|
||||
track: vi.fn(),
|
||||
};
|
||||
vi.mock('@/composables/useTelemetry', () => ({
|
||||
useTelemetry: () => mockTelemetry,
|
||||
}));
|
||||
|
||||
// Mock N8nDataTableServer like in SettingsUsersTable.test.ts
|
||||
vi.mock('@n8n/design-system', async (importOriginal) => {
|
||||
const original = await importOriginal<object>();
|
||||
return {
|
||||
...original,
|
||||
N8nDataTableServer: defineComponent({
|
||||
props: {
|
||||
headers: { type: Array, required: true },
|
||||
items: { type: Array, required: true },
|
||||
itemsLength: { type: Number, required: true },
|
||||
sortBy: { type: Array },
|
||||
page: { type: Number },
|
||||
itemsPerPage: { type: Number },
|
||||
},
|
||||
setup(_, { emit }) {
|
||||
addEmitter('n8nDataTableServer', emit);
|
||||
},
|
||||
template: `
|
||||
<div data-test-id="insights-table">
|
||||
<div v-for="item in items" :key="item.workflowId"
|
||||
:data-test-id="'workflow-row-' + item.workflowId">
|
||||
<div v-for="header in headers" :key="header.key">
|
||||
<slot :name="'item.' + header.key" :item="item"
|
||||
:value="header.value ? header.value(item) : item[header.key]">
|
||||
<span v-if="header.value">{{ header.value(item) }}</span>
|
||||
<span v-else>{{ item[header.key] }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const mockInsightsData: InsightsByWorkflow = {
|
||||
count: 2,
|
||||
data: [
|
||||
{
|
||||
workflowId: 'workflow-1',
|
||||
workflowName: 'Test Workflow 1',
|
||||
total: 100,
|
||||
failed: 5,
|
||||
failureRate: 0.05,
|
||||
timeSaved: 3600,
|
||||
averageRunTime: 1200,
|
||||
projectName: 'Test Project 1',
|
||||
projectId: 'project-1',
|
||||
succeeded: 95,
|
||||
runTime: 114000,
|
||||
},
|
||||
{
|
||||
workflowId: 'workflow-2',
|
||||
workflowName: 'Test Workflow 2 With Very Long Name That Should Be Truncated',
|
||||
total: 50,
|
||||
failed: 0,
|
||||
failureRate: 0,
|
||||
timeSaved: 0,
|
||||
averageRunTime: 800,
|
||||
projectName: '',
|
||||
projectId: 'project-2',
|
||||
succeeded: 50,
|
||||
runTime: 40000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let renderComponent: ReturnType<typeof createComponentRenderer>;
|
||||
|
||||
describe('InsightsTableWorkflows', () => {
|
||||
beforeEach(() => {
|
||||
renderComponent = createComponentRenderer(InsightsTableWorkflows, {
|
||||
pinia: createTestingPinia(),
|
||||
props: {
|
||||
data: mockInsightsData,
|
||||
loading: false,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
'router-link': {
|
||||
template: '<a><slot /></a>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('basic rendering', () => {
|
||||
it('should display the correct heading', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Workflow insights' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render workflow data in table rows', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(screen.getByTestId('workflow-row-workflow-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('workflow-row-workflow-2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('data display', () => {
|
||||
it('should display formatted numbers for total and failed columns', () => {
|
||||
renderComponent();
|
||||
|
||||
const row1 = screen.getByTestId('workflow-row-workflow-1');
|
||||
expect(within(row1).getByText('100')).toBeInTheDocument();
|
||||
expect(within(row1).getByText('5')).toBeInTheDocument();
|
||||
|
||||
const row2 = screen.getByTestId('workflow-row-workflow-2');
|
||||
expect(within(row2).getByText('50')).toBeInTheDocument();
|
||||
expect(within(row2).getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle large numbers with locale formatting', () => {
|
||||
const largeNumberData = {
|
||||
...mockInsightsData,
|
||||
data: [
|
||||
{
|
||||
...mockInsightsData.data[0],
|
||||
total: 1000000,
|
||||
failed: 50000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
renderComponent = createComponentRenderer(InsightsTableWorkflows, {
|
||||
pinia: createTestingPinia(),
|
||||
props: {
|
||||
data: largeNumberData,
|
||||
loading: false,
|
||||
},
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
const row = screen.getByTestId('workflow-row-workflow-1');
|
||||
expect(within(row).getByText('1,000,000')).toBeInTheDocument();
|
||||
expect(within(row).getByText('50,000')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('template slots', () => {
|
||||
describe('workflowName slot', () => {
|
||||
it('should render workflow name with tooltip', () => {
|
||||
renderComponent();
|
||||
|
||||
const row1 = screen.getByTestId('workflow-row-workflow-1');
|
||||
expect(within(row1).getByText('Test Workflow 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should track telemetry on workflow name click', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
const row1 = screen.getByTestId('workflow-row-workflow-1');
|
||||
const workflowLink = within(row1).getByText('Test Workflow 1');
|
||||
await user.click(workflowLink);
|
||||
|
||||
expect(mockTelemetry.track).toHaveBeenCalledWith(
|
||||
'User clicked on workflow from insights table',
|
||||
{
|
||||
workflow_id: 'workflow-1',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeSaved slot', () => {
|
||||
it('should show estimate link when timeSaved is 0', () => {
|
||||
renderComponent();
|
||||
|
||||
const row2 = screen.getByTestId('workflow-row-workflow-2');
|
||||
expect(within(row2).getByText('Estimate')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show formatted value when timeSaved exists', () => {
|
||||
renderComponent();
|
||||
|
||||
// The actual formatted value will be processed by the utility functions
|
||||
// We just verify the slot is rendered with the item that has timeSaved
|
||||
const row1 = screen.getByTestId('workflow-row-workflow-1');
|
||||
expect(row1).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectName slot', () => {
|
||||
it('should render project name with tooltip when projectName exists', () => {
|
||||
renderComponent();
|
||||
|
||||
const row1 = screen.getByTestId('workflow-row-workflow-1');
|
||||
expect(within(row1).getByText('Test Project 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render dash when projectName is null', () => {
|
||||
renderComponent();
|
||||
|
||||
const row2 = screen.getByTestId('workflow-row-workflow-2');
|
||||
expect(within(row2).getByText('-')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('event delegation', () => {
|
||||
it('should delegate update:options event from N8nDataTableServer', () => {
|
||||
const { emitted } = renderComponent();
|
||||
|
||||
const updateOptions = {
|
||||
page: 1,
|
||||
itemsPerPage: 20,
|
||||
sortBy: [{ id: 'workflowName', desc: false }],
|
||||
};
|
||||
|
||||
emitters.n8nDataTableServer.emit('update:options', updateOptions);
|
||||
|
||||
expect(emitted()).toHaveProperty('update:options');
|
||||
expect(emitted()['update:options'][0]).toEqual([updateOptions]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('telemetry tracking', () => {
|
||||
it('should track sort changes with correct payload', async () => {
|
||||
const { rerender } = renderComponent();
|
||||
|
||||
// Simulate sortBy change
|
||||
await rerender({
|
||||
sortBy: [{ id: 'total', desc: true }],
|
||||
});
|
||||
|
||||
expect(mockTelemetry.track).toHaveBeenCalledWith('User sorted insights table', {
|
||||
sorted_by: [
|
||||
{
|
||||
id: 'total',
|
||||
desc: true,
|
||||
label: 'Prod. executions',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty sortBy array', async () => {
|
||||
const { rerender } = renderComponent();
|
||||
|
||||
// Simulate sortBy change to empty array
|
||||
await rerender({
|
||||
sortBy: [],
|
||||
});
|
||||
|
||||
expect(mockTelemetry.track).toHaveBeenCalledWith('User sorted insights table', {
|
||||
sorted_by: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty data array', () => {
|
||||
const emptyData = { count: 0, data: [] };
|
||||
|
||||
renderComponent = createComponentRenderer(InsightsTableWorkflows, {
|
||||
pinia: createTestingPinia(),
|
||||
props: {
|
||||
data: emptyData,
|
||||
loading: false,
|
||||
},
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(screen.getByTestId('insights-table')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('workflow-row-workflow-1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle loading state', () => {
|
||||
renderComponent = createComponentRenderer(InsightsTableWorkflows, {
|
||||
pinia: createTestingPinia(),
|
||||
props: {
|
||||
data: mockInsightsData,
|
||||
loading: true,
|
||||
},
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(screen.getByTestId('insights-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle missing optional props', () => {
|
||||
renderComponent = createComponentRenderer(InsightsTableWorkflows, {
|
||||
pinia: createTestingPinia(),
|
||||
props: {
|
||||
data: mockInsightsData,
|
||||
// loading prop omitted
|
||||
},
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(screen.getByTestId('insights-table')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,10 +7,8 @@ import {
|
||||
transformInsightsTimeSaved,
|
||||
} from '@/features/insights/insights.utils';
|
||||
import type { InsightsByWorkflow } from '@n8n/api-types';
|
||||
import { N8nTooltip } from '@n8n/design-system';
|
||||
import N8nDataTableServer, {
|
||||
type TableHeader,
|
||||
} from '@n8n/design-system/components/N8nDataTableServer/N8nDataTableServer.vue';
|
||||
import { N8nTooltip, N8nDataTableServer } from '@n8n/design-system';
|
||||
import type { TableHeader } from '@n8n/design-system/components/N8nDataTableServer';
|
||||
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { VIEWS } from '@/constants';
|
||||
@@ -34,7 +32,6 @@ const headers = ref<Array<TableHeader<Item>>>([
|
||||
title: 'Name',
|
||||
key: 'workflowName',
|
||||
width: 400,
|
||||
disableSort: true,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('insights.banner.title.total'),
|
||||
|
||||
Reference in New Issue
Block a user