@genesislcap/foundation-react-utils 14.496.2-alpha-da9ccbd.0 → 14.497.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Merge a static route table with PBC routes (e.g. from `getApp().routes`) into
3
+ * one {@link AppRouteConfig} array. PBC routes are guarded, in-layout, and carry
4
+ * their element/tag + navItems through `data` so downstream consumers (a PBC
5
+ * container, nav-item derivation) can read them — matching the `data` shape the
6
+ * hand-rolled version produced.
7
+ */
8
+ export function mergePbcRoutes(staticRoutes, pbcRoutes, { renderPbc }) {
9
+ const mapped = pbcRoutes.map((pbc) => {
10
+ var _a;
11
+ return ({
12
+ path: `/${pbc.path}`,
13
+ element: renderPbc(pbc),
14
+ permissionCode: (_a = pbc.settings) === null || _a === void 0 ? void 0 : _a.permissionCode,
15
+ data: Object.assign(Object.assign({}, pbc.settings), { pbcElement: pbc.element, pbcElementTag: pbc.elementTag, navItems: pbc.navItems }),
16
+ });
17
+ });
18
+ return [...staticRoutes, ...mapped];
19
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Build the post-login redirect target from a router location's `state.from`,
3
+ * preserving the query string and hash so deep-link params (e.g.
4
+ * `?component=<name>`) survive the login bounce. Falls back to `defaultPath`
5
+ * when there is no stashed origin.
6
+ *
7
+ * @param location - The current router location (needs only `state.from`).
8
+ * @param defaultPath - Where to land when nothing was stashed. Defaults to `/`.
9
+ */
10
+ export function buildPostLoginRedirect(location, defaultPath = '/') {
11
+ var _a, _b, _c;
12
+ const from = (_a = location.state) === null || _a === void 0 ? void 0 : _a.from;
13
+ return from ? `${from.pathname}${(_b = from.search) !== null && _b !== void 0 ? _b : ''}${(_c = from.hash) !== null && _c !== void 0 ? _c : ''}` : defaultPath;
14
+ }
@@ -0,0 +1,46 @@
1
+ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Navigate, useLocation } from 'react-router-dom';
3
+ /**
4
+ * Route guard. In order:
5
+ * - not authenticated → redirect to `loginPath`, stashing the full current
6
+ * location in `state.from` (incl. `search` + `hash`) so the login flow can
7
+ * restore the original deep-link via {@link buildPostLoginRedirect};
8
+ * - authenticated but `hasPermission === false` → redirect to `notPermittedPath`;
9
+ * - otherwise → render `children`.
10
+ *
11
+ * Deliberately decoupled from any auth package — pass the booleans in (or use
12
+ * {@link createProtectedRoute} to bind the checks once).
13
+ */
14
+ export function ProtectedRoute({ isAuthenticated, hasPermission = true, loginPath = '/login', notPermittedPath = '/not-permitted', children, }) {
15
+ const location = useLocation();
16
+ if (!isAuthenticated) {
17
+ return _jsx(Navigate, { to: loginPath, state: { from: location }, replace: true });
18
+ }
19
+ if (!hasPermission) {
20
+ return _jsx(Navigate, { to: notPermittedPath, replace: true });
21
+ }
22
+ return _jsx(_Fragment, { children: children });
23
+ }
24
+ /**
25
+ * Bind auth (and optionally permission) checks once and get an ergonomic
26
+ * `<ProtectedRoute>` that only needs `children` (plus an optional
27
+ * `permissionCode`) — ideal when a route table wraps many elements.
28
+ *
29
+ * **Call this at module scope, never inside a component's render.** It returns a
30
+ * new component type each call; invoking it during render recreates that type
31
+ * every render, forcing React to unmount and remount the entire protected
32
+ * subtree (flicker + lost state).
33
+ *
34
+ * @example
35
+ * ```tsx
36
+ * const ProtectedRoute = createProtectedRoute({
37
+ * getIsAuthenticated: () => getUser().isAuthenticated,
38
+ * hasPermission: (code) => !code || canView(getUser(), code),
39
+ * });
40
+ * ```
41
+ */
42
+ export function createProtectedRoute({ getIsAuthenticated, hasPermission, loginPath, notPermittedPath, }) {
43
+ return function BoundProtectedRoute({ children, permissionCode }) {
44
+ return (_jsx(ProtectedRoute, { isAuthenticated: getIsAuthenticated(), hasPermission: hasPermission ? hasPermission(permissionCode) : true, loginPath: loginPath, notPermittedPath: notPermittedPath, children: children }));
45
+ };
46
+ }
@@ -0,0 +1,82 @@
1
+ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
2
+ import { mergePbcRoutes } from './pbc-routes';
3
+ import { buildPostLoginRedirect } from './post-login-redirect';
4
+ import { createComponentRegistry } from './single-component';
5
+ const RedirectSuite = createLogicSuite('buildPostLoginRedirect');
6
+ RedirectSuite('falls back to `/` when there is no stashed origin', () => {
7
+ assert.is(buildPostLoginRedirect({}), '/');
8
+ assert.is(buildPostLoginRedirect({ state: null }), '/');
9
+ assert.is(buildPostLoginRedirect({ state: {} }), '/');
10
+ });
11
+ RedirectSuite('honours a custom default path', () => {
12
+ assert.is(buildPostLoginRedirect({}, '/home'), '/home');
13
+ });
14
+ RedirectSuite('returns the pathname when there is no search/hash', () => {
15
+ assert.is(buildPostLoginRedirect({ state: { from: { pathname: '/grids' } } }), '/grids');
16
+ });
17
+ RedirectSuite('preserves search and hash so deep-link params survive', () => {
18
+ assert.is(buildPostLoginRedirect({
19
+ state: { from: { pathname: '/', search: '?component=Home', hash: '#top' } },
20
+ }), '/?component=Home#top');
21
+ });
22
+ RedirectSuite.run();
23
+ const RegistrySuite = createLogicSuite('createComponentRegistry');
24
+ const Home = () => null;
25
+ const GridsShowcase = () => null;
26
+ const registry = createComponentRegistry({ Home, GridsShowcase });
27
+ RegistrySuite('resolves an exact name', () => {
28
+ assert.is(registry.resolve('Home'), Home);
29
+ assert.is(registry.resolve('GridsShowcase'), GridsShowcase);
30
+ });
31
+ RegistrySuite('resolves case- and separator-insensitively', () => {
32
+ assert.is(registry.resolve('home'), Home);
33
+ assert.is(registry.resolve('gridsshowcase'), GridsShowcase);
34
+ assert.is(registry.resolve('grids-showcase'), GridsShowcase);
35
+ assert.is(registry.resolve('grids_showcase'), GridsShowcase);
36
+ assert.is(registry.resolve('Grids Showcase'), GridsShowcase);
37
+ });
38
+ RegistrySuite('returns undefined for unknown or empty names', () => {
39
+ assert.is(registry.resolve('nope'), undefined);
40
+ assert.is(registry.resolve(null), undefined);
41
+ assert.is(registry.resolve(undefined), undefined);
42
+ assert.is(registry.resolve(''), undefined);
43
+ });
44
+ RegistrySuite('exposes the registered names via available()', () => {
45
+ assert.equal(registry.available(), ['Home', 'GridsShowcase']);
46
+ });
47
+ RegistrySuite.run();
48
+ const PbcSuite = createLogicSuite('mergePbcRoutes');
49
+ PbcSuite('appends PBC routes after the static ones', () => {
50
+ const staticRoutes = [{ path: '/home', element: null }];
51
+ const merged = mergePbcRoutes(staticRoutes, [{ path: 'reporting' }], {
52
+ renderPbc: () => 'pbc',
53
+ });
54
+ assert.is(merged.length, 2);
55
+ assert.is(merged[0].path, '/home');
56
+ assert.is(merged[1].path, '/reporting');
57
+ assert.is(merged[1].element, 'pbc');
58
+ });
59
+ PbcSuite('carries pbc element/tag + navItems + settings through `data`', () => {
60
+ var _a, _b, _c, _d;
61
+ const navItems = [{ navId: 'header', title: 'Reporting' }];
62
+ const el = () => 'loader';
63
+ const merged = mergePbcRoutes([], [
64
+ {
65
+ path: 'reporting',
66
+ element: el,
67
+ elementTag: 'reporting-app',
68
+ navItems,
69
+ settings: { permissionCode: 'ReportView' },
70
+ },
71
+ ], { renderPbc: () => null });
72
+ assert.is(merged[0].permissionCode, 'ReportView');
73
+ assert.is((_a = merged[0].data) === null || _a === void 0 ? void 0 : _a.pbcElement, el);
74
+ assert.is((_b = merged[0].data) === null || _b === void 0 ? void 0 : _b.pbcElementTag, 'reporting-app');
75
+ assert.equal((_c = merged[0].data) === null || _c === void 0 ? void 0 : _c.navItems, navItems);
76
+ assert.is((_d = merged[0].data) === null || _d === void 0 ? void 0 : _d.permissionCode, 'ReportView');
77
+ });
78
+ PbcSuite('returns only the static routes when there are no PBC routes', () => {
79
+ const staticRoutes = [{ path: '/home', element: null }];
80
+ assert.equal(mergePbcRoutes(staticRoutes, [], { renderPbc: () => null }), staticRoutes);
81
+ });
82
+ PbcSuite.run();
@@ -0,0 +1,51 @@
1
+ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * Read a query-string parameter from the current URL.
4
+ *
5
+ * Call this at **module-evaluation time** in your app (i.e. as a top-level
6
+ * `const`), before the app shell's async bootstrap rewrites the URL and drops
7
+ * the query string, and store the result. That way a deep-link param such as
8
+ * `?component=<name>` survives even though it is gone by the time the router
9
+ * mounts.
10
+ */
11
+ export function readInitialParam(key) {
12
+ const search = typeof window !== 'undefined' ? window.location.search : '';
13
+ return new URLSearchParams(search).get(key);
14
+ }
15
+ /** Normalize a name so lookups are case-insensitive and separator-agnostic. */
16
+ const normalize = (name) => name.toLowerCase().replace(/[-_\s]/g, '');
17
+ /**
18
+ * Create a name → component registry whose `resolve` is case- and
19
+ * separator-insensitive (`LimitDistributionChart` === `limit-distribution-chart`).
20
+ */
21
+ export function createComponentRegistry(entries) {
22
+ return {
23
+ entries,
24
+ available: () => Object.keys(entries),
25
+ resolve(name) {
26
+ if (!name) {
27
+ return undefined;
28
+ }
29
+ const direct = entries[name];
30
+ if (direct) {
31
+ return direct;
32
+ }
33
+ const target = normalize(name);
34
+ const key = Object.keys(entries).find((k) => normalize(k) === target);
35
+ return key ? entries[key] : undefined;
36
+ },
37
+ };
38
+ }
39
+ const defaultUnknown = (name, available) => (_jsx("div", { role: "alert", style: { padding: 16 }, children: `Unknown component "${name !== null && name !== void 0 ? name : ''}". Available: ${available.join(', ')}` }));
40
+ /**
41
+ * Resolve `name` against `registry` and render the matching component, or a
42
+ * fallback when unmatched.
43
+ *
44
+ * Theming/provider chrome is intentionally left to the caller — wrap this outlet
45
+ * in your app's design-system provider so the standalone component is themed
46
+ * identically to how it appears in-app.
47
+ */
48
+ export function SingleComponentOutlet({ name, registry, renderUnknown = defaultUnknown, }) {
49
+ const Component = registry.resolve(name);
50
+ return _jsx(_Fragment, { children: Component ? _jsx(Component, {}) : renderUnknown(name, registry.available()) });
51
+ }
@@ -1 +1 @@
1
- {"root":["../src/create-grid-pro-cell-portals.tsx","../src/create-grid-pro-cell-renderer.ts","../src/create-react-renderer.ts","../src/index.ts","../src/react-layout-factory.tsx"],"version":"5.9.2"}
1
+ {"root":["../src/create-grid-pro-cell-portals.tsx","../src/create-grid-pro-cell-renderer.ts","../src/create-react-renderer.ts","../src/index.ts","../src/react-layout-factory.tsx","../src/router/app-routes.tsx","../src/router/index.ts","../src/router/pbc-routes.ts","../src/router/post-login-redirect.ts","../src/router/protected-route.tsx","../src/router/router.test.ts","../src/router/single-component.tsx"],"version":"5.9.2"}
package/package.json CHANGED
@@ -1,11 +1,29 @@
1
1
  {
2
2
  "name": "@genesislcap/foundation-react-utils",
3
3
  "description": "Genesis Foundation React Utils",
4
- "version": "14.496.2-alpha-da9ccbd.0",
4
+ "version": "14.497.0",
5
5
  "sideEffects": false,
6
6
  "license": "SEE LICENSE IN license.txt",
7
7
  "main": "dist/esm/index.js",
8
8
  "types": "dist/dts/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/dts/index.d.ts",
12
+ "default": "./dist/esm/index.js"
13
+ },
14
+ "./router": {
15
+ "types": "./dist/dts/router/index.d.ts",
16
+ "default": "./dist/esm/router/index.js"
17
+ },
18
+ "./package.json": "./package.json"
19
+ },
20
+ "typesVersions": {
21
+ "*": {
22
+ "router": [
23
+ "./dist/dts/router/index.d.ts"
24
+ ]
25
+ }
26
+ },
9
27
  "engines": {
10
28
  "node": ">=22.0.0"
11
29
  },
