@shipfox/client-shell 0.2.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.
Files changed (252) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +26 -0
  5. package/LICENSE +21 -0
  6. package/dist/components/main-layout.d.ts +11 -0
  7. package/dist/components/main-layout.d.ts.map +1 -0
  8. package/dist/components/main-layout.js +39 -0
  9. package/dist/components/main-layout.js.map +1 -0
  10. package/dist/components/nav-bar.d.ts +4 -0
  11. package/dist/components/nav-bar.d.ts.map +1 -0
  12. package/dist/components/nav-bar.js +62 -0
  13. package/dist/components/nav-bar.js.map +1 -0
  14. package/dist/components/nav-tabs.d.ts +6 -0
  15. package/dist/components/nav-tabs.d.ts.map +1 -0
  16. package/dist/components/nav-tabs.js +38 -0
  17. package/dist/components/nav-tabs.js.map +1 -0
  18. package/dist/components/not-found-page.d.ts +2 -0
  19. package/dist/components/not-found-page.d.ts.map +1 -0
  20. package/dist/components/not-found-page.js +25 -0
  21. package/dist/components/not-found-page.js.map +1 -0
  22. package/dist/components/settings-nav.d.ts +5 -0
  23. package/dist/components/settings-nav.d.ts.map +1 -0
  24. package/dist/components/settings-nav.js +45 -0
  25. package/dist/components/settings-nav.js.map +1 -0
  26. package/dist/components/user-menu.d.ts +2 -0
  27. package/dist/components/user-menu.d.ts.map +1 -0
  28. package/dist/components/user-menu.js +76 -0
  29. package/dist/components/user-menu.js.map +1 -0
  30. package/dist/components/workspace-crumb.d.ts +7 -0
  31. package/dist/components/workspace-crumb.d.ts.map +1 -0
  32. package/dist/components/workspace-crumb.js +54 -0
  33. package/dist/components/workspace-crumb.js.map +1 -0
  34. package/dist/components/workspace-switcher.d.ts +5 -0
  35. package/dist/components/workspace-switcher.d.ts.map +1 -0
  36. package/dist/components/workspace-switcher.js +90 -0
  37. package/dist/components/workspace-switcher.js.map +1 -0
  38. package/dist/compose/compose-routes.d.ts +6 -0
  39. package/dist/compose/compose-routes.d.ts.map +1 -0
  40. package/dist/compose/compose-routes.js +53 -0
  41. package/dist/compose/compose-routes.js.map +1 -0
  42. package/dist/compose/errors.d.ts +26 -0
  43. package/dist/compose/errors.d.ts.map +1 -0
  44. package/dist/compose/errors.js +39 -0
  45. package/dist/compose/errors.js.map +1 -0
  46. package/dist/compose/merge-config.d.ts +4 -0
  47. package/dist/compose/merge-config.d.ts.map +1 -0
  48. package/dist/compose/merge-config.js +28 -0
  49. package/dist/compose/merge-config.js.map +1 -0
  50. package/dist/compose/normalize-route-path.d.ts +2 -0
  51. package/dist/compose/normalize-route-path.d.ts.map +1 -0
  52. package/dist/compose/normalize-route-path.js +7 -0
  53. package/dist/compose/normalize-route-path.js.map +1 -0
  54. package/dist/compose/validate-providers.d.ts +3 -0
  55. package/dist/compose/validate-providers.d.ts.map +1 -0
  56. package/dist/compose/validate-providers.js +32 -0
  57. package/dist/compose/validate-providers.js.map +1 -0
  58. package/dist/compose/validate-registries.d.ts +4 -0
  59. package/dist/compose/validate-registries.d.ts.map +1 -0
  60. package/dist/compose/validate-registries.js +50 -0
  61. package/dist/compose/validate-registries.js.map +1 -0
  62. package/dist/config/shell-config.d.ts +10 -0
  63. package/dist/config/shell-config.d.ts.map +1 -0
  64. package/dist/config/shell-config.js +12 -0
  65. package/dist/config/shell-config.js.map +1 -0
  66. package/dist/contract.d.ts +39 -0
  67. package/dist/contract.d.ts.map +1 -0
  68. package/dist/contract.js +5 -0
  69. package/dist/contract.js.map +1 -0
  70. package/dist/index.d.ts +2 -0
  71. package/dist/index.d.ts.map +1 -0
  72. package/dist/index.js +3 -0
  73. package/dist/index.js.map +1 -0
  74. package/dist/runtime/active-workspace.d.ts +4 -0
  75. package/dist/runtime/active-workspace.d.ts.map +1 -0
  76. package/dist/runtime/active-workspace.js +15 -0
  77. package/dist/runtime/active-workspace.js.map +1 -0
  78. package/dist/runtime/anchor-paths.d.ts +9 -0
  79. package/dist/runtime/anchor-paths.d.ts.map +1 -0
  80. package/dist/runtime/anchor-paths.js +18 -0
  81. package/dist/runtime/anchor-paths.js.map +1 -0
  82. package/dist/runtime/anchors.d.ts +19 -0
  83. package/dist/runtime/anchors.d.ts.map +1 -0
  84. package/dist/runtime/anchors.js +125 -0
  85. package/dist/runtime/anchors.js.map +1 -0
  86. package/dist/runtime/assemble-route-tree.d.ts +10 -0
  87. package/dist/runtime/assemble-route-tree.d.ts.map +1 -0
  88. package/dist/runtime/assemble-route-tree.js +30 -0
  89. package/dist/runtime/assemble-route-tree.js.map +1 -0
  90. package/dist/runtime/auth.d.ts +50 -0
  91. package/dist/runtime/auth.d.ts.map +1 -0
  92. package/dist/runtime/auth.js +194 -0
  93. package/dist/runtime/auth.js.map +1 -0
  94. package/dist/runtime/chrome-context.d.ts +10 -0
  95. package/dist/runtime/chrome-context.d.ts.map +1 -0
  96. package/dist/runtime/chrome-context.js +16 -0
  97. package/dist/runtime/chrome-context.js.map +1 -0
  98. package/dist/runtime/compose-client-app.d.ts +13 -0
  99. package/dist/runtime/compose-client-app.d.ts.map +1 -0
  100. package/dist/runtime/compose-client-app.js +93 -0
  101. package/dist/runtime/compose-client-app.js.map +1 -0
  102. package/dist/runtime/define-route.d.ts +19 -0
  103. package/dist/runtime/define-route.d.ts.map +1 -0
  104. package/dist/runtime/define-route.js +12 -0
  105. package/dist/runtime/define-route.js.map +1 -0
  106. package/dist/runtime/index.d.ts +20 -0
  107. package/dist/runtime/index.d.ts.map +1 -0
  108. package/dist/runtime/index.js +21 -0
  109. package/dist/runtime/index.js.map +1 -0
  110. package/dist/runtime/last-workspace.d.ts +4 -0
  111. package/dist/runtime/last-workspace.d.ts.map +1 -0
  112. package/dist/runtime/last-workspace.js +20 -0
  113. package/dist/runtime/last-workspace.js.map +1 -0
  114. package/dist/runtime/nav-order.d.ts +7 -0
  115. package/dist/runtime/nav-order.d.ts.map +1 -0
  116. package/dist/runtime/nav-order.js +8 -0
  117. package/dist/runtime/nav-order.js.map +1 -0
  118. package/dist/runtime/provider-stack.d.ts +14 -0
  119. package/dist/runtime/provider-stack.d.ts.map +1 -0
  120. package/dist/runtime/provider-stack.js +28 -0
  121. package/dist/runtime/provider-stack.js.map +1 -0
  122. package/dist/runtime/registries.d.ts +4 -0
  123. package/dist/runtime/registries.d.ts.map +1 -0
  124. package/dist/runtime/registries.js +23 -0
  125. package/dist/runtime/registries.js.map +1 -0
  126. package/dist/runtime/router-context.d.ts +9 -0
  127. package/dist/runtime/router-context.d.ts.map +1 -0
  128. package/dist/runtime/router-context.js +3 -0
  129. package/dist/runtime/router-context.js.map +1 -0
  130. package/dist/runtime/workspace-setup.d.ts +18 -0
  131. package/dist/runtime/workspace-setup.d.ts.map +1 -0
  132. package/dist/runtime/workspace-setup.js +64 -0
  133. package/dist/runtime/workspace-setup.js.map +1 -0
  134. package/dist/testing/index.d.ts +17 -0
  135. package/dist/testing/index.d.ts.map +1 -0
  136. package/dist/testing/index.js +35 -0
  137. package/dist/testing/index.js.map +1 -0
  138. package/dist/tsconfig.test.tsbuildinfo +1 -0
  139. package/dist/vite/evaluate-features.d.ts +8 -0
  140. package/dist/vite/evaluate-features.d.ts.map +1 -0
  141. package/dist/vite/evaluate-features.js +80 -0
  142. package/dist/vite/evaluate-features.js.map +1 -0
  143. package/dist/vite/generate.d.ts +9 -0
  144. package/dist/vite/generate.d.ts.map +1 -0
  145. package/dist/vite/generate.js +80 -0
  146. package/dist/vite/generate.js.map +1 -0
  147. package/dist/vite/index.d.ts +2 -0
  148. package/dist/vite/index.d.ts.map +1 -0
  149. package/dist/vite/index.js +3 -0
  150. package/dist/vite/index.js.map +1 -0
  151. package/dist/vite/plugin.d.ts +7 -0
  152. package/dist/vite/plugin.d.ts.map +1 -0
  153. package/dist/vite/plugin.js +77 -0
  154. package/dist/vite/plugin.js.map +1 -0
  155. package/package.json +113 -0
  156. package/src/components/main-layout.tsx +45 -0
  157. package/src/components/nav-bar.tsx +41 -0
  158. package/src/components/nav-tabs.test.tsx +60 -0
  159. package/src/components/nav-tabs.tsx +47 -0
  160. package/src/components/not-found-page.tsx +14 -0
  161. package/src/components/settings-nav.tsx +35 -0
  162. package/src/components/user-menu.tsx +59 -0
  163. package/src/components/workspace-crumb.tsx +43 -0
  164. package/src/components/workspace-switcher.tsx +75 -0
  165. package/src/compose/compose-routes.test.ts +112 -0
  166. package/src/compose/compose-routes.ts +60 -0
  167. package/src/compose/errors.ts +54 -0
  168. package/src/compose/merge-config.test.ts +26 -0
  169. package/src/compose/merge-config.ts +27 -0
  170. package/src/compose/normalize-route-path.ts +6 -0
  171. package/src/compose/validate-providers.test.ts +31 -0
  172. package/src/compose/validate-providers.ts +36 -0
  173. package/src/compose/validate-registries.test.ts +92 -0
  174. package/src/compose/validate-registries.ts +61 -0
  175. package/src/config/config-screen.test.tsx +37 -0
  176. package/src/config/shell-config.ts +10 -0
  177. package/src/contract.ts +47 -0
  178. package/src/index.ts +1 -0
  179. package/src/runtime/active-workspace.ts +13 -0
  180. package/src/runtime/anchor-paths.ts +18 -0
  181. package/src/runtime/anchors.tsx +106 -0
  182. package/src/runtime/assemble-route-tree.ts +42 -0
  183. package/src/runtime/auth.tsx +213 -0
  184. package/src/runtime/chrome-context.tsx +22 -0
  185. package/src/runtime/compose-client-app.tsx +122 -0
  186. package/src/runtime/define-route.test.ts +15 -0
  187. package/src/runtime/define-route.ts +33 -0
  188. package/src/runtime/index.ts +19 -0
  189. package/src/runtime/last-workspace.ts +22 -0
  190. package/src/runtime/nav-order.ts +1 -0
  191. package/src/runtime/provider-stack.tsx +39 -0
  192. package/src/runtime/providers.test.tsx +51 -0
  193. package/src/runtime/registries.test.tsx +100 -0
  194. package/src/runtime/registries.ts +42 -0
  195. package/src/runtime/router-context.ts +9 -0
  196. package/src/runtime/routes.test.tsx +48 -0
  197. package/src/runtime/workspace-setup.tsx +61 -0
  198. package/src/testing/index.test.tsx +57 -0
  199. package/src/testing/index.tsx +51 -0
  200. package/src/vite/evaluate-features.test.ts +34 -0
  201. package/src/vite/evaluate-features.ts +93 -0
  202. package/src/vite/generate.test.ts +22 -0
  203. package/src/vite/generate.ts +99 -0
  204. package/src/vite/generated-router.test.tsx +58 -0
  205. package/src/vite/index.ts +1 -0
  206. package/src/vite/plugin.test.ts +181 -0
  207. package/src/vite/plugin.ts +114 -0
  208. package/src/vite/validate.test.ts +166 -0
  209. package/test/default-route-impl.tsx +3 -0
  210. package/test/external/FINDINGS.md +53 -0
  211. package/test/external/README.md +39 -0
  212. package/test/external/fixture/index.html +12 -0
  213. package/test/external/fixture/package.json +32 -0
  214. package/test/external/fixture/src/app.fixture.tsx +72 -0
  215. package/test/external/fixture/src/features/override-impl.tsx +12 -0
  216. package/test/external/fixture/src/features/override.tsx +20 -0
  217. package/test/external/fixture/src/features.collision.ts +15 -0
  218. package/test/external/fixture/src/features.ts +4 -0
  219. package/test/external/fixture/src/link-typecheck.tsx +10 -0
  220. package/test/external/fixture/src/main.tsx +4 -0
  221. package/test/external/fixture/tsconfig.json +15 -0
  222. package/test/external/fixture/vite.config.ts +10 -0
  223. package/test/external/fixture/vitest.config.ts +15 -0
  224. package/test/external/toy-feature/CHANGELOG.md +15 -0
  225. package/test/external/toy-feature/node_modules/.bin/shipfox-biome-check +43 -0
  226. package/test/external/toy-feature/node_modules/.bin/shipfox-biome-format +43 -0
  227. package/test/external/toy-feature/node_modules/.bin/shipfox-biome-lint +43 -0
  228. package/test/external/toy-feature/node_modules/.bin/shipfox-swc +43 -0
  229. package/test/external/toy-feature/node_modules/.bin/shipfox-tsc-check +43 -0
  230. package/test/external/toy-feature/node_modules/.bin/shipfox-tsc-emit +43 -0
  231. package/test/external/toy-feature/package.json +57 -0
  232. package/test/external/toy-feature/src/config.ts +5 -0
  233. package/test/external/toy-feature/src/index.ts +64 -0
  234. package/test/external/toy-feature/src/provider.tsx +33 -0
  235. package/test/external/toy-feature/src/routes/insights.tsx +7 -0
  236. package/test/external/toy-feature/src/routes/settings.tsx +7 -0
  237. package/test/external/toy-feature/tsconfig.build.json +5 -0
  238. package/test/external/toy-feature/tsconfig.json +1 -0
  239. package/test/external/verify.mjs +318 -0
  240. package/test/fixtures/features.ts +56 -0
  241. package/test/named-route-impl.tsx +3 -0
  242. package/test/not-route-impl.ts +1 -0
  243. package/test/render.tsx +67 -0
  244. package/test/search-route-impl.tsx +7 -0
  245. package/test/setup.ts +3 -0
  246. package/test/typecheck/shipfox-app.gen.ts +85 -0
  247. package/test/typecheck/types.tsx +18 -0
  248. package/tsconfig.build.json +10 -0
  249. package/tsconfig.build.tsbuildinfo +1 -0
  250. package/tsconfig.json +1 -0
  251. package/tsconfig.test.json +12 -0
  252. package/vitest.config.ts +22 -0
