fix(editor): Update frontend to handle unlicensed insights dashboard, if only Time saved feature is enabled (#17199)

This commit is contained in:
Csaba Tuncsik
2025-07-15 09:16:10 +02:00
committed by GitHub
parent e5d88eba99
commit 42c61909c4
16 changed files with 867 additions and 64 deletions

View File

@@ -1,19 +1,75 @@
import { defineComponent, reactive } from 'vue';
import { createComponentRenderer } from '@/__tests__/render';
import InsightsDashboard from './InsightsDashboard.vue';
import { createTestingPinia } from '@pinia/testing';
import { defaultSettings } from '@/__tests__/defaults';
import { useInsightsStore } from '@/features/insights/insights.store';
import { mockedStore } from '@/__tests__/utils';
import { within } from '@testing-library/vue';
import { mockedStore, type MockedStore, useEmitters, waitAllPromises } from '@/__tests__/utils';
import { within, screen, waitFor } from '@testing-library/vue';
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, {
props: {
insightType: 'total',
const { emitters, addEmitter } = useEmitters<'n8nDataTableServer'>();
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: {
summary: true,
dashboard: true,
@@ -23,39 +79,436 @@ const moduleSettings = {
licensed: true,
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', () => {
beforeEach(() => {
vi.clearAllMocks();
mockRoute.params.insightType = INSIGHT_TYPES.TOTAL;
createTestingPinia({
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(),
};
});
it('should render without error', () => {
mockedStore(useInsightsStore);
expect(() => renderComponent()).not.toThrow();
expect(document.title).toBe('Insights - n8n');
describe('Component Rendering', () => {
it('should render without error', () => {
expect(() =>
renderComponent({
props: { insightType: INSIGHT_TYPES.TOTAL },
}),
).not.toThrow();
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();
});
});
});
it('should update the selected time range', async () => {
mockedStore(useInsightsStore);
describe('Date Range Selection', () => {
it('should update the selected time range', async () => {
renderComponent({
props: { insightType: INSIGHT_TYPES.TOTAL },
});
const { getByTestId } = renderComponent();
expect(screen.getByTestId('range-select')).toBeVisible();
const select = within(screen.getByTestId('range-select')).getByRole('combobox');
await userEvent.click(select);
expect(getByTestId('range-select')).toBeVisible();
const select = within(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');
}
const controllingId = select.getAttribute('aria-controls');
const actions = document.querySelector(`#${controllingId}`);
if (!actions) {
throw new Error('Actions menu not found');
}
await userEvent.click(actions.querySelectorAll('li')[0]);
expect((select as HTMLInputElement).value).toBe('Last 24 hours');
await userEvent.click(actions.querySelectorAll('li')[0]);
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',
});
});
});
});

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from '@n8n/i18n';
import { useTelemetry } from '@/composables/useTelemetry';
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
import { useInsightsStore } from '@/features/insights/insights.store';
import type { InsightsDateRange, InsightsSummaryType } from '@n8n/api-types';
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
import { TELEMETRY_TIME_RANGE, UNLICENSED_TIME_RANGE } from '../insights.constants';
import { INSIGHT_TYPES, TELEMETRY_TIME_RANGE, UNLICENSED_TIME_RANGE } from '../insights.constants';
import InsightsDateRangeSelect from './InsightsDateRangeSelect.vue';
import InsightsUpgradeModal from './InsightsUpgradeModal.vue';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
@@ -36,11 +37,14 @@ const props = defineProps<{
insightType: InsightsSummaryType;
}>();
const route = useRoute();
const i18n = useI18n();
const telemetry = useTelemetry();
const insightsStore = useInsightsStore();
const isTimeSavedRoute = computed(() => route.params.insightType === INSIGHT_TYPES.TIME_SAVED);
const chartComponents = computed(() => ({
total: InsightsChartTotal,
failed: InsightsChartFailed,
@@ -108,8 +112,8 @@ watch(
void insightsStore.summary.execute(0, { dateRange: selectedDateRange.value });
}
void insightsStore.charts.execute(0, { dateRange: selectedDateRange.value });
if (insightsStore.isDashboardEnabled) {
void insightsStore.charts.execute(0, { dateRange: selectedDateRange.value });
fetchPaginatedTableData({ sortBy: sortTableBy.value, dateRange: selectedDateRange.value });
}
},
@@ -149,11 +153,10 @@ onMounted(() => {
:class="$style.insightsBanner"
/>
<div :class="$style.insightsContent">
<InsightsPaywall
v-if="!insightsStore.isDashboardEnabled"
data-test-id="insights-dashboard-unlicensed"
/>
<div v-else :class="$style.insightsContentWrapper">
<div
v-if="insightsStore.isDashboardEnabled || isTimeSavedRoute"
:class="$style.insightsContentWrapper"
>
<div
:class="[
$style.dataLoader,
@@ -179,10 +182,12 @@ onMounted(() => {
v-model:sort-by="sortTableBy"
:data="insightsStore.table.state"
:loading="insightsStore.table.isLoading"
:is-dashboard-enabled="insightsStore.isDashboardEnabled"
@update:options="fetchPaginatedTableData"
/>
</div>
</div>
<InsightsPaywall v-else />
</div>
</div>
</div>

View File

@@ -13,7 +13,7 @@ const goToUpgrade = async () => {
<template>
<div :class="$style.callout">
<N8nIcon icon="lock" size="xlarge"></N8nIcon>
<N8nText bold tag="h3" size="large">
<N8nText bold tag="h4" size="large">
{{ i18n.baseText('insights.dashboard.paywall.title') }}
</N8nText>
<N8nText>

View File

@@ -63,7 +63,12 @@ const chartData = computed<ChartData<'line'>>(() => {
</script>
<template>
<Line :data="chartData" :options="chartOptions" :plugins="[Filler]" />
<Line
data-test-id="insights-chart-average-runtime"
:data="chartData"
:options="chartOptions"
:plugins="[Filler]"
/>
</template>
<style lang="scss" module></style>

View File

@@ -53,7 +53,7 @@ const chartData = computed<ChartData<'bar'>>(() => {
</script>
<template>
<Bar :data="chartData" :options="chartOptions" />
<Bar data-test-id="insights-chart-failed" :data="chartData" :options="chartOptions" />
</template>
<style lang="scss" module></style>

View File

@@ -56,7 +56,7 @@ const chartData = computed<ChartData<'bar'>>(() => {
</script>
<template>
<Bar :data="chartData" :options="chartOptions" />
<Bar data-test-id="insights-chart-failure-rate" :data="chartData" :options="chartOptions" />
</template>
<style lang="scss" module></style>

View File

@@ -72,7 +72,12 @@ const chartData = computed<ChartData<'line'>>(() => {
</script>
<template>
<Line :data="chartData" :options="chartOptions" :plugins="[Filler]" />
<Line
data-test-id="insights-chart-time-saved"
:data="chartData"
:options="chartOptions"
:plugins="[Filler]"
/>
</template>
<style lang="scss" module></style>

View File

@@ -53,7 +53,7 @@ const chartData = computed<ChartData<'bar'>>(() => {
</script>
<template>
<Bar :data="chartData" :options="chartOptions" />
<Bar data-test-id="insights-chart-total" :data="chartData" :options="chartOptions" />
</template>
<style lang="scss" module></style>

View File

@@ -1,6 +1,6 @@
import { defineComponent } from 'vue';
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 userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
@@ -10,7 +10,6 @@ import type { InsightsByWorkflow } from '@n8n/api-types';
const { emitters, addEmitter } = useEmitters<'n8nDataTableServer'>();
// Mock telemetry
const mockTelemetry = {
track: vi.fn(),
};
@@ -18,7 +17,6 @@ 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 {
@@ -37,6 +35,20 @@ vi.mock('@n8n/design-system', async (importOriginal) => {
},
template: `
<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"
:data-test-id="'workflow-row-' + item.workflowId">
<div v-for="header in headers" :key="header.key">
@@ -47,6 +59,7 @@ vi.mock('@n8n/design-system', async (importOriginal) => {
</slot>
</div>
</div>
<slot name="cover" />
</div>`,
}),
};
@@ -93,6 +106,7 @@ describe('InsightsTableWorkflows', () => {
props: {
data: mockInsightsData,
loading: false,
isDashboardEnabled: true, // Default to true for basic tests
},
global: {
stubs: {
@@ -112,7 +126,7 @@ describe('InsightsTableWorkflows', () => {
it('should display the correct heading', () => {
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', () => {
@@ -148,16 +162,14 @@ describe('InsightsTableWorkflows', () => {
],
};
renderComponent = createComponentRenderer(InsightsTableWorkflows, {
pinia: createTestingPinia(),
renderComponent({
props: {
data: largeNumberData,
loading: false,
isDashboardEnabled: true,
},
});
renderComponent();
const row = screen.getByTestId('workflow-row-workflow-1');
expect(within(row).getByText('1,000,000')).toBeInTheDocument();
expect(within(row).getByText('50,000')).toBeInTheDocument();
@@ -322,4 +334,112 @@ describe('InsightsTableWorkflows', () => {
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 }]]]);
});
});
});

View File

@@ -12,12 +12,17 @@ import type { TableHeader } from '@n8n/design-system/components/N8nDataTableServ
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
import { useTelemetry } from '@/composables/useTelemetry';
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';
const InsightsPaywall = defineAsyncComponent(
async () => await import('@/features/insights/components/InsightsPaywall.vue'),
);
const props = defineProps<{
data: InsightsByWorkflow;
loading?: boolean;
isDashboardEnabled?: boolean;
}>();
const i18n = useI18n();
@@ -25,13 +30,34 @@ const telemetry = useTelemetry();
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>>>([
{
title: 'Name',
key: 'workflowName',
width: 400,
disableSort: !props.isDashboardEnabled,
},
{
title: i18n.baseText('insights.banner.title.total'),
@@ -39,6 +65,7 @@ const headers = ref<Array<TableHeader<Item>>>([
value(row) {
return row.total.toLocaleString('en-US');
},
disableSort: !props.isDashboardEnabled,
},
{
title: i18n.baseText('insights.banner.title.failed'),
@@ -46,6 +73,7 @@ const headers = ref<Array<TableHeader<Item>>>([
value(row) {
return row.failed.toLocaleString('en-US');
},
disableSort: !props.isDashboardEnabled,
},
{
title: i18n.baseText('insights.banner.title.failureRate'),
@@ -56,6 +84,7 @@ const headers = ref<Array<TableHeader<Item>>>([
INSIGHTS_UNIT_MAPPING.failureRate(row.failureRate)
);
},
disableSort: !props.isDashboardEnabled,
},
{
title: i18n.baseText('insights.banner.title.timeSaved'),
@@ -66,6 +95,7 @@ const headers = ref<Array<TableHeader<Item>>>([
INSIGHTS_UNIT_MAPPING.timeSaved(row.timeSaved)
);
},
disableSort: !props.isDashboardEnabled,
},
{
title: i18n.baseText('insights.banner.title.averageRunTime'),
@@ -76,6 +106,7 @@ const headers = ref<Array<TableHeader<Item>>>([
INSIGHTS_UNIT_MAPPING.averageRunTime(row.averageRunTime)
);
},
disableSort: !props.isDashboardEnabled,
},
{
title: i18n.baseText('insights.dashboard.table.projectName'),
@@ -124,20 +155,26 @@ watch(sortBy, (newValue) => {
<template>
<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
v-model:sort-by="sortBy"
v-model:page="currentPage"
v-model:items-per-page="itemsPerPage"
:items="rows"
:headers="headers"
:items-length="data.count"
:items-length="tableData.count"
@update:options="emit('update:options', $event)"
>
<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">
<div class="ellipsis">
<div :class="$style.ellipsis">
{{ item.workflowName }}
</div>
</N8nTooltip>
@@ -147,7 +184,7 @@ watch(sortBy, (newValue) => {
<router-link
v-if="!item.timeSaved"
:to="getWorkflowLink(item, { settings: 'true' })"
class="link"
:class="$style.link"
>
{{ i18n.baseText('insights.dashboard.table.estimate') }}
</router-link>
@@ -157,17 +194,22 @@ watch(sortBy, (newValue) => {
</template>
<template #[`item.projectName`]="{ item }">
<N8nTooltip v-if="item.projectName" :content="item.projectName" placement="top">
<div class="ellipsis">
<div :class="$style.ellipsis">
{{ item.projectName }}
</div>
</N8nTooltip>
<template v-else> - </template>
</template>
<template v-if="!isDashboardEnabled" #cover>
<div :class="$style.blurryCover">
<InsightsPaywall />
</div>
</template>
</N8nDataTableServer>
</div>
</template>
<style lang="scss" scoped>
<style lang="scss" module>
.ellipsis {
white-space: nowrap;
overflow: hidden;
@@ -188,4 +230,29 @@ watch(sortBy, (newValue) => {
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>

View File

@@ -20,6 +20,12 @@ export const fetchInsightsByTime = async (
): Promise<InsightsByTime[]> =>
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 (
context: IRestApiContext,
filter?: ListInsightsWorkflowQueryDto,

View File

@@ -2,13 +2,21 @@ import type { InsightsSummaryType } from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
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[] = [
'total',
'failed',
'failureRate',
'timeSaved',
'averageRunTime',
];
INSIGHT_TYPES.TOTAL,
INSIGHT_TYPES.FAILED,
INSIGHT_TYPES.FAILURE_RATE,
INSIGHT_TYPES.TIME_SAVED,
INSIGHT_TYPES.AVERAGE_RUN_TIME,
] as const;
export const INSIGHTS_UNIT_MAPPING: Record<InsightsSummaryType, (value: number) => string> = {
total: () => '',

View File

@@ -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,
);
});
});
});

View File

@@ -22,9 +22,7 @@ export const useInsightsStore = defineStore('insights', () => {
settingsStore.settings.activeModules.includes('insights'),
);
const isDashboardEnabled = computed(
() => settingsStore.moduleSettings.insights?.dashboard ?? false,
);
const isDashboardEnabled = computed(() => !!settingsStore.moduleSettings.insights?.dashboard);
const isSummaryEnabled = computed(
() => globalInsightsPermissions.value.list && isInsightsEnabled.value,
@@ -52,7 +50,10 @@ export const useInsightsStore = defineStore('insights', () => {
const charts = useAsyncState(
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 },