@@ -29,23 +47,30 @@
29
47
  }
30
48
  },
31
49
  "devDependencies": {
32
- "@genesislcap/foundation-testing": "14.496.2-alpha-da9ccbd.0",
33
- "@genesislcap/genx": "14.496.2-alpha-da9ccbd.0",
34
- "@genesislcap/rollup-builder": "14.496.2-alpha-da9ccbd.0",
35
- "@genesislcap/ts-builder": "14.496.2-alpha-da9ccbd.0",
36
- "@genesislcap/uvu-playwright-builder": "14.496.2-alpha-da9ccbd.0",
37
- "@genesislcap/vite-builder": "14.496.2-alpha-da9ccbd.0",
38
- "@genesislcap/webpack-builder": "14.496.2-alpha-da9ccbd.0"
50
+ "@genesislcap/foundation-testing": "14.497.0",
51
+ "@genesislcap/genx": "14.497.0",
52
+ "@genesislcap/rollup-builder": "14.497.0",
53
+ "@genesislcap/ts-builder": "14.497.0",
54
+ "@genesislcap/uvu-playwright-builder": "14.497.0",
55
+ "@genesislcap/vite-builder": "14.497.0",
56
+ "@genesislcap/webpack-builder": "14.497.0",
57
+ "react-router-dom": "^7.1.3"
39
58
  },
