mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-20 11:22:15 +00:00
fix(editor): Update frontend to handle unlicensed insights dashboard, if only Time saved feature is enabled (#17199)
This commit is contained in:
@@ -74,6 +74,7 @@ const slots = useSlots();
|
|||||||
defineSlots<{
|
defineSlots<{
|
||||||
[key: `item.${string}`]: (props: { value: unknown; item: T }) => void;
|
[key: `item.${string}`]: (props: { value: unknown; item: T }) => void;
|
||||||
item: (props: { item: T; cells: Array<Cell<T, unknown>> }) => void;
|
item: (props: { item: T; cells: Array<Cell<T, unknown>> }) => void;
|
||||||
|
cover?: () => void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -433,6 +434,13 @@ const table = useVueTable({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
<template v-if="slots.cover">
|
||||||
|
<tr>
|
||||||
|
<td class="cover" :colspan="table.getVisibleFlatColumns().length">
|
||||||
|
<slot name="cover" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
<template v-if="loading && !table.getRowModel().rows.length">
|
<template v-if="loading && !table.getRowModel().rows.length">
|
||||||
<tr v-for="item in itemsPerPage" :key="item">
|
<tr v-for="item in itemsPerPage" :key="item">
|
||||||
<td
|
<td
|
||||||
@@ -567,6 +575,14 @@ const table = useVueTable({
|
|||||||
&:last-child {
|
&:last-child {
|
||||||
padding-right: 16px;
|
padding-right: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.cover {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3251,6 +3251,7 @@
|
|||||||
"insights.banner.title.timeSavedDailyAverage": "Time saved daily avg.",
|
"insights.banner.title.timeSavedDailyAverage": "Time saved daily avg.",
|
||||||
"insights.banner.title.averageRunTime": "Run time (avg.)",
|
"insights.banner.title.averageRunTime": "Run time (avg.)",
|
||||||
"insights.dashboard.table.projectName": "Project name",
|
"insights.dashboard.table.projectName": "Project name",
|
||||||
|
"insights.dashboard.table.title": "Breakdown by workflow",
|
||||||
"insights.dashboard.table.estimate": "Estimate",
|
"insights.dashboard.table.estimate": "Estimate",
|
||||||
"insights.dashboard.title": "Insights",
|
"insights.dashboard.title": "Insights",
|
||||||
"insights.dashboard.paywall.cta": "Upgrade",
|
"insights.dashboard.paywall.cta": "Upgrade",
|
||||||
|
|||||||
@@ -1,19 +1,75 @@
|
|||||||
|
import { defineComponent, reactive } from 'vue';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import InsightsDashboard from './InsightsDashboard.vue';
|
import InsightsDashboard from './InsightsDashboard.vue';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { defaultSettings } from '@/__tests__/defaults';
|
import { defaultSettings } from '@/__tests__/defaults';
|
||||||
import { useInsightsStore } from '@/features/insights/insights.store';
|
import { useInsightsStore } from '@/features/insights/insights.store';
|
||||||
import { mockedStore } from '@/__tests__/utils';
|
import { mockedStore, type MockedStore, useEmitters, waitAllPromises } from '@/__tests__/utils';
|
||||||
import { within } from '@testing-library/vue';
|
import { within, screen, waitFor } from '@testing-library/vue';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import type {
|
||||||
|
FrontendModuleSettings,
|
||||||
|
InsightsByTime,
|
||||||
|
InsightsByWorkflow,
|
||||||
|
InsightsSummaryType,
|
||||||
|
} from '@n8n/api-types';
|
||||||
|
import { INSIGHT_TYPES } from '@/features/insights/insights.constants';
|
||||||
|
import type { InsightsSummaryDisplay } from '@/features/insights/insights.types';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(InsightsDashboard, {
|
const { emitters, addEmitter } = useEmitters<'n8nDataTableServer'>();
|
||||||
props: {
|
|
||||||
insightType: 'total',
|
const mockRoute = reactive<{
|
||||||
|
params: {
|
||||||
|
insightType: InsightsSummaryType;
|
||||||
|
};
|
||||||
|
}>({
|
||||||
|
params: { insightType: INSIGHT_TYPES.TOTAL },
|
||||||
|
});
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRoute: () => mockRoute,
|
||||||
|
useRouter: vi.fn(),
|
||||||
|
RouterLink: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('vue-chartjs', () => ({
|
||||||
|
Bar: {
|
||||||
|
template: '<div>Bar</div>',
|
||||||
},
|
},
|
||||||
|
Line: {
|
||||||
|
template: '<div>Line</div>',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
setup(_, { emit }) {
|
||||||
|
addEmitter('n8nDataTableServer', emit);
|
||||||
|
},
|
||||||
|
template: '<div data-test-id="insights-table"><slot /></div>',
|
||||||
|
}),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const moduleSettings = {
|
const mockTelemetry = {
|
||||||
|
track: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('@/composables/useTelemetry', () => ({
|
||||||
|
useTelemetry: () => mockTelemetry,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const renderComponent = createComponentRenderer(InsightsDashboard);
|
||||||
|
|
||||||
|
const moduleSettings: FrontendModuleSettings = {
|
||||||
insights: {
|
insights: {
|
||||||
summary: true,
|
summary: true,
|
||||||
dashboard: true,
|
dashboard: true,
|
||||||
@@ -23,30 +79,241 @@ const moduleSettings = {
|
|||||||
licensed: true,
|
licensed: true,
|
||||||
granularity: 'hour',
|
granularity: 'hour',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'week',
|
||||||
|
licensed: true,
|
||||||
|
granularity: 'day',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'month',
|
||||||
|
licensed: true,
|
||||||
|
granularity: 'day',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'quarter',
|
||||||
|
licensed: false,
|
||||||
|
granularity: 'week',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockSummaryData: InsightsSummaryDisplay = [
|
||||||
|
{
|
||||||
|
id: 'total',
|
||||||
|
value: 1250,
|
||||||
|
deviation: 15,
|
||||||
|
unit: '',
|
||||||
|
deviationUnit: '%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'failed',
|
||||||
|
value: 23,
|
||||||
|
deviation: -8,
|
||||||
|
unit: '',
|
||||||
|
deviationUnit: '%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'failureRate',
|
||||||
|
value: 1.84,
|
||||||
|
deviation: -0.5,
|
||||||
|
unit: '%',
|
||||||
|
deviationUnit: '%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'timeSaved',
|
||||||
|
value: 3600,
|
||||||
|
deviation: 20,
|
||||||
|
unit: 's',
|
||||||
|
deviationUnit: '%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'averageRunTime',
|
||||||
|
value: 15,
|
||||||
|
deviation: 2,
|
||||||
|
unit: 's',
|
||||||
|
deviationUnit: '%',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockChartsData: InsightsByTime[] = [
|
||||||
|
{
|
||||||
|
date: '2024-01-01',
|
||||||
|
values: {
|
||||||
|
total: 100,
|
||||||
|
failed: 5,
|
||||||
|
failureRate: 5,
|
||||||
|
timeSaved: 45,
|
||||||
|
averageRunTime: 12,
|
||||||
|
succeeded: 95,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '2024-01-02',
|
||||||
|
values: {
|
||||||
|
total: 120,
|
||||||
|
failed: 8,
|
||||||
|
failureRate: 6.7,
|
||||||
|
timeSaved: 55,
|
||||||
|
averageRunTime: 15,
|
||||||
|
succeeded: 112,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockTableData: InsightsByWorkflow = {
|
||||||
|
count: 2,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
workflowId: 'workflow-1',
|
||||||
|
workflowName: 'Test Workflow 1',
|
||||||
|
total: 100,
|
||||||
|
failed: 5,
|
||||||
|
failureRate: 5,
|
||||||
|
timeSaved: 45,
|
||||||
|
averageRunTime: 12,
|
||||||
|
projectId: 'project-1',
|
||||||
|
projectName: 'Test Project 1',
|
||||||
|
succeeded: 95,
|
||||||
|
runTime: 1200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
workflowId: 'workflow-2',
|
||||||
|
workflowName: 'Test Workflow 2',
|
||||||
|
total: 50,
|
||||||
|
failed: 2,
|
||||||
|
failureRate: 4,
|
||||||
|
timeSaved: 20,
|
||||||
|
averageRunTime: 8,
|
||||||
|
projectId: 'project-2',
|
||||||
|
projectName: 'Test Project 2',
|
||||||
|
succeeded: 48,
|
||||||
|
runTime: 400,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let insightsStore: MockedStore<typeof useInsightsStore>;
|
||||||
|
|
||||||
describe('InsightsDashboard', () => {
|
describe('InsightsDashboard', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
mockRoute.params.insightType = INSIGHT_TYPES.TOTAL;
|
||||||
|
|
||||||
createTestingPinia({
|
createTestingPinia({
|
||||||
initialState: { settings: { settings: defaultSettings, moduleSettings } },
|
initialState: { settings: { settings: defaultSettings, moduleSettings } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
insightsStore = mockedStore(useInsightsStore);
|
||||||
|
|
||||||
|
insightsStore.isSummaryEnabled = true;
|
||||||
|
insightsStore.isDashboardEnabled = true;
|
||||||
|
|
||||||
|
// Mock async states
|
||||||
|
insightsStore.summary = {
|
||||||
|
state: mockSummaryData,
|
||||||
|
isLoading: false,
|
||||||
|
execute: vi.fn(),
|
||||||
|
isReady: true,
|
||||||
|
error: null,
|
||||||
|
then: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
insightsStore.charts = {
|
||||||
|
state: mockChartsData,
|
||||||
|
isLoading: false,
|
||||||
|
execute: vi.fn(),
|
||||||
|
isReady: true,
|
||||||
|
error: null,
|
||||||
|
then: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
insightsStore.table = {
|
||||||
|
state: mockTableData,
|
||||||
|
isLoading: false,
|
||||||
|
execute: vi.fn(),
|
||||||
|
isReady: true,
|
||||||
|
error: null,
|
||||||
|
then: vi.fn(),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Component Rendering', () => {
|
||||||
it('should render without error', () => {
|
it('should render without error', () => {
|
||||||
mockedStore(useInsightsStore);
|
expect(() =>
|
||||||
expect(() => renderComponent()).not.toThrow();
|
renderComponent({
|
||||||
|
props: { insightType: INSIGHT_TYPES.TOTAL },
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
expect(document.title).toBe('Insights - n8n');
|
expect(document.title).toBe('Insights - n8n');
|
||||||
|
expect(screen.getByRole('heading', { level: 2, name: 'Insights' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should render summary when enabled', () => {
|
||||||
|
renderComponent({
|
||||||
|
props: { insightType: INSIGHT_TYPES.TOTAL },
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('insights-summary-tabs')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render summary when disabled', () => {
|
||||||
|
insightsStore.isSummaryEnabled = false;
|
||||||
|
renderComponent({
|
||||||
|
props: { insightType: INSIGHT_TYPES.TOTAL },
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('insights-summary-tabs')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render chart and table when dashboard enabled', async () => {
|
||||||
|
renderComponent({
|
||||||
|
props: { insightType: INSIGHT_TYPES.TOTAL },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('insights-chart-total')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('insights-table')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render chart when in time saved route even if dashboard disabled', async () => {
|
||||||
|
insightsStore.isDashboardEnabled = false;
|
||||||
|
mockRoute.params.insightType = INSIGHT_TYPES.TIME_SAVED;
|
||||||
|
renderComponent({
|
||||||
|
props: { insightType: INSIGHT_TYPES.TIME_SAVED },
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('insights-chart-time-saved')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render paywall when dashboard disabled and not time saved route', async () => {
|
||||||
|
insightsStore.isDashboardEnabled = false;
|
||||||
|
renderComponent({
|
||||||
|
props: { insightType: INSIGHT_TYPES.TOTAL },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId('insights-chart-total')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('insights-table')).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('heading', {
|
||||||
|
level: 4,
|
||||||
|
name: 'Upgrade to access more detailed insights',
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Date Range Selection', () => {
|
||||||
it('should update the selected time range', async () => {
|
it('should update the selected time range', async () => {
|
||||||
mockedStore(useInsightsStore);
|
renderComponent({
|
||||||
|
props: { insightType: INSIGHT_TYPES.TOTAL },
|
||||||
|
});
|
||||||
|
|
||||||
const { getByTestId } = renderComponent();
|
expect(screen.getByTestId('range-select')).toBeVisible();
|
||||||
|
const select = within(screen.getByTestId('range-select')).getByRole('combobox');
|
||||||
expect(getByTestId('range-select')).toBeVisible();
|
|
||||||
const select = within(getByTestId('range-select')).getByRole('combobox');
|
|
||||||
await userEvent.click(select);
|
await userEvent.click(select);
|
||||||
|
|
||||||
const controllingId = select.getAttribute('aria-controls');
|
const controllingId = select.getAttribute('aria-controls');
|
||||||
@@ -57,5 +324,191 @@ describe('InsightsDashboard', () => {
|
|||||||
|
|
||||||
await userEvent.click(actions.querySelectorAll('li')[0]);
|
await userEvent.click(actions.querySelectorAll('li')[0]);
|
||||||
expect((select as HTMLInputElement).value).toBe('Last 24 hours');
|
expect((select as HTMLInputElement).value).toBe('Last 24 hours');
|
||||||
|
|
||||||
|
expect(mockTelemetry.track).toHaveBeenCalledWith('User updated insights time range', {
|
||||||
|
range: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(insightsStore.summary.execute).toHaveBeenCalledWith(0, { dateRange: 'day' });
|
||||||
|
expect(insightsStore.charts.execute).toHaveBeenCalledWith(0, { dateRange: 'day' });
|
||||||
|
expect(insightsStore.table.execute).toHaveBeenCalledWith(0, {
|
||||||
|
skip: 0,
|
||||||
|
take: 25,
|
||||||
|
sortBy: 'total:desc',
|
||||||
|
dateRange: 'day',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show upgrade modal when unlicensed time range selected ', async () => {
|
||||||
|
renderComponent({
|
||||||
|
props: { insightType: INSIGHT_TYPES.TOTAL },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId('range-select')).toBeVisible();
|
||||||
|
const select = within(screen.getByTestId('range-select')).getByRole('combobox');
|
||||||
|
await userEvent.click(select);
|
||||||
|
|
||||||
|
const controllingId = select.getAttribute('aria-controls');
|
||||||
|
const actions = document.querySelector(`#${controllingId}`);
|
||||||
|
if (!actions) {
|
||||||
|
throw new Error('Actions menu not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select a range that requires an enterprise plan
|
||||||
|
await userEvent.click(actions.querySelectorAll('li')[3]);
|
||||||
|
|
||||||
|
// Verify the select value is remained the original, default value, as unlicensed options should not change the selection
|
||||||
|
expect((select as HTMLInputElement).value).toBe('Last 7 days');
|
||||||
|
|
||||||
|
expect(mockTelemetry.track).not.toHaveBeenCalled();
|
||||||
|
expect(insightsStore.summary.execute).toHaveBeenCalledWith(0, { dateRange: 'week' });
|
||||||
|
expect(insightsStore.charts.execute).toHaveBeenCalledWith(0, { dateRange: 'week' });
|
||||||
|
expect(insightsStore.table.execute).toHaveBeenCalledWith(0, {
|
||||||
|
skip: 0,
|
||||||
|
take: 25,
|
||||||
|
sortBy: 'total:desc',
|
||||||
|
dateRange: 'week',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Viewing this time period requires an enterprise plan/),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Component Lifecycle', () => {
|
||||||
|
it('should execute data fetching on mount', () => {
|
||||||
|
renderComponent({
|
||||||
|
props: { insightType: INSIGHT_TYPES.TOTAL },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(insightsStore.summary.execute).toHaveBeenCalledWith(0, { dateRange: 'week' });
|
||||||
|
expect(insightsStore.charts.execute).toHaveBeenCalledWith(0, { dateRange: 'week' });
|
||||||
|
expect(insightsStore.table.execute).toHaveBeenCalledWith(0, {
|
||||||
|
skip: 0,
|
||||||
|
take: 25,
|
||||||
|
sortBy: 'total:desc',
|
||||||
|
dateRange: 'week',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should refetch data when insight type changes', async () => {
|
||||||
|
const { rerender } = renderComponent({
|
||||||
|
props: { insightType: INSIGHT_TYPES.TOTAL },
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
await rerender({ insightType: INSIGHT_TYPES.FAILED });
|
||||||
|
|
||||||
|
expect(insightsStore.summary.execute).toHaveBeenCalledWith(0, { dateRange: 'week' });
|
||||||
|
expect(insightsStore.charts.execute).toHaveBeenCalledWith(0, { dateRange: 'week' });
|
||||||
|
expect(insightsStore.table.execute).toHaveBeenCalledWith(0, {
|
||||||
|
skip: 0,
|
||||||
|
take: 25,
|
||||||
|
sortBy: 'failed:desc',
|
||||||
|
dateRange: 'week',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update sort order when insight type changes', async () => {
|
||||||
|
const { rerender } = renderComponent({
|
||||||
|
props: { insightType: INSIGHT_TYPES.TOTAL },
|
||||||
|
});
|
||||||
|
|
||||||
|
await rerender({ insightType: INSIGHT_TYPES.TIME_SAVED });
|
||||||
|
|
||||||
|
expect(insightsStore.table.execute).toHaveBeenCalledWith(0, {
|
||||||
|
skip: 0,
|
||||||
|
take: 25,
|
||||||
|
sortBy: 'timeSaved:desc',
|
||||||
|
dateRange: 'week',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Chart wrapper', () => {
|
||||||
|
test.each([
|
||||||
|
[INSIGHT_TYPES.TOTAL, 'insights-chart-total'],
|
||||||
|
[INSIGHT_TYPES.FAILED, 'insights-chart-failed'],
|
||||||
|
[INSIGHT_TYPES.FAILURE_RATE, 'insights-chart-failure-rate'],
|
||||||
|
[INSIGHT_TYPES.TIME_SAVED, 'insights-chart-time-saved'],
|
||||||
|
[INSIGHT_TYPES.AVERAGE_RUN_TIME, 'insights-chart-average-runtime'],
|
||||||
|
])('should render %s chart component', async (type, testId) => {
|
||||||
|
renderComponent({
|
||||||
|
props: { insightType: type },
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(testId)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Table Functionality', () => {
|
||||||
|
it('should handle table pagination', async () => {
|
||||||
|
renderComponent({
|
||||||
|
props: { insightType: INSIGHT_TYPES.TOTAL },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitAllPromises();
|
||||||
|
|
||||||
|
// Simulate pagination event
|
||||||
|
emitters.n8nDataTableServer.emit('update:options', {
|
||||||
|
page: 1,
|
||||||
|
itemsPerPage: 50,
|
||||||
|
sortBy: [{ id: 'total', desc: true }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(insightsStore.table.execute).toHaveBeenCalledWith(0, {
|
||||||
|
skip: 50,
|
||||||
|
take: 50,
|
||||||
|
sortBy: 'total:desc',
|
||||||
|
dateRange: 'week',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle table sorting', async () => {
|
||||||
|
renderComponent({
|
||||||
|
props: { insightType: INSIGHT_TYPES.TOTAL },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitAllPromises();
|
||||||
|
|
||||||
|
// Simulate sort event
|
||||||
|
emitters.n8nDataTableServer.emit('update:options', {
|
||||||
|
page: 0,
|
||||||
|
itemsPerPage: 25,
|
||||||
|
sortBy: [{ id: 'failed', desc: false }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(insightsStore.table.execute).toHaveBeenCalledWith(0, {
|
||||||
|
skip: 0,
|
||||||
|
take: 25,
|
||||||
|
sortBy: 'failed:asc',
|
||||||
|
dateRange: 'week',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty sort array', async () => {
|
||||||
|
renderComponent({
|
||||||
|
props: { insightType: INSIGHT_TYPES.TOTAL },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitAllPromises();
|
||||||
|
|
||||||
|
// Simulate event with no sortBy
|
||||||
|
emitters.n8nDataTableServer.emit('update:options', {
|
||||||
|
page: 0,
|
||||||
|
itemsPerPage: 25,
|
||||||
|
sortBy: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(insightsStore.table.execute).toHaveBeenCalledWith(0, {
|
||||||
|
skip: 0,
|
||||||
|
take: 25,
|
||||||
|
sortBy: undefined,
|
||||||
|
dateRange: 'week',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
|
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
|
||||||
import { useInsightsStore } from '@/features/insights/insights.store';
|
import { useInsightsStore } from '@/features/insights/insights.store';
|
||||||
import type { InsightsDateRange, InsightsSummaryType } from '@n8n/api-types';
|
import type { InsightsDateRange, InsightsSummaryType } from '@n8n/api-types';
|
||||||
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
|
import { INSIGHT_TYPES, TELEMETRY_TIME_RANGE, UNLICENSED_TIME_RANGE } from '../insights.constants';
|
||||||
import { TELEMETRY_TIME_RANGE, UNLICENSED_TIME_RANGE } from '../insights.constants';
|
|
||||||
import InsightsDateRangeSelect from './InsightsDateRangeSelect.vue';
|
import InsightsDateRangeSelect from './InsightsDateRangeSelect.vue';
|
||||||
import InsightsUpgradeModal from './InsightsUpgradeModal.vue';
|
import InsightsUpgradeModal from './InsightsUpgradeModal.vue';
|
||||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||||
@@ -36,11 +37,14 @@ const props = defineProps<{
|
|||||||
insightType: InsightsSummaryType;
|
insightType: InsightsSummaryType;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
const insightsStore = useInsightsStore();
|
const insightsStore = useInsightsStore();
|
||||||
|
|
||||||
|
const isTimeSavedRoute = computed(() => route.params.insightType === INSIGHT_TYPES.TIME_SAVED);
|
||||||
|
|
||||||
const chartComponents = computed(() => ({
|
const chartComponents = computed(() => ({
|
||||||
total: InsightsChartTotal,
|
total: InsightsChartTotal,
|
||||||
failed: InsightsChartFailed,
|
failed: InsightsChartFailed,
|
||||||
@@ -108,8 +112,8 @@ watch(
|
|||||||
void insightsStore.summary.execute(0, { dateRange: selectedDateRange.value });
|
void insightsStore.summary.execute(0, { dateRange: selectedDateRange.value });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (insightsStore.isDashboardEnabled) {
|
|
||||||
void insightsStore.charts.execute(0, { dateRange: selectedDateRange.value });
|
void insightsStore.charts.execute(0, { dateRange: selectedDateRange.value });
|
||||||
|
if (insightsStore.isDashboardEnabled) {
|
||||||
fetchPaginatedTableData({ sortBy: sortTableBy.value, dateRange: selectedDateRange.value });
|
fetchPaginatedTableData({ sortBy: sortTableBy.value, dateRange: selectedDateRange.value });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -149,11 +153,10 @@ onMounted(() => {
|
|||||||
:class="$style.insightsBanner"
|
:class="$style.insightsBanner"
|
||||||
/>
|
/>
|
||||||
<div :class="$style.insightsContent">
|
<div :class="$style.insightsContent">
|
||||||
<InsightsPaywall
|
<div
|
||||||
v-if="!insightsStore.isDashboardEnabled"
|
v-if="insightsStore.isDashboardEnabled || isTimeSavedRoute"
|
||||||
data-test-id="insights-dashboard-unlicensed"
|
:class="$style.insightsContentWrapper"
|
||||||
/>
|
>
|
||||||
<div v-else :class="$style.insightsContentWrapper">
|
|
||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
$style.dataLoader,
|
$style.dataLoader,
|
||||||
@@ -179,10 +182,12 @@ onMounted(() => {
|
|||||||
v-model:sort-by="sortTableBy"
|
v-model:sort-by="sortTableBy"
|
||||||
:data="insightsStore.table.state"
|
:data="insightsStore.table.state"
|
||||||
:loading="insightsStore.table.isLoading"
|
:loading="insightsStore.table.isLoading"
|
||||||
|
:is-dashboard-enabled="insightsStore.isDashboardEnabled"
|
||||||
@update:options="fetchPaginatedTableData"
|
@update:options="fetchPaginatedTableData"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<InsightsPaywall v-else />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const goToUpgrade = async () => {
|
|||||||
<template>
|
<template>
|
||||||
<div :class="$style.callout">
|
<div :class="$style.callout">
|
||||||
<N8nIcon icon="lock" size="xlarge"></N8nIcon>
|
<N8nIcon icon="lock" size="xlarge"></N8nIcon>
|
||||||
<N8nText bold tag="h3" size="large">
|
<N8nText bold tag="h4" size="large">
|
||||||
{{ i18n.baseText('insights.dashboard.paywall.title') }}
|
{{ i18n.baseText('insights.dashboard.paywall.title') }}
|
||||||
</N8nText>
|
</N8nText>
|
||||||
<N8nText>
|
<N8nText>
|
||||||
|
|||||||
@@ -63,7 +63,12 @@ const chartData = computed<ChartData<'line'>>(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Line :data="chartData" :options="chartOptions" :plugins="[Filler]" />
|
<Line
|
||||||
|
data-test-id="insights-chart-average-runtime"
|
||||||
|
:data="chartData"
|
||||||
|
:options="chartOptions"
|
||||||
|
:plugins="[Filler]"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" module></style>
|
<style lang="scss" module></style>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const chartData = computed<ChartData<'bar'>>(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Bar :data="chartData" :options="chartOptions" />
|
<Bar data-test-id="insights-chart-failed" :data="chartData" :options="chartOptions" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" module></style>
|
<style lang="scss" module></style>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const chartData = computed<ChartData<'bar'>>(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Bar :data="chartData" :options="chartOptions" />
|
<Bar data-test-id="insights-chart-failure-rate" :data="chartData" :options="chartOptions" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" module></style>
|
<style lang="scss" module></style>
|
||||||
|
|||||||
@@ -72,7 +72,12 @@ const chartData = computed<ChartData<'line'>>(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Line :data="chartData" :options="chartOptions" :plugins="[Filler]" />
|
<Line
|
||||||
|
data-test-id="insights-chart-time-saved"
|
||||||
|
:data="chartData"
|
||||||
|
:options="chartOptions"
|
||||||
|
:plugins="[Filler]"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" module></style>
|
<style lang="scss" module></style>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const chartData = computed<ChartData<'bar'>>(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Bar :data="chartData" :options="chartOptions" />
|
<Bar data-test-id="insights-chart-total" :data="chartData" :options="chartOptions" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" module></style>
|
<style lang="scss" module></style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { screen, within } from '@testing-library/vue';
|
import { screen, waitFor, within } from '@testing-library/vue';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
@@ -10,7 +10,6 @@ import type { InsightsByWorkflow } from '@n8n/api-types';
|
|||||||
|
|
||||||
const { emitters, addEmitter } = useEmitters<'n8nDataTableServer'>();
|
const { emitters, addEmitter } = useEmitters<'n8nDataTableServer'>();
|
||||||
|
|
||||||
// Mock telemetry
|
|
||||||
const mockTelemetry = {
|
const mockTelemetry = {
|
||||||
track: vi.fn(),
|
track: vi.fn(),
|
||||||
};
|
};
|
||||||
@@ -18,7 +17,6 @@ vi.mock('@/composables/useTelemetry', () => ({
|
|||||||
useTelemetry: () => mockTelemetry,
|
useTelemetry: () => mockTelemetry,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock N8nDataTableServer like in SettingsUsersTable.test.ts
|
|
||||||
vi.mock('@n8n/design-system', async (importOriginal) => {
|
vi.mock('@n8n/design-system', async (importOriginal) => {
|
||||||
const original = await importOriginal<object>();
|
const original = await importOriginal<object>();
|
||||||
return {
|
return {
|
||||||
@@ -37,6 +35,20 @@ vi.mock('@n8n/design-system', async (importOriginal) => {
|
|||||||
},
|
},
|
||||||
template: `
|
template: `
|
||||||
<div data-test-id="insights-table">
|
<div data-test-id="insights-table">
|
||||||
|
<div class="table-header">
|
||||||
|
<div v-for="header in headers" :key="header.key">
|
||||||
|
<button
|
||||||
|
v-if="!header.disableSort"
|
||||||
|
:data-test-id="'sort-' + header.key"
|
||||||
|
@click="$emit('update:sortBy', [{ id: header.key, desc: false }])"
|
||||||
|
>
|
||||||
|
{{ header.title }}
|
||||||
|
</button>
|
||||||
|
<span v-else :data-test-id="'header-' + header.key">
|
||||||
|
{{ header.title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div v-for="item in items" :key="item.workflowId"
|
<div v-for="item in items" :key="item.workflowId"
|
||||||
:data-test-id="'workflow-row-' + item.workflowId">
|
:data-test-id="'workflow-row-' + item.workflowId">
|
||||||
<div v-for="header in headers" :key="header.key">
|
<div v-for="header in headers" :key="header.key">
|
||||||
@@ -47,6 +59,7 @@ vi.mock('@n8n/design-system', async (importOriginal) => {
|
|||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<slot name="cover" />
|
||||||
</div>`,
|
</div>`,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -93,6 +106,7 @@ describe('InsightsTableWorkflows', () => {
|
|||||||
props: {
|
props: {
|
||||||
data: mockInsightsData,
|
data: mockInsightsData,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
isDashboardEnabled: true, // Default to true for basic tests
|
||||||
},
|
},
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
@@ -112,7 +126,7 @@ describe('InsightsTableWorkflows', () => {
|
|||||||
it('should display the correct heading', () => {
|
it('should display the correct heading', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { name: 'Workflow insights' })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: 'Breakdown by workflow' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render workflow data in table rows', () => {
|
it('should render workflow data in table rows', () => {
|
||||||
@@ -148,16 +162,14 @@ describe('InsightsTableWorkflows', () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
renderComponent = createComponentRenderer(InsightsTableWorkflows, {
|
renderComponent({
|
||||||
pinia: createTestingPinia(),
|
|
||||||
props: {
|
props: {
|
||||||
data: largeNumberData,
|
data: largeNumberData,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
isDashboardEnabled: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
renderComponent();
|
|
||||||
|
|
||||||
const row = screen.getByTestId('workflow-row-workflow-1');
|
const row = screen.getByTestId('workflow-row-workflow-1');
|
||||||
expect(within(row).getByText('1,000,000')).toBeInTheDocument();
|
expect(within(row).getByText('1,000,000')).toBeInTheDocument();
|
||||||
expect(within(row).getByText('50,000')).toBeInTheDocument();
|
expect(within(row).getByText('50,000')).toBeInTheDocument();
|
||||||
@@ -322,4 +334,112 @@ describe('InsightsTableWorkflows', () => {
|
|||||||
expect(screen.getByTestId('insights-table')).toBeInTheDocument();
|
expect(screen.getByTestId('insights-table')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('paywall functionality', () => {
|
||||||
|
it('should not display paywall when dashboard is enabled', () => {
|
||||||
|
renderComponent = createComponentRenderer(InsightsTableWorkflows, {
|
||||||
|
pinia: createTestingPinia(),
|
||||||
|
props: {
|
||||||
|
data: mockInsightsData,
|
||||||
|
loading: false,
|
||||||
|
isDashboardEnabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('heading', { name: 'Upgrade to access more detailed insights' }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display paywall when dashboard is not enabled', async () => {
|
||||||
|
renderComponent({
|
||||||
|
props: {
|
||||||
|
data: mockInsightsData,
|
||||||
|
loading: false,
|
||||||
|
isDashboardEnabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole('heading', {
|
||||||
|
level: 4,
|
||||||
|
name: 'Upgrade to access more detailed insights',
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use sample data when dashboard is not enabled', () => {
|
||||||
|
renderComponent({
|
||||||
|
props: {
|
||||||
|
data: mockInsightsData,
|
||||||
|
loading: false,
|
||||||
|
isDashboardEnabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should render sample workflows instead of actual data
|
||||||
|
expect(screen.getByTestId('workflow-row-sample-workflow-1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('workflow-row-sample-workflow-2')).toBeInTheDocument();
|
||||||
|
// Should not render the original test data
|
||||||
|
expect(screen.queryByTestId('workflow-row-workflow-1')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('workflow-row-workflow-2')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable sorting when dashboard is not enabled', () => {
|
||||||
|
renderComponent({
|
||||||
|
props: {
|
||||||
|
data: mockInsightsData,
|
||||||
|
loading: false,
|
||||||
|
isDashboardEnabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// When sorting is disabled, columns should not have clickable sort buttons
|
||||||
|
expect(screen.queryByTestId('sort-workflowName')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('sort-total')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('sort-failed')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('sort-failureRate')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('sort-timeSaved')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('sort-averageRunTime')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Headers should be present as non-clickable elements
|
||||||
|
expect(screen.getByTestId('header-workflowName')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('header-total')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('header-failed')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('header-failureRate')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('header-timeSaved')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('header-averageRunTime')).toBeInTheDocument();
|
||||||
|
// projectName is always disabled
|
||||||
|
expect(screen.getByTestId('header-projectName')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enable sorting when dashboard is enabled', () => {
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
// When sorting is enabled, most columns should have clickable sort buttons
|
||||||
|
expect(screen.getByTestId('sort-workflowName')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('sort-total')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('sort-failed')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('sort-failureRate')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('sort-timeSaved')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('sort-averageRunTime')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// projectName is always disabled (non-clickable)
|
||||||
|
expect(screen.getByTestId('header-projectName')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('sort-projectName')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger sort when clicking on sortable column header', async () => {
|
||||||
|
const { emitted } = renderComponent();
|
||||||
|
|
||||||
|
const sortButton = screen.getByTestId('sort-workflowName');
|
||||||
|
await userEvent.click(sortButton);
|
||||||
|
|
||||||
|
expect(emitted()['update:sortBy']).toEqual([[[{ id: 'workflowName', desc: false }]]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,12 +12,17 @@ import type { TableHeader } from '@n8n/design-system/components/N8nDataTableServ
|
|||||||
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
|
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, defineAsyncComponent, ref, watch } from 'vue';
|
||||||
import { type RouteLocationRaw, type LocationQueryRaw } from 'vue-router';
|
import { type RouteLocationRaw, type LocationQueryRaw } from 'vue-router';
|
||||||
|
|
||||||
|
const InsightsPaywall = defineAsyncComponent(
|
||||||
|
async () => await import('@/features/insights/components/InsightsPaywall.vue'),
|
||||||
|
);
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
data: InsightsByWorkflow;
|
data: InsightsByWorkflow;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
isDashboardEnabled?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
@@ -25,13 +30,34 @@ const telemetry = useTelemetry();
|
|||||||
|
|
||||||
type Item = InsightsByWorkflow['data'][number];
|
type Item = InsightsByWorkflow['data'][number];
|
||||||
|
|
||||||
const rows = computed(() => props.data.data);
|
const sampleWorkflows: InsightsByWorkflow['data'] = Array.from({ length: 10 }, (_, i) => ({
|
||||||
|
workflowId: `sample-workflow-${i + 1}`,
|
||||||
|
workflowName: `Sample Workflow ${i + 1}`,
|
||||||
|
total: Math.floor(Math.random() * 2000) + 500,
|
||||||
|
failed: Math.floor(Math.random() * 100) + 20,
|
||||||
|
failureRate: Math.random() * 100,
|
||||||
|
timeSaved: Math.floor(Math.random() * 300000) + 60000,
|
||||||
|
averageRunTime: Math.floor(Math.random() * 60000) + 15000,
|
||||||
|
projectName: `Sample Project ${i + 1}`,
|
||||||
|
projectId: `sample-project-${i + 1}`,
|
||||||
|
succeeded: Math.floor(Math.random() * 2000) + 500,
|
||||||
|
runTime: Math.floor(Math.random() * 60000) + 15000,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sampleData: InsightsByWorkflow = {
|
||||||
|
data: sampleWorkflows,
|
||||||
|
count: sampleWorkflows.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableData = computed(() => (props.isDashboardEnabled ? props.data : sampleData));
|
||||||
|
const rows = computed(() => tableData.value.data);
|
||||||
|
|
||||||
const headers = ref<Array<TableHeader<Item>>>([
|
const headers = ref<Array<TableHeader<Item>>>([
|
||||||
{
|
{
|
||||||
title: 'Name',
|
title: 'Name',
|
||||||
key: 'workflowName',
|
key: 'workflowName',
|
||||||
width: 400,
|
width: 400,
|
||||||
|
disableSort: !props.isDashboardEnabled,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: i18n.baseText('insights.banner.title.total'),
|
title: i18n.baseText('insights.banner.title.total'),
|
||||||
@@ -39,6 +65,7 @@ const headers = ref<Array<TableHeader<Item>>>([
|
|||||||
value(row) {
|
value(row) {
|
||||||
return row.total.toLocaleString('en-US');
|
return row.total.toLocaleString('en-US');
|
||||||
},
|
},
|
||||||
|
disableSort: !props.isDashboardEnabled,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: i18n.baseText('insights.banner.title.failed'),
|
title: i18n.baseText('insights.banner.title.failed'),
|
||||||
@@ -46,6 +73,7 @@ const headers = ref<Array<TableHeader<Item>>>([
|
|||||||
value(row) {
|
value(row) {
|
||||||
return row.failed.toLocaleString('en-US');
|
return row.failed.toLocaleString('en-US');
|
||||||
},
|
},
|
||||||
|
disableSort: !props.isDashboardEnabled,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: i18n.baseText('insights.banner.title.failureRate'),
|
title: i18n.baseText('insights.banner.title.failureRate'),
|
||||||
@@ -56,6 +84,7 @@ const headers = ref<Array<TableHeader<Item>>>([
|
|||||||
INSIGHTS_UNIT_MAPPING.failureRate(row.failureRate)
|
INSIGHTS_UNIT_MAPPING.failureRate(row.failureRate)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
disableSort: !props.isDashboardEnabled,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: i18n.baseText('insights.banner.title.timeSaved'),
|
title: i18n.baseText('insights.banner.title.timeSaved'),
|
||||||
@@ -66,6 +95,7 @@ const headers = ref<Array<TableHeader<Item>>>([
|
|||||||
INSIGHTS_UNIT_MAPPING.timeSaved(row.timeSaved)
|
INSIGHTS_UNIT_MAPPING.timeSaved(row.timeSaved)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
disableSort: !props.isDashboardEnabled,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: i18n.baseText('insights.banner.title.averageRunTime'),
|
title: i18n.baseText('insights.banner.title.averageRunTime'),
|
||||||
@@ -76,6 +106,7 @@ const headers = ref<Array<TableHeader<Item>>>([
|
|||||||
INSIGHTS_UNIT_MAPPING.averageRunTime(row.averageRunTime)
|
INSIGHTS_UNIT_MAPPING.averageRunTime(row.averageRunTime)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
disableSort: !props.isDashboardEnabled,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: i18n.baseText('insights.dashboard.table.projectName'),
|
title: i18n.baseText('insights.dashboard.table.projectName'),
|
||||||
@@ -124,20 +155,26 @@ watch(sortBy, (newValue) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<N8nHeading bold tag="h3" size="medium" class="mb-s">Workflow insights</N8nHeading>
|
<N8nHeading bold tag="h3" size="medium" class="mb-s">{{
|
||||||
|
i18n.baseText('insights.dashboard.table.title')
|
||||||
|
}}</N8nHeading>
|
||||||
<N8nDataTableServer
|
<N8nDataTableServer
|
||||||
v-model:sort-by="sortBy"
|
v-model:sort-by="sortBy"
|
||||||
v-model:page="currentPage"
|
v-model:page="currentPage"
|
||||||
v-model:items-per-page="itemsPerPage"
|
v-model:items-per-page="itemsPerPage"
|
||||||
:items="rows"
|
:items="rows"
|
||||||
:headers="headers"
|
:headers="headers"
|
||||||
:items-length="data.count"
|
:items-length="tableData.count"
|
||||||
@update:options="emit('update:options', $event)"
|
@update:options="emit('update:options', $event)"
|
||||||
>
|
>
|
||||||
<template #[`item.workflowName`]="{ item }">
|
<template #[`item.workflowName`]="{ item }">
|
||||||
<router-link :to="getWorkflowLink(item)" class="link" @click="trackWorkflowClick(item)">
|
<router-link
|
||||||
|
:to="getWorkflowLink(item)"
|
||||||
|
:class="$style.link"
|
||||||
|
@click="trackWorkflowClick(item)"
|
||||||
|
>
|
||||||
<N8nTooltip :content="item.workflowName" placement="top">
|
<N8nTooltip :content="item.workflowName" placement="top">
|
||||||
<div class="ellipsis">
|
<div :class="$style.ellipsis">
|
||||||
{{ item.workflowName }}
|
{{ item.workflowName }}
|
||||||
</div>
|
</div>
|
||||||
</N8nTooltip>
|
</N8nTooltip>
|
||||||
@@ -147,7 +184,7 @@ watch(sortBy, (newValue) => {
|
|||||||
<router-link
|
<router-link
|
||||||
v-if="!item.timeSaved"
|
v-if="!item.timeSaved"
|
||||||
:to="getWorkflowLink(item, { settings: 'true' })"
|
:to="getWorkflowLink(item, { settings: 'true' })"
|
||||||
class="link"
|
:class="$style.link"
|
||||||
>
|
>
|
||||||
{{ i18n.baseText('insights.dashboard.table.estimate') }}
|
{{ i18n.baseText('insights.dashboard.table.estimate') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -157,17 +194,22 @@ watch(sortBy, (newValue) => {
|
|||||||
</template>
|
</template>
|
||||||
<template #[`item.projectName`]="{ item }">
|
<template #[`item.projectName`]="{ item }">
|
||||||
<N8nTooltip v-if="item.projectName" :content="item.projectName" placement="top">
|
<N8nTooltip v-if="item.projectName" :content="item.projectName" placement="top">
|
||||||
<div class="ellipsis">
|
<div :class="$style.ellipsis">
|
||||||
{{ item.projectName }}
|
{{ item.projectName }}
|
||||||
</div>
|
</div>
|
||||||
</N8nTooltip>
|
</N8nTooltip>
|
||||||
<template v-else> - </template>
|
<template v-else> - </template>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-if="!isDashboardEnabled" #cover>
|
||||||
|
<div :class="$style.blurryCover">
|
||||||
|
<InsightsPaywall />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</N8nDataTableServer>
|
</N8nDataTableServer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" module>
|
||||||
.ellipsis {
|
.ellipsis {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -188,4 +230,29 @@ watch(sortBy, (newValue) => {
|
|||||||
color: var(--color-text-dark);
|
color: var(--color-text-dark);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blurryCover {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-foreground-xlight);
|
||||||
|
opacity: 0.5;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export const fetchInsightsByTime = async (
|
|||||||
): Promise<InsightsByTime[]> =>
|
): Promise<InsightsByTime[]> =>
|
||||||
await makeRestApiRequest(context, 'GET', '/insights/by-time', filter);
|
await makeRestApiRequest(context, 'GET', '/insights/by-time', filter);
|
||||||
|
|
||||||
|
export const fetchInsightsTimeSaved = async (
|
||||||
|
context: IRestApiContext,
|
||||||
|
filter?: { dateRange: InsightsDateRange['key'] },
|
||||||
|
): Promise<InsightsByTime[]> =>
|
||||||
|
await makeRestApiRequest(context, 'GET', '/insights/by-time/time-saved', filter);
|
||||||
|
|
||||||
export const fetchInsightsByWorkflow = async (
|
export const fetchInsightsByWorkflow = async (
|
||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
filter?: ListInsightsWorkflowQueryDto,
|
filter?: ListInsightsWorkflowQueryDto,
|
||||||
|
|||||||
@@ -2,13 +2,21 @@ import type { InsightsSummaryType } from '@n8n/api-types';
|
|||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import dateformat from 'dateformat';
|
import dateformat from 'dateformat';
|
||||||
|
|
||||||
|
export const INSIGHT_TYPES = {
|
||||||
|
TOTAL: 'total',
|
||||||
|
FAILED: 'failed',
|
||||||
|
FAILURE_RATE: 'failureRate',
|
||||||
|
TIME_SAVED: 'timeSaved',
|
||||||
|
AVERAGE_RUN_TIME: 'averageRunTime',
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const INSIGHTS_SUMMARY_ORDER: InsightsSummaryType[] = [
|
export const INSIGHTS_SUMMARY_ORDER: InsightsSummaryType[] = [
|
||||||
'total',
|
INSIGHT_TYPES.TOTAL,
|
||||||
'failed',
|
INSIGHT_TYPES.FAILED,
|
||||||
'failureRate',
|
INSIGHT_TYPES.FAILURE_RATE,
|
||||||
'timeSaved',
|
INSIGHT_TYPES.TIME_SAVED,
|
||||||
'averageRunTime',
|
INSIGHT_TYPES.AVERAGE_RUN_TIME,
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
export const INSIGHTS_UNIT_MAPPING: Record<InsightsSummaryType, (value: number) => string> = {
|
export const INSIGHTS_UNIT_MAPPING: Record<InsightsSummaryType, (value: number) => string> = {
|
||||||
total: () => '',
|
total: () => '',
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { useInsightsStore } from '@/features/insights/insights.store';
|
||||||
|
import * as insightsApi from '@/features/insights/insights.api';
|
||||||
|
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||||
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import { mockedStore, type MockedStore } from '@/__tests__/utils';
|
||||||
|
import type { IUser } from '@/Interface';
|
||||||
|
import { reactive } from 'vue';
|
||||||
|
import type { FrontendModuleSettings } from '@n8n/api-types';
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRoute: () => reactive({}),
|
||||||
|
}));
|
||||||
|
vi.mock('@/features/insights/insights.api');
|
||||||
|
|
||||||
|
const mockFilter = { dateRange: 'week' as const };
|
||||||
|
const mockData = [
|
||||||
|
{
|
||||||
|
date: '2023-01-01',
|
||||||
|
values: {
|
||||||
|
total: 100,
|
||||||
|
failed: 10,
|
||||||
|
failureRate: 10,
|
||||||
|
timeSaved: 50,
|
||||||
|
averageRunTime: 5,
|
||||||
|
succeeded: 90,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('useInsightsStore', () => {
|
||||||
|
let insightsStore: ReturnType<typeof useInsightsStore>;
|
||||||
|
let settingsStore: MockedStore<typeof useSettingsStore>;
|
||||||
|
let rootStore: MockedStore<typeof useRootStore>;
|
||||||
|
let usersStore: MockedStore<typeof useUsersStore>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
createTestingPinia();
|
||||||
|
|
||||||
|
settingsStore = mockedStore(useSettingsStore);
|
||||||
|
rootStore = mockedStore(useRootStore);
|
||||||
|
usersStore = mockedStore(useUsersStore);
|
||||||
|
|
||||||
|
rootStore.restApiContext = {
|
||||||
|
baseUrl: 'http://localhost',
|
||||||
|
pushRef: 'pushRef',
|
||||||
|
};
|
||||||
|
|
||||||
|
usersStore.currentUser = { globalScopes: ['insights:list'] } as IUser;
|
||||||
|
|
||||||
|
insightsStore = useInsightsStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('charts data fetcher', () => {
|
||||||
|
it('should use fetchInsightsTimeSaved when dashboard is disabled', async () => {
|
||||||
|
settingsStore.moduleSettings = { insights: { dashboard: false } } as FrontendModuleSettings;
|
||||||
|
|
||||||
|
vi.mocked(insightsApi.fetchInsightsTimeSaved).mockResolvedValue(mockData);
|
||||||
|
|
||||||
|
await insightsStore.charts.execute(0, mockFilter);
|
||||||
|
|
||||||
|
expect(insightsApi.fetchInsightsTimeSaved).toHaveBeenCalledWith(
|
||||||
|
rootStore.restApiContext,
|
||||||
|
mockFilter,
|
||||||
|
);
|
||||||
|
expect(insightsApi.fetchInsightsByTime).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use fetchInsightsByTime when dashboard is enabled', async () => {
|
||||||
|
settingsStore.moduleSettings = { insights: { dashboard: true } } as FrontendModuleSettings;
|
||||||
|
|
||||||
|
vi.mocked(insightsApi.fetchInsightsByTime).mockResolvedValue(mockData);
|
||||||
|
|
||||||
|
await insightsStore.charts.execute(0, mockFilter);
|
||||||
|
|
||||||
|
expect(insightsApi.fetchInsightsByTime).toHaveBeenCalledWith(
|
||||||
|
rootStore.restApiContext,
|
||||||
|
mockFilter,
|
||||||
|
);
|
||||||
|
expect(insightsApi.fetchInsightsTimeSaved).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use fetchInsightsTimeSaved when dashboard setting is undefined', async () => {
|
||||||
|
settingsStore.moduleSettings = { insights: {} } as FrontendModuleSettings;
|
||||||
|
|
||||||
|
vi.mocked(insightsApi.fetchInsightsTimeSaved).mockResolvedValue(mockData);
|
||||||
|
|
||||||
|
await insightsStore.charts.execute(0, mockFilter);
|
||||||
|
|
||||||
|
expect(insightsApi.fetchInsightsTimeSaved).toHaveBeenCalledWith(
|
||||||
|
rootStore.restApiContext,
|
||||||
|
mockFilter,
|
||||||
|
);
|
||||||
|
expect(insightsApi.fetchInsightsByTime).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work without filter parameter', async () => {
|
||||||
|
settingsStore.moduleSettings = { insights: { dashboard: true } } as FrontendModuleSettings;
|
||||||
|
|
||||||
|
vi.mocked(insightsApi.fetchInsightsByTime).mockResolvedValue(mockData);
|
||||||
|
|
||||||
|
await insightsStore.charts.execute();
|
||||||
|
|
||||||
|
expect(insightsApi.fetchInsightsByTime).toHaveBeenCalledWith(
|
||||||
|
rootStore.restApiContext,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,9 +22,7 @@ export const useInsightsStore = defineStore('insights', () => {
|
|||||||
settingsStore.settings.activeModules.includes('insights'),
|
settingsStore.settings.activeModules.includes('insights'),
|
||||||
);
|
);
|
||||||
|
|
||||||
const isDashboardEnabled = computed(
|
const isDashboardEnabled = computed(() => !!settingsStore.moduleSettings.insights?.dashboard);
|
||||||
() => settingsStore.moduleSettings.insights?.dashboard ?? false,
|
|
||||||
);
|
|
||||||
|
|
||||||
const isSummaryEnabled = computed(
|
const isSummaryEnabled = computed(
|
||||||
() => globalInsightsPermissions.value.list && isInsightsEnabled.value,
|
() => globalInsightsPermissions.value.list && isInsightsEnabled.value,
|
||||||
@@ -52,7 +50,10 @@ export const useInsightsStore = defineStore('insights', () => {
|
|||||||
|
|
||||||
const charts = useAsyncState(
|
const charts = useAsyncState(
|
||||||
async (filter?: { dateRange: InsightsDateRange['key'] }) => {
|
async (filter?: { dateRange: InsightsDateRange['key'] }) => {
|
||||||
return await insightsApi.fetchInsightsByTime(rootStore.restApiContext, filter);
|
const dataFetcher = isDashboardEnabled.value
|
||||||
|
? insightsApi.fetchInsightsByTime
|
||||||
|
: insightsApi.fetchInsightsTimeSaved;
|
||||||
|
return await dataFetcher(rootStore.restApiContext, filter);
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
{ immediate: false, resetOnExecute: false },
|
{ immediate: false, resetOnExecute: false },
|
||||||
|
|||||||
Reference in New Issue
Block a user