diff --git a/src/modules/app/app.router.js b/src/modules/app/app.router.js index 2683fca86..f69eb6dca 100644 --- a/src/modules/app/app.router.js +++ b/src/modules/app/app.router.js @@ -18,60 +18,95 @@ import tasks from '../tasks/router/tasks.router'; import billing, { organizationRoutes as billingOrganizationRoutes } from '../billing/router/billing.router'; import legal from '../legal/router/legal.router'; -// Core modules — always mounted -const coreRoutes = [].concat(home, auth, users); - /** - * Admin child modules — routes injected as children of the `/admin` parent - * route via `injectAdminChildren`. Downstream projects should register any - * module that contributes an admin tab here (see MIGRATIONS.md). - * - * Each module's router file should export routes with **relative** paths - * (e.g. `'my-tab'` rather than `'/admin/my-tab'`) so they resolve under - * the `/admin/` parent. + * Downstream route registries — mutated by `registerDownstreamRoutes` before + * the router is instantiated. Module-load order guarantees downstream code + * (imported before this file's composition runs) populates these arrays first. * - * @example - * import myTabRoutes from '../my-tab/router/my-tab.router'; - * const adminChildModules = [ - * { name: 'my-tab', routes: myTabRoutes }, - * ]; + * @type {Array} */ -const adminChildModules = []; -injectAdminChildren(admin, adminChildModules, isModuleActive); +const _downstreamCoreModules = []; +const _downstreamAdminChildModules = []; +const _downstreamOptionalModules = []; /** - * Organization-settings child modules — routes injected as children of the - * `/users/organizations/:organizationId` parent route via `injectModuleChildren`. - * Base devkit ships this empty; PR (c) and downstream projects populate it - * (e.g. a billing-settings tab rendered inside the org detail layout). + * Register downstream-specific route extensions. + * + * Call this from your downstream module (e.g. `src/modules//index.js`) + * BEFORE the router is instantiated. Calling it mutates the internal registry + * arrays in place; the router composition picks up the additions automatically. * - * Each module's router file should export routes with **relative** paths - * (e.g. `'billing'` rather than `'/users/organizations/:organizationId/billing'`) - * so they resolve under the org parent. + * @param {object} [options={}] + * @param {Array} [options.coreModules] Routes spread into `coreRoutes` (always mounted, no activation gate). + * @param {Array} [options.adminChildModules] Added to `adminChildModules` (injected under `/admin`). + * @param {Array} [options.optionalModules] Added to `optionalModules` (gated by `isModuleActive`). + * @returns {void} */ -const organizationChildModules = [ - { name: 'billing', routes: billingOrganizationRoutes }, -]; -injectModuleChildren(organizations, organizationChildModules, isModuleActive, ORG_PARENT_PATH); - -// Optional modules — mounted only when activated -const optionalModules = [ - { name: 'organizations', routes: organizations }, - { name: 'admin', routes: admin }, - { name: 'tasks', routes: tasks }, - { name: 'billing', routes: billing }, - { name: 'legal', routes: legal }, -]; - -const routes = optionalModules.reduce( - (acc, mod) => (isModuleActive(mod.name) ? acc.concat(mod.routes) : acc), - coreRoutes, -); +export function registerDownstreamRoutes(options = {}) { + if (options.coreModules) _downstreamCoreModules.push(...options.coreModules); + if (options.adminChildModules) _downstreamAdminChildModules.push(...options.adminChildModules); + if (options.optionalModules) _downstreamOptionalModules.push(...options.optionalModules); +} /** * Router configuration. + * + * Route composition is deferred inside `getRouter()` so that + * `registerDownstreamRoutes` calls made during module initialisation (before + * `getRouter` is invoked from `main.js`) are always visible to the composition. */ const getRouter = () => { + // Core modules — always mounted + const coreRoutes = [].concat(home, auth, users, ..._downstreamCoreModules); + + /** + * Admin child modules — routes injected as children of the `/admin` parent + * route via `injectAdminChildren`. Downstream projects should register any + * module that contributes an admin tab here (see MIGRATIONS.md). + * + * Each module's router file should export routes with **relative** paths + * (e.g. `'my-tab'` rather than `'/admin/my-tab'`) so they resolve under + * the `/admin/` parent. + * + * @example + * import myTabRoutes from '../my-tab/router/my-tab.router'; + * const adminChildModules = [ + * { name: 'my-tab', routes: myTabRoutes }, + * ]; + */ + const adminChildModules = [..._downstreamAdminChildModules]; + injectAdminChildren(admin, adminChildModules, isModuleActive); + + /** + * Organization-settings child modules — routes injected as children of the + * `/users/organizations/:organizationId` parent route via `injectModuleChildren`. + * Base devkit ships this empty; PR (c) and downstream projects populate it + * (e.g. a billing-settings tab rendered inside the org detail layout). + * + * Each module's router file should export routes with **relative** paths + * (e.g. `'billing'` rather than `'/users/organizations/:organizationId/billing'`) + * so they resolve under the org parent. + */ + const organizationChildModules = [ + { name: 'billing', routes: billingOrganizationRoutes }, + ]; + injectModuleChildren(organizations, organizationChildModules, isModuleActive, ORG_PARENT_PATH); + + // Optional modules — mounted only when activated + const optionalModules = [ + { name: 'organizations', routes: organizations }, + { name: 'admin', routes: admin }, + { name: 'tasks', routes: tasks }, + { name: 'billing', routes: billing }, + { name: 'legal', routes: legal }, + ..._downstreamOptionalModules, + ]; + + const routes = optionalModules.reduce( + (acc, mod) => (isModuleActive(mod.name) ? acc.concat(mod.routes) : acc), + coreRoutes, + ); + const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes, diff --git a/src/modules/app/tests/app.router.unit.tests.js b/src/modules/app/tests/app.router.unit.tests.js index 32e60447b..6a4b062f6 100644 --- a/src/modules/app/tests/app.router.unit.tests.js +++ b/src/modules/app/tests/app.router.unit.tests.js @@ -560,3 +560,120 @@ describe('app.router', () => { }); }); }); + +describe('registerDownstreamRoutes', () => { + /** + * Each test resets modules so it gets a fresh registry (arrays start empty). + * registerDownstreamRoutes is imported from the same fresh module instance + * as getRouter, so mutations are visible to the composition. + */ + async function setupFreshModule(registerFn) { + vi.resetModules(); + vi.doMock('../../auth/stores/auth.store', () => ({ + useAuthStore: () => mockAuthStore, + })); + vi.doMock('../../../lib/services/config', () => ({ + default: testConfig, + })); + vi.doMock('../../../lib/helpers/ability.js', () => ({ + ability: mockAbility, + })); + vi.doMock('../../billing/stores/billing.store', () => ({ + useBillingStore: () => mockBillingStore, + })); + vi.doMock('../../../lib/helpers/analytics', () => ({ + capturePageview: (...args) => mockCapturePageview(...args), + })); + vi.doMock('../../../lib/helpers/modules', () => ({ + isModuleActive: (...args) => mockIsModuleActive(...args), + })); + const mod = await import('../app.router.js'); + if (registerFn) registerFn(mod.registerDownstreamRoutes); + return mod; + } + + beforeEach(() => { + mockIsModuleActive = () => true; + mockAuthStore.isLoggedIn = false; + mockAuthStore.serverConfig = null; + mockAuthStore.user = null; + }); + + it('(a) routes work unchanged when registerDownstreamRoutes is never called', async () => { + const mod = await setupFreshModule(null); + const router = mod.default(); + const paths = router.options.routes.map((r) => r.path); + // Stack-defined routes must still exist + expect(paths).toContain('/'); + expect(paths).toContain('/signin'); + expect(paths).toContain('/tasks'); + expect(paths).toContain('/admin'); + }); + + it('(b) calling registerDownstreamRoutes({ optionalModules }) adds the route to the compiled list', async () => { + const fakeRoute = { path: '/downstream-feature', name: 'DownstreamFeature', component: { template: '
' } }; + const mod = await setupFreshModule((register) => { + register({ optionalModules: [{ name: 'downstream-feature', routes: [fakeRoute] }] }); + }); + const router = mod.default(); + const paths = router.options.routes.map((r) => r.path); + expect(paths).toContain('/downstream-feature'); + }); + + it('(b) calling registerDownstreamRoutes({ coreModules }) adds core route (no activation gate)', async () => { + const fakeRoute = { path: '/ds-core', name: 'DsCore', component: { template: '
' } }; + const mod = await setupFreshModule((register) => { + register({ coreModules: [fakeRoute] }); + }); + const router = mod.default(); + const paths = router.options.routes.map((r) => r.path); + expect(paths).toContain('/ds-core'); + }); + + it('(b) registerDownstreamRoutes({ adminChildModules }) populates adminChildModules before injectAdminChildren runs', async () => { + // We verify indirectly: the downstream admin module entry must not throw and + // the router must still create without error. + const fakeAdminChild = { path: 'ds-admin-tab', name: 'DsAdminTab', component: { template: '
' } }; + let caughtError = null; + try { + const mod = await setupFreshModule((register) => { + register({ adminChildModules: [{ name: 'ds-admin-tab', routes: [fakeAdminChild] }] }); + }); + mod.default(); // must not throw + } catch (err) { + caughtError = err; + } + expect(caughtError).toBeNull(); + }); + + it('(c) stack routes appear before downstream routes in the compiled list', async () => { + const fakeRoute = { path: '/downstream-last', name: 'DownstreamLast', component: { template: '
' } }; + const mod = await setupFreshModule((register) => { + register({ optionalModules: [{ name: 'downstream-last', routes: [fakeRoute] }] }); + }); + const router = mod.default(); + const paths = router.options.routes.map((r) => r.path); + const homeIdx = paths.indexOf('/'); + const dsIdx = paths.indexOf('/downstream-last'); + // Stack home route must appear BEFORE the downstream-injected route + expect(homeIdx).toBeGreaterThanOrEqual(0); + expect(dsIdx).toBeGreaterThan(homeIdx); + }); + + it('(c) downstream optional module is gated by isModuleActive', async () => { + const fakeRoute = { path: '/gated-ds', name: 'GatedDs', component: { template: '
' } }; + // Deactivate the downstream module + mockIsModuleActive = (name) => name !== 'gated-ds'; + const mod = await setupFreshModule((register) => { + register({ optionalModules: [{ name: 'gated-ds', routes: [fakeRoute] }] }); + }); + const router = mod.default(); + const paths = router.options.routes.map((r) => r.path); + expect(paths).not.toContain('/gated-ds'); + }); + + it('registerDownstreamRoutes is a named export of app.router.js', async () => { + const mod = await setupFreshModule(null); + expect(typeof mod.registerDownstreamRoutes).toBe('function'); + }); +});