40
59
  "peerDependencies": {
41
60
  "react": "^19.0.0",
42
- "react-dom": "^19.0.0"
61
+ "react-dom": "^19.0.0",
62
+ "react-router-dom": "^7.0.0"
63
+ },
64
+ "peerDependenciesMeta": {
65
+ "react-router-dom": {
66
+ "optional": true
67
+ }
43
68
  },
44
69
  "dependencies": {
45
- "@genesislcap/foundation-forms": "14.496.2-alpha-da9ccbd.0",
46
- "@genesislcap/foundation-layout": "14.496.2-alpha-da9ccbd.0",
47
- "@genesislcap/foundation-logger": "14.496.2-alpha-da9ccbd.0",
48
- "@genesislcap/web-core": "14.496.2-alpha-da9ccbd.0",
70
+ "@genesislcap/foundation-forms": "14.497.0",
71
+ "@genesislcap/foundation-layout": "14.497.0",
72
+ "@genesislcap/foundation-logger": "14.497.0",
73
+ "@genesislcap/web-core": "14.497.0",
49
74
  "@jsonforms/core": "^3.2.1",
50
75
  "@r2wc/react-to-web-component": "^2.0.2"
51
76
  },
@@ -58,5 +83,5 @@
58
83
  "access": "public"
59
84
  },
