Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 77 additions & 42 deletions src/modules/app/app.router.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/<project>/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);
}
Comment on lines +45 to +49

/**
* 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);
Comment on lines +77 to +93

// 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,
Expand Down
117 changes: 117 additions & 0 deletions src/modules/app/tests/app.router.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment on lines +565 to +570
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: '<div />' } };
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: '<div />' } };
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: '<div />' } };
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: '<div />' } };
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: '<div />' } };
// 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');
});
});
Loading