@n8n/frontend-module-insights 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +88 -0
- package/README.md +64 -0
- package/biome.jsonc +4 -0
- package/eslint.config.mjs +33 -0
- package/package.json +72 -0
- package/src/__tests__/setup.ts +4 -0
- package/src/chartjs.utils.ts +208 -0
- package/src/components/InsightsDashboard.test.ts +891 -0
- package/src/components/InsightsDashboard.vue +426 -0
- package/src/components/InsightsDataRangePicker.vue +195 -0
- package/src/components/InsightsDateRangeAlert.test.ts +134 -0
- package/src/components/InsightsDateRangeAlert.vue +89 -0
- package/src/components/InsightsPaywall.vue +42 -0
- package/src/components/InsightsSummary.test.ts +206 -0
- package/src/components/InsightsSummary.vue +357 -0
- package/src/components/InsightsUpgradeModal.vue +74 -0
- package/src/components/__snapshots__/InsightsSummary.test.ts.snap +446 -0
- package/src/components/charts/InsightsChartAverageRuntime.vue +67 -0
- package/src/components/charts/InsightsChartFailed.vue +57 -0
- package/src/components/charts/InsightsChartFailureRate.vue +58 -0
- package/src/components/charts/InsightsChartTimeSaved.vue +74 -0
- package/src/components/charts/InsightsChartTotal.vue +58 -0
- package/src/components/charts/insightChartProps.ts +9 -0
- package/src/components/tables/InsightsTableWorkflows.test.ts +509 -0
- package/src/components/tables/InsightsTableWorkflows.vue +279 -0
- package/src/index.ts +4 -0
- package/src/insights.api.test.ts +435 -0
- package/src/insights.api.ts +71 -0
- package/src/insights.constants.ts +69 -0
- package/src/insights.module.test.ts +59 -0
- package/src/insights.module.ts +45 -0
- package/src/insights.store.test.ts +118 -0
- package/src/insights.store.ts +88 -0
- package/src/insights.types.ts +16 -0
- package/src/insights.utils.test.ts +339 -0
- package/src/insights.utils.ts +172 -0
- package/stylelint.config.mjs +9 -0
- package/vite.config.ts +29 -0
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FrontendModuleSettings,
|
|
3
|
+
InsightsByTime,
|
|
4
|
+
InsightsByWorkflow,
|
|
5
|
+
InsightsSummaryType,
|
|
6
|
+
} from '@n8n/api-types';
|
|
7
|
+
import { componentRegistry } from '@n8n/frontend-module-sdk';
|
|
8
|
+
import {
|
|
9
|
+
createComponentRenderer,
|
|
10
|
+
defaultSettings,
|
|
11
|
+
mockedStore,
|
|
12
|
+
useEmitters,
|
|
13
|
+
waitAllPromises,
|
|
14
|
+
type MockedStore,
|
|
15
|
+
} from '@n8n/frontend-test-utils';
|
|
16
|
+
import { ResponseError } from '@n8n/rest-api-client/utils';
|
|
17
|
+
import { createTestingPinia } from '@pinia/testing';
|
|
18
|
+
import userEvent from '@testing-library/user-event';
|
|
19
|
+
import { within, screen, waitFor } from '@testing-library/vue';
|
|
20
|
+
import { vi } from 'vitest';
|
|
21
|
+
import { defineComponent, h, reactive } from 'vue';
|
|
22
|
+
|
|
23
|
+
import InsightsDashboard from './InsightsDashboard.vue';
|
|
24
|
+
import { INSIGHT_TYPES } from '../insights.constants';
|
|
25
|
+
import { useInsightsStore } from '../insights.store';
|
|
26
|
+
import type { InsightsSummaryDisplay } from '../insights.types';
|
|
27
|
+
|
|
28
|
+
// Called in a hook, not at module scope: the design-system mock below is hoisted above the
|
|
29
|
+
// imports, and `@n8n/frontend-test-utils` reaches design-system through its renderer.
|
|
30
|
+
let emitterHandles: ReturnType<typeof useEmitters<'n8nDataTableServer'>>;
|
|
31
|
+
|
|
32
|
+
beforeAll(() => {
|
|
33
|
+
emitterHandles = useEmitters<'n8nDataTableServer'>();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const mockRoute = reactive<{
|
|
37
|
+
params: {
|
|
38
|
+
insightType: InsightsSummaryType;
|
|
39
|
+
};
|
|
40
|
+
}>({
|
|
41
|
+
params: { insightType: INSIGHT_TYPES.TOTAL },
|
|
42
|
+
});
|
|
43
|
+
vi.mock('vue-router', () => ({
|
|
44
|
+
useRoute: () => mockRoute,
|
|
45
|
+
useRouter: vi.fn(),
|
|
46
|
+
RouterLink: vi.fn(),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
vi.mock('vue-chartjs', () => ({
|
|
50
|
+
Bar: {
|
|
51
|
+
template: '<div>Bar</div>',
|
|
52
|
+
},
|
|
53
|
+
Line: {
|
|
54
|
+
template: '<div>Line</div>',
|
|
55
|
+
},
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
vi.mock('@n8n/design-system', async (importOriginal) => {
|
|
59
|
+
const original = await importOriginal<object>();
|
|
60
|
+
return {
|
|
61
|
+
...original,
|
|
62
|
+
// A plain options object, not `defineComponent`. This factory is hoisted above the
|
|
63
|
+
// imports and runs while `@n8n/frontend-test-utils` is still initialising — it reaches
|
|
64
|
+
// design-system through its renderer — so calling anything imported from `vue` here
|
|
65
|
+
// throws a TDZ error on an import that has not been evaluated yet.
|
|
66
|
+
N8nDataTableServer: {
|
|
67
|
+
props: {
|
|
68
|
+
headers: { type: Array, required: true },
|
|
69
|
+
items: { type: Array, required: true },
|
|
70
|
+
itemsLength: { type: Number, required: true },
|
|
71
|
+
},
|
|
72
|
+
setup(_: unknown, { emit }: { emit: (event: string, ...args: unknown[]) => void }) {
|
|
73
|
+
emitterHandles.addEmitter('n8nDataTableServer', emit);
|
|
74
|
+
},
|
|
75
|
+
template: '<div data-test-id="insights-table"><slot /></div>',
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const mockTelemetry = {
|
|
81
|
+
track: vi.fn(),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const showError = vi.fn();
|
|
85
|
+
|
|
86
|
+
vi.mock('@n8n/composables/useToast', () => ({
|
|
87
|
+
useToast: () => ({ showError }),
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
vi.mock('@n8n/composables/useTelemetry', () => ({
|
|
91
|
+
useTelemetry: () => mockTelemetry,
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
const renderComponent = createComponentRenderer(InsightsDashboard);
|
|
95
|
+
|
|
96
|
+
const moduleSettings: FrontendModuleSettings = {
|
|
97
|
+
insights: {
|
|
98
|
+
summary: true,
|
|
99
|
+
dashboard: true,
|
|
100
|
+
earliestDataDate: null,
|
|
101
|
+
dateRanges: [
|
|
102
|
+
{
|
|
103
|
+
key: 'day',
|
|
104
|
+
licensed: true,
|
|
105
|
+
granularity: 'hour',
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
key: 'week',
|
|
109
|
+
licensed: true,
|
|
110
|
+
granularity: 'day',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
key: 'month',
|
|
114
|
+
licensed: true,
|
|
115
|
+
granularity: 'day',
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
key: 'quarter',
|
|
119
|
+
licensed: false,
|
|
120
|
+
granularity: 'week',
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const mockSummaryData: InsightsSummaryDisplay = [
|
|
127
|
+
{
|
|
128
|
+
id: 'total',
|
|
129
|
+
value: 1250,
|
|
130
|
+
deviation: 15,
|
|
131
|
+
unit: '',
|
|
132
|
+
deviationUnit: '%',
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: 'failed',
|
|
136
|
+
value: 23,
|
|
137
|
+
deviation: -8,
|
|
138
|
+
unit: '',
|
|
139
|
+
deviationUnit: '%',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: 'failureRate',
|
|
143
|
+
value: 1.84,
|
|
144
|
+
deviation: -0.5,
|
|
145
|
+
unit: '%',
|
|
146
|
+
deviationUnit: '%',
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: 'timeSaved',
|
|
150
|
+
value: 3600,
|
|
151
|
+
deviation: 20,
|
|
152
|
+
unit: 's',
|
|
153
|
+
deviationUnit: '%',
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: 'averageRunTime',
|
|
157
|
+
value: 15,
|
|
158
|
+
deviation: 2,
|
|
159
|
+
unit: 's',
|
|
160
|
+
deviationUnit: '%',
|
|
161
|
+
},
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
const mockChartsData: InsightsByTime[] = [
|
|
165
|
+
{
|
|
166
|
+
date: '2024-01-01',
|
|
167
|
+
values: {
|
|
168
|
+
total: 100,
|
|
169
|
+
failed: 5,
|
|
170
|
+
failureRate: 5,
|
|
171
|
+
timeSaved: 45,
|
|
172
|
+
averageRunTime: 12,
|
|
173
|
+
succeeded: 95,
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
date: '2024-01-02',
|
|
178
|
+
values: {
|
|
179
|
+
total: 120,
|
|
180
|
+
failed: 8,
|
|
181
|
+
failureRate: 6.7,
|
|
182
|
+
timeSaved: 55,
|
|
183
|
+
averageRunTime: 15,
|
|
184
|
+
succeeded: 112,
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
const mockTableData: InsightsByWorkflow = {
|
|
190
|
+
count: 2,
|
|
191
|
+
data: [
|
|
192
|
+
{
|
|
193
|
+
workflowId: 'workflow-1',
|
|
194
|
+
workflowName: 'Test Workflow 1',
|
|
195
|
+
hasReadAccess: true,
|
|
196
|
+
total: 100,
|
|
197
|
+
failed: 5,
|
|
198
|
+
failureRate: 5,
|
|
199
|
+
timeSaved: 45,
|
|
200
|
+
averageRunTime: 12,
|
|
201
|
+
projectId: 'project-1',
|
|
202
|
+
projectName: 'Test Project 1',
|
|
203
|
+
succeeded: 95,
|
|
204
|
+
runTime: 1200,
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
workflowId: 'workflow-2',
|
|
208
|
+
workflowName: 'Test Workflow 2',
|
|
209
|
+
hasReadAccess: true,
|
|
210
|
+
total: 50,
|
|
211
|
+
failed: 2,
|
|
212
|
+
failureRate: 4,
|
|
213
|
+
timeSaved: 20,
|
|
214
|
+
averageRunTime: 8,
|
|
215
|
+
projectId: 'project-2',
|
|
216
|
+
projectName: 'Test Project 2',
|
|
217
|
+
succeeded: 48,
|
|
218
|
+
runTime: 400,
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
let insightsStore: MockedStore<typeof useInsightsStore>;
|
|
224
|
+
|
|
225
|
+
const teamProjects = [
|
|
226
|
+
{ id: 'project-a', name: 'Project A' },
|
|
227
|
+
{ id: 'project-b', name: 'Project B' },
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The project picker is shell-hosted (the `project-filter` component slot), so this
|
|
232
|
+
* suite registers a stub that emits the same narrow selection the real host emits.
|
|
233
|
+
* What is under test here is how the dashboard reacts to a selection, not how the
|
|
234
|
+
* picker collects one — that belongs to `ProjectFilter.vue` in the shell.
|
|
235
|
+
*/
|
|
236
|
+
const ProjectFilterStub = defineComponent({
|
|
237
|
+
props: { modelValue: { type: Object, default: null } },
|
|
238
|
+
emits: ['update:modelValue'],
|
|
239
|
+
setup(_props, { emit }) {
|
|
240
|
+
return () =>
|
|
241
|
+
h(
|
|
242
|
+
'div',
|
|
243
|
+
{ 'data-test-id': 'project-filter' },
|
|
244
|
+
teamProjects.map((project) =>
|
|
245
|
+
h(
|
|
246
|
+
'button',
|
|
247
|
+
{
|
|
248
|
+
key: project.id,
|
|
249
|
+
'data-test-id': `project-option-${project.id}`,
|
|
250
|
+
onClick: () => emit('update:modelValue', { id: project.id }),
|
|
251
|
+
},
|
|
252
|
+
project.name,
|
|
253
|
+
),
|
|
254
|
+
),
|
|
255
|
+
);
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
const date = new Date('2000-12-19T00:00:00.000Z');
|
|
259
|
+
|
|
260
|
+
// Test helper constants
|
|
261
|
+
const DEFAULT_DATE_RANGE = {
|
|
262
|
+
startDate: new Date('2000-12-12T00:00:00.000Z'),
|
|
263
|
+
endDate: new Date('2000-12-19T00:00:00.000Z'),
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const SINGLE_DAY_RANGE = {
|
|
267
|
+
startDate: new Date('2000-12-18T00:00:00.000Z'),
|
|
268
|
+
endDate: new Date('2000-12-19T00:00:00.000Z'),
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const DEFAULT_TABLE_PARAMS = {
|
|
272
|
+
skip: 0,
|
|
273
|
+
take: 25,
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// Helper functions
|
|
277
|
+
const expectStoreExecutions = (params: { summary?: object; charts?: object; table?: object }) => {
|
|
278
|
+
if (params.summary) {
|
|
279
|
+
expect(insightsStore.summary.execute).toHaveBeenCalledWith(0, params.summary);
|
|
280
|
+
}
|
|
281
|
+
if (params.charts) {
|
|
282
|
+
expect(insightsStore.charts.execute).toHaveBeenCalledWith(0, params.charts);
|
|
283
|
+
}
|
|
284
|
+
if (params.table) {
|
|
285
|
+
expect(insightsStore.table.execute).toHaveBeenCalledWith(0, params.table);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const openDatePicker = async (getByRole: (role: string, options?: object) => HTMLElement) => {
|
|
290
|
+
const trigger = getByRole('button', { name: '12 Dec - 19 Dec, 2000' });
|
|
291
|
+
expect(trigger).toBeInTheDocument();
|
|
292
|
+
await userEvent.click(trigger);
|
|
293
|
+
|
|
294
|
+
const controllingId = trigger.getAttribute('aria-controls');
|
|
295
|
+
expect(controllingId).toBeDefined();
|
|
296
|
+
|
|
297
|
+
const picker = document.getElementById(controllingId as string);
|
|
298
|
+
expect(picker).toBeInTheDocument();
|
|
299
|
+
|
|
300
|
+
return picker as HTMLElement;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const selectProject = async (projectName: string) => {
|
|
304
|
+
const project = teamProjects.find((candidate) => candidate.name === projectName);
|
|
305
|
+
expect(project).toBeDefined();
|
|
306
|
+
await userEvent.click(screen.getByTestId(`project-option-${project?.id}`));
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const setupStores = () => {
|
|
310
|
+
insightsStore = mockedStore(useInsightsStore);
|
|
311
|
+
|
|
312
|
+
insightsStore.isSummaryEnabled = true;
|
|
313
|
+
insightsStore.isDashboardEnabled = true;
|
|
314
|
+
|
|
315
|
+
insightsStore.summary = {
|
|
316
|
+
state: mockSummaryData,
|
|
317
|
+
isLoading: false,
|
|
318
|
+
execute: vi.fn(),
|
|
319
|
+
executeImmediate: vi.fn(),
|
|
320
|
+
isReady: true,
|
|
321
|
+
error: null,
|
|
322
|
+
then: vi.fn(),
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
insightsStore.charts = {
|
|
326
|
+
state: mockChartsData,
|
|
327
|
+
isLoading: false,
|
|
328
|
+
execute: vi.fn(),
|
|
329
|
+
executeImmediate: vi.fn(),
|
|
330
|
+
isReady: true,
|
|
331
|
+
error: null,
|
|
332
|
+
then: vi.fn(),
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
insightsStore.table = {
|
|
336
|
+
state: mockTableData,
|
|
337
|
+
isLoading: false,
|
|
338
|
+
execute: vi.fn(),
|
|
339
|
+
executeImmediate: vi.fn(),
|
|
340
|
+
isReady: true,
|
|
341
|
+
error: null,
|
|
342
|
+
then: vi.fn(),
|
|
343
|
+
};
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
describe('InsightsDashboard', () => {
|
|
347
|
+
beforeEach(() => {
|
|
348
|
+
vi.clearAllMocks();
|
|
349
|
+
vi.setSystemTime(date);
|
|
350
|
+
|
|
351
|
+
mockRoute.params.insightType = INSIGHT_TYPES.TOTAL;
|
|
352
|
+
|
|
353
|
+
componentRegistry.clear();
|
|
354
|
+
componentRegistry.register('project-filter', ProjectFilterStub);
|
|
355
|
+
|
|
356
|
+
createTestingPinia({
|
|
357
|
+
initialState: { settings: { settings: defaultSettings, moduleSettings } },
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
setupStores();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe('Component Rendering', () => {
|
|
364
|
+
it('should render without error', () => {
|
|
365
|
+
expect(() =>
|
|
366
|
+
renderComponent({
|
|
367
|
+
props: { insightType: INSIGHT_TYPES.TOTAL },
|
|
368
|
+
}),
|
|
369
|
+
).not.toThrow();
|
|
370
|
+
expect(document.title).toBe('Insights - n8n');
|
|
371
|
+
expect(screen.getByRole('heading', { level: 2, name: 'Insights' })).toBeInTheDocument();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('should render summary when enabled', () => {
|
|
375
|
+
renderComponent({
|
|
376
|
+
props: { insightType: INSIGHT_TYPES.TOTAL },
|
|
377
|
+
});
|
|
378
|
+
expect(screen.getByTestId('insights-summary-tabs')).toBeInTheDocument();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('should not render summary when disabled', () => {
|
|
382
|
+
insightsStore.isSummaryEnabled = false;
|
|
383
|
+
renderComponent({
|
|
384
|
+
props: { insightType: INSIGHT_TYPES.TOTAL },
|
|
385
|
+
});
|
|
386
|
+
expect(screen.queryByTestId('insights-summary-tabs')).not.toBeInTheDocument();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('should render chart and table when dashboard enabled', async () => {
|
|
390
|
+
renderComponent({
|
|
391
|
+
props: { insightType: INSIGHT_TYPES.TOTAL },
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
await waitFor(() => {
|
|
395
|
+
expect(screen.getByTestId('insights-chart-total')).toBeInTheDocument();
|
|
396
|
+
expect(screen.getByTestId('insights-table')).toBeInTheDocument();
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('should render chart when in time saved route even if dashboard disabled', async () => {
|
|
401
|
+
insightsStore.isDashboardEnabled = false;
|
|
402
|
+
mockRoute.params.insightType = INSIGHT_TYPES.TIME_SAVED;
|
|
403
|
+
renderComponent({
|
|
404
|
+
props: { insightType: INSIGHT_TYPES.TIME_SAVED },
|
|
405
|
+
});
|
|
406
|
+
await waitFor(() => {
|
|
407
|
+
expect(screen.getByTestId('insights-chart-time-saved')).toBeInTheDocument();
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('should render paywall when dashboard disabled and not time saved route', async () => {
|
|
412
|
+
insightsStore.isDashboardEnabled = false;
|
|
413
|
+
renderComponent({
|
|
414
|
+
props: { insightType: INSIGHT_TYPES.TOTAL },
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
await waitFor(() => {
|
|
418
|
+
expect(screen.queryByTestId('insights-chart-total')).not.toBeInTheDocument();
|
|
419
|
+
expect(screen.queryByTestId('insights-table')).not.toBeInTheDocument();
|
|
420
|
+
expect(
|
|
421
|
+
screen.getByRole('heading', {
|
|
422
|
+
level: 4,
|
|
423
|
+
name: 'Upgrade to access more detailed insights',
|
|
424
|
+
}),
|
|
425
|
+
).toBeInTheDocument();
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
describe('Date Range Selection', () => {
|
|
431
|
+
it('should update the selected time range', async () => {
|
|
432
|
+
const { getByRole } = renderComponent({
|
|
433
|
+
props: { insightType: INSIGHT_TYPES.TOTAL },
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const picker = await openDatePicker(getByRole);
|
|
437
|
+
const dayOption = within(picker).getByText('Last 24 hours');
|
|
438
|
+
await userEvent.click(dayOption);
|
|
439
|
+
|
|
440
|
+
expect(mockTelemetry.track).toHaveBeenCalledWith('User updated insights time range', {
|
|
441
|
+
end_date: SINGLE_DAY_RANGE.endDate.toISOString(),
|
|
442
|
+
start_date: SINGLE_DAY_RANGE.startDate.toISOString(),
|
|
443
|
+
range_length_days: 1,
|
|
444
|
+
type: 'preset',
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
expectStoreExecutions({
|
|
448
|
+
summary: SINGLE_DAY_RANGE,
|
|
449
|
+
charts: SINGLE_DAY_RANGE,
|
|
450
|
+
table: {
|
|
451
|
+
...DEFAULT_TABLE_PARAMS,
|
|
452
|
+
sortBy: 'total:desc',
|
|
453
|
+
...SINGLE_DAY_RANGE,
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('should show upgrade modal when unlicensed time range selected ', async () => {
|
|
459
|
+
const { getByRole } = renderComponent({
|
|
460
|
+
props: { insightType: INSIGHT_TYPES.TOTAL },
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const picker = await openDatePicker(getByRole);
|
|
464
|
+
const dayOption = within(picker).getByText('Last 90 days');
|
|
465
|
+
await userEvent.click(dayOption);
|
|
466
|
+
|
|
467
|
+
expect(mockTelemetry.track).not.toHaveBeenCalled();
|
|
468
|
+
expect(
|
|
469
|
+
screen.getByText(/Viewing this time period requires an enterprise plan/),
|
|
470
|
+
).toBeVisible();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('should set start date to beginning of day for multi-day ranges', async () => {
|
|
474
|
+
// Set system time to Dec 19, 2000 at 14:30:45
|
|
475
|
+
const currentTime = new Date('2000-12-19T14:30:45.000Z');
|
|
476
|
+
vi.setSystemTime(currentTime);
|
|
477
|
+
|
|
478
|
+
const { getByRole } = renderComponent({
|
|
479
|
+
props: { insightType: INSIGHT_TYPES.TOTAL },
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
vi.clearAllMocks();
|
|
483
|
+
|
|
484
|
+
const picker = await openDatePicker(getByRole);
|
|
485
|
+
// Select month option to get a multi-day range
|
|
486
|
+
const monthOption = within(picker).getByText('Last 30 days');
|
|
487
|
+
await userEvent.click(monthOption);
|
|
488
|
+
|
|
489
|
+
// For multi-day ranges ending today: start is midnight, end is current time
|
|
490
|
+
const expectedRange = {
|
|
491
|
+
startDate: new Date('2000-11-19T00:00:00.000Z'), // 30 days ago at midnight
|
|
492
|
+
endDate: currentTime, // Current time
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
expect(mockTelemetry.track).toHaveBeenCalledWith('User updated insights time range', {
|
|
496
|
+
end_date: expectedRange.endDate.toISOString(),
|
|
497
|
+
start_date: expectedRange.startDate.toISOString(),
|
|
498
|
+
range_length_days: 30,
|
|
499
|
+
type: 'preset',
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
expectStoreExecutions({
|
|
503
|
+
summary: expectedRange,
|
|
504
|
+
charts: expectedRange,
|
|
505
|
+
table: {
|
|
506
|
+
...DEFAULT_TABLE_PARAMS,
|
|
507
|
+
sortBy: 'total:desc',
|
|
508
|
+
...expectedRange,
|
|
509
|
+
},
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
describe('Component Lifecycle', () => {
|
|
515
|
+
it('should execute data fetching on mount', () => {
|
|
516
|
+
renderComponent({
|
|
517
|
+
props: { insightType: INSIGHT_TYPES.TOTAL },
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
expectStoreExecutions({
|
|
521
|
+
summary: DEFAULT_DATE_RANGE,
|
|
522
|
+
charts: DEFAULT_DATE_RANGE,
|
|
523
|
+
table: {
|
|
524
|
+
...DEFAULT_TABLE_PARAMS,
|
|
525
|
+
sortBy: 'total:desc',
|
|
526
|
+
...DEFAULT_DATE_RANGE,
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('should refetch data when insight type changes', async () => {
|
|
532
|
+
const { rerender } = renderComponent({
|
|
533
|
+
props: { insightType: INSIGHT_TYPES.TOTAL },
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
vi.clearAllMocks();
|
|
537
|
+
await rerender({ insightType: INSIGHT_TYPES.FAILED });
|
|
538
|
+
|
|
539
|
+
expectStoreExecutions({
|
|
540
|
+
summary: DEFAULT_DATE_RANGE,
|
|
541
|
+
charts: DEFAULT_DATE_RANGE,
|
|
542
|
+
table: {
|
|
543
|
+
...DEFAULT_TABLE_PARAMS,
|
|
544
|
+
sortBy: 'failed:desc',
|
|
545
|
+
...DEFAULT_DATE_RANGE,
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('should update sort order when insight type changes', async () => {
|
|
551
|
+
const { rerender } = renderComponent({
|
|
552
|
+
props: { insightType: INSIGHT_TYPES.TOTAL },
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
await rerender({ insightType: INSIGHT_TYPES.TIME_SAVED });
|
|
556
|
+
|
|
557
|
+
expectStoreExecutions({
|
|
558
|
+
table: {
|
|
559
|
+
...DEFAULT_TABLE_PARAMS,
|
|
560
|
+
sortBy: 'timeSaved:desc',
|
|
561
|
+
...DEFAULT_DATE_RANGE,
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
describe('Chart wrapper', () => {
|
|
568
|
+
test.each([
|
|
569
|
+
[INSIGHT_TYPES.TOTAL, 'insights-chart-total'],
|
|
570
|
+
[INSIGHT_TYPES.FAILED, 'insights-chart-failed'],
|
|
571
|
+
[INSIGHT_TYPES.FAILURE_RATE, 'insights-chart-failure-rate'],
|
|
572
|
+
[INSIGHT_TYPES.TIME_SAVED, 'insights-chart-time-saved'],
|
|
573
|
+
[INSIGHT_TYPES.AVERAGE_RUN_TIME, 'insights-chart-average-runtime'],
|
|
574
|
+
])('should render %s chart component', async (type, testId) => {
|
|
575
|
+
renderComponent({
|
|
576
|
+
props: { insightType: type },
|
|
577
|
+
});
|
|
578
|
+
await waitFor(() => {
|
|
579
|
+
expect(screen.getByTestId(testId)).toBeInTheDocument();
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
describe('Table Functionality', () => {
|
|
585
|
+
it('should handle table pagination', async () => {
|
|
586
|
+
renderComponent({
|
|
587
|
+
props: { insightType: INSIGHT_TYPES.TOTAL },
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
await waitAllPromises();
|
|
591
|
+
|
|
592
|
+
emitterHandles.emitters.n8nDataTableServer.emit('update:options', {
|
|
593
|
+
page: 1,
|
|
594
|
+
itemsPerPage: 50,
|
|
595
|
+
sortBy: [{ id: 'total', desc: true }],
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
expectStoreExecutions({
|
|
599
|
+
table: {
|
|
600
|
+
skip: 50,
|
|
601
|
+
take: 50,
|
|
602
|
+
sortBy: 'total:desc',
|
|
603
|
+
...DEFAULT_DATE_RANGE,
|
|
604
|
+
},
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('should handle table sorting', async () => {
|
|
609
|
+
renderComponent({
|
|
610
|
+
props: { insightType: INSIGHT_TYPES.TOTAL },
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
await waitAllPromises();
|
|
614
|
+
|
|
615
|
+
await waitFor(() => {
|
|
616
|
+
emitterHandles.emitters.n8nDataTableServer.emit('update:options', {
|
|
617
|
+
page: 0,
|
|
618
|
+
itemsPerPage: 25,
|
|
619
|
+
sortBy: [{ id: 'failed', desc: false }],
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
expectStoreExecutions({
|
|
624
|
+
table: {
|
|
625
|
+
...DEFAULT_TABLE_PARAMS,
|
|
626
|
+
sortBy: 'failed:asc',
|
|
627
|
+
...DEFAULT_DATE_RANGE,
|
|
628
|
+
},
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it('should handle empty sort array', async () => {
|
|
633
|
+
renderComponent({
|
|
634
|
+
props: { insightType: INSIGHT_TYPES.TOTAL },
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
await waitAllPromises();
|
|
638
|
+
|
|
639
|
+
await waitFor(() => {
|
|
640
|
+
emitterHandles.emitters.n8nDataTableServer.emit('update:options', {
|
|
641
|
+
page: 0,
|
|
642
|
+
itemsPerPage: 25,
|
|
643
|
+
sortBy: [],
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
expectStoreExecutions({
|
|
648
|
+
table: {
|
|
649
|
+
...DEFAULT_TABLE_PARAMS,
|
|
650
|
+
sortBy: undefined,
|
|
651
|
+
...DEFAULT_DATE_RANGE,
|
|
652
|
+
},
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
describe('Project Filter Functionality', () => {
|
|
658
|
+
it('should render the registered project-filter slot', async () => {
|
|
659
|
+
renderComponent({
|
|
660
|
+
props: { insightType: INSIGHT_TYPES.TOTAL },
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
await waitFor(() => {
|
|
664
|
+
expect(screen.getByTestId('project-filter')).toBeInTheDocument();
|
|
665
|
+
});
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it('should render no picker when the project-filter slot is unregistered', async () => {
|
|
669
|
+
componentRegistry.clear();
|
|
670
|
+
|
|
671
|
+
renderComponent({
|
|
672
|
+
props: { insightType: INSIGHT_TYPES.TOTAL },
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
await waitFor(() => {
|
|
676
|
+
expect(screen.getByRole('heading', { level: 2, name: 'Insights' })).toBeInTheDocument();
|
|
677
|
+
});
|
|
678
|
+
expect(screen.queryByTestId('project-filter')).not.toBeInTheDocument();
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it('should select a project and filter data by project ID', async () => {
|
|
682
|
+
renderComponent({
|
|
683
|
+
props: { insightType: INSIGHT_TYPES.TOTAL },
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
await waitFor(() => {
|
|
687
|
+
expect(screen.getByTestId('project-filter')).toBeInTheDocument();
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
await selectProject(teamProjects[0].name);
|
|
691
|
+
|
|
692
|
+
const projectId = teamProjects[0].id;
|
|
693
|
+
expectStoreExecutions({
|
|
694
|
+
summary: { ...DEFAULT_DATE_RANGE, projectId },
|
|
695
|
+
charts: { ...DEFAULT_DATE_RANGE, projectId },
|
|
696
|
+
table: {
|
|
697
|
+
...DEFAULT_TABLE_PARAMS,
|
|
698
|
+
sortBy: 'total:desc',
|
|
699
|
+
...DEFAULT_DATE_RANGE,
|
|
700
|
+
projectId,
|
|
701
|
+
},
|
|
702
|
+
});
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it('should combine project filter with date range changes', async () => {
|
|
706
|
+
const { getByRole } = renderComponent({
|
|
707
|
+
props: { insightType: INSIGHT_TYPES.TOTAL },
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
await selectProject(teamProjects[0].name);
|
|
711
|
+
vi.clearAllMocks();
|
|
712
|
+
|
|
713
|
+
const picker = await openDatePicker(getByRole);
|
|
714
|
+
const dayOption = within(picker).getByText('Last 24 hours');
|
|
715
|
+
await userEvent.click(dayOption);
|
|
716
|
+
|
|
717
|
+
const projectId = teamProjects[0].id;
|
|
718
|
+
expectStoreExecutions({
|
|
719
|
+
summary: { ...SINGLE_DAY_RANGE, projectId },
|
|
720
|
+
charts: { ...SINGLE_DAY_RANGE, projectId },
|
|
721
|
+
table: {
|
|
722
|
+
...DEFAULT_TABLE_PARAMS,
|
|
723
|
+
sortBy: 'total:desc',
|
|
724
|
+
...SINGLE_DAY_RANGE,
|
|
725
|
+
projectId,
|
|
726
|
+
},
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it('should maintain project filter when insight type changes', async () => {
|
|
731
|
+
const { rerender } = renderComponent({
|
|
732
|
+
props: { insightType: INSIGHT_TYPES.TOTAL },
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
await selectProject(teamProjects[0].name);
|
|
736
|
+
vi.clearAllMocks();
|
|
737
|
+
|
|
738
|
+
await rerender({ insightType: INSIGHT_TYPES.FAILED });
|
|
739
|
+
|
|
740
|
+
const projectId = teamProjects[0].id;
|
|
741
|
+
expectStoreExecutions({
|
|
742
|
+
summary: { ...DEFAULT_DATE_RANGE, projectId },
|
|
743
|
+
charts: { ...DEFAULT_DATE_RANGE, projectId },
|
|
744
|
+
table: {
|
|
745
|
+
...DEFAULT_TABLE_PARAMS,
|
|
746
|
+
sortBy: 'failed:desc',
|
|
747
|
+
...DEFAULT_DATE_RANGE,
|
|
748
|
+
projectId,
|
|
749
|
+
},
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it('should pass project ID to table pagination and sorting events', async () => {
|
|
754
|
+
renderComponent({
|
|
755
|
+
props: { insightType: INSIGHT_TYPES.TOTAL },
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
await selectProject(teamProjects[0].name);
|
|
759
|
+
await waitAllPromises();
|
|
760
|
+
vi.clearAllMocks();
|
|
761
|
+
|
|
762
|
+
emitterHandles.emitters.n8nDataTableServer.emit('update:options', {
|
|
763
|
+
page: 1,
|
|
764
|
+
itemsPerPage: 50,
|
|
765
|
+
sortBy: [{ id: 'failed', desc: true }],
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
expectStoreExecutions({
|
|
769
|
+
table: {
|
|
770
|
+
skip: 50,
|
|
771
|
+
take: 50,
|
|
772
|
+
sortBy: 'failed:desc',
|
|
773
|
+
...DEFAULT_DATE_RANGE,
|
|
774
|
+
projectId: teamProjects[0].id,
|
|
775
|
+
},
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it('should show an error and revert to all projects when the selected project is forbidden', async () => {
|
|
780
|
+
insightsStore.charts.execute = vi.fn().mockImplementation(async (_delay, params) => {
|
|
781
|
+
insightsStore.charts.error = params?.projectId
|
|
782
|
+
? new ResponseError('You do not have access to insights for this project.', {
|
|
783
|
+
httpStatusCode: 403,
|
|
784
|
+
})
|
|
785
|
+
: null;
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
renderComponent({
|
|
789
|
+
props: { insightType: INSIGHT_TYPES.TOTAL },
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
await waitFor(() => {
|
|
793
|
+
expect(screen.getByTestId('project-filter')).toBeInTheDocument();
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
await selectProject(teamProjects[0].name);
|
|
797
|
+
await waitAllPromises();
|
|
798
|
+
|
|
799
|
+
expect(showError).toHaveBeenCalledWith(
|
|
800
|
+
expect.objectContaining({
|
|
801
|
+
httpStatusCode: 403,
|
|
802
|
+
message: 'You do not have access to insights for this project.',
|
|
803
|
+
}),
|
|
804
|
+
"Couldn't load insights",
|
|
805
|
+
{ message: "You don't have access to insights for this project" },
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
await waitFor(() => {
|
|
809
|
+
expect(insightsStore.charts.execute).toHaveBeenLastCalledWith(0, {
|
|
810
|
+
...DEFAULT_DATE_RANGE,
|
|
811
|
+
projectId: undefined,
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
it('should not revert the current selection when a stale forbidden request resolves after a newer one', async () => {
|
|
817
|
+
let resolveForbiddenFetch: () => void = () => {};
|
|
818
|
+
const forbiddenFetchGate = new Promise<void>((resolve) => {
|
|
819
|
+
resolveForbiddenFetch = resolve;
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
insightsStore.charts.execute = vi.fn().mockImplementation(async (_delay, params) => {
|
|
823
|
+
if (params?.projectId === teamProjects[0].id) {
|
|
824
|
+
await forbiddenFetchGate;
|
|
825
|
+
insightsStore.charts.error = new ResponseError(
|
|
826
|
+
'You do not have access to insights for this project.',
|
|
827
|
+
{ httpStatusCode: 403 },
|
|
828
|
+
);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
insightsStore.charts.error = null;
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
renderComponent({
|
|
835
|
+
props: { insightType: INSIGHT_TYPES.TOTAL },
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
await waitFor(() => {
|
|
839
|
+
expect(screen.getByTestId('project-filter')).toBeInTheDocument();
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
// Select the forbidden project — its request is held open below.
|
|
843
|
+
await selectProject(teamProjects[0].name);
|
|
844
|
+
|
|
845
|
+
// Switch to an accessible project before the first request resolves.
|
|
846
|
+
await selectProject(teamProjects[1].name);
|
|
847
|
+
await waitAllPromises();
|
|
848
|
+
|
|
849
|
+
vi.clearAllMocks();
|
|
850
|
+
|
|
851
|
+
// Let the stale (forbidden) request resolve now that a newer selection is active.
|
|
852
|
+
resolveForbiddenFetch();
|
|
853
|
+
await waitAllPromises();
|
|
854
|
+
|
|
855
|
+
expect(showError).not.toHaveBeenCalled();
|
|
856
|
+
expect(insightsStore.charts.execute).not.toHaveBeenCalledWith(
|
|
857
|
+
0,
|
|
858
|
+
expect.objectContaining({ projectId: undefined }),
|
|
859
|
+
);
|
|
860
|
+
});
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
describe('Default date range initialization', () => {
|
|
864
|
+
it('should default to 7 days ago when earliestDataDate is null', () => {
|
|
865
|
+
const { getByRole } = renderComponent({ props: { insightType: INSIGHT_TYPES.TOTAL } });
|
|
866
|
+
// System date is 2000-12-19, so 7 days ago is 2000-12-12
|
|
867
|
+
expect(getByRole('button', { name: '12 Dec - 19 Dec, 2000' })).toBeInTheDocument();
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it('should clamp default range start to the earliest date with data with a maximum of 7', () => {
|
|
871
|
+
// earliestDataDate is Dec 15, which is within the last 7 days (Dec 12–19)
|
|
872
|
+
createTestingPinia({
|
|
873
|
+
initialState: {
|
|
874
|
+
settings: {
|
|
875
|
+
settings: defaultSettings,
|
|
876
|
+
moduleSettings: {
|
|
877
|
+
...moduleSettings,
|
|
878
|
+
insights: {
|
|
879
|
+
...moduleSettings.insights!,
|
|
880
|
+
earliestDataDate: '2000-12-15T00:00:00.000Z',
|
|
881
|
+
},
|
|
882
|
+
},
|
|
883
|
+
},
|
|
884
|
+
},
|
|
885
|
+
});
|
|
886
|
+
setupStores();
|
|
887
|
+
const { getByRole } = renderComponent({ props: { insightType: INSIGHT_TYPES.TOTAL } });
|
|
888
|
+
expect(getByRole('button', { name: '15 Dec - 19 Dec, 2000' })).toBeInTheDocument();
|
|
889
|
+
});
|
|
890
|
+
});
|
|
891
|
+
});
|