60
85
  "customElements": "dist/custom-elements.json",
61
- "gitHead": "daac29f621ee844cc6e269c0b8bd52c56bff8cf0"
86
+ "gitHead": "8859a3cd83bd40fc5e5bbff9be5ebe02380fb6ec"
62
87
  }
@@ -0,0 +1,141 @@
1
+ import type { ComponentType, ReactElement, ReactNode } from 'react';
2
+ import { Navigate, Route, Routes } from 'react-router-dom';
3
+ import type { BoundProtectedRouteProps } from './protected-route';
4
+
5
+ /**
6
+ * Declarative description of one app route. The `element` is rendered when the
7
+ * path matches; guarding, layout nesting, and permission are derived from the
8
+ * flags below so the app doesn't hand-write the `<Route>`/`<ProtectedRoute>`
9
+ * boilerplate per route.
10
+ */
11
+ export interface AppRouteConfig {
12
+ path: string;
13
+ element: ReactNode;
14
+ /** Skip the auth guard (e.g. login, not-permitted). Default `false`. */
15
+ public?: boolean;
16
+ /** Render outside the shared layout. Default `false` (guarded routes sit in the layout). */
17
+ noLayout?: boolean;
18
+ /** Permission code handed to the guard's optional permission check. */
19
+ permissionCode?: string;
20
+ /**
21
+ * Nested child routes rendered inside this route's `element` (which must
22
+ * render an `<Outlet />`). Children use paths relative to this route and
23
+ * inherit its guard — they are rendered as plain routes, not re-guarded.
24
+ */
25
+ children?: AppRouteConfig[];
26
+ /** Arbitrary metadata preserved for other consumers (nav items, PBC info, ...). */
27
+ data?: Record<string, any>;
28
+ }
29
+
30
+ /** A simple `from → to` redirect. */
31
+ export interface RouteRedirect {
32
+ from: string;
33
+ to: string;
34
+ }
35
+
36
+ /**
37
+ * Options for {@link renderAppRoutes}.
38
+ */
39
+ export interface RenderAppRoutesOptions {
40
+ /** The full route table (static + any merged PBC routes). */
41
+ routes: AppRouteConfig[];
42
+ /**
43
+ * Guard component (typically from `createProtectedRoute`) wrapping every
44
+ * non-public route; receives each route's `permissionCode`.
45
+ */
46
+ ProtectedRoute: ComponentType<BoundProtectedRouteProps>;
47
+ /**
48
+ * Layout route element (rendering an `<Outlet />`) that wraps in-layout
49
+ * routes. Omit to render every route at the top level.
50
+ */
51
+ layout?: ReactElement;
52
+ /** `from → to` redirects rendered before the routes. */
53
+ redirects?: RouteRedirect[];
54
+ /** Element for unmatched paths (`*`). */
55
+ notFound?: ReactNode;
56
+ /**
57
+ * When `name` is truthy, short-circuit to single-component mode: render only
58
+ * the public routes (so login / session-restore still work) plus a catch-all
59
+ * rendering `element` behind the guard. See the single-component deep-link.
60
+ */
61
+ singleComponent?: { name: string | null | undefined; element: ReactNode };
62
+ }
63
+
64
+ const resolvePermissionCode = (route: AppRouteConfig): string | undefined =>
65
+ route.permissionCode ?? (route.data?.permissionCode as string | undefined);
66
+
67
+ /**
68
+ * Build the app's `<Routes>` tree from a declarative route table, applying the
69
+ * auth guard, layout nesting, redirects, not-found, and the single-component
70
+ * short-circuit — so a consuming app configures routes instead of hand-writing
71
+ * repetitive `<Route element={<ProtectedRoute>…}>` markup.
72
+ */
73
+ export function renderAppRoutes({
74
+ routes,
75
+ ProtectedRoute,
76
+ layout,
77
+ redirects = [],
78
+ notFound,
79
+ singleComponent,
80
+ }: RenderAppRoutesOptions): ReactElement {
81
+ const publicRoutes = routes.filter((r) => r.public);
82
+
83
+ // Single-component mode: keep public routes reachable (login / session
84
+ // restore) and gate everything else behind the guard rendering the component.
85
+ if (singleComponent?.name) {
86
+ return (
87
+ <Routes>
88
+ {publicRoutes.map((r) => (
89
+ <Route key={r.path} path={r.path} element={r.element} />
90
+ ))}
91
+ <Route path="*" element={<ProtectedRoute>{singleComponent.element}</ProtectedRoute>} />
92
+ </Routes>
93
+ );
94
+ }
95
+
96
+ const guarded = routes.filter((r) => !r.public);
97
+ const inLayout = guarded.filter((r) => !r.noLayout);
98
+ const topLevelGuarded = guarded.filter((r) => r.noLayout);
99
+
100
+ // Nested children render as plain relative routes inside the parent's
101
+ // `<Outlet />`; they inherit the parent's guard, so they are not re-wrapped.
102
+ const renderChild = (c: AppRouteConfig): ReactElement =>
103
+ c.children?.length ? (
104
+ <Route key={c.path} path={c.path} element={c.element}>
105
+ {c.children.map(renderChild)}
106
+ </Route>
107
+ ) : (
108
+ <Route key={c.path} path={c.path} element={c.element} />
109
+ );
110
+
111
+ const renderGuarded = (r: AppRouteConfig) => {
112
+ const element = (
113
+ <ProtectedRoute permissionCode={resolvePermissionCode(r)}>{r.element}</ProtectedRoute>
114
+ );
115
+ return r.children?.length ? (
116
+ <Route key={r.path} path={r.path} element={element}>
117
+ {r.children.map(renderChild)}
118
+ </Route>
119
+ ) : (
120
+ <Route key={r.path} path={r.path} element={element} />
121
+ );
122
+ };
123
+
124
+ return (
125
+ <Routes>
126
+ {redirects.map((r) => (
127
+ <Route key={`redirect:${r.from}`} path={r.from} element={<Navigate to={r.to} replace />} />
128
+ ))}
129
+ {publicRoutes.map((r) => (
130
+ <Route key={r.path} path={r.path} element={r.element} />
131
+ ))}
132
+ {topLevelGuarded.map(renderGuarded)}
133
+ {layout ? (
134
+ <Route element={layout}>{inLayout.map(renderGuarded)}</Route>
135
+ ) : (
136
+ inLayout.map(renderGuarded)
137
+ )}
138
+ {notFound ? <Route path="*" element={notFound} /> : null}
139
+ </Routes>
140
+ );
141
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * `@genesislcap/foundation-react-utils/router`
3
+ *
4
+ * Reusable `react-router-dom` primitives for Genesis Foundation React apps, so
5
+ * the app-shell routing logic doesn't have to be reimplemented per app.
6
+ *
7
+ * Exposed as a subpath (`/router`) rather than from the package root so that
8
+ * consumers which don't route (and don't depend on `react-router-dom`) are
9
+ * unaffected.
10
+ *
11
+ * Key exports:
12
+ * - `ProtectedRoute` / `createProtectedRoute` — auth + permission gate that
13
+ * stashes the full origin location (incl. `search` + `hash`) for post-login
14
+ * restore, and redirects to a not-permitted path when authorized-but-blocked.
15
+ * - `buildPostLoginRedirect` — rebuild the return URL preserving query + hash.
16
+ * - `readInitialParam` / `createComponentRegistry` / `SingleComponentOutlet` —
17
+ * render a single registered component full-screen from a `?param=<name>`
18
+ * deep-link captured before the shell strips the query string.
19
+ * - `renderAppRoutes` — build the app's `<Routes>` from a declarative route
20
+ * table (guarding, layout nesting, redirects, not-found, single-component).
21
+ * - `mergePbcRoutes` — merge static routes with shell/PBC routes into one table.
22
+ */
23
+
24
+ export { ProtectedRoute, createProtectedRoute } from './protected-route';
25
+ export type {
26
+ ProtectedRouteProps,
27
+ BoundProtectedRouteProps,
28
+ CreateProtectedRouteOptions,
29
+ } from './protected-route';
30
+ export { buildPostLoginRedirect } from './post-login-redirect';
31
+ export type { RedirectableLocationState } from './post-login-redirect';
32
+ export {
33
+ readInitialParam,
34
+ createComponentRegistry,
35
+ SingleComponentOutlet,
36
+ } from './single-component';
37
+ export type { ComponentRegistry, SingleComponentOutletProps } from './single-component';
38
+ export { renderAppRoutes } from './app-routes';
39
+ export type { AppRouteConfig, RouteRedirect, RenderAppRoutesOptions } from './app-routes';
40
+ export { mergePbcRoutes } from './pbc-routes';
41
+ export type { PbcRouteInput, MergePbcRoutesOptions } from './pbc-routes';
@@ -0,0 +1,56 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { AppRouteConfig } from './app-routes';
3
+
4
+ /**
5
+ * Minimal shape of a PBC route as provided by the shell (e.g. `getApp().routes`).
6
+ * Kept as an input type — rather than importing `foundation-shell` — so this
7
+ * helper stays dependency-free and the `/router` subpath doesn't pull the shell
8
+ * into apps that don't use PBCs.
9
+ */
10
+ export interface PbcRouteInput {
11
+ path: string;
12
+ title?: string;
13
+ /** The PBC element (module/loader) the container will mount. */
14
+ element?: unknown;
15
+ /** Explicit custom-element tag, if the container shouldn't derive one. */
16
+ elementTag?: string;
17
+ /** Nav items contributed by this PBC. */
18
+ navItems?: unknown;
19
+ /** Extra per-route settings (e.g. `permissionCode`), spread into `data`. */
20
+ settings?: Record<string, unknown>;
21
+ }
22
+
23
+ /**
24
+ * Options for {@link mergePbcRoutes}.
25
+ */
26
+ export interface MergePbcRoutesOptions {
27
+ /** Render the element for a PBC route (typically `() => <PBCContainer />`). */
28
+ renderPbc: (pbc: PbcRouteInput) => ReactNode;
29
+ }
30
+
31
+ /**
32
+ * Merge a static route table with PBC routes (e.g. from `getApp().routes`) into
33
+ * one {@link AppRouteConfig} array. PBC routes are guarded, in-layout, and carry
34
+ * their element/tag + navItems through `data` so downstream consumers (a PBC
35
+ * container, nav-item derivation) can read them — matching the `data` shape the
36
+ * hand-rolled version produced.
37
+ */
38
+ export function mergePbcRoutes(
39
+ staticRoutes: AppRouteConfig[],
40
+ pbcRoutes: PbcRouteInput[],
41
+ { renderPbc }: MergePbcRoutesOptions,
42
+ ): AppRouteConfig[] {
43
+ const mapped: AppRouteConfig[] = pbcRoutes.map((pbc) => ({
44
+ path: `/${pbc.path}`,
45
+ element: renderPbc(pbc),
46
+ permissionCode: pbc.settings?.permissionCode as string | undefined,
47
+ data: {
48
+ ...pbc.settings,
49
+ pbcElement: pbc.element,
50
+ pbcElementTag: pbc.elementTag,
51
+ navItems: pbc.navItems,
52
+ },
53
+ }));
54
+
55
+ return [...staticRoutes, ...mapped];
56
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Shape of the router location `state` that a route guard stashes before
3
+ * redirecting to login. Structural, so it works with `react-router-dom`'s
4
+ * `Location` without importing it here.
5
+ */
6
+ export interface RedirectableLocationState {
7
+ from?: {
8
+ pathname: string;
9
+ search?: string;
10
+ hash?: string;
11
+ };
12
+ }
13
+
14
+ /**
15
+ * Build the post-login redirect target from a router location's `state.from`,
16
+ * preserving the query string and hash so deep-link params (e.g.
17
+ * `?component=<name>`) survive the login bounce. Falls back to `defaultPath`
18
+ * when there is no stashed origin.
19
+ *
20
+ * @param location - The current router location (needs only `state.from`).
21
+ * @param defaultPath - Where to land when nothing was stashed. Defaults to `/`.
22
+ */
23
+ export function buildPostLoginRedirect(
24
+ location: { state?: RedirectableLocationState | null },
25
+ defaultPath = '/',
26
+ ): string {
27
+ const from = location.state?.from;
28
+ return from ? `${from.pathname}${from.search ?? ''}${from.hash ?? ''}` : defaultPath;
29
+ }