@@ -0,0 +1,122 @@
1
+ import {configureApiClient} from '@shipfox/client-api';
2
+ import {
3
+ ConfigErrorScreen,
4
+ getWindowRuntimeConfig,
5
+ loadConfig,
6
+ setLoadedConfig,
7
+ } from '@shipfox/client-config';
8
+ import {ThemeProvider} from '@shipfox/react-ui/theme';
9
+ import {Toaster} from '@shipfox/react-ui/toast';
10
+ import {TooltipProvider} from '@shipfox/react-ui/tooltip';
11
+ import {QueryClient} from '@tanstack/react-query';
12
+ import {type AnyRouter, RouterProvider} from '@tanstack/react-router';
13
+ import {createStore} from 'jotai';
14
+ import {StrictMode, useEffect} from 'react';
15
+ import {createRoot} from 'react-dom/client';
16
+ import {composeRoutes} from '#compose/compose-routes.js';
17
+ import {mergeConfigShapes} from '#compose/merge-config.js';
18
+ import {validateProviderIds} from '#compose/validate-providers.js';
19
+ import {validateNavigation, validateSettingsSections} from '#compose/validate-registries.js';
20
+ import type {ClientFeature} from '#contract.js';
21
+ import {useAuthState} from './auth.js';
22
+ import {ChromeProvider, type ChromeSlots} from './chrome-context.js';
23
+ import {ShellProviderStack} from './provider-stack.js';
24
+ import type {WorkspaceSetupGate} from './workspace-setup.js';
25
+
26
+ export function composeClientApp({
27
+ features,
28
+ router,
29
+ chrome,
30
+ workspaceSetup,
31
+ }: {
32
+ features: readonly ClientFeature[];
33
+ router: AnyRouter;
34
+ chrome?: ChromeSlots;
35
+ workspaceSetup?: WorkspaceSetupGate;
36
+ }) {
37
+ const routes = composeRoutes(features);
38
+ validateProviderIds(features);
39
+ validateNavigation(
40
+ features,
41
+ routes.map((route) => route.path),
42
+ );
43
+ validateSettingsSections(
44
+ features,
45
+ routes.map((route) => route.path),
46
+ );
47
+ const config = loadConfig(mergeConfigShapes(features), {
48
+ runtime: getWindowRuntimeConfig(),
49
+ build: (import.meta as ImportMeta & {env?: Record<string, unknown>}).env,
50
+ });
51
+ if (config.ok) setLoadedConfig(config.config);
52
+
53
+ return {
54
+ mount(element: HTMLElement): void {
55
+ const root = createRoot(element);
56
+ if (!config.ok) {
57
+ root.render(
58
+ <StrictMode>
59
+ <ThemeProvider>
60
+ <TooltipProvider>
61
+ <ConfigErrorScreen errors={config.errors} />
62
+ </TooltipProvider>
63
+ </ThemeProvider>
64
+ </StrictMode>,
65
+ );
66
+ return;
67
+ }
68
+
69
+ configureApiClient({baseUrl: configApiUrl(config.config)});
70
+ const queryClient = new QueryClient();
71
+ root.render(
72
+ <StrictMode>
73
+ <ChromeProvider chrome={chrome}>
74
+ <ShellProviderStack features={features} queryClient={queryClient} store={createStore()}>
75
+ <RoutedApp
76
+ router={router}
77
+ queryClient={queryClient}
78
+ workspaceSetup={workspaceSetup}
79
+ />
80
+ <Toaster />
81
+ </ShellProviderStack>
82
+ </ChromeProvider>
83
+ </StrictMode>,
84
+ );
85
+ },
86
+ };
87
+ }
88
+
89
+ function RoutedApp({
90
+ router,
91
+ queryClient,
92
+ workspaceSetup,
93
+ }: {
94
+ router: AnyRouter;
95
+ queryClient: QueryClient;
96
+ workspaceSetup: WorkspaceSetupGate | undefined;
97
+ }) {
98
+ const auth = useAuthState();
99
+
100
+ useEffect(() => {
101
+ if (!auth.isLoading) router.invalidate();
102
+ }, [auth.isLoading, router]);
103
+
104
+ return (
105
+ <RouterProvider
106
+ router={router as never}
107
+ context={{auth, queryClient, workspaceSetup} as never}
108
+ />
109
+ );
110
+ }
111
+
112
+ function configApiUrl(config: unknown): string {
113
+ if (
114
+ typeof config !== 'object' ||
115
+ config === null ||
116
+ !('apiUrl' in config) ||
117
+ typeof config.apiUrl !== 'string'
118
+ ) {
119
+ throw new Error('Composed client configuration must include a string apiUrl.');
120
+ }
121
+ return config.apiUrl;
122
+ }
@@ -0,0 +1,15 @@
1
+ import {defineRoute, isRouteImpl} from './define-route.js';
2
+
3
+ describe('isRouteImpl', () => {
4
+ test('accepts implementations created by defineRoute', () => {
5
+ const route = defineRoute({component: () => null});
6
+
7
+ expect(isRouteImpl(route)).toBe(true);
8
+ });
9
+
10
+ test('rejects a default component without a route implementation', () => {
11
+ const component = () => null;
12
+
13
+ expect(isRouteImpl(component)).toBe(false);
14
+ });
15
+ });
@@ -0,0 +1,33 @@
1
+ import type {ComponentType} from 'react';
2
+
3
+ export interface RouteImplOptions {
4
+ component?: ComponentType;
5
+ loader?: unknown;
6
+ validateSearch?: unknown;
7
+ beforeLoad?: unknown;
8
+ staticData?: unknown;
9
+ pendingComponent?: ComponentType;
10
+ errorComponent?: ComponentType;
11
+ }
12
+
13
+ const routeImplBrand = Symbol('routeImpl');
14
+
15
+ export interface RouteImpl<O extends RouteImplOptions = RouteImplOptions> {
16
+ readonly options: O;
17
+ readonly [routeImplBrand]: true;
18
+ }
19
+
20
+ export function defineRoute<const O extends RouteImplOptions>(options: O): RouteImpl<O> {
21
+ return {options, [routeImplBrand]: true};
22
+ }
23
+
24
+ export function isRouteImpl(value: unknown): value is RouteImpl {
25
+ return (
26
+ typeof value === 'object' &&
27
+ value !== null &&
28
+ routeImplBrand in value &&
29
+ 'options' in value &&
30
+ typeof value.options === 'object' &&
31
+ value.options !== null
32
+ );
33
+ }
@@ -0,0 +1,19 @@
1
+ export {WorkspaceCrumb, type WorkspaceCrumbProps} from '#components/workspace-crumb.js';
2
+ export {WorkspaceSwitcher} from '#components/workspace-switcher.js';
3
+ export * from '../compose/compose-routes.js';
4
+ export * from '../compose/errors.js';
5
+ export * from '../compose/merge-config.js';
6
+ export * from '../compose/normalize-route-path.js';
7
+ export * from '../compose/validate-providers.js';
8
+ export * from '../compose/validate-registries.js';
9
+ export * from './active-workspace.js';
10
+ export * from './anchor-paths.js';
11
+ export * from './anchors.js';
12
+ export * from './auth.js';
13
+ export * from './chrome-context.js';
14
+ export * from './compose-client-app.js';
15
+ export * from './define-route.js';
16
+ export * from './last-workspace.js';
17
+ export * from './nav-order.js';
18
+ export * from './router-context.js';
19
+ export * from './workspace-setup.js';
@@ -0,0 +1,22 @@
1
+ import {atomWithStorage} from 'jotai/utils';
2
+
3
+ const storageKey = 'shipfox.lastWorkspaceId';
4
+
5
+ export const lastWorkspaceIdAtom = atomWithStorage<string | undefined>(storageKey, undefined);
6
+
7
+ export function getLastWorkspaceId(): string | undefined {
8
+ if (typeof window === 'undefined') return undefined;
9
+ try {
10
+ const raw = window.localStorage.getItem(storageKey);
11
+ if (!raw) return undefined;
12
+ const parsed: unknown = JSON.parse(raw);
13
+ return typeof parsed === 'string' ? parsed : undefined;
14
+ } catch {
15
+ return undefined;
16
+ }
17
+ }
18
+
19
+ export function rememberLastWorkspaceId(workspaceId: string): void {
20
+ if (typeof window === 'undefined') return;
21
+ window.localStorage.setItem(storageKey, JSON.stringify(workspaceId));
22
+ }
@@ -0,0 +1 @@
1
+ export const NAV_ORDER = {projects: 100, settings: 900, runs: 100, workflows: 200} as const;
@@ -0,0 +1,39 @@
1
+ import {ThemeProvider} from '@shipfox/react-ui/theme';
2
+ import {TooltipProvider} from '@shipfox/react-ui/tooltip';
3
+ import {type QueryClient, QueryClientProvider} from '@tanstack/react-query';
4
+ import {type createStore, Provider as JotaiProvider} from 'jotai';
5
+ import type {PropsWithChildren, ReactNode} from 'react';
6
+ import type {ClientFeature} from '#contract.js';
7
+ import {AuthRuntime, type AuthRuntimeProps} from './auth.js';
8
+
9
+ type Store = ReturnType<typeof createStore>;
10
+
11
+ export function ShellProviderStack({
12
+ features,
13
+ queryClient,
14
+ store,
15
+ auth,
16
+ children,
17
+ }: PropsWithChildren<{
18
+ features: readonly ClientFeature[];
19
+ queryClient: QueryClient;
20
+ store: Store;
21
+ auth?: Pick<AuthRuntimeProps, 'effects'>;
22
+ }>) {
23
+ const featureProviders = features.flatMap((feature) => feature.providers ?? []);
24
+ const nestedProviders = featureProviders.reduceRight<ReactNode>(
25
+ (content, provider) => <provider.Component key={provider.id}>{content}</provider.Component>,
26
+ children,
27
+ );
28
+ return (
29
+ <ThemeProvider>
30
+ <TooltipProvider>
31
+ <QueryClientProvider client={queryClient}>
32
+ <JotaiProvider store={store}>
33
+ <AuthRuntime {...auth}>{nestedProviders}</AuthRuntime>
34
+ </JotaiProvider>
35
+ </QueryClientProvider>
36
+ </TooltipProvider>
37
+ </ThemeProvider>
38
+ );
39
+ }
@@ -0,0 +1,51 @@
1
+ import {QueryClient, useQueryClient} from '@tanstack/react-query';
2
+ import {render} from '@testing-library/react';
3
+ import {createStore, useStore} from 'jotai';
4
+ import type {PropsWithChildren} from 'react';
5
+ import {defineClientFeature} from '#contract.js';
6
+ import {ShellProviderStack} from './provider-stack.js';
7
+
8
+ describe('ShellProviderStack', () => {
9
+ test('gives providers the shell query client and store in feature declaration order', () => {
10
+ const queryClient = new QueryClient();
11
+ const store = createStore();
12
+ const breadcrumbs: string[] = [];
13
+ const received: Array<{queryClient: QueryClient; store: ReturnType<typeof createStore>}> = [];
14
+ const Probe = ({id, children}: PropsWithChildren<{id: string}>) => {
15
+ received.push({queryClient: useQueryClient(), store: useStore()});
16
+ breadcrumbs.push(id);
17
+ return <>{children}</>;
18
+ };
19
+ const features = [
20
+ defineClientFeature({
21
+ id: 'shipfox.first',
22
+ providers: [{id: 'first', Component: ({children}) => <Probe id="first">{children}</Probe>}],
23
+ }),
24
+ defineClientFeature({
25
+ id: 'acme.second',
26
+ providers: [
27
+ {id: 'second', Component: ({children}) => <Probe id="second">{children}</Probe>},
28
+ {id: 'third', Component: ({children}) => <Probe id="third">{children}</Probe>},
29
+ ],
30
+ }),
31
+ ];
32
+
33
+ render(
34
+ <ShellProviderStack
35
+ features={features}
36
+ queryClient={queryClient}
37
+ store={store}
38
+ auth={{effects: false}}
39
+ >
40
+ <div>Content</div>
41
+ </ShellProviderStack>,
42
+ );
43
+
44
+ expect(breadcrumbs).toEqual(['first', 'second', 'third']);
45
+ expect(received).toEqual([
46
+ {queryClient, store},
47
+ {queryClient, store},
48
+ {queryClient, store},
49
+ ]);
50
+ });
51
+ });
@@ -0,0 +1,100 @@
1
+ import {screen, within} from '@testing-library/react';
2
+ import {defineClientFeature} from '#contract.js';
3
+ import {renderComposedShell} from '#test/render.js';
4
+ import {defineRoute} from './define-route.js';
5
+
6
+ describe('composition registries', () => {
7
+ test('sorts navigation and settings entries by order, feature position, then declaration order', async () => {
8
+ const features = [
9
+ defineClientFeature({
10
+ id: 'shipfox.first',
11
+ navigation: [
12
+ {
13
+ id: 'first-a',
14
+ scope: 'workspace',
15
+ label: 'First A',
16
+ to: '/workspaces/$wid/first-a/',
17
+ order: 100,
18
+ },
19
+ {
20
+ id: 'first-b',
21
+ scope: 'workspace',
22
+ label: 'First B',
23
+ to: '/workspaces/$wid/first-b',
24
+ order: 100,
25
+ },
26
+ ],
27
+ settingsSections: [
28
+ {id: 'first', pathSegment: 'first', label: 'First setting', icon: 'userLine', order: 100},
29
+ ],
30
+ routes: [
31
+ {path: '/workspaces/$wid/first-a', parent: 'workspaceLayout', impl: 'first-a'},
32
+ {path: '/workspaces/$wid/first-b', parent: 'workspaceLayout', impl: 'first-b'},
33
+ {
34
+ path: '/workspaces/$wid/settings/first',
35
+ parent: 'workspaceSettings',
36
+ impl: 'first-setting',
37
+ },
38
+ ],
39
+ }),
40
+ defineClientFeature({
41
+ id: 'acme.second',
42
+ navigation: [
43
+ {
44
+ id: 'second',
45
+ scope: 'workspace',
46
+ label: 'Second',
47
+ to: '/workspaces/$wid/second',
48
+ order: 100,
49
+ },
50
+ ],
51
+ settingsSections: [
52
+ {
53
+ id: 'second',
54
+ pathSegment: 'second',
55
+ label: 'Second setting',
56
+ icon: 'userLine',
57
+ order: 200,
58
+ },
59
+ ],
60
+ routes: [
61
+ {path: '/workspaces/$wid/second', parent: 'workspaceLayout', impl: 'second'},
62
+ {
63
+ path: '/workspaces/$wid/settings/second',
64
+ parent: 'workspaceSettings',
65
+ impl: 'second-setting',
66
+ },
67
+ ],
68
+ }),
69
+ ];
70
+
71
+ await renderComposedShell({
72
+ features,
73
+ initialPath: '/workspaces/workspace/settings/first',
74
+ resolveImpl: () => defineRoute({component: () => <h1>Settings page</h1>}),
75
+ });
76
+
77
+ expect(await screen.findByRole('heading', {name: 'Workspace settings'})).toBeVisible();
78
+ expect((await screen.findAllByRole('tab')).map((tab) => tab.textContent)).toEqual([
79
+ 'First A',
80
+ 'First B',
81
+ 'Second',
82
+ ]);
83
+ expect((await screen.findAllByRole('tab'))[0]).toHaveAttribute(
84
+ 'href',
85
+ '/workspaces/workspace/first-a',
86
+ );
87
+ const settingsNavigation = screen.getByRole('navigation', {name: 'Workspace settings'});
88
+ expect(settingsNavigation.parentElement).toHaveClass('grid', 'grid-cols-[180px_minmax(0,1fr)]');
89
+ const settingsLinks = within(settingsNavigation).getAllByRole('link');
90
+ const [firstSettingsLink] = settingsLinks;
91
+ if (!firstSettingsLink) throw new Error('Expected at least one settings link.');
92
+
93
+ expect(settingsLinks.map((link) => link.textContent)).toEqual([
94
+ 'First setting',
95
+ 'Second setting',
96
+ ]);
97
+ expect(firstSettingsLink).toHaveClass('w-full', 'justify-start');
98
+ expect(firstSettingsLink.querySelector('svg')).toHaveClass('size-16');
99
+ });
100
+ });
@@ -0,0 +1,42 @@
1
+ import {normalizeRoutePath} from '#compose/normalize-route-path.js';
2
+ import type {ClientFeature, NavTabEntry, SettingsSectionEntry} from '#contract.js';
3
+
4
+ interface Ordered<T> {
5
+ entry: T;
6
+ featureIndex: number;
7
+ declarationIndex: number;
8
+ }
9
+
10
+ function byOrder<T extends {order?: number}>(left: Ordered<T>, right: Ordered<T>): number {
11
+ return (
12
+ (left.entry.order ?? 500) - (right.entry.order ?? 500) ||
13
+ left.featureIndex - right.featureIndex ||
14
+ left.declarationIndex - right.declarationIndex
15
+ );
16
+ }
17
+
18
+ export function navigationEntries(features: readonly ClientFeature[]): NavTabEntry[] {
19
+ return features
20
+ .flatMap((feature, featureIndex) =>
21
+ (feature.navigation ?? []).map((entry, declarationIndex) => ({
22
+ entry,
23
+ featureIndex,
24
+ declarationIndex,
25
+ })),
26
+ )
27
+ .sort(byOrder)
28
+ .map(({entry}) => ({...entry, to: normalizeRoutePath(entry.to)}));
29
+ }
30
+
31
+ export function settingsEntries(features: readonly ClientFeature[]): SettingsSectionEntry[] {
32
+ return features
33
+ .flatMap((feature, featureIndex) =>
34
+ (feature.settingsSections ?? []).map((entry, declarationIndex) => ({
35
+ entry,
36
+ featureIndex,
37
+ declarationIndex,
38
+ })),
39
+ )
40
+ .sort(byOrder)
41
+ .map(({entry}) => entry);
42
+ }
@@ -0,0 +1,9 @@
1
+ import type {QueryClient} from '@tanstack/react-query';
2
+ import type {AuthStateValue} from './auth.js';
3
+ import type {WorkspaceSetupGate} from './workspace-setup.js';
4
+
5
+ export interface RouterContext {
6
+ auth: AuthStateValue | undefined;
7
+ queryClient: QueryClient | undefined;
8
+ workspaceSetup?: WorkspaceSetupGate;
9
+ }
@@ -0,0 +1,48 @@
1
+ import {screen} from '@testing-library/react';
2
+ import {defineClientFeature} from '#contract.js';
3
+ import {renderComposedShell} from '#test/render.js';
4
+ import {defineRoute} from './define-route.js';
5
+
6
+ describe('composed routes', () => {
7
+ test('renders a feature-added route through memory history', async () => {
8
+ const feature = defineClientFeature({
9
+ id: 'acme.insights',
10
+ routes: [{path: '/insights', parent: 'root', impl: 'insights'}],
11
+ });
12
+
13
+ await renderComposedShell({
14
+ features: [feature],
15
+ initialPath: '/insights',
16
+ resolveImpl: () => defineRoute({component: () => <h1>Insights</h1>}),
17
+ });
18
+
19
+ expect(await screen.findByRole('heading', {name: 'Insights'})).toBeVisible();
20
+ });
21
+
22
+ test('renders an explicit route override instead of the upstream route', async () => {
23
+ const features = [
24
+ defineClientFeature({
25
+ id: 'shipfox.projects',
26
+ routes: [{path: '/projects', parent: 'root', impl: 'upstream'}],
27
+ }),
28
+ defineClientFeature({
29
+ id: 'acme.projects',
30
+ routes: [{path: '/projects', parent: 'root', override: true, impl: 'override'}],
31
+ }),
32
+ ];
33
+
34
+ await renderComposedShell({
35
+ features,
36
+ initialPath: '/projects',
37
+ resolveImpl: (specifier) =>
38
+ defineRoute({
39
+ component: () => (
40
+ <h1>{specifier === 'override' ? 'Commercial projects' : 'Upstream projects'}</h1>
41
+ ),
42
+ }),
43
+ });
44
+
45
+ expect(await screen.findByRole('heading', {name: 'Commercial projects'})).toBeVisible();
46
+ expect(screen.queryByText('Upstream projects')).not.toBeInTheDocument();
47
+ });
48
+ });
@@ -0,0 +1,61 @@
1
+ import {ApiError} from '@shipfox/client-api';
2
+ import {Button} from '@shipfox/react-ui/button';
3
+ import {Callout} from '@shipfox/react-ui/callout';
4
+ import {FullPageLoader} from '@shipfox/react-ui/loader';
5
+ import {Header, Text} from '@shipfox/react-ui/typography';
6
+ import type {QueryClient} from '@tanstack/react-query';
7
+ import {type ErrorComponentProps, useRouter} from '@tanstack/react-router';
8
+
9
+ export interface WorkspaceSetupState {
10
+ hideProjectNavigation: boolean;
11
+ }
12
+ export interface WorkspaceSetupRouteOptions {
13
+ queryClient: QueryClient;
14
+ workspaceId: string;
15
+ pathname: string;
16
+ }
17
+ export type WorkspaceSetupGate = (
18
+ options: WorkspaceSetupRouteOptions,
19
+ ) => Promise<WorkspaceSetupState>;
20
+
21
+ export class WorkspaceSetupLoadError extends Error {
22
+ constructor(public override readonly cause: unknown) {
23
+ super(cause instanceof Error ? cause.message : 'Workspace setup load failed');
24
+ this.name = 'WorkspaceSetupLoadError';
25
+ }
26
+ }
27
+ export function WorkspaceSetupPending() {
28
+ return <FullPageLoader />;
29
+ }
30
+ export function WorkspaceLayoutErrorRoute({error, reset}: ErrorComponentProps) {
31
+ const router = useRouter();
32
+ const retry = () => {
33
+ reset();
34
+ void router.invalidate();
35
+ };
36
+ const setupError = error instanceof WorkspaceSetupLoadError;
37
+ const message =
38
+ error instanceof ApiError
39
+ ? error.message
40
+ : setupError && error.cause instanceof ApiError
41
+ ? error.cause.message
42
+ : 'Try again in a moment.';
43
+ return (
44
+ <main className="min-h-screen bg-background-subtle-base px-24 py-32 max-[520px]:px-16">
45
+ <div className="mx-auto flex w-full max-w-[640px] flex-col gap-24">
46
+ <Header variant="h1">{setupError ? 'Workspace setup' : 'Workspace'}</Header>
47
+ <Callout role="alert" type="error">
48
+ <div className="flex flex-col gap-8">
49
+ <Text size="sm" bold>
50
+ {setupError ? 'Could not load workspace setup' : 'Could not load workspace'}
51
+ </Text>
52
+ <Text size="sm">{message}</Text>
53
+ <Button size="sm" variant="secondary" onClick={retry} className="w-fit">
54
+ Retry
55
+ </Button>
56
+ </div>
57
+ </Callout>
58
+ </div>
59
+ </main>
60
+ );
61
+ }
@@ -0,0 +1,57 @@
1
+ import {getLoadedConfig} from '@shipfox/client-config';
2
+ import {QueryClient, useQueryClient} from '@tanstack/react-query';
3
+ import {render, screen} from '@testing-library/react';
4
+ import {createStore, useStore} from 'jotai';
5
+ import {createShellDecorator, ShellProviders} from './index.js';
6
+
7
+ describe('shell testing helpers', () => {
8
+ test('uses caller-owned state and installs runtime config', () => {
9
+ const queryClient = new QueryClient();
10
+ const store = createStore();
11
+ const config = {apiUrl: 'https://example.test'};
12
+ const Probe = () => {
13
+ const receivedQueryClient = useQueryClient();
14
+ const receivedStore = useStore();
15
+ return (
16
+ <div>
17
+ {receivedQueryClient === queryClient && receivedStore === store
18
+ ? 'caller state'
19
+ : 'default state'}
20
+ </div>
21
+ );
22
+ };
23
+
24
+ render(
25
+ <ShellProviders queryClient={queryClient} store={store} config={config}>
26
+ <Probe />
27
+ </ShellProviders>,
28
+ );
29
+
30
+ expect(screen.getByText('caller state')).toBeInTheDocument();
31
+ expect(getLoadedConfig()).toEqual(config);
32
+ });
33
+
34
+ test('creates a configured Storybook decorator', () => {
35
+ const DecoratedStory = createShellDecorator({config: {mode: 'storybook'}})(
36
+ () => <div>Story content</div>,
37
+ {} as never,
38
+ );
39
+
40
+ render(DecoratedStory);
41
+
42
+ expect(screen.getByText('Story content')).toBeInTheDocument();
43
+ expect(getLoadedConfig()).toEqual({mode: 'storybook'});
44
+ });
45
+
46
+ test('does not refresh auth when rendering static testing providers', () => {
47
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
48
+
49
+ render(
50
+ <ShellProviders>
51
+ <div>Static content</div>
52
+ </ShellProviders>,
53
+ );
54
+
55
+ expect(fetchSpy).not.toHaveBeenCalled();
56
+ });
57
+ });
@@ -0,0 +1,51 @@
1
+ import {setLoadedConfig} from '@shipfox/client-config';
2
+ import type {Decorator} from '@storybook/react';
3
+ import {QueryClient, type QueryClient as QueryClientInstance} from '@tanstack/react-query';
4
+ import {createStore} from 'jotai';
5
+ import {type PropsWithChildren, useState} from 'react';
6
+ import type {ClientFeature} from '#contract.js';
7
+ import {ShellProviderStack} from '../runtime/provider-stack.js';
8
+
9
+ type Store = ReturnType<typeof createStore>;
10
+
11
+ export interface ShellProvidersOptions {
12
+ features?: readonly ClientFeature[];
13
+ queryClient?: QueryClientInstance;
14
+ store?: Store;
15
+ config?: unknown;
16
+ }
17
+
18
+ export function ShellProviders({
19
+ features = [],
20
+ queryClient: providedQueryClient,
21
+ store: providedStore,
22
+ config,
23
+ children,
24
+ }: PropsWithChildren<ShellProvidersOptions>) {
25
+ const [defaultQueryClient] = useState(
26
+ () => new QueryClient({defaultOptions: {queries: {retry: false}}}),
27
+ );
28
+ const [defaultStore] = useState(createStore);
29
+ if (config !== undefined) setLoadedConfig(config);
30
+
31
+ return (
32
+ <ShellProviderStack
33
+ features={features}
34
+ queryClient={providedQueryClient ?? defaultQueryClient}
35
+ store={providedStore ?? defaultStore}
36
+ auth={{effects: false}}
37
+ >
38
+ {children}
39
+ </ShellProviderStack>
40
+ );
41
+ }
42
+
43
+ export function createShellDecorator(options: ShellProvidersOptions = {}): Decorator {
44
+ return (Story) => (
45
+ <ShellProviders {...options}>
46
+ <Story />
47
+ </ShellProviders>
48
+ );
49
+ }
50
+
51
+ export const shellDecorator = createShellDecorator();