diff --git a/packages/frontend/@n8n/frontend-module-sdk/src/index.ts b/packages/frontend/@n8n/frontend-module-sdk/src/index.ts index c5423a30cc2..22e30304937 100644 --- a/packages/frontend/@n8n/frontend-module-sdk/src/index.ts +++ b/packages/frontend/@n8n/frontend-module-sdk/src/index.ts @@ -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'; diff --git a/packages/frontend/@n8n/frontend-module-sdk/src/registries/componentRegistry.test.ts b/packages/frontend/@n8n/frontend-module-sdk/src/registries/componentRegistry.test.ts new file mode 100644 index 00000000000..36803181f2a --- /dev/null +++ b/packages/frontend/@n8n/frontend-module-sdk/src/registries/componentRegistry.test.ts @@ -0,0 +1,73 @@ +import { computed, defineComponent } from 'vue'; + +import * as componentRegistry from './componentRegistry'; + +const First = defineComponent({ template: '
first
' }); +const Second = defineComponent({ template: '
second
' }); + +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(); + }); +}); diff --git a/packages/frontend/@n8n/frontend-module-sdk/src/registries/componentRegistry.ts b/packages/frontend/@n8n/frontend-module-sdk/src/registries/componentRegistry.ts new file mode 100644 index 00000000000..8aad304c622 --- /dev/null +++ b/packages/frontend/@n8n/frontend-module-sdk/src/registries/componentRegistry.ts @@ -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()); + +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(); +} diff --git a/packages/frontend/@n8n/frontend-module-sdk/src/types/component.ts b/packages/frontend/@n8n/frontend-module-sdk/src/types/component.ts new file mode 100644 index 00000000000..58403a1d370 --- /dev/null +++ b/packages/frontend/@n8n/frontend-module-sdk/src/types/component.ts @@ -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; diff --git a/packages/frontend/@n8n/frontend-module-sdk/src/types/index.ts b/packages/frontend/@n8n/frontend-module-sdk/src/types/index.ts index 13c9b1405ef..2e5a82a31ba 100644 --- a/packages/frontend/@n8n/frontend-module-sdk/src/types/index.ts +++ b/packages/frontend/@n8n/frontend-module-sdk/src/types/index.ts @@ -1,3 +1,4 @@ +export type * from './component'; export type * from './descriptor'; export type * from './modal'; export type * from './parameterInput'; diff --git a/packages/frontend/@n8n/frontend-vite-config/index.ts b/packages/frontend/@n8n/frontend-vite-config/index.ts index 21368a3097a..9f0a2d0a987 100644 --- a/packages/frontend/@n8n/frontend-vite-config/index.ts +++ b/packages/frontend/@n8n/frontend-vite-config/index.ts @@ -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' }, ]; diff --git a/packages/frontend/@n8n/stores/src/composables/useBasePageRedirectionHelper.test.ts b/packages/frontend/@n8n/stores/src/composables/useBasePageRedirectionHelper.test.ts index 0c2bbbce598..dccbd7ee995 100644 --- a/packages/frontend/@n8n/stores/src/composables/useBasePageRedirectionHelper.test.ts +++ b/packages/frontend/@n8n/stores/src/composables/useBasePageRedirectionHelper.test.ts @@ -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().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().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().mockResolvedValue(true); + const passed = vi.fn().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'; diff --git a/packages/frontend/@n8n/stores/src/composables/useBasePageRedirectionHelper.ts b/packages/frontend/@n8n/stores/src/composables/useBasePageRedirectionHelper.ts index b840b83d2ee..83776ae8789 100644 --- a/packages/frontend/@n8n/stores/src/composables/useBasePageRedirectionHelper.ts +++ b/packages/frontend/@n8n/stores/src/composables/useBasePageRedirectionHelper.ts @@ -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; /** * 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; diff --git a/packages/frontend/@n8n/stores/src/registries/upgradeRedirectGuard.test.ts b/packages/frontend/@n8n/stores/src/registries/upgradeRedirectGuard.test.ts new file mode 100644 index 00000000000..d5e0a359703 --- /dev/null +++ b/packages/frontend/@n8n/stores/src/registries/upgradeRedirectGuard.test.ts @@ -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); + }); +}); diff --git a/packages/frontend/@n8n/stores/src/registries/upgradeRedirectGuard.ts b/packages/frontend/@n8n/stores/src/registries/upgradeRedirectGuard.ts new file mode 100644 index 00000000000..18f43a4473b --- /dev/null +++ b/packages/frontend/@n8n/stores/src/registries/upgradeRedirectGuard.ts @@ -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; +} diff --git a/packages/frontend/editor-ui/eslint.config.mjs b/packages/frontend/editor-ui/eslint.config.mjs index 0a08b41c569..35d138f5fd7 100644 --- a/packages/frontend/editor-ui/eslint.config.mjs +++ b/packages/frontend/editor-ui/eslint.config.mjs @@ -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( diff --git a/packages/frontend/editor-ui/package.json b/packages/frontend/editor-ui/package.json index 445d707be0a..a80a6165c85 100644 --- a/packages/frontend/editor-ui/package.json +++ b/packages/frontend/editor-ui/package.json @@ -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", diff --git a/packages/frontend/editor-ui/src/app/bootstrapRegistrations.test.ts b/packages/frontend/editor-ui/src/app/bootstrapRegistrations.test.ts new file mode 100644 index 00000000000..a8724960ab4 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/bootstrapRegistrations.test.ts @@ -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')); + }); +}); diff --git a/packages/frontend/editor-ui/src/app/componentSlots.manifest.test.ts b/packages/frontend/editor-ui/src/app/componentSlots.manifest.test.ts new file mode 100644 index 00000000000..2e392993b2f --- /dev/null +++ b/packages/frontend/editor-ui/src/app/componentSlots.manifest.test.ts @@ -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); + }); +}); diff --git a/packages/frontend/editor-ui/src/app/componentSlots.manifest.ts b/packages/frontend/editor-ui/src/app/componentSlots.manifest.ts new file mode 100644 index 00000000000..436b0d090f6 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/componentSlots.manifest.ts @@ -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'), + ), + ); +}; diff --git a/packages/frontend/editor-ui/src/app/extractionRatchet.test.ts b/packages/frontend/editor-ui/src/app/extractionRatchet.test.ts new file mode 100644 index 00000000000..86c9dae5722 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/extractionRatchet.test.ts @@ -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; + + 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; + }; + 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'); + }); +}); diff --git a/packages/frontend/editor-ui/src/app/modules.manifest.ts b/packages/frontend/editor-ui/src/app/modules.manifest.ts index be8beaafab1..d481ae7b35a 100644 --- a/packages/frontend/editor-ui/src/app/modules.manifest.ts +++ b/packages/frontend/editor-ui/src/app/modules.manifest.ts @@ -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, ]; diff --git a/packages/frontend/editor-ui/src/app/upgradeRedirectGuard.manifest.test.ts b/packages/frontend/editor-ui/src/app/upgradeRedirectGuard.manifest.test.ts new file mode 100644 index 00000000000..8c8d5a4e1c8 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/upgradeRedirectGuard.manifest.test.ts @@ -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(); + }); +}); diff --git a/packages/frontend/editor-ui/src/app/upgradeRedirectGuard.manifest.ts b/packages/frontend/editor-ui/src/app/upgradeRedirectGuard.manifest.ts new file mode 100644 index 00000000000..a596f868077 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/upgradeRedirectGuard.manifest.ts @@ -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(); + }); +}; diff --git a/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue b/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue index fef2657f092..3709a9c11ff 100644 --- a/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue +++ b/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue @@ -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, diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentsListView.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentsListView.test.ts index ccfc47d9ead..1eb1a060845 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentsListView.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentsListView.test.ts @@ -45,8 +45,8 @@ vi.mock('@/features/collaboration/projects/projects.store', () => ({ }), })); -vi.mock('@/features/execution/insights', async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock('@n8n/frontend-module-insights', async (importOriginal) => ({ + ...(await importOriginal()), useInsightsStore: () => ({ isSummaryEnabled: false, weeklySummary: { isLoading: false, state: null }, diff --git a/packages/frontend/editor-ui/src/features/agents/views/AgentsListView.vue b/packages/frontend/editor-ui/src/features/agents/views/AgentsListView.vue index b365f44d48f..f869f783278 100644 --- a/packages/frontend/editor-ui/src/features/agents/views/AgentsListView.vue +++ b/packages/frontend/editor-ui/src/features/agents/views/AgentsListView.vue @@ -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 { diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectFilter.slot.test.ts b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectFilter.slot.test.ts new file mode 100644 index 00000000000..4d158ec6eae --- /dev/null +++ b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectFilter.slot.test.ts @@ -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. `` 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()), + 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 => { + 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>; + let projectsStore: ReturnType>; + + 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); diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectFilter.test.ts b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectFilter.test.ts new file mode 100644 index 00000000000..181270adbc4 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectFilter.test.ts @@ -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>; + +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: '' }; +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(null); + const Host = defineComponent({ + components: { ProjectFilter }, + setup: () => ({ selected }), + template: '', + }); + + 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('')); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectFilter.vue b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectFilter.vue new file mode 100644 index 00000000000..91d6f178877 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectFilter.vue @@ -0,0 +1,101 @@ + + + diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/projects.routes.test.ts b/packages/frontend/editor-ui/src/features/collaboration/projects/projects.routes.test.ts index b55b28ffd93..647c064aeab 100644 --- a/packages/frontend/editor-ui/src/features/collaboration/projects/projects.routes.test.ts +++ b/packages/frontend/editor-ui/src/features/collaboration/projects/projects.routes.test.ts @@ -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 }, diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/projects.routes.ts b/packages/frontend/editor-ui/src/features/collaboration/projects/projects.routes.ts index 74882b63f6d..c1d2a8e691d 100644 --- a/packages/frontend/editor-ui/src/features/collaboration/projects/projects.routes.ts +++ b/packages/frontend/editor-ui/src/features/collaboration/projects/projects.routes.ts @@ -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) { diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/views/ProjectVariables.vue b/packages/frontend/editor-ui/src/features/collaboration/projects/views/ProjectVariables.vue index 8d575696648..7fc7c9d362e 100644 --- a/packages/frontend/editor-ui/src/features/collaboration/projects/views/ProjectVariables.vue +++ b/packages/frontend/editor-ui/src/features/collaboration/projects/views/ProjectVariables.vue @@ -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'; diff --git a/packages/frontend/editor-ui/src/features/core/dataTable/DataTableView.vue b/packages/frontend/editor-ui/src/features/core/dataTable/DataTableView.vue index 7e8d0238553..b26815ae401 100644 --- a/packages/frontend/editor-ui/src/features/core/dataTable/DataTableView.vue +++ b/packages/frontend/editor-ui/src/features/core/dataTable/DataTableView.vue @@ -1,7 +1,7 @@