@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.
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/dist/components/main-layout.d.ts +11 -0
- package/dist/components/main-layout.d.ts.map +1 -0
- package/dist/components/main-layout.js +39 -0
- package/dist/components/main-layout.js.map +1 -0
- package/dist/components/nav-bar.d.ts +4 -0
- package/dist/components/nav-bar.d.ts.map +1 -0
- package/dist/components/nav-bar.js +62 -0
- package/dist/components/nav-bar.js.map +1 -0
- package/dist/components/nav-tabs.d.ts +6 -0
- package/dist/components/nav-tabs.d.ts.map +1 -0
- package/dist/components/nav-tabs.js +38 -0
- package/dist/components/nav-tabs.js.map +1 -0
- package/dist/components/not-found-page.d.ts +2 -0
- package/dist/components/not-found-page.d.ts.map +1 -0
- package/dist/components/not-found-page.js +25 -0
- package/dist/components/not-found-page.js.map +1 -0
- package/dist/components/settings-nav.d.ts +5 -0
- package/dist/components/settings-nav.d.ts.map +1 -0
- package/dist/components/settings-nav.js +45 -0
- package/dist/components/settings-nav.js.map +1 -0
- package/dist/components/user-menu.d.ts +2 -0
- package/dist/components/user-menu.d.ts.map +1 -0
- package/dist/components/user-menu.js +76 -0
- package/dist/components/user-menu.js.map +1 -0
- package/dist/components/workspace-crumb.d.ts +7 -0
- package/dist/components/workspace-crumb.d.ts.map +1 -0
- package/dist/components/workspace-crumb.js +54 -0
- package/dist/components/workspace-crumb.js.map +1 -0
- package/dist/components/workspace-switcher.d.ts +5 -0
- package/dist/components/workspace-switcher.d.ts.map +1 -0
- package/dist/components/workspace-switcher.js +90 -0
- package/dist/components/workspace-switcher.js.map +1 -0
- package/dist/compose/compose-routes.d.ts +6 -0
- package/dist/compose/compose-routes.d.ts.map +1 -0
- package/dist/compose/compose-routes.js +53 -0
- package/dist/compose/compose-routes.js.map +1 -0
- package/dist/compose/errors.d.ts +26 -0
- package/dist/compose/errors.d.ts.map +1 -0
- package/dist/compose/errors.js +39 -0
- package/dist/compose/errors.js.map +1 -0
- package/dist/compose/merge-config.d.ts +4 -0
- package/dist/compose/merge-config.d.ts.map +1 -0
- package/dist/compose/merge-config.js +28 -0
- package/dist/compose/merge-config.js.map +1 -0
- package/dist/compose/normalize-route-path.d.ts +2 -0
- package/dist/compose/normalize-route-path.d.ts.map +1 -0
- package/dist/compose/normalize-route-path.js +7 -0
- package/dist/compose/normalize-route-path.js.map +1 -0
- package/dist/compose/validate-providers.d.ts +3 -0
- package/dist/compose/validate-providers.d.ts.map +1 -0
- package/dist/compose/validate-providers.js +32 -0
- package/dist/compose/validate-providers.js.map +1 -0
- package/dist/compose/validate-registries.d.ts +4 -0
- package/dist/compose/validate-registries.d.ts.map +1 -0
- package/dist/compose/validate-registries.js +50 -0
- package/dist/compose/validate-registries.js.map +1 -0
- package/dist/config/shell-config.d.ts +10 -0
- package/dist/config/shell-config.d.ts.map +1 -0
- package/dist/config/shell-config.js +12 -0
- package/dist/config/shell-config.js.map +1 -0
- package/dist/contract.d.ts +39 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +5 -0
- package/dist/contract.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/active-workspace.d.ts +4 -0
- package/dist/runtime/active-workspace.d.ts.map +1 -0
- package/dist/runtime/active-workspace.js +15 -0
- package/dist/runtime/active-workspace.js.map +1 -0
- package/dist/runtime/anchor-paths.d.ts +9 -0
- package/dist/runtime/anchor-paths.d.ts.map +1 -0
- package/dist/runtime/anchor-paths.js +18 -0
- package/dist/runtime/anchor-paths.js.map +1 -0
- package/dist/runtime/anchors.d.ts +19 -0
- package/dist/runtime/anchors.d.ts.map +1 -0
- package/dist/runtime/anchors.js +125 -0
- package/dist/runtime/anchors.js.map +1 -0
- package/dist/runtime/assemble-route-tree.d.ts +10 -0
- package/dist/runtime/assemble-route-tree.d.ts.map +1 -0
- package/dist/runtime/assemble-route-tree.js +30 -0
- package/dist/runtime/assemble-route-tree.js.map +1 -0
- package/dist/runtime/auth.d.ts +50 -0
- package/dist/runtime/auth.d.ts.map +1 -0
- package/dist/runtime/auth.js +194 -0
- package/dist/runtime/auth.js.map +1 -0
- package/dist/runtime/chrome-context.d.ts +10 -0
- package/dist/runtime/chrome-context.d.ts.map +1 -0
- package/dist/runtime/chrome-context.js +16 -0
- package/dist/runtime/chrome-context.js.map +1 -0
- package/dist/runtime/compose-client-app.d.ts +13 -0
- package/dist/runtime/compose-client-app.d.ts.map +1 -0
- package/dist/runtime/compose-client-app.js +93 -0
- package/dist/runtime/compose-client-app.js.map +1 -0
- package/dist/runtime/define-route.d.ts +19 -0
- package/dist/runtime/define-route.d.ts.map +1 -0
- package/dist/runtime/define-route.js +12 -0
- package/dist/runtime/define-route.js.map +1 -0
- package/dist/runtime/index.d.ts +20 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +21 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/runtime/last-workspace.d.ts +4 -0
- package/dist/runtime/last-workspace.d.ts.map +1 -0
- package/dist/runtime/last-workspace.js +20 -0
- package/dist/runtime/last-workspace.js.map +1 -0
- package/dist/runtime/nav-order.d.ts +7 -0
- package/dist/runtime/nav-order.d.ts.map +1 -0
- package/dist/runtime/nav-order.js +8 -0
- package/dist/runtime/nav-order.js.map +1 -0
- package/dist/runtime/provider-stack.d.ts +14 -0
- package/dist/runtime/provider-stack.d.ts.map +1 -0
- package/dist/runtime/provider-stack.js +28 -0
- package/dist/runtime/provider-stack.js.map +1 -0
- package/dist/runtime/registries.d.ts +4 -0
- package/dist/runtime/registries.d.ts.map +1 -0
- package/dist/runtime/registries.js +23 -0
- package/dist/runtime/registries.js.map +1 -0
- package/dist/runtime/router-context.d.ts +9 -0
- package/dist/runtime/router-context.d.ts.map +1 -0
- package/dist/runtime/router-context.js +3 -0
- package/dist/runtime/router-context.js.map +1 -0
- package/dist/runtime/workspace-setup.d.ts +18 -0
- package/dist/runtime/workspace-setup.d.ts.map +1 -0
- package/dist/runtime/workspace-setup.js +64 -0
- package/dist/runtime/workspace-setup.js.map +1 -0
- package/dist/testing/index.d.ts +17 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +35 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/dist/vite/evaluate-features.d.ts +8 -0
- package/dist/vite/evaluate-features.d.ts.map +1 -0
- package/dist/vite/evaluate-features.js +80 -0
- package/dist/vite/evaluate-features.js.map +1 -0
- package/dist/vite/generate.d.ts +9 -0
- package/dist/vite/generate.d.ts.map +1 -0
- package/dist/vite/generate.js +80 -0
- package/dist/vite/generate.js.map +1 -0
- package/dist/vite/index.d.ts +2 -0
- package/dist/vite/index.d.ts.map +1 -0
- package/dist/vite/index.js +3 -0
- package/dist/vite/index.js.map +1 -0
- package/dist/vite/plugin.d.ts +7 -0
- package/dist/vite/plugin.d.ts.map +1 -0
- package/dist/vite/plugin.js +77 -0
- package/dist/vite/plugin.js.map +1 -0
- package/package.json +113 -0
- package/src/components/main-layout.tsx +45 -0
- package/src/components/nav-bar.tsx +41 -0
- package/src/components/nav-tabs.test.tsx +60 -0
- package/src/components/nav-tabs.tsx +47 -0
- package/src/components/not-found-page.tsx +14 -0
- package/src/components/settings-nav.tsx +35 -0
- package/src/components/user-menu.tsx +59 -0
- package/src/components/workspace-crumb.tsx +43 -0
- package/src/components/workspace-switcher.tsx +75 -0
- package/src/compose/compose-routes.test.ts +112 -0
- package/src/compose/compose-routes.ts +60 -0
- package/src/compose/errors.ts +54 -0
- package/src/compose/merge-config.test.ts +26 -0
- package/src/compose/merge-config.ts +27 -0
- package/src/compose/normalize-route-path.ts +6 -0
- package/src/compose/validate-providers.test.ts +31 -0
- package/src/compose/validate-providers.ts +36 -0
- package/src/compose/validate-registries.test.ts +92 -0
- package/src/compose/validate-registries.ts +61 -0
- package/src/config/config-screen.test.tsx +37 -0
- package/src/config/shell-config.ts +10 -0
- package/src/contract.ts +47 -0
- package/src/index.ts +1 -0
- package/src/runtime/active-workspace.ts +13 -0
- package/src/runtime/anchor-paths.ts +18 -0
- package/src/runtime/anchors.tsx +106 -0
- package/src/runtime/assemble-route-tree.ts +42 -0
- package/src/runtime/auth.tsx +213 -0
- package/src/runtime/chrome-context.tsx +22 -0
- package/src/runtime/compose-client-app.tsx +122 -0
- package/src/runtime/define-route.test.ts +15 -0
- package/src/runtime/define-route.ts +33 -0
- package/src/runtime/index.ts +19 -0
- package/src/runtime/last-workspace.ts +22 -0
- package/src/runtime/nav-order.ts +1 -0
- package/src/runtime/provider-stack.tsx +39 -0
- package/src/runtime/providers.test.tsx +51 -0
- package/src/runtime/registries.test.tsx +100 -0
- package/src/runtime/registries.ts +42 -0
- package/src/runtime/router-context.ts +9 -0
- package/src/runtime/routes.test.tsx +48 -0
- package/src/runtime/workspace-setup.tsx +61 -0
- package/src/testing/index.test.tsx +57 -0
- package/src/testing/index.tsx +51 -0
- package/src/vite/evaluate-features.test.ts +34 -0
- package/src/vite/evaluate-features.ts +93 -0
- package/src/vite/generate.test.ts +22 -0
- package/src/vite/generate.ts +99 -0
- package/src/vite/generated-router.test.tsx +58 -0
- package/src/vite/index.ts +1 -0
- package/src/vite/plugin.test.ts +181 -0
- package/src/vite/plugin.ts +114 -0
- package/src/vite/validate.test.ts +166 -0
- package/test/default-route-impl.tsx +3 -0
- package/test/external/FINDINGS.md +53 -0
- package/test/external/README.md +39 -0
- package/test/external/fixture/index.html +12 -0
- package/test/external/fixture/package.json +32 -0
- package/test/external/fixture/src/app.fixture.tsx +72 -0
- package/test/external/fixture/src/features/override-impl.tsx +12 -0
- package/test/external/fixture/src/features/override.tsx +20 -0
- package/test/external/fixture/src/features.collision.ts +15 -0
- package/test/external/fixture/src/features.ts +4 -0
- package/test/external/fixture/src/link-typecheck.tsx +10 -0
- package/test/external/fixture/src/main.tsx +4 -0
- package/test/external/fixture/tsconfig.json +15 -0
- package/test/external/fixture/vite.config.ts +10 -0
- package/test/external/fixture/vitest.config.ts +15 -0
- package/test/external/toy-feature/CHANGELOG.md +15 -0
- package/test/external/toy-feature/node_modules/.bin/shipfox-biome-check +43 -0
- package/test/external/toy-feature/node_modules/.bin/shipfox-biome-format +43 -0
- package/test/external/toy-feature/node_modules/.bin/shipfox-biome-lint +43 -0
- package/test/external/toy-feature/node_modules/.bin/shipfox-swc +43 -0
- package/test/external/toy-feature/node_modules/.bin/shipfox-tsc-check +43 -0
- package/test/external/toy-feature/node_modules/.bin/shipfox-tsc-emit +43 -0
- package/test/external/toy-feature/package.json +57 -0
- package/test/external/toy-feature/src/config.ts +5 -0
- package/test/external/toy-feature/src/index.ts +64 -0
- package/test/external/toy-feature/src/provider.tsx +33 -0
- package/test/external/toy-feature/src/routes/insights.tsx +7 -0
- package/test/external/toy-feature/src/routes/settings.tsx +7 -0
- package/test/external/toy-feature/tsconfig.build.json +5 -0
- package/test/external/toy-feature/tsconfig.json +1 -0
- package/test/external/verify.mjs +318 -0
- package/test/fixtures/features.ts +56 -0
- package/test/named-route-impl.tsx +3 -0
- package/test/not-route-impl.ts +1 -0
- package/test/render.tsx +67 -0
- package/test/search-route-impl.tsx +7 -0
- package/test/setup.ts +3 -0
- package/test/typecheck/shipfox-app.gen.ts +85 -0
- package/test/typecheck/types.tsx +18 -0
- package/tsconfig.build.json +10 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +1 -0
- package/tsconfig.test.json +12 -0
- package/vitest.config.ts +22 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {Link, useParams} from '@tanstack/react-router';
|
|
2
|
+
import {useReducedMotion} from 'framer-motion';
|
|
3
|
+
import type {NavTabEntry} from '#contract.js';
|
|
4
|
+
|
|
5
|
+
export function NavTabs({
|
|
6
|
+
entries,
|
|
7
|
+
scope,
|
|
8
|
+
}: {
|
|
9
|
+
entries: readonly NavTabEntry[];
|
|
10
|
+
scope: NavTabEntry['scope'];
|
|
11
|
+
}) {
|
|
12
|
+
const params = useParams({strict: false}) as {wid?: string; pid?: string};
|
|
13
|
+
const reduced = useReducedMotion();
|
|
14
|
+
const tabs = entries.filter((entry) => entry.scope === scope);
|
|
15
|
+
const tabClassName = `h-40 inline-flex items-center px-4 text-sm font-medium transition-colors ${reduced ? '' : 'transition-[border-color]'}`;
|
|
16
|
+
const activeProps = {
|
|
17
|
+
className: 'border-b-2 border-border-highlights-interactive text-foreground-neutral-base',
|
|
18
|
+
'aria-selected': 'true' as const,
|
|
19
|
+
};
|
|
20
|
+
const inactiveProps = {
|
|
21
|
+
className: 'border-b-2 border-transparent text-foreground-neutral-muted',
|
|
22
|
+
'aria-selected': 'false' as const,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
role="tablist"
|
|
28
|
+
aria-label={`${scope === 'project' ? 'Project' : 'Workspace'} sections`}
|
|
29
|
+
className="sticky top-56 z-20 h-40 px-16 flex items-end gap-12 bg-background-subtle-base border-b border-border-neutral-base"
|
|
30
|
+
>
|
|
31
|
+
{tabs.map((entry) => (
|
|
32
|
+
<Link
|
|
33
|
+
key={entry.id}
|
|
34
|
+
to={entry.to as never}
|
|
35
|
+
params={params as never}
|
|
36
|
+
role="tab"
|
|
37
|
+
activeOptions={{exact: entry.exact ?? false}}
|
|
38
|
+
activeProps={activeProps}
|
|
39
|
+
inactiveProps={inactiveProps}
|
|
40
|
+
className={tabClassName}
|
|
41
|
+
>
|
|
42
|
+
{entry.label}
|
|
43
|
+
</Link>
|
|
44
|
+
))}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import {Header, Text} from '@shipfox/react-ui/typography';
|
|
2
|
+
import {Link} from '@tanstack/react-router';
|
|
3
|
+
|
|
4
|
+
export function NotFoundPage() {
|
|
5
|
+
return (
|
|
6
|
+
<main className="mx-auto max-w-[960px] px-24 py-48">
|
|
7
|
+
<Header variant="h1">Page not found</Header>
|
|
8
|
+
<Text size="md" className="text-foreground-neutral-muted">
|
|
9
|
+
This Shipfox page does not exist.
|
|
10
|
+
</Text>
|
|
11
|
+
<Link to="/">Go home</Link>
|
|
12
|
+
</main>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {Button} from '@shipfox/react-ui/button';
|
|
2
|
+
import {Icon} from '@shipfox/react-ui/icon';
|
|
3
|
+
import {Link, useMatchRoute, useParams} from '@tanstack/react-router';
|
|
4
|
+
import type {SettingsSectionEntry} from '#contract.js';
|
|
5
|
+
|
|
6
|
+
export function SettingsNav({entries}: {entries: readonly SettingsSectionEntry[]}) {
|
|
7
|
+
const params = useParams({strict: false}) as {wid?: string};
|
|
8
|
+
const matchRoute = useMatchRoute();
|
|
9
|
+
if (!params.wid) return null;
|
|
10
|
+
return (
|
|
11
|
+
<nav aria-label="Workspace settings" className="flex flex-col gap-4">
|
|
12
|
+
{entries.map((entry) => {
|
|
13
|
+
const to = `/workspaces/$wid/settings/${entry.pathSegment}`;
|
|
14
|
+
const active = Boolean(matchRoute({to: to as never, params: {wid: params.wid} as never}));
|
|
15
|
+
return (
|
|
16
|
+
<Button
|
|
17
|
+
key={entry.id}
|
|
18
|
+
asChild
|
|
19
|
+
variant={active ? 'secondary' : 'transparent'}
|
|
20
|
+
className="w-full justify-start"
|
|
21
|
+
>
|
|
22
|
+
<Link
|
|
23
|
+
to={to as never}
|
|
24
|
+
params={{wid: params.wid} as never}
|
|
25
|
+
aria-current={active ? 'page' : undefined}
|
|
26
|
+
>
|
|
27
|
+
<Icon name={entry.icon} className="size-16" />
|
|
28
|
+
{entry.label}
|
|
29
|
+
</Link>
|
|
30
|
+
</Button>
|
|
31
|
+
);
|
|
32
|
+
})}
|
|
33
|
+
</nav>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {Avatar} from '@shipfox/react-ui/avatar';
|
|
2
|
+
import {
|
|
3
|
+
DropdownMenu,
|
|
4
|
+
DropdownMenuContent,
|
|
5
|
+
DropdownMenuItem,
|
|
6
|
+
DropdownMenuLabel,
|
|
7
|
+
DropdownMenuRadioGroup,
|
|
8
|
+
DropdownMenuRadioItem,
|
|
9
|
+
DropdownMenuSeparator,
|
|
10
|
+
DropdownMenuTrigger,
|
|
11
|
+
} from '@shipfox/react-ui/dropdown-menu';
|
|
12
|
+
import {useTheme} from '@shipfox/react-ui/hooks';
|
|
13
|
+
import type {Theme} from '@shipfox/react-ui/theme';
|
|
14
|
+
import {Link} from '@tanstack/react-router';
|
|
15
|
+
import {useAuthState} from '#runtime/auth.js';
|
|
16
|
+
|
|
17
|
+
const themeOptions: Array<{value: Theme; label: string}> = [
|
|
18
|
+
{value: 'light', label: 'Light'},
|
|
19
|
+
{value: 'dark', label: 'Dark'},
|
|
20
|
+
{value: 'system', label: 'System'},
|
|
21
|
+
];
|
|
22
|
+
export function UserMenu() {
|
|
23
|
+
const {user} = useAuthState();
|
|
24
|
+
const {theme, setTheme} = useTheme();
|
|
25
|
+
const email = user?.email ?? '';
|
|
26
|
+
return (
|
|
27
|
+
<DropdownMenu>
|
|
28
|
+
<DropdownMenuTrigger asChild>
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
aria-label="User menu"
|
|
32
|
+
className="rounded-full focus-visible:outline-none focus-visible:shadow-button-neutral-focus"
|
|
33
|
+
>
|
|
34
|
+
<Avatar size="sm" content="letters" fallback={email} />
|
|
35
|
+
</button>
|
|
36
|
+
</DropdownMenuTrigger>
|
|
37
|
+
<DropdownMenuContent align="end" sideOffset={8} className="min-w-[220px]">
|
|
38
|
+
<DropdownMenuLabel className="text-xs text-foreground-neutral-muted truncate">
|
|
39
|
+
{email}
|
|
40
|
+
</DropdownMenuLabel>
|
|
41
|
+
<DropdownMenuSeparator />
|
|
42
|
+
<DropdownMenuLabel className="text-xs text-foreground-neutral-muted">
|
|
43
|
+
Theme
|
|
44
|
+
</DropdownMenuLabel>
|
|
45
|
+
<DropdownMenuRadioGroup value={theme} onValueChange={(value) => setTheme(value as Theme)}>
|
|
46
|
+
{themeOptions.map((option) => (
|
|
47
|
+
<DropdownMenuRadioItem key={option.value} value={option.value}>
|
|
48
|
+
{option.label}
|
|
49
|
+
</DropdownMenuRadioItem>
|
|
50
|
+
))}
|
|
51
|
+
</DropdownMenuRadioGroup>
|
|
52
|
+
<DropdownMenuSeparator />
|
|
53
|
+
<DropdownMenuItem asChild>
|
|
54
|
+
<Link to={'/auth/logout' as never}>Logout</Link>
|
|
55
|
+
</DropdownMenuItem>
|
|
56
|
+
</DropdownMenuContent>
|
|
57
|
+
</DropdownMenu>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {Icon} from '@shipfox/react-ui/icon';
|
|
2
|
+
import {Popover, PopoverContent, PopoverTrigger} from '@shipfox/react-ui/popover';
|
|
3
|
+
import {Link} from '@tanstack/react-router';
|
|
4
|
+
import {useState} from 'react';
|
|
5
|
+
import type {Workspace} from '#runtime/auth.js';
|
|
6
|
+
import {WorkspaceSwitcher} from './workspace-switcher.js';
|
|
7
|
+
|
|
8
|
+
export interface WorkspaceCrumbProps {
|
|
9
|
+
workspace: Workspace;
|
|
10
|
+
compact?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function WorkspaceCrumb({workspace, compact = false}: WorkspaceCrumbProps) {
|
|
14
|
+
const [open, setOpen] = useState(false);
|
|
15
|
+
return (
|
|
16
|
+
<div className="flex items-center">
|
|
17
|
+
<Link
|
|
18
|
+
to="/workspaces/$wid"
|
|
19
|
+
params={{wid: workspace.id}}
|
|
20
|
+
aria-current="page"
|
|
21
|
+
className={`inline-block text-md font-medium text-foreground-neutral-base px-6 py-4 rounded-6 hover:bg-background-components-hover transition-colors truncate ${compact ? 'max-w-[120px] sm:max-w-[200px]' : 'max-w-[200px]'}`}
|
|
22
|
+
>
|
|
23
|
+
{workspace.name}
|
|
24
|
+
</Link>
|
|
25
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
26
|
+
<PopoverTrigger asChild>
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
aria-label="Switch workspace"
|
|
30
|
+
aria-haspopup="listbox"
|
|
31
|
+
aria-expanded={open}
|
|
32
|
+
className="ml-2 grid place-items-center size-24 rounded-4 text-foreground-neutral-muted hover:bg-background-components-hover hover:text-foreground-neutral-base transition-colors"
|
|
33
|
+
>
|
|
34
|
+
<Icon name="arrowDownSLine" className="size-16" />
|
|
35
|
+
</button>
|
|
36
|
+
</PopoverTrigger>
|
|
37
|
+
<PopoverContent className="w-[280px] p-0" align="start" sideOffset={8}>
|
|
38
|
+
<WorkspaceSwitcher activeWorkspaceId={workspace.id} onSelect={() => setOpen(false)} />
|
|
39
|
+
</PopoverContent>
|
|
40
|
+
</Popover>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
}
|