refactor(editor): Extract insights into a frontend module package (no-changelog) (#37030)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Alex Grozav 2026-09-08 15:41:15 +00:00 committed by GitHub
parent b482979c14
commit 0a286c0530
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
74 changed files with 1585 additions and 261 deletions

View File

@ -6,4 +6,5 @@ export * as modalRegistry from './registries/modalRegistry';
export * from './registries/resourceRegistry';
export * as pushHandlerRegistry from './registries/pushHandlerRegistry';
export * as commandRegistry from './registries/commandRegistry';
export * as componentRegistry from './registries/componentRegistry';
export * as parameterInputRegistry from './registries/parameterInputRegistry';

View File

@ -0,0 +1,73 @@
import { computed, defineComponent } from 'vue';
import * as componentRegistry from './componentRegistry';
const First = defineComponent({ template: '<div>first</div>' });
const Second = defineComponent({ template: '<div>second</div>' });
describe('componentRegistry', () => {
beforeEach(() => {
componentRegistry.clear();
});
it('resolves a registered slot', () => {
componentRegistry.register('project-filter', First);
expect(componentRegistry.get('project-filter')).toBe(First);
expect(componentRegistry.has('project-filter')).toBe(true);
});
it('resolves an unregistered slot to undefined', () => {
expect(componentRegistry.get('project-filter')).toBeUndefined();
expect(componentRegistry.has('project-filter')).toBe(false);
});
it('treats re-registering the same component as a no-op', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
componentRegistry.register('project-filter', First);
componentRegistry.register('project-filter', First);
expect(componentRegistry.get('project-filter')).toBe(First);
expect(warn).not.toHaveBeenCalled();
});
it('keeps the first component and warns when a second claims the slot', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
componentRegistry.register('project-filter', First);
componentRegistry.register('project-filter', Second);
expect(componentRegistry.get('project-filter')).toBe(First);
expect(warn).toHaveBeenCalledWith(
'Component slot "project-filter" is already registered. Skipping.',
);
});
// The registry is `shallowReactive` so a module can resolve a slot with a plain
// `computed`. The shell registers slots lazily, after a module may already have
// read an empty slot — swapping in a plain `Map` would break that silently.
it('makes a computed reader see a later registration', () => {
const slot = computed(() => componentRegistry.get('project-filter'));
expect(slot.value).toBeUndefined();
componentRegistry.register('project-filter', First);
expect(slot.value).toBe(First);
});
it('makes a computed reader see an unregister', () => {
componentRegistry.register('project-filter', First);
const slot = computed(() => componentRegistry.get('project-filter'));
expect(slot.value).toBe(First);
componentRegistry.unregister('project-filter');
expect(slot.value).toBeUndefined();
});
it('frees the slot on unregister', () => {
componentRegistry.register('project-filter', First);
componentRegistry.unregister('project-filter');
expect(componentRegistry.get('project-filter')).toBeUndefined();
});
});

View File

@ -0,0 +1,42 @@
import { shallowReactive, type Component } from 'vue';
import type { ModuleComponentSlot } from '../types/component';
/**
* Shell-hosted components, keyed by slot. The shell registers; modules read.
*
* Shallow-reactive so a module can resolve a slot with a plain `computed`, and
* shallow specifically so a component is never turned into a reactive object.
*
* A slot with nothing registered resolves to `undefined`, and the module renders
* nothing for it. That is the correct behavior in a module's own test run, where
* no shell exists — a test that needs the slot registers a stub.
*/
const components = shallowReactive(new Map<ModuleComponentSlot, Component>());
export function register(slot: ModuleComponentSlot, component: Component): void {
const existing = components.get(slot);
if (existing && existing !== component) {
console.warn(`Component slot "${slot}" is already registered. Skipping.`);
return;
}
components.set(slot, component);
}
export function get(slot: ModuleComponentSlot): Component | undefined {
return components.get(slot);
}
export function has(slot: ModuleComponentSlot): boolean {
return components.has(slot);
}
export function unregister(slot: ModuleComponentSlot): void {
components.delete(slot);
}
/** Test isolation only. */
export function clear(): void {
components.clear();
}

View File

@ -0,0 +1,22 @@
/**
* UI a module renders but does not own.
*
* Each id names one shell-hosted component with a fixed contract. The union is
* closed on purpose: this is not a generic slot system, and adding an id is a
* reviewed change to the module contract, not a local decision.
*
* `project-filter` — a project picker. The project list, the permissions that
* decide between local and remote search, and the search itself all live in
* `features/collaboration/projects`, which sits above the module layer. A module
* that needs to filter by project renders this instead of importing any of it.
*/
export type ModuleComponentSlot = 'project-filter';
/**
* The `v-model` value of the `project-filter` slot. `null` means "all projects".
*
* Deliberately narrow: `id` is all a consumer needs, and the host keeps the full
* project object. A consumer writes only `null` (to clear); every non-null value
* originates in the host.
*/
export type SlotProjectSelection = { id: string } | null;

View File

@ -1,3 +1,4 @@
export type * from './component';
export type * from './descriptor';
export type * from './modal';
export type * from './parameterInput';

View File

