@shipfox/client-shell 1.0.0 → 3.0.1
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.
- package/assets/apple-touch-icon.png +0 -0
- package/assets/favicon-96x96.png +0 -0
- package/assets/favicon.ico +0 -0
- package/assets/favicon.svg +1 -0
- package/assets/site.webmanifest +22 -0
- package/assets/web-app-manifest-192x192.png +0 -0
- package/assets/web-app-manifest-512x512.png +0 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/dist/vite/index.d.ts +1 -0
- package/dist/vite/index.d.ts.map +1 -1
- package/dist/vite/index.js +1 -0
- package/dist/vite/index.js.map +1 -1
- package/dist/vite/manifest.d.ts +3 -0
- package/dist/vite/manifest.d.ts.map +1 -0
- package/dist/vite/manifest.js +140 -0
- package/dist/vite/manifest.js.map +1 -0
- package/package.json +15 -9
- package/.turbo/turbo-build.log +0 -2
- package/.turbo/turbo-type$colon$emit.log +0 -1
- package/.turbo/turbo-type.log +0 -1
- package/CHANGELOG.md +0 -41
- package/src/components/main-layout.tsx +0 -45
- package/src/components/nav-bar.tsx +0 -41
- package/src/components/nav-tabs.test.tsx +0 -60
- package/src/components/nav-tabs.tsx +0 -47
- package/src/components/not-found-page.tsx +0 -14
- package/src/components/settings-nav.tsx +0 -35
- package/src/components/user-menu.tsx +0 -59
- package/src/components/workspace-crumb.tsx +0 -43
- package/src/components/workspace-switcher.tsx +0 -75
- package/src/compose/compose-routes.test.ts +0 -112
- package/src/compose/compose-routes.ts +0 -60
- package/src/compose/errors.ts +0 -54
- package/src/compose/merge-config.test.ts +0 -26
- package/src/compose/merge-config.ts +0 -27
- package/src/compose/normalize-route-path.ts +0 -6
- package/src/compose/validate-providers.test.ts +0 -31
- package/src/compose/validate-providers.ts +0 -36
- package/src/compose/validate-registries.test.ts +0 -92
- package/src/compose/validate-registries.ts +0 -61
- package/src/config/config-screen.test.tsx +0 -37
- package/src/config/shell-config.ts +0 -10
- package/src/contract.ts +0 -47
- package/src/index.ts +0 -1
- package/src/runtime/active-workspace.ts +0 -13
- package/src/runtime/anchor-paths.ts +0 -18
- package/src/runtime/anchors.tsx +0 -106
- package/src/runtime/assemble-route-tree.ts +0 -42
- package/src/runtime/auth.tsx +0 -213
- package/src/runtime/chrome-context.tsx +0 -22
- package/src/runtime/compose-client-app.tsx +0 -122
- package/src/runtime/define-route.test.ts +0 -15
- package/src/runtime/define-route.ts +0 -33
- package/src/runtime/index.ts +0 -19
- package/src/runtime/last-workspace.ts +0 -22
- package/src/runtime/nav-order.ts +0 -1
- package/src/runtime/provider-stack.tsx +0 -39
- package/src/runtime/providers.test.tsx +0 -51
- package/src/runtime/registries.test.tsx +0 -100
- package/src/runtime/registries.ts +0 -42
- package/src/runtime/router-context.ts +0 -9
- package/src/runtime/routes.test.tsx +0 -48
- package/src/runtime/workspace-setup.tsx +0 -61
- package/src/testing/index.test.tsx +0 -57
- package/src/testing/index.tsx +0 -51
- package/src/vite/evaluate-features.test.ts +0 -89
- package/src/vite/evaluate-features.ts +0 -114
- package/src/vite/generate.test.ts +0 -22
- package/src/vite/generate.ts +0 -99
- package/src/vite/generated-router.test.tsx +0 -58
- package/src/vite/index.ts +0 -1
- package/src/vite/plugin.test.ts +0 -231
- package/src/vite/plugin.ts +0 -101
- package/src/vite/validate.test.ts +0 -166
- package/test/default-route-impl.tsx +0 -3
- package/test/external/FINDINGS.md +0 -41
- package/test/external/README.md +0 -37
- package/test/external/fixture/index.html +0 -12
- package/test/external/fixture/package.json +0 -31
- package/test/external/fixture/src/app.fixture.tsx +0 -104
- package/test/external/fixture/src/features/external-settings.tsx +0 -19
- package/test/external/fixture/src/features/login-override.tsx +0 -9
- package/test/external/fixture/src/features.collision.ts +0 -15
- package/test/external/fixture/src/features.ts +0 -50
- package/test/external/fixture/src/link-typecheck.tsx +0 -10
- package/test/external/fixture/src/main.tsx +0 -86
- package/test/external/fixture/src/provider.ts +0 -45
- package/test/external/fixture/tsconfig.json +0 -15
- package/test/external/fixture/vite.config.ts +0 -21
- package/test/external/fixture/vitest.config.ts +0 -17
- package/test/external/verify.mjs +0 -504
- package/test/fixtures/features.ts +0 -56
- package/test/named-route-impl.tsx +0 -3
- package/test/render.tsx +0 -67
- package/test/search-route-impl.tsx +0 -7
- package/test/setup.ts +0 -3
- package/test/typecheck/shipfox-app.gen.ts +0 -85
- package/test/typecheck/types.tsx +0 -23
- package/tsconfig.build.json +0 -10
- package/tsconfig.build.tsbuildinfo +0 -1
- package/tsconfig.json +0 -1
- package/tsconfig.test.json +0 -12
- package/vitest.config.ts +0 -22
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Command,
|
|
3
|
-
CommandEmpty,
|
|
4
|
-
CommandGroup,
|
|
5
|
-
CommandInput,
|
|
6
|
-
CommandItem,
|
|
7
|
-
CommandList,
|
|
8
|
-
CommandSeparator,
|
|
9
|
-
} from '@shipfox/react-ui/command';
|
|
10
|
-
import {Icon} from '@shipfox/react-ui/icon';
|
|
11
|
-
import {useNavigate} from '@tanstack/react-router';
|
|
12
|
-
import {useSetAtom} from 'jotai';
|
|
13
|
-
import {useAuthState} from '#runtime/auth.js';
|
|
14
|
-
import {lastWorkspaceIdAtom} from '#runtime/last-workspace.js';
|
|
15
|
-
|
|
16
|
-
export function WorkspaceSwitcher({
|
|
17
|
-
activeWorkspaceId,
|
|
18
|
-
onSelect,
|
|
19
|
-
}: {
|
|
20
|
-
activeWorkspaceId?: string;
|
|
21
|
-
onSelect?: () => void;
|
|
22
|
-
}) {
|
|
23
|
-
const {workspaces} = useAuthState();
|
|
24
|
-
const navigate = useNavigate();
|
|
25
|
-
const setLastWorkspaceId = useSetAtom(lastWorkspaceIdAtom);
|
|
26
|
-
const selectWorkspace = (wid: string) => {
|
|
27
|
-
try {
|
|
28
|
-
setLastWorkspaceId(wid);
|
|
29
|
-
} catch {
|
|
30
|
-
// Local storage is best effort.
|
|
31
|
-
}
|
|
32
|
-
navigate({to: '/workspaces/$wid', params: {wid}});
|
|
33
|
-
onSelect?.();
|
|
34
|
-
};
|
|
35
|
-
return (
|
|
36
|
-
<Command>
|
|
37
|
-
<CommandInput placeholder="Search workspaces..." />
|
|
38
|
-
<CommandList className="max-h-none overflow-visible overflow-y-visible p-0">
|
|
39
|
-
<div className="max-h-300 overflow-y-auto overflow-x-hidden p-4 scrollbar">
|
|
40
|
-
<CommandEmpty>No workspaces found.</CommandEmpty>
|
|
41
|
-
<CommandGroup heading="Workspaces">
|
|
42
|
-
{workspaces.map((workspace) => (
|
|
43
|
-
<CommandItem
|
|
44
|
-
key={workspace.id}
|
|
45
|
-
value={workspace.id}
|
|
46
|
-
keywords={[workspace.name]}
|
|
47
|
-
onSelect={() => selectWorkspace(workspace.id)}
|
|
48
|
-
>
|
|
49
|
-
<Icon
|
|
50
|
-
name="check"
|
|
51
|
-
className={`size-16 mr-8 ${activeWorkspaceId === workspace.id ? 'opacity-100' : 'opacity-0'}`}
|
|
52
|
-
/>
|
|
53
|
-
{workspace.name}
|
|
54
|
-
</CommandItem>
|
|
55
|
-
))}
|
|
56
|
-
</CommandGroup>
|
|
57
|
-
</div>
|
|
58
|
-
<CommandSeparator alwaysRender className="mx-0" />
|
|
59
|
-
<CommandGroup forceMount className="p-4">
|
|
60
|
-
<CommandItem
|
|
61
|
-
value="__create"
|
|
62
|
-
onSelect={() => {
|
|
63
|
-
navigate({to: '/setup/workspaces/new' as never});
|
|
64
|
-
onSelect?.();
|
|
65
|
-
}}
|
|
66
|
-
forceMount
|
|
67
|
-
>
|
|
68
|
-
<Icon name="addLine" className="size-16" />
|
|
69
|
-
Create workspace
|
|
70
|
-
</CommandItem>
|
|
71
|
-
</CommandGroup>
|
|
72
|
-
</CommandList>
|
|
73
|
-
</Command>
|
|
74
|
-
);
|
|
75
|
-
}
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import type {ClientFeature} from '#contract.js';
|
|
2
|
-
import {composeRoutes} from './compose-routes.js';
|
|
3
|
-
|
|
4
|
-
const base: ClientFeature = {
|
|
5
|
-
id: 'shipfox.base',
|
|
6
|
-
routes: [{path: '/projects', parent: 'root', impl: 'base'}],
|
|
7
|
-
};
|
|
8
|
-
const collisionMessage = /\/projects.*shipfox\.base.*acme\.insights/u;
|
|
9
|
-
const danglingOverrideMessage = /\/missing.*acme\.insights/u;
|
|
10
|
-
const competingOverridesMessage = /\/projects.*acme\.one.*acme\.two/u;
|
|
11
|
-
|
|
12
|
-
describe('composeRoutes', () => {
|
|
13
|
-
test('appends a unique route and explicitly replaces an existing route', () => {
|
|
14
|
-
const routes = composeRoutes([
|
|
15
|
-
base,
|
|
16
|
-
{
|
|
17
|
-
id: 'acme.insights',
|
|
18
|
-
routes: [{path: '/projects', parent: 'root', override: true, impl: 'override'}],
|
|
19
|
-
},
|
|
20
|
-
{id: 'acme.audit', routes: [{path: '/audit', parent: 'root', impl: 'audit'}]},
|
|
21
|
-
]);
|
|
22
|
-
|
|
23
|
-
expect(routes).toEqual([
|
|
24
|
-
{
|
|
25
|
-
path: '/projects',
|
|
26
|
-
parent: 'root',
|
|
27
|
-
override: true,
|
|
28
|
-
impl: 'override',
|
|
29
|
-
featureId: 'acme.insights',
|
|
30
|
-
},
|
|
31
|
-
{path: '/audit', parent: 'root', impl: 'audit', featureId: 'acme.audit'},
|
|
32
|
-
]);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test('names the path and both features for a collision', () => {
|
|
36
|
-
expect(() =>
|
|
37
|
-
composeRoutes([
|
|
38
|
-
base,
|
|
39
|
-
{id: 'acme.insights', routes: [{path: '/projects', parent: 'root', impl: 'next'}]},
|
|
40
|
-
]),
|
|
41
|
-
).toThrow(collisionMessage);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test('normalizes trailing slashes before collision checks', () => {
|
|
45
|
-
expect(() =>
|
|
46
|
-
composeRoutes([
|
|
47
|
-
base,
|
|
48
|
-
{
|
|
49
|
-
id: 'acme.insights',
|
|
50
|
-
routes: [{path: '/projects/', parent: 'root', impl: 'next'}],
|
|
51
|
-
},
|
|
52
|
-
]),
|
|
53
|
-
).toThrow(collisionMessage);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test('names the path and feature for a dangling override', () => {
|
|
57
|
-
expect(() =>
|
|
58
|
-
composeRoutes([
|
|
59
|
-
{
|
|
60
|
-
id: 'acme.insights',
|
|
61
|
-
routes: [{path: '/missing', parent: 'root', override: true, impl: 'next'}],
|
|
62
|
-
},
|
|
63
|
-
]),
|
|
64
|
-
).toThrow(danglingOverrideMessage);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test('names the path and both features for competing overrides', () => {
|
|
68
|
-
expect(() =>
|
|
69
|
-
composeRoutes([
|
|
70
|
-
base,
|
|
71
|
-
{
|
|
72
|
-
id: 'acme.one',
|
|
73
|
-
routes: [{path: '/projects', parent: 'root', override: true, impl: 'one'}],
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
id: 'acme.two',
|
|
77
|
-
routes: [{path: '/projects', parent: 'root', override: true, impl: 'two'}],
|
|
78
|
-
},
|
|
79
|
-
]),
|
|
80
|
-
).toThrow(competingOverridesMessage);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
test('rejects an override that changes the route anchor', () => {
|
|
84
|
-
expect(() =>
|
|
85
|
-
composeRoutes([
|
|
86
|
-
{
|
|
87
|
-
id: 'shipfox.projects',
|
|
88
|
-
routes: [
|
|
89
|
-
{
|
|
90
|
-
path: '/workspaces/$wid/projects',
|
|
91
|
-
parent: 'workspaceLayout',
|
|
92
|
-
impl: 'base',
|
|
93
|
-
},
|
|
94
|
-
],
|
|
95
|
-
},
|
|
96
|
-
{
|
|
97
|
-
id: 'acme.projects',
|
|
98
|
-
routes: [
|
|
99
|
-
{
|
|
100
|
-
path: '/workspaces/$wid/projects',
|
|
101
|
-
parent: 'root',
|
|
102
|
-
override: true,
|
|
103
|
-
impl: 'override',
|
|
104
|
-
},
|
|
105
|
-
],
|
|
106
|
-
},
|
|
107
|
-
]),
|
|
108
|
-
).toThrow(
|
|
109
|
-
'Route override for "/workspaces/$wid/projects" from feature "acme.projects" cannot change anchor from "workspaceLayout" in feature "shipfox.projects" to "root".',
|
|
110
|
-
);
|
|
111
|
-
});
|
|
112
|
-
});
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import type {ClientFeature, RouteContribution} from '#contract.js';
|
|
2
|
-
import {RouteCompositionError} from './errors.js';
|
|
3
|
-
import {normalizeRoutePath} from './normalize-route-path.js';
|
|
4
|
-
|
|
5
|
-
export interface ComposedRoute extends RouteContribution {
|
|
6
|
-
featureId: string;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function composeRoutes(features: readonly ClientFeature[]): ComposedRoute[] {
|
|
10
|
-
const routes = new Map<string, ComposedRoute>();
|
|
11
|
-
for (const feature of features) {
|
|
12
|
-
for (const contribution of feature.routes ?? []) {
|
|
13
|
-
const normalizedContribution = {
|
|
14
|
-
...contribution,
|
|
15
|
-
path: normalizeRoutePath(contribution.path),
|
|
16
|
-
};
|
|
17
|
-
const existing = routes.get(normalizedContribution.path);
|
|
18
|
-
if (!existing) {
|
|
19
|
-
if (normalizedContribution.override) {
|
|
20
|
-
throw new RouteCompositionError(
|
|
21
|
-
normalizedContribution.path,
|
|
22
|
-
`Route override for "${normalizedContribution.path}" from feature "${feature.id}" has no route to replace.`,
|
|
23
|
-
[feature.id],
|
|
24
|
-
);
|
|
25
|
-
}
|
|
26
|
-
routes.set(normalizedContribution.path, {
|
|
27
|
-
...normalizedContribution,
|
|
28
|
-
featureId: feature.id,
|
|
29
|
-
});
|
|
30
|
-
continue;
|
|
31
|
-
}
|
|
32
|
-
if (!normalizedContribution.override) {
|
|
33
|
-
throw new RouteCompositionError(
|
|
34
|
-
normalizedContribution.path,
|
|
35
|
-
`Route "${normalizedContribution.path}" is contributed by both features "${existing.featureId}" and "${feature.id}". Set override: true to replace it explicitly.`,
|
|
36
|
-
[existing.featureId, feature.id],
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
if (existing.override) {
|
|
40
|
-
throw new RouteCompositionError(
|
|
41
|
-
normalizedContribution.path,
|
|
42
|
-
`Route "${normalizedContribution.path}" has competing overrides from features "${existing.featureId}" and "${feature.id}".`,
|
|
43
|
-
[existing.featureId, feature.id],
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
if (existing.parent !== normalizedContribution.parent) {
|
|
47
|
-
throw new RouteCompositionError(
|
|
48
|
-
normalizedContribution.path,
|
|
49
|
-
`Route override for "${normalizedContribution.path}" from feature "${feature.id}" cannot change anchor from "${existing.parent}" in feature "${existing.featureId}" to "${normalizedContribution.parent}".`,
|
|
50
|
-
[existing.featureId, feature.id],
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
routes.set(normalizedContribution.path, {
|
|
54
|
-
...normalizedContribution,
|
|
55
|
-
featureId: feature.id,
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
return [...routes.values()];
|
|
60
|
-
}
|
package/src/compose/errors.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
abstract class CompositionError extends Error {
|
|
2
|
-
public readonly featureIds: readonly string[];
|
|
3
|
-
|
|
4
|
-
protected constructor(name: string, message: string, featureIds: readonly string[]) {
|
|
5
|
-
super(message);
|
|
6
|
-
this.name = name;
|
|
7
|
-
this.featureIds = featureIds;
|
|
8
|
-
}
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export class RouteCompositionError extends CompositionError {
|
|
12
|
-
public readonly path: string;
|
|
13
|
-
|
|
14
|
-
constructor(path: string, message: string, featureIds: readonly string[]) {
|
|
15
|
-
super('RouteCompositionError', message, featureIds);
|
|
16
|
-
this.path = path;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export class ProviderCompositionError extends CompositionError {
|
|
21
|
-
public readonly id: string;
|
|
22
|
-
|
|
23
|
-
constructor(id: string, message: string, featureIds: readonly string[]) {
|
|
24
|
-
super('ProviderCompositionError', message, featureIds);
|
|
25
|
-
this.id = id;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export class NavCompositionError extends CompositionError {
|
|
30
|
-
public readonly id: string;
|
|
31
|
-
|
|
32
|
-
constructor(id: string, message: string, featureIds: readonly string[]) {
|
|
33
|
-
super('NavCompositionError', message, featureIds);
|
|
34
|
-
this.id = id;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export class SettingsCompositionError extends CompositionError {
|
|
39
|
-
public readonly id: string;
|
|
40
|
-
|
|
41
|
-
constructor(id: string, message: string, featureIds: readonly string[]) {
|
|
42
|
-
super('SettingsCompositionError', message, featureIds);
|
|
43
|
-
this.id = id;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export class ConfigCompositionError extends CompositionError {
|
|
48
|
-
public readonly key: string;
|
|
49
|
-
|
|
50
|
-
constructor(key: string, message: string, featureIds: readonly string[]) {
|
|
51
|
-
super('ConfigCompositionError', message, featureIds);
|
|
52
|
-
this.key = key;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import {z} from 'zod';
|
|
2
|
-
import {mergeConfigShapes} from './merge-config.js';
|
|
3
|
-
|
|
4
|
-
const duplicateConfigMessage = /shared.*shipfox\.one.*acme\.two/u;
|
|
5
|
-
|
|
6
|
-
describe('mergeConfigShapes', () => {
|
|
7
|
-
test('names the key and both features for independent duplicate schemas', () => {
|
|
8
|
-
expect(() =>
|
|
9
|
-
mergeConfigShapes([
|
|
10
|
-
{id: 'shipfox.one', configShape: {shared: z.string()}},
|
|
11
|
-
{id: 'acme.two', configShape: {shared: z.string()}},
|
|
12
|
-
]),
|
|
13
|
-
).toThrow(duplicateConfigMessage);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
test('deduplicates the exact same schema instance', () => {
|
|
17
|
-
const shared = z.string();
|
|
18
|
-
|
|
19
|
-
expect(
|
|
20
|
-
mergeConfigShapes([
|
|
21
|
-
{id: 'shipfox.one', configShape: {shared}},
|
|
22
|
-
{id: 'acme.two', configShape: {shared}},
|
|
23
|
-
]).shared,
|
|
24
|
-
).toBe(shared);
|
|
25
|
-
});
|
|
26
|
-
});
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import type {z} from 'zod';
|
|
2
|
-
import {shellConfigShape} from '#config/shell-config.js';
|
|
3
|
-
import type {ClientFeature} from '#contract.js';
|
|
4
|
-
import {ConfigCompositionError} from './errors.js';
|
|
5
|
-
|
|
6
|
-
export function mergeConfigShapes(features: readonly ClientFeature[]): z.ZodRawShape {
|
|
7
|
-
const merged = {...shellConfigShape} as Record<string, z.ZodRawShape[string]>;
|
|
8
|
-
const owners = new Map<string, string>(
|
|
9
|
-
Object.keys(shellConfigShape).map((key) => [key, 'shell']),
|
|
10
|
-
);
|
|
11
|
-
for (const feature of features) {
|
|
12
|
-
for (const [key, schema] of Object.entries(feature.configShape ?? {})) {
|
|
13
|
-
const existing = merged[key];
|
|
14
|
-
if (existing && existing !== schema) {
|
|
15
|
-
const existingFeatureId = owners.get(key) ?? 'shell';
|
|
16
|
-
throw new ConfigCompositionError(
|
|
17
|
-
key,
|
|
18
|
-
`Config key "${key}" is contributed by both features "${existingFeatureId}" and "${feature.id}". Reuse the same schema instance to intentionally share it.`,
|
|
19
|
-
[existingFeatureId, feature.id],
|
|
20
|
-
);
|
|
21
|
-
}
|
|
22
|
-
merged[key] = schema;
|
|
23
|
-
owners.set(key, feature.id);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return merged as z.ZodRawShape;
|
|
27
|
-
}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import type {ClientFeature} from '#contract.js';
|
|
2
|
-
import {validateProviderIds} from './validate-providers.js';
|
|
3
|
-
|
|
4
|
-
const duplicateProviderMessage = /flags.*shipfox\.one.*acme\.two/u;
|
|
5
|
-
|
|
6
|
-
describe('validateProviderIds', () => {
|
|
7
|
-
test('names an id and both features for duplicate providers', () => {
|
|
8
|
-
expect(() =>
|
|
9
|
-
validateProviderIds([
|
|
10
|
-
{id: 'shipfox.one', providers: [{id: 'flags', Component: () => null}]},
|
|
11
|
-
{id: 'acme.two', providers: [{id: 'flags', Component: () => null}]},
|
|
12
|
-
]),
|
|
13
|
-
).toThrow(duplicateProviderMessage);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
test.each([
|
|
17
|
-
'theme',
|
|
18
|
-
'tooltip',
|
|
19
|
-
'query-client',
|
|
20
|
-
'jotai-store',
|
|
21
|
-
'auth',
|
|
22
|
-
'router',
|
|
23
|
-
'toaster',
|
|
24
|
-
])('rejects reserved provider id %s', (id) => {
|
|
25
|
-
const features: ClientFeature[] = [
|
|
26
|
-
{id: 'acme.feature', providers: [{id, Component: () => null}]},
|
|
27
|
-
];
|
|
28
|
-
|
|
29
|
-
expect(() => validateProviderIds(features)).toThrow(new RegExp(`${id}.*acme\\.feature`, 'u'));
|
|
30
|
-
});
|
|
31
|
-
});
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import type {ClientFeature} from '#contract.js';
|
|
2
|
-
import {ProviderCompositionError} from './errors.js';
|
|
3
|
-
|
|
4
|
-
const reservedProviderIds = new Set([
|
|
5
|
-
'theme',
|
|
6
|
-
'tooltip',
|
|
7
|
-
'query-client',
|
|
8
|
-
'jotai-store',
|
|
9
|
-
'auth',
|
|
10
|
-
'router',
|
|
11
|
-
'toaster',
|
|
12
|
-
]);
|
|
13
|
-
|
|
14
|
-
export function validateProviderIds(features: readonly ClientFeature[]): void {
|
|
15
|
-
const providers = new Map<string, string>();
|
|
16
|
-
for (const feature of features) {
|
|
17
|
-
for (const provider of feature.providers ?? []) {
|
|
18
|
-
if (reservedProviderIds.has(provider.id)) {
|
|
19
|
-
throw new ProviderCompositionError(
|
|
20
|
-
provider.id,
|
|
21
|
-
`Provider id "${provider.id}" in feature "${feature.id}" is reserved by the shell.`,
|
|
22
|
-
[feature.id],
|
|
23
|
-
);
|
|
24
|
-
}
|
|
25
|
-
const existingFeatureId = providers.get(provider.id);
|
|
26
|
-
if (existingFeatureId) {
|
|
27
|
-
throw new ProviderCompositionError(
|
|
28
|
-
provider.id,
|
|
29
|
-
`Provider id "${provider.id}" is contributed by both features "${existingFeatureId}" and "${feature.id}".`,
|
|
30
|
-
[existingFeatureId, feature.id],
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
providers.set(provider.id, feature.id);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import {validateNavigation, validateSettingsSections} from './validate-registries.js';
|
|
2
|
-
|
|
3
|
-
const duplicateNavigationMessage = /projects.*shipfox\.one.*acme\.two/u;
|
|
4
|
-
const missingNavigationMessage = /insights.*acme\.two.*\/insights/u;
|
|
5
|
-
const duplicateSettingsMessage = /members.*shipfox\.one.*acme\.two/u;
|
|
6
|
-
const missingSettingsMessage = /sso.*acme\.two.*\/workspaces\/\$wid\/settings\/sso/u;
|
|
7
|
-
|
|
8
|
-
describe('registry validation', () => {
|
|
9
|
-
test('names the id and both features for a duplicate navigation entry', () => {
|
|
10
|
-
expect(() =>
|
|
11
|
-
validateNavigation(
|
|
12
|
-
[
|
|
13
|
-
{
|
|
14
|
-
id: 'shipfox.one',
|
|
15
|
-
navigation: [{id: 'projects', scope: 'workspace', label: 'Projects', to: '/projects'}],
|
|
16
|
-
},
|
|
17
|
-
{
|
|
18
|
-
id: 'acme.two',
|
|
19
|
-
navigation: [{id: 'projects', scope: 'workspace', label: 'Projects', to: '/projects'}],
|
|
20
|
-
},
|
|
21
|
-
],
|
|
22
|
-
['/projects'],
|
|
23
|
-
),
|
|
24
|
-
).toThrow(duplicateNavigationMessage);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test('names the id, target, and feature for a missing navigation route', () => {
|
|
28
|
-
expect(() =>
|
|
29
|
-
validateNavigation(
|
|
30
|
-
[
|
|
31
|
-
{
|
|
32
|
-
id: 'acme.two',
|
|
33
|
-
navigation: [{id: 'insights', scope: 'workspace', label: 'Insights', to: '/insights'}],
|
|
34
|
-
},
|
|
35
|
-
],
|
|
36
|
-
[],
|
|
37
|
-
),
|
|
38
|
-
).toThrow(missingNavigationMessage);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test('normalizes navigation targets before checking route existence', () => {
|
|
42
|
-
expect(() =>
|
|
43
|
-
validateNavigation(
|
|
44
|
-
[
|
|
45
|
-
{
|
|
46
|
-
id: 'acme.two',
|
|
47
|
-
navigation: [{id: 'insights', scope: 'workspace', label: 'Insights', to: '/insights/'}],
|
|
48
|
-
},
|
|
49
|
-
],
|
|
50
|
-
['/insights'],
|
|
51
|
-
),
|
|
52
|
-
).not.toThrow();
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test('names the id and both features for a duplicate settings section', () => {
|
|
56
|
-
expect(() =>
|
|
57
|
-
validateSettingsSections(
|
|
58
|
-
[
|
|
59
|
-
{
|
|
60
|
-
id: 'shipfox.one',
|
|
61
|
-
settingsSections: [
|
|
62
|
-
{id: 'members', pathSegment: 'members', label: 'Members', icon: 'userLine'},
|
|
63
|
-
],
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
id: 'acme.two',
|
|
67
|
-
settingsSections: [
|
|
68
|
-
{id: 'members', pathSegment: 'members', label: 'Members', icon: 'userLine'},
|
|
69
|
-
],
|
|
70
|
-
},
|
|
71
|
-
],
|
|
72
|
-
['/workspaces/$wid/settings/members'],
|
|
73
|
-
),
|
|
74
|
-
).toThrow(duplicateSettingsMessage);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test('names the id, expected path, and feature for a missing settings route', () => {
|
|
78
|
-
expect(() =>
|
|
79
|
-
validateSettingsSections(
|
|
80
|
-
[
|
|
81
|
-
{
|
|
82
|
-
id: 'acme.two',
|
|
83
|
-
settingsSections: [
|
|
84
|
-
{id: 'sso', pathSegment: 'sso', label: 'Single sign-on', icon: 'userLine'},
|
|
85
|
-
],
|
|
86
|
-
},
|
|
87
|
-
],
|
|
88
|
-
[],
|
|
89
|
-
),
|
|
90
|
-
).toThrow(missingSettingsMessage);
|
|
91
|
-
});
|
|
92
|
-
});
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import type {ClientFeature} from '#contract.js';
|
|
2
|
-
import {NavCompositionError, SettingsCompositionError} from './errors.js';
|
|
3
|
-
import {normalizeRoutePath} from './normalize-route-path.js';
|
|
4
|
-
|
|
5
|
-
export function validateNavigation(
|
|
6
|
-
features: readonly ClientFeature[],
|
|
7
|
-
routePaths: Iterable<string>,
|
|
8
|
-
): void {
|
|
9
|
-
const routes = new Set([...routePaths].map(normalizeRoutePath));
|
|
10
|
-
const entries = new Map<string, string>();
|
|
11
|
-
for (const feature of features) {
|
|
12
|
-
for (const entry of feature.navigation ?? []) {
|
|
13
|
-
const existingFeatureId = entries.get(entry.id);
|
|
14
|
-
if (existingFeatureId) {
|
|
15
|
-
throw new NavCompositionError(
|
|
16
|
-
entry.id,
|
|
17
|
-
`Navigation entry "${entry.id}" is contributed by both features "${existingFeatureId}" and "${feature.id}".`,
|
|
18
|
-
[existingFeatureId, feature.id],
|
|
19
|
-
);
|
|
20
|
-
}
|
|
21
|
-
const target = normalizeRoutePath(entry.to);
|
|
22
|
-
if (!routes.has(target)) {
|
|
23
|
-
throw new NavCompositionError(
|
|
24
|
-
entry.id,
|
|
25
|
-
`Navigation entry "${entry.id}" in feature "${feature.id}" targets missing route "${target}".`,
|
|
26
|
-
[feature.id],
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
entries.set(entry.id, feature.id);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function validateSettingsSections(
|
|
35
|
-
features: readonly ClientFeature[],
|
|
36
|
-
routePaths: Iterable<string>,
|
|
37
|
-
): void {
|
|
38
|
-
const routes = new Set(routePaths);
|
|
39
|
-
const sections = new Map<string, string>();
|
|
40
|
-
for (const feature of features) {
|
|
41
|
-
for (const section of feature.settingsSections ?? []) {
|
|
42
|
-
const existingFeatureId = sections.get(section.id);
|
|
43
|
-
if (existingFeatureId) {
|
|
44
|
-
throw new SettingsCompositionError(
|
|
45
|
-
section.id,
|
|
46
|
-
`Settings section "${section.id}" is contributed by both features "${existingFeatureId}" and "${feature.id}".`,
|
|
47
|
-
[existingFeatureId, feature.id],
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
const path = `/workspaces/$wid/settings/${section.pathSegment}`;
|
|
51
|
-
if (!routes.has(path)) {
|
|
52
|
-
throw new SettingsCompositionError(
|
|
53
|
-
section.id,
|
|
54
|
-
`Settings section "${section.id}" in feature "${feature.id}" requires route "${path}".`,
|
|
55
|
-
[feature.id],
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
sections.set(section.id, feature.id);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import {getLoadedConfig} from '@shipfox/client-config';
|
|
2
|
-
import {act, screen} from '@testing-library/react';
|
|
3
|
-
import {z} from 'zod';
|
|
4
|
-
import {defineClientFeature} from '#contract.js';
|
|
5
|
-
import {composeClientApp} from '#runtime/compose-client-app.js';
|
|
6
|
-
|
|
7
|
-
describe('composed config', () => {
|
|
8
|
-
test('renders ConfigErrorScreen when a feature-required key is missing', async () => {
|
|
9
|
-
window.__SHIPFOX_CONFIG__ = undefined;
|
|
10
|
-
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
11
|
-
const element = document.createElement('div');
|
|
12
|
-
document.body.append(element);
|
|
13
|
-
const app = composeClientApp({
|
|
14
|
-
features: [defineClientFeature({id: 'acme.config', configShape: {ssoIssuer: z.url()}})],
|
|
15
|
-
router: {} as never,
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
await act(async () => app.mount(element));
|
|
19
|
-
|
|
20
|
-
expect(await screen.findByRole('heading', {name: 'Configuration error'})).toBeVisible();
|
|
21
|
-
expect(fetchSpy).not.toHaveBeenCalled();
|
|
22
|
-
element.remove();
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test('loads a present feature config value into the shared config', () => {
|
|
26
|
-
const shape = z.string();
|
|
27
|
-
window.__SHIPFOX_CONFIG__ = {SSO_ISSUER: 'https://id.example.test'};
|
|
28
|
-
const app = composeClientApp({
|
|
29
|
-
features: [defineClientFeature({id: 'acme.config', configShape: {ssoIssuer: shape}})],
|
|
30
|
-
router: {} as never,
|
|
31
|
-
});
|
|
32
|
-
expect(app).toBeDefined();
|
|
33
|
-
expect(getLoadedConfig<{ssoIssuer: string}>()).toMatchObject({
|
|
34
|
-
ssoIssuer: 'https://id.example.test',
|
|
35
|
-
});
|
|
36
|
-
});
|
|
37
|
-
});
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import {apiConfigShape} from '@shipfox/client-api';
|
|
2
|
-
import {z} from 'zod';
|
|
3
|
-
|
|
4
|
-
export const shellConfigShape = {
|
|
5
|
-
...apiConfigShape,
|
|
6
|
-
environment: z
|
|
7
|
-
.enum(['development', 'staging', 'production'])
|
|
8
|
-
.default('production')
|
|
9
|
-
.describe('Deployment environment name shown in diagnostics and reported to monitoring.'),
|
|
10
|
-
};
|
package/src/contract.ts
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import type {IconName} from '@shipfox/react-ui/icon';
|
|
2
|
-
import type {ComponentType, PropsWithChildren} from 'react';
|
|
3
|
-
import type {z} from 'zod';
|
|
4
|
-
|
|
5
|
-
export type AnchorId = 'root' | 'workspaceLayout' | 'projectLayout' | 'workspaceSettings';
|
|
6
|
-
|
|
7
|
-
export interface RouteContribution {
|
|
8
|
-
path: string;
|
|
9
|
-
parent: AnchorId;
|
|
10
|
-
override?: boolean;
|
|
11
|
-
impl: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface FeatureProvider {
|
|
15
|
-
id: string;
|
|
16
|
-
Component: ComponentType<PropsWithChildren>;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface NavTabEntry {
|
|
20
|
-
id: string;
|
|
21
|
-
scope: 'workspace' | 'project';
|
|
22
|
-
label: string;
|
|
23
|
-
to: string;
|
|
24
|
-
exact?: boolean;
|
|
25
|
-
order?: number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface SettingsSectionEntry {
|
|
29
|
-
id: string;
|
|
30
|
-
pathSegment: string;
|
|
31
|
-
label: string;
|
|
32
|
-
icon: IconName;
|
|
33
|
-
order?: number;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface ClientFeature<S extends z.ZodRawShape = z.ZodRawShape> {
|
|
37
|
-
id: string;
|
|
38
|
-
routes?: readonly RouteContribution[];
|
|
39
|
-
providers?: readonly FeatureProvider[];
|
|
40
|
-
navigation?: readonly NavTabEntry[];
|
|
41
|
-
settingsSections?: readonly SettingsSectionEntry[];
|
|
42
|
-
configShape?: S;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function defineClientFeature<const T extends ClientFeature>(feature: T): T {
|
|
46
|
-
return feature;
|
|
47
|
-
}
|