@ -44,6 +44,7 @@ export const sourcePackages = [
* The module tsconfig base holds that boundary.
*/
export const modulePackages: Array<{ name: string; dir: string; entry?: boolean }> = [
{ name: '@n8n/frontend-module-insights', dir: 'modules/insights/frontend' },
{ name: '@n8n/frontend-module-instance-registry', dir: 'modules/instance-registry/frontend' },
{ name: '@n8n/frontend-module-otel', dir: 'modules/otel/frontend' },
];

View File

@ -6,6 +6,10 @@ import { createPinia, setActivePinia } from 'pinia';
import { mock } from 'vitest-mock-extended';
import { useCloudPlanStore } from '../cloudPlan.store';
import {
clearDefaultUpgradeRedirectGuard,
setDefaultUpgradeRedirectGuard,
} from '../registries/upgradeRedirectGuard';
import { useSettingsStore } from '../settings.store';
import { useUsersStore } from '../users.store';
import { useVersionsStore } from '../versions.store';
@ -247,6 +251,78 @@ describe('useBasePageRedirectionHelper', () => {
});
});
// A module package cannot pass the shell's guard, so it omits the argument and
// gets whatever the shell registered. Nothing registered means fail-open.
describe('goToUpgrade with no guard argument', () => {
beforeEach(() => {
usersStore.addUsers([{ id: '1', isPending: false, role: ROLE.Owner }]);
usersStore.currentUserId = '1';
settingsStore.setSettings(settingsFor('cloud'));
clearDefaultUpgradeRedirectGuard();
});
afterEach(() => {
clearDefaultUpgradeRedirectGuard();
});
test('consults the registered default guard and aborts when it resolves false', async () => {
const telemetry = useTelemetry();
const initialHref = location.href;
const guard = vi.fn<UpgradeRedirectGuard>().mockResolvedValue(false);
setDefaultUpgradeRedirectGuard(guard);
await useBasePageRedirectionHelper().goToUpgrade(
'advanced-permissions',
'upgrade-api',
'redirect',
);
expect(guard).toHaveBeenCalledTimes(1);
expect(location.href).toBe(initialHref);
expect(telemetry.track).not.toHaveBeenCalled();
});
test('consults the registered default guard and proceeds when it resolves true', async () => {
const guard = vi.fn<UpgradeRedirectGuard>().mockResolvedValue(true);
setDefaultUpgradeRedirectGuard(guard);
await useBasePageRedirectionHelper().goToUpgrade(
'advanced-permissions',
'upgrade-api',
'redirect',
);
expect(guard).toHaveBeenCalledTimes(1);
expect(location.href).toContain('utm_campaign=upgrade-api');
});
test('proceeds when no guard is registered at all', async () => {
await useBasePageRedirectionHelper().goToUpgrade(
'advanced-permissions',
'upgrade-api',
'redirect',
);
expect(location.href).toContain('utm_campaign=upgrade-api');
});
test('prefers an explicitly passed guard over the registered default', async () => {
const registered = vi.fn<UpgradeRedirectGuard>().mockResolvedValue(true);
const passed = vi.fn<UpgradeRedirectGuard>().mockResolvedValue(false);
setDefaultUpgradeRedirectGuard(registered);
await useBasePageRedirectionHelper({ guard: passed }).goToUpgrade(
'advanced-permissions',
'upgrade-api',
'redirect',
);
expect(passed).toHaveBeenCalledTimes(1);
expect(registered).not.toHaveBeenCalled();
});
});
describe('goToCloudDashboard', () => {
const reservedTab = { close: vi.fn(), location: { href: '' }, opener: {} as Window | null };
const CONNECT_PATH = '/manage/connect';

View File

@ -2,6 +2,7 @@ import { useTelemetry } from '@n8n/composables/useTelemetry';
import { N8N_PRICING_PAGE_URL } from '@n8n/frontend-constants/urls';
import { useCloudPlanStore } from '../cloudPlan.store';
import { getDefaultUpgradeRedirectGuard } from '../registries/upgradeRedirectGuard';
import { useSettingsStore } from '../settings.store';
import type { CloudUpdateLinkSourceType, UTMCampaign } from '../types/pageRedirection';
import { useUsersStore } from '../users.store';
@ -17,14 +18,15 @@ export type UpgradeRedirectGuard = () => Promise<boolean>;
/**
* Injectable page-redirection composable. The app-facing `usePageRedirectionHelper`
* wraps this and supplies the guard, which keeps this base free of any feature
* dependency.
* dependency. A caller that cannot reach the shell — a module package — omits it
* and gets the guard the shell registered (see `registries/upgradeRedirectGuard`).
*
* It lives in `@n8n/stores` rather than `@n8n/composables` because its body is
* store orchestration end to end — all four stores it reads are in this package,
* and `@n8n/composables` sits *below* the stores tier (see that package's
* `packageBoundary.test.ts`), so it cannot reach them.
*/
export function useBasePageRedirectionHelper({ guard }: { guard: UpgradeRedirectGuard }) {
export function useBasePageRedirectionHelper({ guard }: { guard?: UpgradeRedirectGuard } = {}) {
const usersStore = useUsersStore();
const cloudPlanStore = useCloudPlanStore();
const versionsStore = useVersionsStore();
@ -102,7 +104,7 @@ export function useBasePageRedirectionHelper({ guard }: { guard: UpgradeRedirect
utm_campaign: UTMCampaign,
mode: 'open' | 'redirect' = 'open',
) => {
const shouldProceed = await guard();
const shouldProceed = await (guard ?? getDefaultUpgradeRedirectGuard())();
if (!shouldProceed) return;
const { usageLeft, trialDaysLeft, userIsTrialing } = cloudPlanStore;

View File

@ -0,0 +1,30 @@
import {
clearDefaultUpgradeRedirectGuard,
getDefaultUpgradeRedirectGuard,
setDefaultUpgradeRedirectGuard,
} from './upgradeRedirectGuard';
describe('upgradeRedirectGuard', () => {
beforeEach(() => {
clearDefaultUpgradeRedirectGuard();
});
it('proceeds when nothing is registered', async () => {
await expect(getDefaultUpgradeRedirectGuard()()).resolves.toBe(true);
});
it('returns the registered guard', async () => {
const guard = vi.fn().mockResolvedValue(false);
setDefaultUpgradeRedirectGuard(guard);
await expect(getDefaultUpgradeRedirectGuard()()).resolves.toBe(false);
expect(guard).toHaveBeenCalledTimes(1);
});
it('lets a later registration replace an earlier one', async () => {
setDefaultUpgradeRedirectGuard(async () => await Promise.resolve(false));
setDefaultUpgradeRedirectGuard(async () => await Promise.resolve(true));
await expect(getDefaultUpgradeRedirectGuard()()).resolves.toBe(true);
});
});

View File

@ -0,0 +1,29 @@
import type { UpgradeRedirectGuard } from '../composables/useBasePageRedirectionHelper';
/**
* The guard `useBasePageRedirectionHelper` consults when a caller supplies none.
*
* A module package cannot reach the shell, so it cannot pass the app's guard (the
* AI-builder streaming confirmation) itself. The shell registers that guard here
* once at init; a module then calls the base composable with no argument and gets
* the same confirmation an in-shell caller gets.
*
* The fallback proceeds, so a test or a boot path that registers nothing behaves
* as it did before any guard existed.
*/
let registered: UpgradeRedirectGuard | undefined;
const proceed: UpgradeRedirectGuard = async () => await Promise.resolve(true);
export function setDefaultUpgradeRedirectGuard(guard: UpgradeRedirectGuard): void {
registered = guard;
}
export function getDefaultUpgradeRedirectGuard(): UpgradeRedirectGuard {
return registered ?? proceed;
}
/** Test isolation only. */
export function clearDefaultUpgradeRedirectGuard(): void {
registered = undefined;
}

View File

@ -25,6 +25,11 @@ const extractedFeatures = [
message:
'otel is the @n8n/frontend-module-otel package. The shell registers a module through src/app/modules.manifest.ts.',
},
{
group: ['@/features/execution/insights', '@/features/execution/insights/*'],
message:
'insights is the @n8n/frontend-module-insights package. The shell registers a module through src/app/modules.manifest.ts.',
},
];
export default defineConfig(

View File

@ -53,6 +53,7 @@
"@n8n/design-system": "workspace:*",
"@n8n/eslint-config": "workspace:*",
"@n8n/frontend-constants": "workspace:*",
"@n8n/frontend-module-insights": "workspace:*",
"@n8n/frontend-module-instance-registry": "workspace:*",
"@n8n/frontend-module-otel": "workspace:*",
"@n8n/frontend-module-sdk": "workspace:*",
@ -122,6 +123,7 @@
"dateformat": "^3.0.3",
"element-plus": "catalog:frontend",
"email-providers": "^2.0.1",
"eslint": "catalog:",
"eslint-plugin-oxlint": "catalog:",
"esprima-next": "5.8.4",
"fake-indexeddb": "^6.0.0",

View File

@ -0,0 +1,27 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* `main.ts` boots the app, so it cannot be imported into a unit test. These
* registrations are still load-bearing and silent when missing:
*
* - drop `registerUpgradeRedirectGuard` and an upgrade CTA raised from a module
* package skips the AI-builder confirmation, because the registry fails open;
* - drop `registerComponentSlots` and the insights dashboard renders no project
* picker, because an unregistered slot resolves to `undefined`.
*
* Both failures are invisible to every other test, so this asserts the call sites
* exist. It is the same shape as `vite/aliases.test.ts`, which reads config files
* to hold two of them in step.
*/
const mainTs = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
describe('main.ts bootstrap registrations', () => {
it.each([
['registerUpgradeRedirectGuard', '@/app/upgradeRedirectGuard.manifest'],
['registerComponentSlots', '@/app/componentSlots.manifest'],
])('imports %s from %s and calls it', (fn, from) => {
expect(mainTs).toContain(`import { ${fn} } from '${from}';`);
expect(mainTs).toMatch(new RegExp(`^${fn}\\(\\);$`, 'm'));
});
});

View File

@ -0,0 +1,32 @@
import { componentRegistry } from '@n8n/frontend-module-sdk';
import { registerComponentSlots } from './componentSlots.manifest';
describe('registerComponentSlots', () => {
beforeEach(() => {
componentRegistry.clear();
});
afterEach(() => {
componentRegistry.clear();
});
it('leaves the project-filter slot empty until it is called', () => {
expect(componentRegistry.get('project-filter')).toBeUndefined();
});
it('registers the project-filter slot', () => {
registerComponentSlots();
expect(componentRegistry.get('project-filter')).toBeDefined();
});
it('registers the host lazily, so the projects feature stays out of the boot chunk', () => {
registerComponentSlots();
// An async component wrapper, not the resolved SFC: resolving here would make
// `ProjectSharing` and the projects store statically reachable from `main.ts`.
const slot = componentRegistry.get('project-filter');
expect((slot as { __asyncLoader?: unknown }).__asyncLoader).toBeInstanceOf(Function);
});
});

View File

@ -0,0 +1,19 @@
import { componentRegistry } from '@n8n/frontend-module-sdk';
import { defineAsyncComponent } from 'vue';
/**
* Shell-hosted components that modules render but do not own — the counterpart to
* `modals.manifest.ts` for the `componentRegistry` slots.
*
* Async on purpose: registering these eagerly would pull the owning feature into
* the boot chunk (the project filter reaches the projects store and ProjectSharing's
* design-system tree), which is exactly what a module renders a slot to avoid.
*/
export const registerComponentSlots = () => {
componentRegistry.register(
'project-filter',
defineAsyncComponent(
async () => await import('@/features/collaboration/projects/components/ProjectFilter.vue'),
),
);
};

View File

@ -0,0 +1,79 @@
import { ESLint } from 'eslint';
/**
* The extraction ratchet must hold for every file under `src`.
*
* `no-restricted-imports` is not mergeable across flat-config blocks — a later
* block replaces the rule outright. That is exactly how the ratchet was turned off
* for `src/features/agents/**` once already: a narrower block set the rule without
* spreading `extractedFeatures`. A comment cannot hold that, so this asserts
* the *resolved* config rather than the config source.
*
* Add a block that sets `no-restricted-imports` for some subtree and forgets the
* spread, and the matching path below fails.
*/
type RestrictedImports = [
string | number,
{ patterns?: Array<{ group?: string[]; message?: string }> },
];
const EXTRACTED_MODULES = [
{ group: '@/features/instanceRegistry', package: '@n8n/frontend-module-instance-registry' },
{ group: '@/features/settings/otel', package: '@n8n/frontend-module-otel' },
{ group: '@/features/execution/insights', package: '@n8n/frontend-module-insights' },
];
// One representative path per block in `eslint.config.mjs` that sets the rule, plus
// plain feature and app paths. `features/agents` is the subtree that regressed.
const PATHS = [
'src/app/main.ts',
'src/app/views/WorkflowsView.vue',
'src/features/agents/views/AgentsListView.vue',
'src/features/agents/agents.store.ts',
'src/features/credentials/views/CredentialsView.vue',
'src/features/collaboration/projects/projects.routes.ts',
'src/features/ndv/ndv.store.ts',
];
describe('extraction ratchet', () => {
let configs: Map<string, RestrictedImports | undefined>;
beforeAll(async () => {
const eslint = new ESLint({ cwd: process.cwd() });
configs = new Map();
for (const path of PATHS) {
const config = (await eslint.calculateConfigForFile(path)) as {
rules?: Record<string, unknown>;
};
configs.set(
path,
config.rules?.['@typescript-eslint/no-restricted-imports'] as RestrictedImports | undefined,
);
}
}, 60000);
it.each(PATHS)('restricts every extracted module for %s', (path) => {
const rule = configs.get(path);
expect(rule, `no-restricted-imports is unset for ${path}`).toBeDefined();
// ESLint normalises the severity, so `error` arrives as 2.
const [severity, options] = rule as RestrictedImports;
expect(severity).toBe(2);
const groups = (options.patterns ?? []).flatMap((pattern) => pattern.group ?? []);
for (const { group, package: pkg } of EXTRACTED_MODULES) {
expect(groups, `${path} does not restrict ${pkg}'s old path`).toContain(group);
}
});
it('keeps the agents-only RunData restriction alongside the ratchet', () => {
// The pattern the regression dropped. It proves the narrow block still sets its
// own patterns rather than being replaced by the wide one.
const rule = configs.get('src/features/agents/views/AgentsListView.vue');
const groups = ((rule as RestrictedImports)[1].patterns ?? []).flatMap(
(pattern) => pattern.group ?? [],
);
expect(groups).toContain('**/ndv/runData/components/RunData.vue');
});
});

View File

@ -1,6 +1,5 @@
import type { FrontendModuleDescription } from '@n8n/frontend-module-sdk';
import { DataTableModule } from '@/features/core/dataTable/module.descriptor';
import { InsightsModule } from '@/features/execution/insights/module.descriptor';
import { MCPModule } from '@/features/ai/mcpAccess/module.descriptor';
import { ChatModule } from '@/features/ai/chatHub/module.descriptor';
import { InstanceAiModule } from '@/features/ai/instanceAi/module.descriptor';
@ -8,13 +7,13 @@ import { AgentsModule } from '@/features/agents/module.descriptor';
import { WorkflowReviewsModule } from '@/features/workflow-reviews/module.descriptor';
import { InstanceRegistryModule } from '@n8n/frontend-module-instance-registry';
import { OtelModule } from '@n8n/frontend-module-otel';
import { InsightsModule } from '@n8n/frontend-module-insights/insights.module';
import { PromotionsModule } from '@/features/integrations/promotions.ee/module.descriptor';
/**
* Hard-coding modules list until we have a dynamic way to load modules.
*/
export const modules: FrontendModuleDescription[] = [
InsightsModule,
DataTableModule,
MCPModule,
ChatModule,
@ -23,5 +22,6 @@ export const modules: FrontendModuleDescription[] = [
OtelModule,
WorkflowReviewsModule,
InstanceRegistryModule,
InsightsModule,
PromotionsModule,
];

View File

@ -0,0 +1,55 @@
import {
clearDefaultUpgradeRedirectGuard,
getDefaultUpgradeRedirectGuard,
} from '@n8n/stores/registries/upgradeRedirectGuard';
import { registerUpgradeRedirectGuard } from './upgradeRedirectGuard.manifest';
const confirmIfBuilderStreaming = vi.fn();
vi.mock('@/features/ai/assistant/composables/useBuilderStreamingGuard', () => ({
get confirmIfBuilderStreaming() {
return confirmIfBuilderStreaming;
},
}));
describe('registerUpgradeRedirectGuard', () => {
beforeEach(() => {
clearDefaultUpgradeRedirectGuard();
confirmIfBuilderStreaming.mockReset();
});
afterEach(() => {
clearDefaultUpgradeRedirectGuard();
});
it('leaves the registry failing open until it is called', async () => {
// The unregistered default proceeds, so an unregistered guard is silent, not loud.
await expect(getDefaultUpgradeRedirectGuard()()).resolves.toBe(true);
expect(confirmIfBuilderStreaming).not.toHaveBeenCalled();
});
it('registers a guard that delegates to the builder streaming confirmation', async () => {
confirmIfBuilderStreaming.mockResolvedValue(false);
registerUpgradeRedirectGuard();
await expect(getDefaultUpgradeRedirectGuard()()).resolves.toBe(false);
expect(confirmIfBuilderStreaming).toHaveBeenCalledTimes(1);
});
it('passes a confirmation through', async () => {
confirmIfBuilderStreaming.mockResolvedValue(true);
registerUpgradeRedirectGuard();
await expect(getDefaultUpgradeRedirectGuard()()).resolves.toBe(true);
});
it('does not load the guard module until the guard runs', () => {
confirmIfBuilderStreaming.mockResolvedValue(true);
registerUpgradeRedirectGuard();
// Registration alone must not reach the builder store: that is what keeps the
// builder out of the boot chunk.
expect(confirmIfBuilderStreaming).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,18 @@
import { setDefaultUpgradeRedirectGuard } from '@n8n/stores/registries/upgradeRedirectGuard';
/**
* The upgrade-CTA guard, for callers that cannot import it themselves — a module
* package must not reach into `features/`. Without this registration the registry
* fails open and an upgrade CTA raised from a module skips the confirmation.
*
* The guard body loads on the click, not here: it reads the AI builder store, and
* importing that eagerly would put the builder in the boot chunk.
*/
export const registerUpgradeRedirectGuard = () => {
setDefaultUpgradeRedirectGuard(async () => {
const { confirmIfBuilderStreaming } = await import(
'@/features/ai/assistant/composables/useBuilderStreamingGuard'
);
return await confirmIfBuilderStreaming();
});
};

View File

@ -51,7 +51,7 @@ import { useTrialIntroModalStore } from '@/experiments/trialIntroModal/stores/tr
import EmptyStateLayout from '@/app/components/layouts/EmptyStateLayout.vue';
import { useReadyToRunStore } from '@/features/workflows/readyToRun/stores/readyToRun.store';
import { useEmptyStateDetection } from '@/features/workflows/readyToRun/composables/useEmptyStateDetection';
import { InsightsSummary, useInsightsStore } from '@/features/execution/insights';
import { InsightsSummary, useInsightsStore } from '@n8n/frontend-module-insights';
import { useWorkflowsEmptyState } from '@/features/workflows/composables/useWorkflowsEmptyState';
import type {
BaseFilters,

View File

@ -45,8 +45,8 @@ vi.mock('@/features/collaboration/projects/projects.store', () => ({
}),
}));
vi.mock('@/features/execution/insights', async (importOriginal) => ({
...(await importOriginal<typeof import('@/features/execution/insights')>()),
vi.mock('@n8n/frontend-module-insights', async (importOriginal) => ({
...(await importOriginal<typeof import('@n8n/frontend-module-insights')>()),
useInsightsStore: () => ({
isSummaryEnabled: false,
weeklySummary: { isLoading: false, state: null },

View File

@ -10,7 +10,7 @@ import { useProjectsStore } from '@/features/collaboration/projects/projects.sto
import ProjectHeader from '@/features/collaboration/projects/components/ProjectHeader.vue';
import ResourcesListLayout from '@/app/components/layouts/ResourcesListLayout.vue';
import ResourcesListEmptyState from '@/app/components/layouts/ResourcesListEmptyState.vue';
import { InsightsSummary, useInsightsStore } from '@/features/execution/insights';
import { InsightsSummary, useInsightsStore } from '@n8n/frontend-module-insights';
import { useProjectPages } from '@/features/collaboration/projects/composables/useProjectPages';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import {

View File

@ -0,0 +1,155 @@
import type { FrontendModuleSettings } from '@n8n/api-types';
import { componentRegistry } from '@n8n/frontend-module-sdk';
import { InsightsModule } from '@n8n/frontend-module-insights/insights.module';
import { useInsightsStore } from '@n8n/frontend-module-insights';
import { createTestingPinia } from '@pinia/testing';
import { screen, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { reactive } from 'vue';
import type { Component } from 'vue';
import { createComponentRenderer } from '@/__tests__/render';
import { defaultSettings, mockedStore } from '@n8n/frontend-test-utils';
import { getDropdownItems } from '@/__tests__/utils';
import { createProjectListItem } from '../__tests__/utils';
import { useProjectsStore } from '../projects.store';
import { registerComponentSlots } from '@/app/componentSlots.manifest';
/**
* The `project-filter` slot, end to end, with no stub on either side.
*
* This is the only place the contract can be checked. `<component :is>` erases prop
* types, so neither package's typecheck sees the other's half: the module passes
* `placeholder` and `size` into a host it cannot import, and the host is written
* against a module it must not import. Rename a prop on either side and only a test
* that renders both together notices.
*
* Both halves are reached through their public entries — the dashboard through the
* descriptor's own route record, so this test widens no package API.
*/
const teamProjects = Array.from({ length: 2 }, () => createProjectListItem('team'));
const mockRoute = reactive<{ params: { insightType: string } }>({
params: { insightType: 'total' },
});
vi.mock('vue-router', async (importOriginal) => ({
...(await importOriginal<typeof import('vue-router')>()),
useRoute: () => mockRoute,
}));
const moduleSettings: FrontendModuleSettings = {
insights: {
summary: false,
dashboard: false,
earliestDataDate: null,
dateRanges: [{ key: 'week', licensed: true, granularity: 'day' }],
},
};
/** The dashboard as the shell reaches it: the descriptor's lazy route component. */
const loadDashboard = async (): Promise<Component> => {
const child = InsightsModule.routes?.[0]?.children?.[0];
const loader = child?.components?.default ?? child?.component;
if (typeof loader !== 'function') throw new Error('insights dashboard route component missing');
const loaded = (await (loader as () => Promise<{ default: Component }>)()) as {
default?: Component;
};
return loaded.default ?? (loaded as Component);
};
describe('project-filter slot, composed', () => {
let insightsStore: ReturnType<typeof mockedStore<typeof useInsightsStore>>;
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
beforeEach(() => {
vi.setSystemTime(new Date('2000-12-19T00:00:00.000Z'));
mockRoute.params.insightType = 'total';
createTestingPinia({
initialState: { settings: { settings: defaultSettings, moduleSettings } },
});
insightsStore = mockedStore(useInsightsStore);
// Both panels off, and `insightType` is not `timeSaved`, so the charts and the
// table do not render. This test is about the slot, not the dashboard's content;
// `charts.execute` still runs on every selection, which is the round trip below.
insightsStore.isSummaryEnabled = false;
insightsStore.isDashboardEnabled = false;
insightsStore.earliestDataDate = null;
insightsStore.dateRanges = [{ key: 'week', licensed: true, granularity: 'day' }];
for (const key of ['summary', 'charts', 'table'] as const) {
insightsStore[key] = {
state: null,
isLoading: false,
execute: vi.fn(),
executeImmediate: vi.fn(),
isReady: true,
error: null,
then: vi.fn(),
} as never;
}
projectsStore = mockedStore(useProjectsStore);
projectsStore.myProjects = teamProjects;
projectsStore.availableProjects = teamProjects;
projectsStore.getAvailableProjects.mockResolvedValue();
projectsStore.searchProjects.mockResolvedValue({
count: teamProjects.length,
data: teamProjects,
});
projectsStore.globalProjectPermissions = { list: true };
componentRegistry.clear();
registerComponentSlots();
});
afterEach(() => {
componentRegistry.clear();
vi.useRealTimers();
});
it("lands the module's placeholder and size props on the real host", async () => {
const renderDashboard = createComponentRenderer(await loadDashboard());
renderDashboard({ props: { insightType: 'total' } });
const select = await screen.findByTestId('project-sharing-select');
// `insights.dashboard.search.placeholder`, carried across the slot as a prop.
// The host sets `inheritAttrs: false`, so a misspelled prop cannot reach the
// input as a bare attribute and imitate a working contract — it falls back to
// ProjectSharing's own default, 'Select project or user'.
const input = select.querySelector('input') as HTMLInputElement;
expect(input.placeholder).toBe('All projects');
// `size="mini"` crosses the slot; N8nSelect maps it onto element-plus `small`.
expect(select.querySelector('.el-select--small')).toBeInTheDocument();
// The module styles the picker for its own layout (`.projectSelect` sets its
// width), so `class` is forwarded explicitly past `inheritAttrs: false`.
const styled = document.querySelector('[class*="projectSelect"]');
expect(styled).toBeInTheDocument();
expect(styled).toContainElement(select);
});
it('carries a selection from the real host back into the module', async () => {
const renderDashboard = createComponentRenderer(await loadDashboard());
renderDashboard({ props: { insightType: 'total' } });
const select = await screen.findByTestId('project-sharing-select');
await userEvent.click(select);
const items = await getDropdownItems(select);
const option = [...items].find(
(item) => item.querySelector('p')?.textContent?.trim() === teamProjects[0].name,
);
expect(option).toBeDefined();
await userEvent.click(option as Element);
await waitFor(() => {
expect(insightsStore.charts.execute).toHaveBeenCalledWith(
0,
expect.objectContaining({ projectId: teamProjects[0].id }),
);
});
});
}, 30000);

View File

@ -0,0 +1,157 @@
import { createTestingPinia } from '@pinia/testing';
import { mockedStore } from '@/__tests__/utils';
import { createComponentRenderer } from '@/__tests__/render';
import { getDropdownItems } from '@/__tests__/utils';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/vue';
import { setActivePinia } from 'pinia';
import { defineComponent, ref } from 'vue';
import type { SlotProjectSelection } from '@n8n/frontend-module-sdk';
import { createProjectListItem } from '../__tests__/utils';
import { useProjectsStore } from '../projects.store';
import ProjectFilter from './ProjectFilter.vue';
const renderComponent = createComponentRenderer(ProjectFilter);
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
const teamProjects = Array.from({ length: 2 }, () => createProjectListItem('team'));
// A personal project's name is the owner's address in angle brackets.
const personalProject = { ...createProjectListItem('personal'), name: '<owner@example.com>' };
const projects = [personalProject, ...teamProjects];
describe('ProjectFilter', () => {
beforeEach(() => {
setActivePinia(createTestingPinia());
projectsStore = mockedStore(useProjectsStore);
projectsStore.myProjects = projects;
projectsStore.availableProjects = projects;
projectsStore.getAvailableProjects.mockResolvedValue();
projectsStore.searchProjects.mockResolvedValue({ count: projects.length, data: projects });
projectsStore.globalProjectPermissions = { list: true };
});
it('emits the selected project id', async () => {
const { emitted, getByTestId } = renderComponent({ props: { modelValue: null } });
const select = getByTestId('project-sharing-select');
await userEvent.click(select);
const items = await getDropdownItems(select);
const match = [...items].find(
(item) => item.querySelector('p')?.textContent?.trim() === teamProjects[0].name,
);
expect(match).toBeDefined();
await userEvent.click(match as Element);
await waitFor(() => {
expect(emitted('update:modelValue')).toEqual([[{ id: teamProjects[0].id }]]);
});
});
it('omits personal projects from the options', async () => {
const { getByTestId } = renderComponent({ props: { modelValue: null } });
const select = getByTestId('project-sharing-select');
await userEvent.click(select);
const items = await getDropdownItems(select);
const names = [...items].map((item) => item.querySelector('p')?.textContent?.trim());
expect(names).toContain(teamProjects[0].name);
expect(names).not.toContain(personalProject.name);
});
it('preloads projects only when the caller cannot list them remotely', async () => {
projectsStore.globalProjectPermissions = { list: false };
renderComponent({ props: { modelValue: null } });
await waitFor(() => {
expect(projectsStore.getAvailableProjects).toHaveBeenCalled();
});
});
it('does not preload projects when remote search is available', async () => {
renderComponent({ props: { modelValue: null } });
await waitFor(() => {
expect(projectsStore.getAvailableProjects).not.toHaveBeenCalled();
});
});
// Reported by filter: mounting with a non-null model showed an empty picker while
// the caller displayed filtered data. A consumer that restores its filter from the
// URL hits this on first paint.
it('shows the project a caller arrives with', async () => {
const { getByTestId } = renderComponent({
props: { modelValue: { id: teamProjects[0].id } },
});
const input = () =>
getByTestId('project-sharing-select').querySelector('input') as HTMLInputElement;
await waitFor(() => expect(input().value).toBe(teamProjects[0].name));
});
it('does not echo an arriving selection back at the caller', async () => {
const { emitted } = renderComponent({
props: { modelValue: { id: teamProjects[0].id } },
});
await waitFor(() => expect(projectsStore.getAvailableProjects).not.toHaveBeenCalled());
expect(emitted('update:modelValue')).toBeUndefined();
});
it('resolves an arriving selection that only the member preload can see', async () => {
// Nothing is seeded up front, so the `immediate` watcher finds no project at
// setup and only the preload can supply it. Seeding it beforehand made this
// test pass with the post-preload resolve deleted.
projectsStore.globalProjectPermissions = { list: false };
projectsStore.myProjects = [];
projectsStore.availableProjects = [];
projectsStore.getAvailableProjects.mockImplementation(async () => {
projectsStore.myProjects = teamProjects;
});
const { getByTestId } = renderComponent({
props: { modelValue: { id: teamProjects[1].id } },
});
const input = () =>
getByTestId('project-sharing-select').querySelector('input') as HTMLInputElement;
await waitFor(() => expect(input().value).toBe(teamProjects[1].name));
});
it('clears the shown selection when the caller resets the model to null', async () => {
// Driven through a real `v-model` parent, because the clear path is what the
// caller writes back: the insights dashboard nulls its ref after a forbidden
// project response.
const selected = ref<SlotProjectSelection>(null);
const Host = defineComponent({
components: { ProjectFilter },
setup: () => ({ selected }),
template: '<ProjectFilter v-model="selected" />',
});
const { getByTestId } = createComponentRenderer(Host)();
const select = getByTestId('project-sharing-select');
await userEvent.click(select);
const items = await getDropdownItems(select);
const match = [...items].find(
(item) => item.querySelector('p')?.textContent?.trim() === teamProjects[0].name,
);
await userEvent.click(match as Element);
await waitFor(() => expect(selected.value).toEqual({ id: teamProjects[0].id }));
const input = () =>
getByTestId('project-sharing-select').querySelector('input') as HTMLInputElement;
await waitFor(() => expect(input().value).toBe(teamProjects[0].name));
selected.value = null;
await waitFor(() => expect(input().value).toBe(''));
});
});

View File

@ -0,0 +1,101 @@
<script lang="ts" setup>
import type { SelectSize } from '@n8n/design-system';
import type { SlotProjectSelection } from '@n8n/frontend-module-sdk';
import { useI18n } from '@n8n/i18n';
import { onBeforeMount, ref, watch } from 'vue';
import { useProjectsStore } from '../projects.store';
import type { ProjectListItem, ProjectSharingData } from '../projects.types';
import { useAvailableProjectSearch } from '../projects.utils';
import ProjectSharing from './ProjectSharing.vue';
/**
* The `project-filter` component slot (see `@n8n/frontend-module-sdk`): a project
* picker for callers that must not import this feature. It owns the store access,
* the search strategy and the personal-project filter, and hands the caller back
* only the chosen project id.
*/
defineProps<{
placeholder?: string;
size?: SelectSize;
}>();
/**
* The slot contract is these props and the model — nothing else.
*
* Without this, an unknown attribute from a consumer falls through onto
* `ProjectSharing` and can imitate the prop it misspelled: a `placeholder` typo
* still reached the input as a bare attribute, so a broken contract rendered
* correctly. `class` is forwarded explicitly because consumers style the picker
* for their own layout.
*/
defineOptions({ inheritAttrs: false });
const model = defineModel<SlotProjectSelection>({ required: true });
const i18n = useI18n();
const projectsStore = useProjectsStore();
const searchFn = useAvailableProjectSearch();
// A personal project is named `<email>`, which is not a useful thing to filter by.
const emailPattern = /^<([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})>$/;
const filterFn = (project: ProjectListItem) =>
!!project.name && !emailPattern.test(project.name.trim());
const selected = ref<ProjectSharingData | null>(null);
/** The project the model's id refers to, if this caller can already see it. */
const findProject = (id: string) =>
[...projectsStore.availableProjects, ...projectsStore.myProjects].find(
(project) => project.id === id,
) ?? null;
// The model carries an id; the picker needs the whole project to show a label. Keep
// the two in step in both directions, and resolve an id the caller arrived with —
// a consumer that restores its filter from the URL mounts with one already set.
watch(
model,
(value) => {
if (!value) {
selected.value = null;
return;
}
if (selected.value?.id === value.id) return;
selected.value = findProject(value.id);
},
{ immediate: true },
);
// Guarded against the echo of the sync above, which would otherwise emit an
// identical id straight back at the caller.
watch(selected, (value) => {
const next = value ? { id: value.id } : null;
if (next?.id === model.value?.id) return;
model.value = next;
});
onBeforeMount(async () => {
// Members filter locally over myProjects — preload them.
// Admins use remote search, so skip the unpaginated GET /projects call.
if (!projectsStore.globalProjectPermissions.list) {
await projectsStore.getAvailableProjects();
}
// The preload above may be what makes an arriving id resolvable.
if (model.value && !selected.value) selected.value = findProject(model.value.id);
});
</script>
<template>
<ProjectSharing
v-model="selected"
:class="$attrs.class"
:search-fn="searchFn"
:filter-fn="filterFn"
:placeholder="placeholder"
:empty-options-text="i18n.baseText('projects.sharing.noMatchingProjects')"
:size="size"
clearable
@clear="selected = null"
/>
</template>

View File

@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({
execute: vi.fn(),
}));
vi.mock('@/features/execution/insights', () => ({
vi.mock('@n8n/frontend-module-insights', () => ({
useInsightsStore: () => ({
isSummaryEnabled: mocks.isSummaryEnabled,
weeklySummary: { execute: mocks.execute },

View File

@ -15,7 +15,7 @@ const ExecutionsView = async () =>
const ProjectVariables = async () => await import('./views/ProjectVariables.vue');
function refreshInsightsSummary() {
void import('@/features/execution/insights')
void import('@n8n/frontend-module-insights')
.then(({ useInsightsStore }) => {
const insightsStore = useInsightsStore();
if (insightsStore.isSummaryEnabled) {

View File

@ -33,7 +33,7 @@ import {
import { useAsyncState } from '@vueuse/core';
import pickBy from 'lodash/pickBy';
import type { ComponentExposed } from 'vue-component-type-helpers';
import { InsightsSummary, useInsightsStore } from '@/features/execution/insights';
import { InsightsSummary, useInsightsStore } from '@n8n/frontend-module-insights';
import { useEnvironmentsStore } from '@/features/settings/environments.ee/environments.store';
import type { EnvironmentVariable } from '@/features/settings/environments.ee/environments.types';
import VariablesUsageBadge from '@/features/settings/environments.ee/components/VariablesUsageBadge.vue';

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import ProjectHeader from '@/features/collaboration/projects/components/ProjectHeader.vue';
import { useProjectPages } from '@/features/collaboration/projects/composables/useProjectPages';
import { InsightsSummary, useInsightsStore } from '@/features/execution/insights';
import { InsightsSummary, useInsightsStore } from '@n8n/frontend-module-insights';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import DataTableCard from '@/features/core/dataTable/components/DataTableCard.vue';

View File

@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({
execute: vi.fn(),
}));
vi.mock('@/features/execution/insights', () => ({
vi.mock('@n8n/frontend-module-insights', () => ({
useInsightsStore: () => ({
isSummaryEnabled: mocks.isSummaryEnabled,
weeklySummary: { execute: mocks.execute },

View File

@ -35,7 +35,7 @@ export const DataTableModule: FrontendModuleDescription = {
// Refresh the weekly summary when entering the datatables route. The import is
// lazy and unawaited: this descriptor is in the boot graph through the shell
// manifest, and a chunk that fails to load must not hold up navigation.
void import('@/features/execution/insights')
void import('@n8n/frontend-module-insights')
.then(({ useInsightsStore }) => {
const insightsStore = useInsightsStore();
if (insightsStore.isSummaryEnabled) {

View File

@ -11,7 +11,7 @@ import { useProjectPages } from '@/features/collaboration/projects/composables/u
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { CREDENTIAL_EDIT_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY } from '../credentials.constants';
import { EnterpriseEditionFeature, VIEWS } from '@/app/constants';
import { InsightsSummary, useInsightsStore } from '@/features/execution/insights';
import { InsightsSummary, useInsightsStore } from '@n8n/frontend-module-insights';
import { useExternalSecretsStore } from '@/features/integrations/externalSecrets.ee/externalSecrets.ee.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';

View File

@ -8,7 +8,7 @@ import { useI18n } from '@n8n/i18n';
import { useProjectPages } from '@/features/collaboration/projects/composables/useProjectPages';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { useToast } from '@n8n/composables/useToast';
import { InsightsSummary, useInsightsStore } from '@/features/execution/insights';
import { InsightsSummary, useInsightsStore } from '@n8n/frontend-module-insights';
import { useExecutionsStore } from '../executions.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import { storeToRefs } from 'pinia';

View File

@ -32,6 +32,8 @@ import { SentryPlugin } from '@/app/plugins/sentry';
import { registerVitePreloadErrorHandler } from '@/app/plugins/vitePreloadError';
import { registerModuleRoutes } from '@/app/moduleInitializer/moduleInitializer';
import { registerEagerModals } from '@/app/modals.manifest';
import { registerComponentSlots } from '@/app/componentSlots.manifest';
import { registerUpgradeRedirectGuard } from '@/app/upgradeRedirectGuard.manifest';
import { installRenderTracker } from '@/app/dev/render-tracker';
import type { VueScanOptions } from 'z-vue-scan';
@ -53,6 +55,12 @@ registerModuleRoutes(router);
// Always-on modals, needed before login — see modals.manifest.ts
registerEagerModals();
// Shell-hosted components modules render — see componentSlots.manifest.ts
registerComponentSlots();
// The upgrade-CTA guard modules rely on — see upgradeRedirectGuard.manifest.ts
registerUpgradeRedirectGuard();
app.use(TelemetryPlugin);
app.use(PiniaVuePlugin);
app.use(GlobalComponentsPlugin);

View File

@ -25,6 +25,8 @@
"@n8n/composables*": ["../@n8n/composables/src*"],
"@n8n/constants*": ["../../@n8n/constants/src*"],
"@n8n/frontend-module-sdk": ["../@n8n/frontend-module-sdk/src/index.ts"],
"@n8n/frontend-module-insights": ["../../modules/insights/frontend/src/index.ts"],
"@n8n/frontend-module-insights/*": ["../../modules/insights/frontend/src/*"],
"@n8n/frontend-module-instance-registry": [
"../../modules/instance-registry/frontend/src/index.ts"
],

View File

@ -0,0 +1,64 @@
# @n8n/frontend-module-insights
Frontend feature module. Consumed from source by the editor-ui shell; there is no
build step and no `dist`.
## Two entry points, on purpose
- `@n8n/frontend-module-insights` — `useInsightsStore` and `InsightsSummary`, for
the shell views that show the summary widget. Every one of them reaches it
through a lazy `import()`.
- `@n8n/frontend-module-insights/insights.module` — the descriptor, for
`src/app/modules.manifest.ts`.
The split is measured, not stylistic. The manifest is imported eagerly at boot,
and `InsightsSummary` is an SFC. With both behind one entry, the SFC becomes
statically reachable from the boot chunk: `pnpm build` put `InsightsSummary`'s JS
**and a render-blocking stylesheet** into `dist/index.html` (353 `modulepreload`
entries against 346, and 99 stylesheet links against 98). Keep component code out
of the descriptor's entry.
```bash
pnpm turbo typecheck --filter=@n8n/frontend-module-insights
pnpm turbo lint --filter=@n8n/frontend-module-insights
pnpm turbo test --filter=@n8n/frontend-module-insights
```
Go through turbo, not `pnpm --filter <pkg> typecheck`: this package is consumed
from source, and on a cold tree its platform dependencies have not been built
yet. Turbo builds them first; the bare pnpm form does not.
## Import rules
- Depend on foundation and platform packages only (`@n8n/design-system`,
`@n8n/stores`, `@n8n/composables`, `@n8n/i18n`, `@n8n/rest-api-client`,
`@n8n/api-types`, `@n8n/permissions`, `@n8n/frontend-constants`,
`@n8n/utils`, `@n8n/frontend-module-sdk`). Never import another
`@n8n/frontend-module-*`, and never import `@/…` from the shell.
- `@n8n/stores` and `@n8n/composables` are **subpath-only** — import
`@n8n/stores/settings.store`, not `@n8n/stores`.
- The no-cross-module rule is currently a convention: the shared tsconfig base
omits sibling modules from `paths`, which blocks an accidental import but not
a deliberate one (declaring the dependency makes it typecheck clean). The
ESLint rule that actually enforces it is CAT-3692.
## Adding UI
`@vitejs/plugin-vue`, `unplugin-icons` and `vite-svg-loader` are wired into
`vite.config.ts`, so a `.vue` file that renders a design-system component
compiles in tests without further setup. All three are needed: the design-system
icon set imports `~icons/lucide/*` and `./custom/*.svg`, and without the icon
plugins an icon resolves to a URL string and rendering throws. Route components
must load lazily — see the note in `src/insights.module.ts`.
## Test harness
`src/__tests__/render.ts` is this package's own renderer (i18n, pinia,
design-system, a telemetry stub). It is deliberately not the shell's
`@/__tests__/render`, which also provides the workflow document store and the
touch-events directive — editor-core concerns no insights component reads. The
shared harness (`@n8n/vitest-config/setup/frontend`) cannot host a Vue renderer:
it must not import vue, pinia or i18n, or the turbo graph gains a cycle.
Store-level fixtures that several packages need live next to the store instead:
`@n8n/stores/__tests__/defaultSettings` and `@n8n/stores/__tests__/mockedStore`.

View File

@ -0,0 +1,4 @@
{
"$schema": "../../../../node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["../../../../biome.jsonc"]
}

View File

@ -0,0 +1,33 @@
import { frontendConfig } from '@n8n/eslint-config/frontend';
import { defineConfig } from 'eslint/config';
export default defineConfig(frontendConfig, {
/**
* Suppressions inherited with the code, not granted to it.
*
* These files came from `editor-ui/src/features/execution/insights`, where
* `eslint.config.mjs` turns each of these rules off or down to `warn` for the
* whole shell. The move is content-pristine on purpose — blame and `--follow`
* survive — so the debt travels with it instead of being rewritten in the same
* PR. `warn` here, never `off`: the count is visible and can only go down.
*
* Most of it is one root cause: `chart.js` / `vue-chartjs` option and dataset
* types degrade to `any`, which propagates through the chart components. The
* `naming-convention` hits are data keys (`2weeks`, `6months`, `update:options`)
* that cannot be camelCase — those need a narrow inline disable, not a fix.
*
* Boundary rules are untouched and stay at error: nothing here relaxes what
* this module may import.
*/
files: ['src/**/*.ts', 'src/**/*.vue'],
rules: {
'@typescript-eslint/naming-convention': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
'@typescript-eslint/no-unsafe-assignment': 'warn',
'@typescript-eslint/no-unsafe-call': 'warn',
'@typescript-eslint/no-unsafe-member-access': 'warn',
'@typescript-eslint/no-unsafe-return': 'warn',
'@typescript-eslint/await-thenable': 'warn',
'@typescript-eslint/require-await': 'warn',
},
});

View File

@ -0,0 +1,72 @@
{
"name": "@n8n/frontend-module-insights",
"version": "0.1.0",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./insights.module": "./src/insights.module.ts"
},
"scripts": {
"clean": "rimraf .turbo",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run",
"test:changed": "janitor test-scoped",
"test:dev": "vitest",
"lint": "eslint src --quiet",
"lint:fix": "eslint src --fix",
"lint:styles": "stylelint \"src/**/*.{scss,sass,vue}\" --cache",
"lint:styles:fix": "stylelint \"src/**/*.{scss,sass,vue}\" --fix --cache",
"format": "biome format --write . && prettier --write . --ignore-path ../../../../.prettierignore",
"format:check": "biome ci . && prettier --check . --ignore-path ../../../../.prettierignore"
},
"dependencies": {
"@internationalized/date": "catalog:frontend",
"@n8n/api-types": "workspace:*",
"@n8n/composables": "workspace:*",
"@n8n/design-system": "workspace:*",
"@n8n/frontend-constants": "workspace:*",
"@n8n/frontend-module-sdk": "workspace:*",
"@n8n/i18n": "workspace:*",
"@n8n/permissions": "workspace:*",
"@n8n/rest-api-client": "workspace:*",
"@n8n/stores": "workspace:*",
"@n8n/utils": "workspace:*",
"@vueuse/core": "catalog:frontend",
"chart.js": "^4.4.0",
"dateformat": "^3.0.3",
"element-plus": "catalog:frontend",
"lodash": "catalog:",
"pinia": "catalog:frontend",
"vue": "catalog:frontend",
"vue-chartjs": "^5.2.0",
"vue-i18n": "catalog:frontend",
"vue-router": "catalog:frontend"
},
"devDependencies": {
"@iconify/json": "catalog:",
"@n8n/eslint-config": "workspace:*",
"@n8n/frontend-test-utils": "workspace:*",
"@n8n/frontend-vite-config": "workspace:*",
"@n8n/playwright-janitor": "workspace:*",
"@n8n/stylelint-config": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@pinia/testing": "^0.1.6",
"@testing-library/jest-dom": "catalog:frontend",
"@testing-library/user-event": "catalog:frontend",
"@testing-library/vue": "catalog:frontend",
"@types/dateformat": "^3.0.0",
"@types/lodash": "catalog:",
"@vitejs/plugin-vue": "catalog:frontend",
"eslint": "catalog:",
"stylelint": "catalog:",
"typescript": "catalog:",
"unplugin-icons": "catalog:frontend",
"vite": "catalog:",
"vite-svg-loader": "catalog:frontend",
"vitest": "catalog:",
"vue-tsc": "catalog:frontend"
},
"license": "LicenseRef-n8n-sustainable-use"
}

View File

@ -0,0 +1,4 @@
// The shared frontend test harness: the jsdom environment, english messages, and a fresh pinia
// per test. It lives in `@n8n/frontend-test-utils` rather than in `@n8n/vitest-config`, because
// `@n8n/i18n` devDepends on the latter and importing i18n there closes a cycle in the turbo graph.
import '@n8n/frontend-test-utils/setup';

View File

@ -1,7 +1,7 @@
import merge from 'lodash/merge';
import { type ChartOptions, type ScriptableContext } from 'chart.js';
import { useCssVar } from '@vueuse/core';
import { smartDecimal } from '@n8n/utils/number/smart-decimal';
import { useCssVar } from '@vueuse/core';
import { type ChartOptions, type ScriptableContext } from 'chart.js';
import merge from 'lodash/merge';
/**
*

View File

@ -1,32 +1,37 @@
import { defineComponent, reactive } from 'vue';
import { createComponentRenderer } from '@/__tests__/render';
import InsightsDashboard from './InsightsDashboard.vue';
import { createTestingPinia } from '@pinia/testing';
import { defaultSettings } from '@n8n/frontend-test-utils';
import { useInsightsStore } from '@/features/execution/insights/insights.store';
import {
mockedStore,
type MockedStore,
useEmitters,
waitAllPromises,
getDropdownItems,
} from '@/__tests__/utils';
import { within, screen, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createProjectListItem } from '@/features/collaboration/projects/__tests__/utils';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import type {
FrontendModuleSettings,
InsightsByTime,
InsightsByWorkflow,
InsightsSummaryType,
} from '@n8n/api-types';
import { INSIGHT_TYPES } from '@/features/execution/insights/insights.constants';
import type { InsightsSummaryDisplay } from '@/features/execution/insights/insights.types';
import { componentRegistry } from '@n8n/frontend-module-sdk';
import {
createComponentRenderer,
defaultSettings,
mockedStore,
useEmitters,
waitAllPromises,
type MockedStore,
} from '@n8n/frontend-test-utils';
import { ResponseError } from '@n8n/rest-api-client/utils';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { within, screen, waitFor } from '@testing-library/vue';
import { vi } from 'vitest';
import { defineComponent, h, reactive } from 'vue';
const { emitters, addEmitter } = useEmitters<'n8nDataTableServer'>();
import InsightsDashboard from './InsightsDashboard.vue';
import { INSIGHT_TYPES } from '../insights.constants';
import { useInsightsStore } from '../insights.store';
import type { InsightsSummaryDisplay } from '../insights.types';
// Called in a hook, not at module scope: the design-system mock below is hoisted above the
// imports, and `@n8n/frontend-test-utils` reaches design-system through its renderer.
let emitterHandles: ReturnType<typeof useEmitters<'n8nDataTableServer'>>;
beforeAll(() => {
emitterHandles = useEmitters<'n8nDataTableServer'>();
});
const mockRoute = reactive<{
params: {
@ -54,17 +59,21 @@ vi.mock('@n8n/design-system', async (importOriginal) => {
const original = await importOriginal<object>();
return {
...original,
N8nDataTableServer: defineComponent({
// A plain options object, not `defineComponent`. This factory is hoisted above the
// imports and runs while `@n8n/frontend-test-utils` is still initialising — it reaches
// design-system through its renderer — so calling anything imported from `vue` here
// throws a TDZ error on an import that has not been evaluated yet.
N8nDataTableServer: {
props: {
headers: { type: Array, required: true },
items: { type: Array, required: true },
itemsLength: { type: Number, required: true },
},
setup(_, { emit }) {
addEmitter('n8nDataTableServer', emit);
setup(_: unknown, { emit }: { emit: (event: string, ...args: unknown[]) => void }) {
emitterHandles.addEmitter('n8nDataTableServer', emit);
},
template: '<div data-test-id="insights-table"><slot /></div>',
}),
},
};
});
@ -212,11 +221,40 @@ const mockTableData: InsightsByWorkflow = {
};
let insightsStore: MockedStore<typeof useInsightsStore>;
let projectsStore: MockedStore<typeof useProjectsStore>;
const personalProject = createProjectListItem('personal');
const teamProjects = Array.from({ length: 2 }, () => createProjectListItem('team'));
const projects = [personalProject, ...teamProjects];
const teamProjects = [
{ id: 'project-a', name: 'Project A' },
{ id: 'project-b', name: 'Project B' },
];
/**
* The project picker is shell-hosted (the `project-filter` component slot), so this
* suite registers a stub that emits the same narrow selection the real host emits.
* What is under test here is how the dashboard reacts to a selection, not how the
* picker collects one — that belongs to `ProjectFilter.vue` in the shell.
*/
const ProjectFilterStub = defineComponent({
props: { modelValue: { type: Object, default: null } },
emits: ['update:modelValue'],
setup(_props, { emit }) {
return () =>
h(
'div',
{ 'data-test-id': 'project-filter' },
teamProjects.map((project) =>
h(
'button',
{
key: project.id,
'data-test-id': `project-option-${project.id}`,
onClick: () => emit('update:modelValue', { id: project.id }),
},
project.name,
),
),
);
},
});
const date = new Date('2000-12-19T00:00:00.000Z');
// Test helper constants
@ -262,30 +300,18 @@ const openDatePicker = async (getByRole: (role: string, options?: object) => HTM
return picker as HTMLElement;
};
const selectProject = async (projectName: string | null) => {
const projectSelect = screen.getByTestId('project-sharing-select');
await userEvent.click(projectSelect);
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
const teamProject = [...projectSelectDropdownItems].find(
(item) => item.querySelector('p')?.textContent?.trim() === projectName,
);
expect(teamProject).toBeDefined();
await userEvent.click(teamProject as Element);
const selectProject = async (projectName: string) => {
const project = teamProjects.find((candidate) => candidate.name === projectName);
expect(project).toBeDefined();
await userEvent.click(screen.getByTestId(`project-option-${project?.id}`));
};
const setupStores = () => {
insightsStore = mockedStore(useInsightsStore);
projectsStore = mockedStore(useProjectsStore);
insightsStore.isSummaryEnabled = true;
insightsStore.isDashboardEnabled = true;
projectsStore.availableProjects = projects;
projectsStore.getAvailableProjects = vi.fn().mockResolvedValue(projects);
projectsStore.searchProjects.mockResolvedValue({ count: projects.length, data: projects });
projectsStore.globalProjectPermissions = { list: true };
insightsStore.summary = {
state: mockSummaryData,
isLoading: false,
@ -324,6 +350,9 @@ describe('InsightsDashboard', () => {
mockRoute.params.insightType = INSIGHT_TYPES.TOTAL;
componentRegistry.clear();
componentRegistry.register('project-filter', ProjectFilterStub);
createTestingPinia({
initialState: { settings: { settings: defaultSettings, moduleSettings } },
});
@ -560,7 +589,7 @@ describe('InsightsDashboard', () => {
await waitAllPromises();
emitters.n8nDataTableServer.emit('update:options', {
emitterHandles.emitters.n8nDataTableServer.emit('update:options', {
page: 1,
itemsPerPage: 50,
sortBy: [{ id: 'total', desc: true }],
@ -584,7 +613,7 @@ describe('InsightsDashboard', () => {
await waitAllPromises();
await waitFor(() => {
emitters.n8nDataTableServer.emit('update:options', {
emitterHandles.emitters.n8nDataTableServer.emit('update:options', {
page: 0,
itemsPerPage: 25,
sortBy: [{ id: 'failed', desc: false }],
@ -608,7 +637,7 @@ describe('InsightsDashboard', () => {
await waitAllPromises();
await waitFor(() => {
emitters.n8nDataTableServer.emit('update:options', {
emitterHandles.emitters.n8nDataTableServer.emit('update:options', {
page: 0,
itemsPerPage: 25,
sortBy: [],
@ -626,23 +655,36 @@ describe('InsightsDashboard', () => {
});
describe('Project Filter Functionality', () => {
it('should render project sharing component', async () => {
it('should render the registered project-filter slot', async () => {
renderComponent({
props: { insightType: INSIGHT_TYPES.TOTAL },
});
await waitFor(() => {
expect(screen.getByTestId('project-sharing-select')).toBeInTheDocument();
expect(screen.getByTestId('project-filter')).toBeInTheDocument();
});
});
it('should render no picker when the project-filter slot is unregistered', async () => {
componentRegistry.clear();
renderComponent({
props: { insightType: INSIGHT_TYPES.TOTAL },
});
await waitFor(() => {
expect(screen.getByRole('heading', { level: 2, name: 'Insights' })).toBeInTheDocument();
});
expect(screen.queryByTestId('project-filter')).not.toBeInTheDocument();
});
it('should select a project and filter data by project ID', async () => {
renderComponent({
props: { insightType: INSIGHT_TYPES.TOTAL },
});
await waitFor(() => {
expect(screen.getByTestId('project-sharing-select')).toBeInTheDocument();
expect(screen.getByTestId('project-filter')).toBeInTheDocument();
});
await selectProject(teamProjects[0].name);
@ -717,7 +759,7 @@ describe('InsightsDashboard', () => {
await waitAllPromises();
vi.clearAllMocks();
emitters.n8nDataTableServer.emit('update:options', {
emitterHandles.emitters.n8nDataTableServer.emit('update:options', {
page: 1,
itemsPerPage: 50,
sortBy: [{ id: 'failed', desc: true }],
@ -748,7 +790,7 @@ describe('InsightsDashboard', () => {
});
await waitFor(() => {
expect(screen.getByTestId('project-sharing-select')).toBeInTheDocument();
expect(screen.getByTestId('project-filter')).toBeInTheDocument();
});
await selectProject(teamProjects[0].name);
@ -794,7 +836,7 @@ describe('InsightsDashboard', () => {
});
await waitFor(() => {
expect(screen.getByTestId('project-sharing-select')).toBeInTheDocument();
expect(screen.getByTestId('project-filter')).toBeInTheDocument();
});
// Select the forbidden project — its request is held open below.

View File

@ -1,60 +1,41 @@
<script setup lang="ts">
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import ProjectSharing from '@/features/collaboration/projects/components/ProjectSharing.vue';
import type { ProjectSharingData } from '@/features/collaboration/projects/projects.types';
import type { ProjectListItem } from '@/features/collaboration/projects/projects.types';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { useAvailableProjectSearch } from '@/features/collaboration/projects/projects.utils';
import InsightsSummary from '@/features/execution/insights/components/InsightsSummary.vue';
import { useInsightsStore } from '@/features/execution/insights/insights.store';
import type { DateValue } from '@internationalized/date';
import { getLocalTimeZone, parseDate, today } from '@internationalized/date';
import type { InsightsSummaryType } from '@n8n/api-types';
import { useDocumentTitle } from '@n8n/composables/useDocumentTitle';
import { useToast } from '@n8n/composables/useToast';
import { N8nHeading, N8nSpinner } from '@n8n/design-system';
import { componentRegistry, type SlotProjectSelection } from '@n8n/frontend-module-sdk';
import { useI18n } from '@n8n/i18n';
import { ResponseError } from '@n8n/rest-api-client/utils';
import {
computed,
defineAsyncComponent,
onBeforeMount,
onMounted,
ref,
shallowRef,
watch,
} from 'vue';
import { computed, defineAsyncComponent, onMounted, ref, shallowRef, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useInsightsStore } from '../insights.store';
import InsightsSummary from './InsightsSummary.vue';
import { INSIGHT_TYPES } from '../insights.constants';
import { getAdjustedDateRange, getTimeRangeLabels, timeRangeMappings } from '../insights.utils';
import InsightsDataRangePicker from './InsightsDataRangePicker.vue';
import InsightsDateRangeAlert from './InsightsDateRangeAlert.vue';
import { N8nHeading, N8nSpinner } from '@n8n/design-system';
const InsightsPaywall = defineAsyncComponent(
async () => await import('@/features/execution/insights/components/InsightsPaywall.vue'),
);
const InsightsPaywall = defineAsyncComponent(async () => await import('./InsightsPaywall.vue'));
const InsightsChartTotal = defineAsyncComponent(
async () =>
await import('@/features/execution/insights/components/charts/InsightsChartTotal.vue'),
async () => await import('./charts/InsightsChartTotal.vue'),
);
const InsightsChartFailed = defineAsyncComponent(
async () =>
await import('@/features/execution/insights/components/charts/InsightsChartFailed.vue'),
async () => await import('./charts/InsightsChartFailed.vue'),
);
const InsightsChartFailureRate = defineAsyncComponent(
async () =>
await import('@/features/execution/insights/components/charts/InsightsChartFailureRate.vue'),
async () => await import('./charts/InsightsChartFailureRate.vue'),
);
const InsightsChartTimeSaved = defineAsyncComponent(
async () =>
await import('@/features/execution/insights/components/charts/InsightsChartTimeSaved.vue'),
async () => await import('./charts/InsightsChartTimeSaved.vue'),
);
const InsightsChartAverageRuntime = defineAsyncComponent(
async () =>
await import('@/features/execution/insights/components/charts/InsightsChartAverageRuntime.vue'),
async () => await import('./charts/InsightsChartAverageRuntime.vue'),
);
const InsightsTableWorkflows = defineAsyncComponent(
async () =>
await import('@/features/execution/insights/components/tables/InsightsTableWorkflows.vue'),
async () => await import('./tables/InsightsTableWorkflows.vue'),
);
const props = defineProps<{
@ -66,7 +47,11 @@ const i18n = useI18n();
const toast = useToast();
const insightsStore = useInsightsStore();
const projectsStore = useProjectsStore();
// The project picker is shell-hosted: its list, permissions and search live in
// `features/collaboration/projects`, above this module. Unregistered (a module's
// own test run) means no picker, and the dashboard reads as "all projects".
const projectFilter = computed(() => componentRegistry.get('project-filter'));
const isTimeSavedRoute = computed(() => route.params.insightType === INSIGHT_TYPES.TIME_SAVED);
const chartComponents = computed(() => ({
@ -95,7 +80,7 @@ const granularity = computed(() => {
if (comparison <= 30) return 'day';
return 'week';
});
const selectedProject = ref<ProjectSharingData | null>(null);
const selectedProject = ref<SlotProjectSelection>(null);
const maxDate = today(getLocalTimeZone());
@ -220,20 +205,6 @@ watch(
onMounted(() => {
useDocumentTitle().set(i18n.baseText('insights.heading'));
});
// Must be *only* <email> — no extra text before or after
const emailPattern = /^<([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})>$/;
const searchFn = useAvailableProjectSearch();
const filterFn = (project: ProjectListItem) =>
!!project.name && !emailPattern.test(project.name.trim());
onBeforeMount(async () => {
// Members filter locally over myProjects — preload them.
// Admins use remote search, so skip the unpaginated GET /projects call.
if (!projectsStore.globalProjectPermissions.list) {
await projectsStore.getAvailableProjects();
}
});
</script>
<template>
@ -244,16 +215,13 @@ onBeforeMount(async () => {
</N8nHeading>
<div class="mt-s" style="display: flex; gap: 12px; align-items: center">
<ProjectSharing
<component
:is="projectFilter"
v-if="projectFilter"
v-model="selectedProject"
:search-fn="searchFn"
:filter-fn="filterFn"
:placeholder="i18n.baseText('insights.dashboard.search.placeholder')"
:empty-options-text="i18n.baseText('projects.sharing.noMatchingProjects')"
size="mini"
:class="$style.projectSelect"
clearable
@clear="selectedProject = null"
/>
<InsightsDataRangePicker

View File

@ -1,6 +1,6 @@
<script lang="ts" setup>
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { getLocalTimeZone, isToday } from '@internationalized/date';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import type {
DateRange,
DateValue,
@ -9,6 +9,7 @@ import type {
} from '@n8n/design-system';
import { N8nButton, N8nDateRangePicker, N8nIcon } from '@n8n/design-system';
import { computed, ref, shallowRef, watch } from 'vue';
import { formatDateRange, getAdjustedDateRange } from '../insights.utils';
import InsightsUpgradeModal from './InsightsUpgradeModal.vue';

View File

@ -1,8 +1,8 @@
import { CalendarDate } from '@internationalized/date';
import InsightsDateRangeAlert from '@/features/execution/insights/components/InsightsDateRangeAlert.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createComponentRenderer, defaultSettings } from '@n8n/frontend-test-utils';
import { createTestingPinia } from '@pinia/testing';
import { defaultSettings } from '@n8n/frontend-test-utils';
import InsightsDateRangeAlert from './InsightsDateRangeAlert.vue';
const renderComponent = createComponentRenderer(InsightsDateRangeAlert, {
global: {

View File

@ -1,9 +1,9 @@
<script lang="ts" setup>
import { useI18n } from '@n8n/i18n';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
const pageRedirectionHelper = usePageRedirectionHelper();
import { useI18n } from '@n8n/i18n';
import { useBasePageRedirectionHelper } from '@n8n/stores/composables/useBasePageRedirectionHelper';
const pageRedirectionHelper = useBasePageRedirectionHelper();
const i18n = useI18n();
const goToUpgrade = async () => {

View File

@ -1,10 +1,10 @@
import { reactive } from 'vue';
import InsightsSummary from '@/features/execution/insights/components/InsightsSummary.vue';
import { createComponentRenderer } from '@/__tests__/render';
import type { InsightsSummaryDisplay } from '@/features/execution/insights/insights.types';
import { createTestingPinia } from '@pinia/testing';
import { defaultSettings } from '@n8n/frontend-test-utils';
import { CalendarDate, getLocalTimeZone, today } from '@internationalized/date';
import { createComponentRenderer, defaultSettings } from '@n8n/frontend-test-utils';
import { createTestingPinia } from '@pinia/testing';
import { reactive } from 'vue';
import InsightsSummary from './InsightsSummary.vue';
import type { InsightsSummaryDisplay } from '../insights.types';
vi.mock('vue-router', () => ({
useRouter: () => ({}),

View File

@ -1,19 +1,17 @@
<script setup lang="ts">
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { VIEWS } from '@/app/constants';
import {
INSIGHT_IMPACT_TYPES,
INSIGHTS_UNIT_IMPACT_MAPPING,
} from '@/features/execution/insights/insights.constants';
import type { InsightsSummaryDisplay } from '@/features/execution/insights/insights.types';
import type { DateValue } from '@internationalized/date';
import type { InsightsSummary } from '@n8n/api-types';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { N8nIcon, N8nTooltip } from '@n8n/design-system';
import { VIEWS } from '@n8n/frontend-constants/views';
import { useI18n } from '@n8n/i18n';
import { smartDecimal } from '@n8n/utils/number/smart-decimal';
import { computed, useCssModule } from 'vue';
import { I18nT } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { INSIGHT_IMPACT_TYPES, INSIGHTS_UNIT_IMPACT_MAPPING } from '../insights.constants';
import type { InsightsSummaryDisplay } from '../insights.types';
import { formatDateRange, getMatchingPreset, getTimeRangeLabels } from '../insights.utils';
const props = defineProps<{

View File

@ -1,17 +1,16 @@
<script setup lang="ts">
import { useI18n } from '@n8n/i18n';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import type { BaseTextKey } from '@n8n/i18n';
import { computed } from 'vue';
import { N8nButton, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { BaseTextKey } from '@n8n/i18n';
import { useBasePageRedirectionHelper } from '@n8n/stores/composables/useBasePageRedirectionHelper';
import { ElDialog } from 'element-plus';
import { computed } from 'vue';
const model = defineModel<boolean>();
const i18n = useI18n();
function goToUpgrade() {
model.value = false;
void usePageRedirectionHelper().goToUpgrade('insights', 'upgrade-insights');
void useBasePageRedirectionHelper().goToUpgrade('insights', 'upgrade-insights');
}
const perks = computed(() =>

View File

@ -1,19 +1,14 @@
<script lang="ts" setup>
import { useI18n } from '@n8n/i18n';
import {
generateLinearGradient,
generateLineChartOptions,
} from '@/features/execution/insights/chartjs.utils';
import {
GRANULARITY_DATE_FORMAT_MASK,
INSIGHTS_UNIT_MAPPING,
} from '@/features/execution/insights/insights.constants';
import { transformInsightsAverageRunTime } from '@/features/execution/insights/insights.utils';
import { smartDecimal } from '@n8n/utils/number/smart-decimal';
import { type ChartData, Filler, type ScriptableContext } from 'chart.js';
import { computed } from 'vue';
import { Line } from 'vue-chartjs';
import type { ChartProps } from './insightChartProps';
import { generateLinearGradient, generateLineChartOptions } from '../../chartjs.utils';
import { GRANULARITY_DATE_FORMAT_MASK, INSIGHTS_UNIT_MAPPING } from '../../insights.constants';
import { transformInsightsAverageRunTime } from '../../insights.utils';
const props = defineProps<ChartProps>();
const i18n = useI18n();

View File

@ -1,7 +1,5 @@
<script lang="ts" setup>
import { useI18n } from '@n8n/i18n';
import { generateBarChartOptions } from '@/features/execution/insights/chartjs.utils';
import { GRANULARITY_DATE_FORMAT_MASK } from '@/features/execution/insights/insights.constants';
import { smartDecimal } from '@n8n/utils/number/smart-decimal';
import { useCssVar } from '@vueuse/core';
import type { ChartData } from 'chart.js';
@ -9,6 +7,8 @@ import { computed } from 'vue';
import { Bar } from 'vue-chartjs';
import type { ChartProps } from './insightChartProps';
import { generateBarChartOptions } from '../../chartjs.utils';
import { GRANULARITY_DATE_FORMAT_MASK } from '../../insights.constants';
const props = defineProps<ChartProps>();

View File

@ -1,17 +1,15 @@
<script lang="ts" setup>
import { useI18n } from '@n8n/i18n';
import { generateBarChartOptions } from '@/features/execution/insights/chartjs.utils';
import {
GRANULARITY_DATE_FORMAT_MASK,
INSIGHTS_UNIT_MAPPING,
} from '@/features/execution/insights/insights.constants';
import { transformInsightsFailureRate } from '@/features/execution/insights/insights.utils';
import { smartDecimal } from '@n8n/utils/number/smart-decimal';
import { useCssVar } from '@vueuse/core';
import type { ChartData } from 'chart.js';
import { computed } from 'vue';
import { Bar } from 'vue-chartjs';
import type { ChartProps } from './insightChartProps';
import { generateBarChartOptions } from '../../chartjs.utils';
import { GRANULARITY_DATE_FORMAT_MASK, INSIGHTS_UNIT_MAPPING } from '../../insights.constants';
import { transformInsightsFailureRate } from '../../insights.utils';
const props = defineProps<ChartProps>();

View File

@ -1,20 +1,13 @@
<script lang="ts" setup>
import { useI18n } from '@n8n/i18n';
import {
generateLinearGradient,
generateLineChartOptions,
} from '@/features/execution/insights/chartjs.utils';
import { transformInsightsTimeSaved } from '@/features/execution/insights/insights.utils';
import {
GRANULARITY_DATE_FORMAT_MASK,
INSIGHTS_UNIT_MAPPING,
} from '@/features/execution/insights/insights.constants';
import { type ChartData, Filler, type ScriptableContext } from 'chart.js';
import { computed } from 'vue';
import { Line } from 'vue-chartjs';
import type { ChartProps } from './insightChartProps';
import { generateLinearGradient, generateLineChartOptions } from '../../chartjs.utils';
import { GRANULARITY_DATE_FORMAT_MASK, INSIGHTS_UNIT_MAPPING } from '../../insights.constants';
import { transformInsightsTimeSaved } from '../../insights.utils';
const props = defineProps<ChartProps>();
const i18n = useI18n();

View File

@ -1,12 +1,13 @@
<script lang="ts" setup>
import { useI18n } from '@n8n/i18n';
import { generateBarChartOptions } from '@/features/execution/insights/chartjs.utils';
import { GRANULARITY_DATE_FORMAT_MASK } from '@/features/execution/insights/insights.constants';
import { useCssVar } from '@vueuse/core';
import type { ChartData } from 'chart.js';
import { computed } from 'vue';
import { Bar } from 'vue-chartjs';
import type { ChartProps } from './insightChartProps';
import { generateBarChartOptions } from '../../chartjs.utils';
import { GRANULARITY_DATE_FORMAT_MASK } from '../../insights.constants';
const props = defineProps<ChartProps>();

View File

@ -1,12 +1,11 @@
import { defineComponent } from 'vue';
import type { InsightsByWorkflow } from '@n8n/api-types';
import { createComponentRenderer, useEmitters } from '@n8n/frontend-test-utils';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { screen, within } from '@testing-library/vue';
import { vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import { useEmitters } from '@/__tests__/utils';
import InsightsTableWorkflows from '@/features/execution/insights/components/tables/InsightsTableWorkflows.vue';
import type { InsightsByWorkflow } from '@n8n/api-types';
import InsightsTableWorkflows from './InsightsTableWorkflows.vue';
const { emitters, addEmitter } = useEmitters<'n8nDataTableServer'>();
@ -21,7 +20,11 @@ vi.mock('@n8n/design-system', async (importOriginal) => {
const original = await importOriginal<object>();
return {
...original,
N8nDataTableServer: defineComponent({
// A plain options object, not `defineComponent`. This factory is hoisted above the
// imports and runs while `@n8n/frontend-test-utils` is still initialising — it reaches
// design-system through its renderer — so calling anything imported from `vue` here
// throws a TDZ error on an import that has not been evaluated yet.
N8nDataTableServer: {
props: {
headers: { type: Array, required: true },
items: { type: Array, required: true },
@ -30,7 +33,7 @@ vi.mock('@n8n/design-system', async (importOriginal) => {
page: { type: Number },
itemsPerPage: { type: Number },
},
setup(_, { emit }) {
setup(_: unknown, { emit }: { emit: (event: string, ...args: unknown[]) => void }) {
addEmitter('n8nDataTableServer', emit);
},
template: `
@ -61,7 +64,7 @@ vi.mock('@n8n/design-system', async (importOriginal) => {
</div>
<slot name="cover" />
</div>`,
}),
},
};
});
@ -102,6 +105,15 @@ const mockInsightsData: InsightsByWorkflow = {
let renderComponent: ReturnType<typeof createComponentRenderer>;
describe('InsightsTableWorkflows', () => {
// The paywall is a `defineAsyncComponent`, and its module pulls `@n8n/stores` in
// from source. Resolving that inside the assertion made the test a race against a
// wall clock: ~1.3s locally, over the 3s budget on a CI runner. Loading it here
// puts the cost in a hook and leaves the loader hitting the module cache, so the
// assertion no longer depends on how fast the machine is.
beforeAll(async () => {
await import('../InsightsPaywall.vue');
}, 60000);
beforeEach(() => {
renderComponent = createComponentRenderer(InsightsTableWorkflows, {
pinia: createTestingPinia(),
@ -418,11 +430,9 @@ describe('InsightsTableWorkflows', () => {
// Wait for the async paywall component to load and render
// The paywall text should appear once the component loads
const paywallText = await screen.findByText(
'Upgrade to access more detailed insights',
{},
{ timeout: 3000 },
);
// The module is already loaded (see `beforeAll`), so this waits only for the
// async component to swap in — a tick, not a transform.
const paywallText = await screen.findByText('Upgrade to access more detailed insights');
expect(paywallText).toBeInTheDocument();
});

View File

@ -1,23 +1,21 @@
<script lang="ts" setup="">
import type { InsightsByWorkflow } from '@n8n/api-types';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import type { TableHeader } from '@n8n/design-system';
import { N8nDataTableServer, N8nHeading, N8nTooltip } from '@n8n/design-system';
import { VIEWS } from '@n8n/frontend-constants/views';
import { useI18n } from '@n8n/i18n';
import { INSIGHTS_UNIT_MAPPING } from '@/features/execution/insights/insights.constants';
import { smartDecimal } from '@n8n/utils/number/smart-decimal';
import { computed, defineAsyncComponent, ref, watch } from 'vue';
import { type RouteLocationRaw, type LocationQueryRaw, RouterLink } from 'vue-router';
import { INSIGHTS_UNIT_MAPPING } from '../../insights.constants';
import {
transformInsightsAverageRunTime,
transformInsightsFailureRate,
transformInsightsTimeSaved,
} from '@/features/execution/insights/insights.utils';
import type { InsightsByWorkflow } from '@n8n/api-types';
import type { TableHeader } from '@n8n/design-system';
import { smartDecimal } from '@n8n/utils/number/smart-decimal';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { VIEWS } from '@/app/constants';
import { computed, defineAsyncComponent, ref, watch } from 'vue';
import { type RouteLocationRaw, type LocationQueryRaw, RouterLink } from 'vue-router';
import { N8nDataTableServer, N8nHeading, N8nTooltip } from '@n8n/design-system';
const InsightsPaywall = defineAsyncComponent(
async () => await import('@/features/execution/insights/components/InsightsPaywall.vue'),
);
} from '../../insights.utils';
const InsightsPaywall = defineAsyncComponent(async () => await import('../InsightsPaywall.vue'));
const props = defineProps<{
data: InsightsByWorkflow;

View File

@ -1,2 +1,4 @@
// The module's public entry for consumers: the store and the summary widget the
// shell renders on its list views.
export { useInsightsStore } from './insights.store';
export { default as InsightsSummary } from './components/InsightsSummary.vue';

View File

@ -1,11 +1,4 @@
import {
fetchInsightsSummary,
fetchInsightsByTime,
fetchInsightsTimeSaved,
fetchInsightsByWorkflow,
serializeInsightsFilter,
} from '@/features/execution/insights/insights.api';
import { makeRestApiRequest } from '@n8n/rest-api-client';
import { getLocalTimeZone } from '@internationalized/date';
import type {
InsightsSummary,
InsightsByTime,
@ -13,9 +6,17 @@ import type {
ListInsightsWorkflowQueryDto,
InsightsDateFilterDto,
} from '@n8n/api-types';
import { getLocalTimeZone } from '@internationalized/date';
import { makeRestApiRequest } from '@n8n/rest-api-client';
import { expect } from 'vitest';
import {
fetchInsightsSummary,
fetchInsightsByTime,
fetchInsightsTimeSaved,
fetchInsightsByWorkflow,
serializeInsightsFilter,
} from './insights.api';
vi.mock('@n8n/rest-api-client', () => ({
makeRestApiRequest: vi.fn(),
}));

View File

@ -1,5 +1,4 @@
import { makeRestApiRequest } from '@n8n/rest-api-client';
import type { IRestApiContext } from '@n8n/rest-api-client';
import { getLocalTimeZone } from '@internationalized/date';
import type {
InsightsSummary,
InsightsByTime,
@ -7,7 +6,8 @@ import type {
ListInsightsWorkflowQueryDto,
InsightsDateFilterDto,
} from '@n8n/api-types';
import { getLocalTimeZone } from '@internationalized/date';
import { makeRestApiRequest } from '@n8n/rest-api-client';
import type { IRestApiContext } from '@n8n/rest-api-client';
type SerializedDateFilter<T> = Omit<T, 'startDate' | 'endDate'> & {
startDate?: string;

View File

@ -1,6 +1,7 @@
import { VIEWS } from '@n8n/frontend-constants/views';
import { createMemoryHistory, createRouter, type RouteRecordRaw } from 'vue-router';
import { VIEWS } from '@/app/constants';
import { InsightsModule } from './module.descriptor';
import { InsightsModule } from './insights.module';
const mocks = vi.hoisted(() => ({ isInsightsEnabled: true }));

View File

@ -1,6 +1,6 @@
import { RouterView } from 'vue-router';
import { VIEWS } from '@n8n/frontend-constants/views';
import type { FrontendModuleDescription } from '@n8n/frontend-module-sdk';
import { VIEWS } from '@/app/constants';
import { RouterView } from 'vue-router';
const InsightsDashboard = async () => await import('./components/InsightsDashboard.vue');

View File

@ -1,20 +1,21 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createTestingPinia } from '@pinia/testing';
import { useInsightsStore } from '@/features/execution/insights/insights.store';
import * as insightsApi from '@/features/execution/insights/insights.api';
import type { FrontendModuleSettings } from '@n8n/api-types';
import { mockedStore, type MockedStore } from '@n8n/frontend-test-utils';
import type { IUser } from '@n8n/rest-api-client/api/users';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useUsersStore } from '@n8n/stores/users.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { mockedStore, type MockedStore } from '@/__tests__/utils';
import type { IUser } from '@n8n/rest-api-client/api/users';
import { createTestingPinia } from '@pinia/testing';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { reactive } from 'vue';
import type { FrontendModuleSettings } from '@n8n/api-types';
import * as insightsApi from './insights.api';
import { useInsightsStore } from './insights.store';
vi.mock('vue-router', async (importOriginal) => ({
...(await importOriginal()),
useRoute: () => reactive({}),
}));
vi.mock('@/features/execution/insights/insights.api');
vi.mock('./insights.api');
const mockFilter = { projectId: 'test-project' };
const mockData = [

View File

@ -1,13 +1,14 @@
import { computed } from 'vue';
import { defineStore } from 'pinia';
import { useAsyncState } from '@vueuse/core';
import type { ListInsightsWorkflowQueryDto, InsightsDateFilterDto } from '@n8n/api-types';
import * as insightsApi from '@/features/execution/insights/insights.api';
import { getResourcePermissions } from '@n8n/permissions';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useUsersStore } from '@n8n/stores/users.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { transformInsightsSummary } from '@/features/execution/insights/insights.utils';
import { getResourcePermissions } from '@n8n/permissions';
import { useAsyncState } from '@vueuse/core';
import { defineStore } from 'pinia';
import { computed } from 'vue';
import * as insightsApi from './insights.api';
import { transformInsightsSummary } from './insights.utils';
export const useInsightsStore = defineStore('insights', () => {
const rootStore = useRootStore();

View File

@ -1,7 +1,4 @@
import type {
INSIGHTS_UNIT_MAPPING,
INSIGHTS_DEVIATION_UNIT_MAPPING,
} from '@/features/execution/insights/insights.constants';
import type { INSIGHTS_UNIT_MAPPING, INSIGHTS_DEVIATION_UNIT_MAPPING } from './insights.constants';
type InsightsDisplayUnits = typeof INSIGHTS_UNIT_MAPPING;
type InsightsDisplayDeviationUnits = typeof INSIGHTS_DEVIATION_UNIT_MAPPING;

View File

@ -1,6 +1,11 @@
import type { InsightsSummary } from '@n8n/api-types';
import { CalendarDate, getLocalTimeZone, today } from '@internationalized/date';
import type { InsightsSummary } from '@n8n/api-types';
import {
INSIGHTS_UNIT_MAPPING,
INSIGHTS_DEVIATION_UNIT_MAPPING,
INSIGHTS_SUMMARY_ORDER,
} from './insights.constants';
import {
transformInsightsTimeSaved,
transformInsightsAverageRunTime,
@ -13,12 +18,6 @@ import {
timeRangeMappings,
} from './insights.utils';
import {
INSIGHTS_UNIT_MAPPING,
INSIGHTS_DEVIATION_UNIT_MAPPING,
INSIGHTS_SUMMARY_ORDER,
} from '@/features/execution/insights/insights.constants';
describe('Insights Transformers', () => {
describe('transformInsightsTimeSaved', () => {
it('should return minutes if absolute value is less than 60', () => {

View File

@ -1,9 +1,3 @@
import {
INSIGHTS_DEVIATION_UNIT_MAPPING,
INSIGHTS_SUMMARY_ORDER,
INSIGHTS_UNIT_MAPPING,
} from '@/features/execution/insights/insights.constants';
import type { InsightsSummaryDisplay } from '@/features/execution/insights/insights.types';
import type { DateValue } from '@internationalized/date';
import { getLocalTimeZone, isToday, now, toCalendarDateTime, today } from '@internationalized/date';
import type { InsightsDateRange, InsightsSummary, InsightsSummaryType } from '@n8n/api-types';
@ -11,6 +5,13 @@ import type { DateRange } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import dateformat from 'dateformat';
import {
INSIGHTS_DEVIATION_UNIT_MAPPING,
INSIGHTS_SUMMARY_ORDER,
INSIGHTS_UNIT_MAPPING,
} from './insights.constants';
import type { InsightsSummaryDisplay } from './insights.types';
const DATE_FORMAT_DAY_MONTH_YEAR = 'd mmm, yyyy';
const DATE_FORMAT_DAY_MONTH = 'd mmm';

View File

@ -0,0 +1,9 @@
import { baseConfig } from '@n8n/stylelint-config/base';
export default {
...baseConfig,
rules: {
...baseConfig.rules,
'@n8n/css-var-naming': [true, { severity: 'error' }],
},
};

View File

@ -0,0 +1,24 @@
{
"extends": "@n8n/typescript-config/tsconfig.frontend-module.json",
"compilerOptions": {
// `rootDirs`, `types` and `include` cannot be inherited from the base: relative entries in
// them resolve against this file, so a copy in the base would point at the base's directory.
// (`paths` is the exception and does come from the base.)
"rootDirs": [".", "../../../frontend/@n8n/design-system/src"],
// The two `.d.ts` entries are ambient declarations this package never imports, so nothing
// pulls them into the program: `~icons/*` and `markdown-it-task-lists` for design-system's
// source, `window.BASE_PATH` for `@n8n/stores`'s. Consuming those packages from source is
// what makes them the consumer's problem — a built `dist` would have carried them.
"types": [
"vite/client",
"vitest/globals",
// This module renders components in its tests. The augmentation reaches the
// program only from here: the shared harness imports it from a built package.
"@testing-library/jest-dom/vitest",
"unplugin-icons/types/vue",
"../../../frontend/@n8n/design-system/src/shims-modules.d.ts",
"../../../frontend/@n8n/stores/src/shims.d.ts"
]
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
}

View File

@ -0,0 +1,29 @@
import vue from '@vitejs/plugin-vue';
import icons from 'unplugin-icons/vite';
import svgLoader from 'vite-svg-loader';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import { defineConfig, mergeConfig } from 'vite';
import { frontendAliases } from '@n8n/frontend-vite-config';
import { vitestConfig } from '@n8n/vitest-config/frontend';
const packageDir = fileURLToPath(new URL('.', import.meta.url));
const packagesDir = resolve(packageDir, '..', '..', '..');
export default mergeConfig(
defineConfig({
// What lets a test render a design-system component. Its icon set imports
// `~icons/lucide/*` (unplugin-icons) and `./custom/*.svg` (svgLoader); without
// either, an icon resolves to a URL string and rendering throws. The shared
// vitest config stubs only the `nodes/*.svg` subset.
plugins: [vue(), icons({ compiler: 'vue3' }), svgLoader()],
resolve: {
// The same platform mapping the editor-ui dev server uses, so a test resolves
// `@n8n/stores/...` from source rather than from a stale `dist` — the two disagreeing is
// what put 1,111 specifiers on the wrong side of the src/dist line. Sibling modules are
// deliberately absent: nothing here should make a cross-module import resolve.
alias: frontendAliases(packagesDir),
},
}),
vitestConfig,
);

View File

@ -6233,6 +6233,9 @@ importers:
'@n8n/frontend-constants':
specifier: workspace:*
version: link:../@n8n/frontend-constants
'@n8n/frontend-module-insights':
specifier: workspace:*
version: link:../../modules/insights/frontend
'@n8n/frontend-module-instance-registry':
specifier: workspace:*
version: link:../../modules/instance-registry/frontend
@ -6440,6 +6443,9 @@ importers:
email-providers:
specifier: ^2.0.1
version: 2.0.1
eslint:
specifier: 'catalog:'
version: 9.29.0(jiti@2.6.1)(supports-color@8.1.1)
eslint-plugin-oxlint:
specifier: 'catalog:'
version: 1.78.0(oxlint@1.78.0(oxlint-tsgolint@0.21.1))
@ -6624,6 +6630,142 @@ importers:
specifier: 3.25.76
version: 3.25.76
packages/modules/insights/frontend:
dependencies:
'@internationalized/date':
specifier: catalog:frontend
version: 3.9.0
'@n8n/api-types':
specifier: workspace:*
version: link:../../../@n8n/api-types
'@n8n/composables':
specifier: workspace:*
version: link:../../../frontend/@n8n/composables
'@n8n/design-system':
specifier: workspace:*
version: link:../../../frontend/@n8n/design-system
'@n8n/frontend-constants':
specifier: workspace:*
version: link:../../../frontend/@n8n/frontend-constants
'@n8n/frontend-module-sdk':
specifier: workspace:*
version: link:../../../frontend/@n8n/frontend-module-sdk
'@n8n/i18n':
specifier: workspace:*
version: link:../../../frontend/@n8n/i18n
'@n8n/permissions':
specifier: workspace:*
version: link:../../../@n8n/permissions
'@n8n/rest-api-client':
specifier: workspace:*
version: link:../../../frontend/@n8n/rest-api-client
'@n8n/stores':
specifier: workspace:*
version: link:../../../frontend/@n8n/stores
'@n8n/utils':
specifier: workspace:*
version: link:../../../@n8n/utils
'@vueuse/core':
specifier: catalog:frontend
version: 14.3.0(vue@3.5.26(typescript@6.0.2))
chart.js:
specifier: ^4.4.0
version: 4.4.0
dateformat:
specifier: ^3.0.3
version: 3.0.3
element-plus:
specifier: catalog:frontend
version: 2.4.3(patch_hash=fbab57fe3750e430abd5d5e7c04cbf1b6a8f9f1c9676b14c73b77d3e06ba9eee)(vue@3.5.26(typescript@6.0.2))
lodash:
specifier: 4.18.1
version: 4.18.1(patch_hash=81ead1b07fc8efda87bd2724dbd876ab49f4a0aa03f96c3770060b2ea47d9e0b)
pinia:
specifier: catalog:frontend
version: 2.2.4(typescript@6.0.2)(vue@3.5.26(typescript@6.0.2))
vue:
specifier: catalog:frontend
version: 3.5.26(typescript@6.0.2)
vue-chartjs:
specifier: ^5.2.0
version: 5.2.0(chart.js@4.4.0)(vue@3.5.26(typescript@6.0.2))
vue-i18n:
specifier: catalog:frontend
version: 11.4.5(vue@3.5.26(typescript@6.0.2))
vue-router:
specifier: catalog:frontend
version: 4.5.0(vue@3.5.26(typescript@6.0.2))
devDependencies:
'@iconify/json':
specifier: 'catalog:'
version: 2.2.488
'@n8n/eslint-config':
specifier: workspace:*
version: link:../../../@n8n/eslint-config
'@n8n/frontend-test-utils':
specifier: workspace:*
version: link:../../../frontend/@n8n/frontend-test-utils
'@n8n/frontend-vite-config':
specifier: workspace:*
version: link:../../../frontend/@n8n/frontend-vite-config
'@n8n/playwright-janitor':
specifier: workspace:*
version: link:../../../testing/janitor
'@n8n/stylelint-config':
specifier: workspace:*
version: link:../../../@n8n/stylelint-config
'@n8n/typescript-config':
specifier: workspace:*
version: link:../../../@n8n/typescript-config
'@n8n/vitest-config':
specifier: workspace:*
version: link:../../../@n8n/vitest-config
'@pinia/testing':
specifier: ^0.1.6
version: 0.1.6(pinia@2.2.4(typescript@6.0.2)(vue@3.5.26(typescript@6.0.2)))(vue@3.5.26(typescript@6.0.2))
'@testing-library/jest-dom':
specifier: catalog:frontend
version: 6.6.3
'@testing-library/user-event':
specifier: catalog:frontend
version: 14.6.1(@testing-library/dom@10.4.1)
'@testing-library/vue':
specifier: catalog:frontend
version: 8.1.0(@vue/compiler-sfc@3.5.26)(vue@3.5.26(typescript@6.0.2))
'@types/dateformat':
specifier: ^3.0.0
version: 3.0.1
'@types/lodash':
specifier: 'catalog:'
version: 4.17.17
'@vitejs/plugin-vue':
specifier: catalog:frontend
version: 6.0.8(vite@8.0.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3))(vue@3.5.26(typescript@6.0.2))
eslint:
specifier: 'catalog:'
version: 9.29.0(jiti@2.6.1)(supports-color@8.1.1)
stylelint:
specifier: 'catalog:'
version: 16.23.0(supports-color@8.1.1)(typescript@6.0.2)
typescript:
specifier: 'catalog:'
version: 6.0.2
unplugin-icons:
specifier: catalog:frontend
version: 23.0.1(@vue/compiler-sfc@3.5.26)
vite:
specifier: 'catalog:'
version: 8.0.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)
vite-svg-loader:
specifier: catalog:frontend
version: 5.1.0(vue@3.5.26(typescript@6.0.2))
vitest:
specifier: 'catalog:'
version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@23.0.1(bufferutil@4.0.9)(supports-color@8.1.1)(utf-8-validate@5.0.10))(vite@8.0.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3))
vue-tsc:
specifier: ^2.2.8
version: 2.2.8(patch_hash=e2aee939ccac8a57fe449bfd92bedd8117841579526217bc39aca26c6b8c317f)(typescript@6.0.2)
packages/modules/instance-registry/frontend:
dependencies:
'@n8n/api-types':