create-keboola-app 0.1.1 → 0.2.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/CHANGELOG.md +54 -0
- package/README.md +16 -11
- package/dist/{blocks-DYU5Ke29.js → blocks-BdJaEmz1.js} +36 -20
- package/dist/cli.js +29 -52
- package/dist/extract.js +9 -14
- package/dist/{regions-B3r5LhXl.js → regions-BNl4QsPP.js} +12 -22
- package/dist/{sanitize-CKs8pAzZ.js → sanitize-DVFBkv8b.js} +14 -24
- package/dist/sanitize.js +1 -2
- package/dist/template/AGENTS.md +20 -6
- package/dist/template/README.md +5 -0
- package/dist/template/oxlint.config.ts +10 -14
- package/dist/template/package.json +21 -20
- package/dist/template/src/auth/AuthScreen.test.tsx +44 -0
- package/dist/template/src/auth/AuthScreen.tsx +43 -0
- package/dist/template/src/auth/session.test.tsx +64 -0
- package/dist/template/src/auth/session.ts +42 -0
- package/dist/template/src/auth/states.tsx +70 -0
- package/dist/template/src/components/AppChrome.tsx +26 -74
- package/dist/template/src/components/SelectionFloater.tsx +10 -3
- package/dist/template/src/components/SidebarModeMenu.tsx +8 -13
- package/dist/template/src/nav.ts +6 -11
- package/dist/template/src/{routes/-fixtures.ts → overview-fixtures.ts} +6 -18
- package/dist/template/src/{routes/-hooks.ts → overview-hooks.ts} +1 -1
- package/dist/template/src/overview-sections.tsx +4 -1
- package/dist/template/src/recipes/Chart.test.tsx +1 -3
- package/dist/template/src/recipes/Chart.tsx +4 -11
- package/dist/template/src/recipes/DataGrid.test.tsx +1 -1
- package/dist/template/src/recipes/DataGrid.tsx +7 -21
- package/dist/template/src/routes/__root.tsx +21 -0
- package/dist/template/src/routes/{-index.test.tsx → index.test.tsx} +1 -1
- package/dist/template/src/routes/index.tsx +2 -3
- package/dist/template/src/routes/signed-out.test.tsx +56 -0
- package/dist/template/src/routes/signed-out.tsx +31 -0
- package/dist/template/src/routes/tokens/$id.tsx +2 -2
- package/dist/template/src/routes/tokens/{-index.test.tsx → index.test.tsx} +2 -2
- package/dist/template/src/routes/tokens/index.tsx +2 -3
- package/dist/template/src/routes/tokens/new.tsx +2 -2
- package/dist/template/src/sections/kpi.tsx +3 -7
- package/dist/template/src/slices/assistant.ts +3 -9
- package/dist/template/src/slices/shell.ts +4 -11
- package/dist/template/src/store.ts +9 -24
- package/dist/template/src/{routes/tokens/-fixtures.test.ts → tokens/fixtures.test.ts} +1 -1
- package/dist/template/src/{routes/tokens/-fixtures.ts → tokens/fixtures.ts} +1 -1
- package/dist/template/src/{routes/tokens/-hooks.ts → tokens/hooks.ts} +2 -2
- package/dist/template/src/{routes/tokens/-nav.ts → tokens/nav.ts} +1 -1
- package/dist/template/vite.config.ts +5 -0
- package/dist/template/vitest.config.ts +2 -5
- package/package.json +13 -12
- /package/dist/template/src/{routes/tokens/-types.ts → tokens/types.ts} +0 -0
package/dist/template/AGENTS.md
CHANGED
|
@@ -60,13 +60,14 @@ src/
|
|
|
60
60
|
routeTree.gen.ts # Generated from src/routes/** by the Vite plugin
|
|
61
61
|
nav.ts # The rail's destinations and the active-match rule
|
|
62
62
|
overview-sections.tsx # The ordered registry the overview renders
|
|
63
|
+
overview-hooks.ts # useOverview, AbortSignal threaded
|
|
64
|
+
overview-fixtures.ts # In-memory overview stub — swap for a real client call
|
|
63
65
|
store.ts # The Zustand store: storage key, partialize, merge guard
|
|
64
66
|
slices/shell.ts # Sidebar mode + help visibility
|
|
65
67
|
components/AppChrome.tsx # AppShell + Navbar + Sidebar
|
|
66
|
-
routes/__root.tsx # Root layout
|
|
68
|
+
routes/__root.tsx # Root layout — and which routes get the app chrome
|
|
67
69
|
routes/index.tsx # / — the overview
|
|
68
|
-
routes
|
|
69
|
-
routes/-fixtures.ts # In-memory overview stub — swap for a real client call
|
|
70
|
+
routes/index.test.tsx # The overview's branches — a route's test sits with the route
|
|
70
71
|
```
|
|
71
72
|
<!-- #region block:kpi -->
|
|
72
73
|
- `components/KpiCard.tsx`, `sections/kpi.tsx` — the KPI row.
|
|
@@ -78,21 +79,34 @@ src/
|
|
|
78
79
|
- `recipes/DataGrid.tsx`, `sections/grid.tsx` — the editable grid.
|
|
79
80
|
<!-- #endregion block:grid -->
|
|
80
81
|
<!-- #region block:crud -->
|
|
81
|
-
- `routes/tokens/**`
|
|
82
|
-
|
|
83
|
-
the fixture rows are labels only.
|
|
82
|
+
- `routes/tokens/**` (`index.tsx`, `$id.tsx`, `new.tsx`) and `tokens/**` (`hooks.ts`,
|
|
83
|
+
`fixtures.ts`, `types.ts`, `nav.ts`) — the CRUD slice. **The entity is a placeholder.**
|
|
84
|
+
Rename it to yours; the fixture rows are labels only.
|
|
84
85
|
<!-- #endregion block:crud -->
|
|
85
86
|
<!-- #region block:assistant -->
|
|
86
87
|
- `components/AssistantPanel.tsx`, `AssistantToggle.tsx`, `HomePrompt.tsx`,
|
|
87
88
|
`SelectionFloater.tsx`, `slices/assistant.ts` — the assistant. `slices/assistant.ts`
|
|
88
89
|
answers with a canned reply; swap that branch for a real request.
|
|
89
90
|
<!-- #endregion block:assistant -->
|
|
91
|
+
<!-- #region block:auth -->
|
|
92
|
+
- `auth/AuthScreen.tsx`, `auth/states.tsx`, `auth/session.ts`, `routes/signed-out.tsx` — the
|
|
93
|
+
signed-out screen. **Presentational: it reaches no network.** Wiring it up is two edits —
|
|
94
|
+
the form's `onSubmit` in `states.tsx`, and where the state comes from. `routes/__root.tsx`
|
|
95
|
+
drops the app chrome for this route, so a visitor with no session sees no rail; a real app
|
|
96
|
+
usually goes further and renders the screen ahead of the router, which `AuthScreen` supports
|
|
97
|
+
by carrying its own `tailwindcss-preflight` scope.
|
|
98
|
+
<!-- #endregion block:auth -->
|
|
90
99
|
|
|
91
100
|
## Adding a route
|
|
92
101
|
|
|
93
102
|
Drop a file in `src/routes/`. The Vite plugin regenerates `routeTree.gen.ts` on the next
|
|
94
103
|
`pnpm dev` or `pnpm build`; nothing needs registering by hand.
|
|
95
104
|
|
|
105
|
+
`src/routes/` holds route files and their tests, nothing else. A route's hooks, fixtures,
|
|
106
|
+
types and components live in a domain directory beside it (`src/reports/`), the way
|
|
107
|
+
`src/auth/` and `src/tokens/` do — a non-route module under `src/routes/` is a route the
|
|
108
|
+
generator has to be told to ignore.
|
|
109
|
+
|
|
96
110
|
```tsx
|
|
97
111
|
import { createFileRoute } from '@tanstack/react-router';
|
|
98
112
|
|
package/dist/template/README.md
CHANGED
|
@@ -47,6 +47,11 @@ fails for a missing credential is a worse welcome than an opt-in gate.
|
|
|
47
47
|
- A docked assistant rail on every route, a composer on the overview, and
|
|
48
48
|
explain-on-selection. All three share one transcript.
|
|
49
49
|
<!-- #endregion block:assistant -->
|
|
50
|
+
<!-- #region block:auth -->
|
|
51
|
+
- **`/signed-out`** — the pre-project screen, rendered without the app chrome: brand lockup,
|
|
52
|
+
one centred card, a colour-scheme control, and the restoring, expired and unreachable
|
|
53
|
+
states. Presentational: the form submits nowhere, and `?state=` stands in for your session.
|
|
54
|
+
<!-- #endregion block:auth -->
|
|
50
55
|
|
|
51
56
|
Every data call is an in-memory fixture that honours an `AbortSignal`. Replacing a fixture with
|
|
52
57
|
a real `@keboola/api-client` call is a drop-in — the query hooks already thread `signal`.
|
|
@@ -1,41 +1,37 @@
|
|
|
1
1
|
import { defineConfig } from 'oxlint';
|
|
2
2
|
|
|
3
3
|
import keboolaShared from '@keboola/oxlint-config';
|
|
4
|
+
import keboolaShadcn, { shadcnSettings } from '@keboola/oxlint-config/shadcn';
|
|
4
5
|
import keboolaTailwind from '@keboola/oxlint-config/tailwind';
|
|
5
6
|
|
|
6
7
|
export default defineConfig({
|
|
7
|
-
|
|
8
|
-
// (eslint-plugin-better-tailwindcss). It carries the plugin + rules; the
|
|
9
|
-
// `entryPoint` below is required — oxlint only reads `settings` from the
|
|
10
|
-
// root config, so it can't live inside the shared fragment.
|
|
11
|
-
extends: [keboolaShared, keboolaTailwind],
|
|
8
|
+
extends: [keboolaShared, keboolaTailwind, keboolaShadcn],
|
|
12
9
|
env: {
|
|
13
10
|
node: true,
|
|
14
11
|
},
|
|
15
12
|
settings: {
|
|
16
13
|
'better-tailwindcss': {
|
|
17
|
-
//
|
|
14
|
+
// The file that `@import`s "tailwindcss".
|
|
18
15
|
entryPoint: 'src/index.css',
|
|
19
16
|
},
|
|
17
|
+
shadcn: shadcnSettings,
|
|
20
18
|
},
|
|
21
19
|
jsPlugins: [
|
|
22
|
-
//
|
|
23
|
-
// IDs read `design/<rule>` in lint output.
|
|
20
|
+
// Aliased to `design`, so rule IDs read `design/<rule>`.
|
|
24
21
|
{ name: 'design', specifier: '@keboola/eslint-plugin-design' },
|
|
25
22
|
],
|
|
26
23
|
rules: {
|
|
27
|
-
|
|
28
|
-
//
|
|
29
|
-
// packages/eslint-plugin-design/README.md.
|
|
24
|
+
'design/max-comment-lines': 'error',
|
|
25
|
+
// 'warn' until generated apps are consistently clean.
|
|
30
26
|
'design/no-layout-primitives': 'warn',
|
|
31
|
-
|
|
27
|
+
// `design/no-inline-color` off: `shadcn/no-inline-styles` and `no-arbitrary-values` cover it.
|
|
32
28
|
'design/no-primitive-tw-color': 'warn',
|
|
33
29
|
'design/no-native-form-controls': 'warn',
|
|
34
30
|
'design/no-raw-heading-on-page': 'warn',
|
|
35
31
|
'design/prefer-design-modal': 'warn',
|
|
32
|
+
'design/htmlfor-points-at-nothing': 'error',
|
|
36
33
|
|
|
37
|
-
// 'error'
|
|
38
|
-
// locale-implicit format here is copied into every generated app.
|
|
34
|
+
// 'error': the scaffold copies any locale-implicit format into every generated app.
|
|
39
35
|
'design/no-implicit-locale': 'error',
|
|
40
36
|
},
|
|
41
37
|
});
|
|
@@ -15,39 +15,40 @@
|
|
|
15
15
|
"type-check": "tsc"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@keboola/brand-registry": "^1.
|
|
19
|
-
"@keboola/design": "^
|
|
18
|
+
"@keboola/brand-registry": "^3.1.0",
|
|
19
|
+
"@keboola/design": "^17.2.0",
|
|
20
20
|
"@tanstack/react-form": "^1.29.1",
|
|
21
|
-
"@tanstack/react-query": "^5.
|
|
22
|
-
"@tanstack/react-router": "^1.
|
|
21
|
+
"@tanstack/react-query": "^5.104.5",
|
|
22
|
+
"@tanstack/react-router": "^1.170.46",
|
|
23
23
|
"@tanstack/react-table": "^8.21.3",
|
|
24
|
-
"@tanstack/router-plugin": "^1.
|
|
25
|
-
"react": "^19.
|
|
26
|
-
"react-dom": "^19.
|
|
24
|
+
"@tanstack/router-plugin": "^1.168.48",
|
|
25
|
+
"react": "^19.3.1",
|
|
26
|
+
"react-dom": "^19.3.1",
|
|
27
27
|
"zod": "^4.3.6",
|
|
28
28
|
"zustand": "^5.0.8"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"@keboola/eslint-plugin-design": "^0.
|
|
32
|
-
"@keboola/oxlint-config": "^10.0
|
|
31
|
+
"@keboola/eslint-plugin-design": "^0.8.0",
|
|
32
|
+
"@keboola/oxlint-config": "^10.3.0",
|
|
33
33
|
"@keboola/tsconfig": "^0.2.1",
|
|
34
|
+
"@shadcn/lint": "^0.1.5",
|
|
34
35
|
"@tailwindcss/vite": "^4.3.3",
|
|
35
|
-
"@tanstack/react-query-devtools": "^5.
|
|
36
|
-
"@testing-library/jest-dom": "^
|
|
37
|
-
"@testing-library/react": "^16.3.
|
|
38
|
-
"@testing-library/user-event": "^14.6.
|
|
36
|
+
"@tanstack/react-query-devtools": "^5.104.5",
|
|
37
|
+
"@testing-library/jest-dom": "^7.0.1",
|
|
38
|
+
"@testing-library/react": "^16.3.3",
|
|
39
|
+
"@testing-library/user-event": "^14.6.8",
|
|
39
40
|
"@types/node": "24.12.0",
|
|
40
|
-
"@types/react": "^19.
|
|
41
|
-
"@types/react-dom": "^19.
|
|
41
|
+
"@types/react": "^19.3.0",
|
|
42
|
+
"@types/react-dom": "^19.3.0",
|
|
42
43
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
43
|
-
"@vitejs/plugin-react": "^6.
|
|
44
|
+
"@vitejs/plugin-react": "^6.1.2",
|
|
44
45
|
"eslint-plugin-better-tailwindcss": "^4.7.0",
|
|
45
46
|
"jsdom": "^26.1.0",
|
|
46
|
-
"oxfmt": "^0.
|
|
47
|
-
"oxlint": "^1.
|
|
47
|
+
"oxfmt": "^0.66.0",
|
|
48
|
+
"oxlint": "^1.89.0",
|
|
48
49
|
"tailwindcss": "^4.3.3",
|
|
49
50
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
50
|
-
"vite": "^8.
|
|
51
|
-
"vitest": "^
|
|
51
|
+
"vite": "^8.3.5",
|
|
52
|
+
"vitest": "^5.0.5"
|
|
52
53
|
}
|
|
53
54
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { keboola } from '@keboola/brand-registry';
|
|
5
|
+
import { Button, ThemeProvider } from '@keboola/design';
|
|
6
|
+
|
|
7
|
+
import { AuthScreen } from './AuthScreen';
|
|
8
|
+
|
|
9
|
+
// The provider is required, not scenery: the header's `ColorModeToggle` reads the scheme through
|
|
10
|
+
// `useTheme`, and `BrandLogo` renders nothing without a brand registered.
|
|
11
|
+
const renderScreen = () =>
|
|
12
|
+
render(
|
|
13
|
+
<ThemeProvider brands={[keboola]} defaultBrandId={keboola.id} defaultScheme="light">
|
|
14
|
+
<AuthScreen title="Sign in">
|
|
15
|
+
<Button>Continue</Button>
|
|
16
|
+
</AuthScreen>
|
|
17
|
+
</ThemeProvider>,
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
describe('<AuthScreen />', () => {
|
|
21
|
+
it('carries the page heading at h1', () => {
|
|
22
|
+
renderScreen();
|
|
23
|
+
|
|
24
|
+
expect(screen.getByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('keeps the colour-scheme control inside the header landmark', () => {
|
|
28
|
+
// The control is positioned away from the lockup, so its landmark is what can silently
|
|
29
|
+
// regress — outside one, axe reports it as unreachable by landmark navigation.
|
|
30
|
+
renderScreen();
|
|
31
|
+
|
|
32
|
+
expect(screen.getByRole('banner')).toContainElement(
|
|
33
|
+
screen.getByRole('button', { name: /^Color mode:/ }),
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('renders the card body inside the main landmark', () => {
|
|
38
|
+
renderScreen();
|
|
39
|
+
|
|
40
|
+
expect(screen.getByRole('main')).toContainElement(
|
|
41
|
+
screen.getByRole('button', { name: 'Continue' }),
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { BrandLogo, Card } from '@keboola/design';
|
|
4
|
+
|
|
5
|
+
import { ColorModeToggle } from '../components/ColorModeToggle';
|
|
6
|
+
|
|
7
|
+
// Must stay a top-level single-quoted `const`: `create-keboola-app` (`parameterise` in
|
|
8
|
+
// `extract.ts`) rewrites it to the generated app's title.
|
|
9
|
+
const PRODUCT = '__APP_TITLE__';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The chrome every pre-project screen shares: brand lockup, one centred card, no navigation.
|
|
13
|
+
*
|
|
14
|
+
* - Renders instead of `AppChrome`; `routes/__root.tsx` picks via `useIsAuthRoute`.
|
|
15
|
+
* - Sets `tailwindcss-preflight` and `text-foreground` itself: a real app mounts it above the router.
|
|
16
|
+
* - The toggle sits in `header` so axe finds it inside a landmark.
|
|
17
|
+
* - `variant="mark"` + text name: the wordmark would repeat the brand; the name survives a `null` logo.
|
|
18
|
+
*/
|
|
19
|
+
export function AuthScreen({ title, children }: { title: string; children: ReactNode }) {
|
|
20
|
+
return (
|
|
21
|
+
<div className="tailwindcss-preflight tw:relative tw:flex tw:min-h-screen tw:flex-col tw:items-center tw:justify-center tw:gap-6 tw:bg-muted tw:px-4 tw:py-12 tw:text-foreground">
|
|
22
|
+
<header>
|
|
23
|
+
<div className="tw:flex tw:items-center tw:gap-3">
|
|
24
|
+
<BrandLogo variant="mark" className="tw:h-8" />
|
|
25
|
+
<span className="tw:text-lg tw:font-semibold">{PRODUCT}</span>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<div className="tw:absolute tw:top-4 tw:right-4">
|
|
29
|
+
<ColorModeToggle />
|
|
30
|
+
</div>
|
|
31
|
+
</header>
|
|
32
|
+
|
|
33
|
+
<main className="tw:w-full tw:max-w-md">
|
|
34
|
+
<Card>
|
|
35
|
+
<Card.Header layout="stack">
|
|
36
|
+
<Card.Title as="h1">{title}</Card.Title>
|
|
37
|
+
</Card.Header>
|
|
38
|
+
<Card.Content className="tw:flex tw:flex-col tw:gap-4">{children}</Card.Content>
|
|
39
|
+
</Card>
|
|
40
|
+
</main>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import type { AuthState } from './session';
|
|
6
|
+
import { AUTH_STATES, isAuthState } from './session';
|
|
7
|
+
|
|
8
|
+
const renderState = (state: AuthState, onRetry = () => {}) => {
|
|
9
|
+
const { body: Body } = AUTH_STATES[state];
|
|
10
|
+
return render(<Body onRetry={onRetry} />);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
describe('AUTH_STATES', () => {
|
|
14
|
+
it('asks for a credential when signed out', () => {
|
|
15
|
+
renderState('signed-out');
|
|
16
|
+
|
|
17
|
+
expect(screen.getByLabelText(/Email/)).toBeInTheDocument();
|
|
18
|
+
expect(screen.getByLabelText(/Password/)).toBeInTheDocument();
|
|
19
|
+
expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('announces the wait while restoring', () => {
|
|
23
|
+
renderState('restoring');
|
|
24
|
+
|
|
25
|
+
// The wait resolves without the reader acting, so the region has to be live.
|
|
26
|
+
const message = screen.getByText(/still signed in/);
|
|
27
|
+
expect(message.closest('[aria-live="polite"]')).not.toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it.each([
|
|
31
|
+
['expired', 'Sign in again'],
|
|
32
|
+
['unavailable', 'Try again'],
|
|
33
|
+
] as const)('lets the reader retry from %s', async (state, action) => {
|
|
34
|
+
const user = userEvent.setup();
|
|
35
|
+
const onRetry = vi.fn();
|
|
36
|
+
renderState(state, onRetry);
|
|
37
|
+
|
|
38
|
+
await user.click(screen.getByRole('button', { name: action }));
|
|
39
|
+
|
|
40
|
+
expect(onRetry).toHaveBeenCalledOnce();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('distinguishes an expired session from an unreachable service', () => {
|
|
44
|
+
// Telling someone their session expired while the network is down sends them to re-enter a
|
|
45
|
+
// password that would have worked, so the two copies must not converge.
|
|
46
|
+
const { unmount } = renderState('expired');
|
|
47
|
+
expect(screen.getByText(/session ended/)).toBeInTheDocument();
|
|
48
|
+
unmount();
|
|
49
|
+
|
|
50
|
+
renderState('unavailable');
|
|
51
|
+
expect(screen.getByText(/could not reach the service/)).toBeInTheDocument();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('isAuthState', () => {
|
|
56
|
+
it('accepts every declared state and nothing else', () => {
|
|
57
|
+
for (const state of Object.keys(AUTH_STATES)) expect(isAuthState(state)).toBe(true);
|
|
58
|
+
|
|
59
|
+
expect(isAuthState('signedout')).toBe(false);
|
|
60
|
+
expect(isAuthState(undefined)).toBe(false);
|
|
61
|
+
// `Object.hasOwn` rather than `in`, so a prototype key is not a state.
|
|
62
|
+
expect(isAuthState('toString')).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ComponentType } from 'react';
|
|
2
|
+
import { useRouterState } from '@tanstack/react-router';
|
|
3
|
+
|
|
4
|
+
import { Expired, Restoring, SignInForm, Unavailable } from './states';
|
|
5
|
+
|
|
6
|
+
/** Keep in sync with the literal in `createFileRoute` — TanStack's generator needs source text. */
|
|
7
|
+
export const AUTH_ROUTE = '/signed-out';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether the router is on the signed-out screen.
|
|
11
|
+
*
|
|
12
|
+
* - Selects the boolean, so moving between chromed routes does not re-render the root.
|
|
13
|
+
* - A real app asks its session instead; the path only makes the screen reachable without a backend.
|
|
14
|
+
*/
|
|
15
|
+
export const useIsAuthRoute = (): boolean =>
|
|
16
|
+
useRouterState({ select: (state) => state.location.pathname === AUTH_ROUTE });
|
|
17
|
+
|
|
18
|
+
/** One pre-project state: the card's heading, and the body that goes under it. */
|
|
19
|
+
type AuthStateView = {
|
|
20
|
+
title: string;
|
|
21
|
+
/** `onRetry` returns the screen to the sign-in form. */
|
|
22
|
+
body: ComponentType<{ onRetry: () => void }>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The pre-project states, keyed by the session status that produces each.
|
|
27
|
+
*
|
|
28
|
+
* - `AuthState` derives from the keys: a state added here is accepted and rendered by the route.
|
|
29
|
+
* - Components, not render functions, so this module holds no JSX.
|
|
30
|
+
*/
|
|
31
|
+
export const AUTH_STATES = {
|
|
32
|
+
'signed-out': { title: 'Sign in', body: SignInForm },
|
|
33
|
+
restoring: { title: 'Restoring your session', body: Restoring },
|
|
34
|
+
expired: { title: 'Session expired', body: Expired },
|
|
35
|
+
unavailable: { title: 'Cannot reach the service', body: Unavailable },
|
|
36
|
+
} as const satisfies Readonly<Record<string, AuthStateView>>;
|
|
37
|
+
|
|
38
|
+
export type AuthState = keyof typeof AUTH_STATES;
|
|
39
|
+
|
|
40
|
+
/** Narrows an unvalidated search param. */
|
|
41
|
+
export const isAuthState = (value: unknown): value is AuthState =>
|
|
42
|
+
typeof value === 'string' && Object.hasOwn(AUTH_STATES, value);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Alert, Button, DotsLoader, useAppForm } from '@keboola/design';
|
|
2
|
+
|
|
3
|
+
type AuthStateProps = { onRetry: () => void };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The credential form — the one part that differs per app.
|
|
7
|
+
*
|
|
8
|
+
* - `onSubmit` reaches no network; each app replaces its body with its own auth.
|
|
9
|
+
*/
|
|
10
|
+
export const SignInForm = () => {
|
|
11
|
+
const form = useAppForm({
|
|
12
|
+
defaultValues: { email: '', password: '' },
|
|
13
|
+
onSubmit: async ({ value }) => {
|
|
14
|
+
await Promise.resolve(value);
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<form.AppForm>
|
|
20
|
+
<form.FormBody>
|
|
21
|
+
<form.AppField name="email">
|
|
22
|
+
{(field) => <field.TextField label="Email" type="email" required />}
|
|
23
|
+
</form.AppField>
|
|
24
|
+
<form.AppField name="password">
|
|
25
|
+
{(field) => <field.PasswordField label="Password" required />}
|
|
26
|
+
</form.AppField>
|
|
27
|
+
<form.SubmitButton className="tw:w-full">Sign in</form.SubmitButton>
|
|
28
|
+
</form.FormBody>
|
|
29
|
+
</form.AppForm>
|
|
30
|
+
);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** A persisted session being rehydrated. */
|
|
34
|
+
export const Restoring = () => (
|
|
35
|
+
<div
|
|
36
|
+
className="tw:flex tw:items-center tw:gap-3 tw:text-sm tw:text-muted-foreground"
|
|
37
|
+
aria-live="polite"
|
|
38
|
+
>
|
|
39
|
+
<DotsLoader />
|
|
40
|
+
Checking whether you are still signed in.
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
/** A credential that was accepted once and is not any more. */
|
|
45
|
+
export const Expired = ({ onRetry }: AuthStateProps) => (
|
|
46
|
+
<>
|
|
47
|
+
<Alert variant="warning">
|
|
48
|
+
Your session ended. Sign in again to pick up where you left off.
|
|
49
|
+
</Alert>
|
|
50
|
+
<Button variant="primary" className="tw:w-full" onClick={onRetry}>
|
|
51
|
+
Sign in again
|
|
52
|
+
</Button>
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The service could not be reached, so the credential was never judged.
|
|
58
|
+
*
|
|
59
|
+
* - Not `Expired`: that would send the user to re-enter a password that works.
|
|
60
|
+
*/
|
|
61
|
+
export const Unavailable = ({ onRetry }: AuthStateProps) => (
|
|
62
|
+
<>
|
|
63
|
+
<Alert variant="error">
|
|
64
|
+
We could not reach the service. Your credentials are fine — the request never got an answer.
|
|
65
|
+
</Alert>
|
|
66
|
+
<Button variant="outline" className="tw:w-full" onClick={onRetry}>
|
|
67
|
+
Try again
|
|
68
|
+
</Button>
|
|
69
|
+
</>
|
|
70
|
+
);
|
|
@@ -13,10 +13,8 @@ import { AssistantToggle } from './AssistantToggle';
|
|
|
13
13
|
import { ColorModeToggle } from './ColorModeToggle';
|
|
14
14
|
import { SidebarModeMenu } from './SidebarModeMenu';
|
|
15
15
|
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// AppSiteHeader — Header (brand) / Content (primary nav) / Footer (actions).
|
|
19
|
-
// The rail's destinations and the active-match rule live in `src/nav.ts`.
|
|
16
|
+
// Composed inline so the AppShell/Navbar/Sidebar wiring stays legible.
|
|
17
|
+
// Navbar follows kbc-ui's AppSiteHeader: Header (brand) / Content (nav) / Footer (actions).
|
|
20
18
|
export function AppChrome({ children }: { children: ReactNode }) {
|
|
21
19
|
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
|
22
20
|
const helpVisible = useAppStore((s) => s.helpVisible);
|
|
@@ -29,62 +27,39 @@ export function AppChrome({ children }: { children: ReactNode }) {
|
|
|
29
27
|
|
|
30
28
|
return (
|
|
31
29
|
<SidebarProvider
|
|
32
|
-
//
|
|
33
|
-
// - Controlled: the mode is a persisted user preference, and the store is its
|
|
34
|
-
// single owner. `SidebarModeMenu` in the rail's footer writes it.
|
|
30
|
+
// Controlled: the store owns the persisted mode; `SidebarModeMenu` writes it.
|
|
35
31
|
leftMode={sidebarMode}
|
|
36
32
|
onLeftModeChange={setSidebarMode}
|
|
37
33
|
leftVariant="inset"
|
|
38
|
-
//
|
|
39
|
-
// all. It is also what `expand-on-hover` collapses to between hovers.
|
|
34
|
+
// Keeps every destination reachable; also what `expand-on-hover` collapses to.
|
|
40
35
|
leftCollapsible="icon"
|
|
41
36
|
// #region block:assistant
|
|
42
|
-
//
|
|
43
|
-
// is `none` (the provider's default) renders NOTHING, silently, so configuring only
|
|
44
|
-
// the left side makes the assistant disappear with no error. Mirrors kbc-ui's Kai
|
|
45
|
-
// rail, which uses the same pair.
|
|
37
|
+
// Required: variant `none` (the default) renders nothing, silently.
|
|
46
38
|
rightVariant="sided-floating"
|
|
47
39
|
rightCollapsible="off-canvas"
|
|
48
|
-
// Closed until
|
|
49
|
-
// the point; holding ~30% of the viewport open on a transcript nobody has
|
|
50
|
-
// started is not. The Navbar's AssistantToggle opens it (and badges unread
|
|
51
|
-
// replies seeded from the page's prompt while it's shut).
|
|
40
|
+
// Closed until the Navbar's AssistantToggle opens it.
|
|
52
41
|
defaultRightMode="collapsed"
|
|
53
|
-
//
|
|
54
|
-
// transcript wants — message bubbles wrap after a few words at that width.
|
|
42
|
+
// The provider default is too narrow for chat; matches kbc-ui's Kai rail.
|
|
55
43
|
rightWidth={384}
|
|
56
44
|
// #endregion block:assistant
|
|
57
45
|
>
|
|
58
|
-
{/*
|
|
59
|
-
the viewport's left edge — the geometry kbc-ui uses. Without it the left rail
|
|
60
|
-
owns the top-left corner and the bar starts beside it. */}
|
|
46
|
+
{/* Header above both rails, so the brand mark aligns to the viewport edge. */}
|
|
61
47
|
<AppShell headerSpan="full">
|
|
62
|
-
{/* `flush` drops
|
|
63
|
-
|
|
64
|
-
-
|
|
65
|
-
those lines start mid-air instead of closing a frame.
|
|
66
|
-
- `sidebar-border`, not `border`: the two meet at the corners, and `border`
|
|
67
|
-
is 4 units lighter. */}
|
|
48
|
+
{/* `flush` drops padding, background and bottom border; the border is restated:
|
|
49
|
+
- It closes the frame the rails' hairlines start.
|
|
50
|
+
- `sidebar-border`, not `border`: they meet at the corners, `border` is lighter. */}
|
|
68
51
|
<AppShell.Header flush className="tw:border-b tw:border-sidebar-border">
|
|
69
|
-
{/*
|
|
70
|
-
shrink to its content width. */}
|
|
52
|
+
{/* As a flex item the bar would otherwise shrink to its content. */}
|
|
71
53
|
<Navbar bordered className="tw:flex-1">
|
|
72
|
-
{/*
|
|
73
|
-
|
|
74
|
-
so a competing `pl-*` there would be settled by cascade order. This slot
|
|
75
|
-
merges with `cn`. The DS default is tuned for the header sitting beside
|
|
76
|
-
the rail; with `headerSpan="full"` the logo would otherwise hug the
|
|
77
|
-
viewport edge instead of lining up with the rail's icons below it.
|
|
78
|
-
`pl-2.5` on top of the bar's `pl-2` puts the logo at 18px — the same
|
|
79
|
-
x as the rail's icons, measured rather than guessed. */}
|
|
54
|
+
{/* Gutter here, not on `Navbar`: the bar's `pl-2` comes from a cva, which doesn't
|
|
55
|
+
tailwind-merge. With it the logo sits at 18px, aligned with the rail's icons. */}
|
|
80
56
|
<Navbar.Header className="tw:pl-2.5">
|
|
81
57
|
<Link to="/" aria-label="Home" className="tw:flex tw:items-center">
|
|
82
58
|
<BrandLogo className="tw:h-6" />
|
|
83
59
|
</Link>
|
|
84
60
|
</Navbar.Header>
|
|
85
61
|
|
|
86
|
-
{/* Navbar.
|
|
87
|
-
bar, so it is what pushes Navbar.Footer out to the right edge. */}
|
|
62
|
+
{/* Carries `flex-1`, pushing Navbar.Footer to the right edge. */}
|
|
88
63
|
<Navbar.Content>
|
|
89
64
|
<Navbar.Menu marker="forward-slash">
|
|
90
65
|
<Navbar.MenuItem>
|
|
@@ -113,31 +88,20 @@ export function AppChrome({ children }: { children: ReactNode }) {
|
|
|
113
88
|
</AppShell.Header>
|
|
114
89
|
|
|
115
90
|
<AppShell.Sidebar>
|
|
116
|
-
{/* Elevation while `expand-on-hover` is
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
App-scoped rather than a `Sidebar` variant rule: kbc-ui runs this mode by
|
|
121
|
-
default and paints its own corner over the rail's right edge with tuned
|
|
122
|
-
`::before` / `::after` wedges, so a shadow from the design side would land
|
|
123
|
-
on that seam unreviewed. Promote it once that composition has been looked
|
|
124
|
-
at in a browser. */}
|
|
91
|
+
{/* Elevation while `expand-on-hover` is open: the rail overlays the page.
|
|
92
|
+
- App-scoped, not a `Sidebar` variant: kbc-ui paints `::before`/`::after` wedges on
|
|
93
|
+
that edge. Promote once reviewed there. */}
|
|
125
94
|
<Sidebar
|
|
126
95
|
side="left"
|
|
127
96
|
className="tw:group-data-[mode=expand-on-hover]/sidebar:group-data-[state=expanded]/sidebar:[&>[data-slot=sidebar]]:shadow-lift"
|
|
128
97
|
>
|
|
129
98
|
<Sidebar.Content>
|
|
130
|
-
{/*
|
|
131
|
-
links sit outside any landmark — axe flags the content as unreachable by
|
|
132
|
-
landmark navigation. Labelled distinctly from the Navbar's "Main
|
|
133
|
-
navigation" so the two are tellable apart in a landmark list. */}
|
|
99
|
+
{/* The rail's landmark (axe); labelled apart from the Navbar's "Main navigation". */}
|
|
134
100
|
<nav aria-label="Sections">
|
|
135
101
|
<Sidebar.Menu>
|
|
136
102
|
{NAV.map((item) => (
|
|
137
103
|
<Sidebar.MenuItem key={item.to}>
|
|
138
|
-
{/*
|
|
139
|
-
— `aria-selected` is not a supported attribute on a link. Both get
|
|
140
|
-
the same active styling from Sidebar.MenuButton. */}
|
|
104
|
+
{/* A link takes `aria-current`, not `aria-selected`. */}
|
|
141
105
|
<Sidebar.MenuButton
|
|
142
106
|
asChild
|
|
143
107
|
tooltip={item.label}
|
|
@@ -156,8 +120,7 @@ export function AppChrome({ children }: { children: ReactNode }) {
|
|
|
156
120
|
</nav>
|
|
157
121
|
</Sidebar.Content>
|
|
158
122
|
|
|
159
|
-
{/* Footer, not Content: `Sidebar.Content`
|
|
160
|
-
control inside it scrolls away with the nav. The footer is pinned. */}
|
|
123
|
+
{/* Footer, not Content: `Sidebar.Content` scrolls, the footer is pinned. */}
|
|
161
124
|
<Sidebar.Footer>
|
|
162
125
|
<Sidebar.Menu>
|
|
163
126
|
<SidebarModeMenu />
|
|
@@ -168,23 +131,12 @@ export function AppChrome({ children }: { children: ReactNode }) {
|
|
|
168
131
|
|
|
169
132
|
<AppShell.Main>{children}</AppShell.Main>
|
|
170
133
|
{/* #region block:assistant */}
|
|
171
|
-
{/* The assistant
|
|
172
|
-
|
|
173
|
-
AppShell
|
|
174
|
-
replacing the rail, so exiting fullscreen returns to the page underneath.
|
|
175
|
-
Render exactly one instance per mode — the rail is omitted from the Aside
|
|
176
|
-
slot while fullscreen, so the two never appear at once. */}
|
|
134
|
+
{/* The assistant rides every route, one instance per mode:
|
|
135
|
+
- Normal: the right rail.
|
|
136
|
+
- Fullscreen: a fixed overlay after AppShell, no rail; exit returns to the page. */}
|
|
177
137
|
{!fullscreen && (
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
// them. `flush` stops the slot painting a second surface underneath it. Same
|
|
181
|
-
// composition kbc-ui uses for its Kai rail.
|
|
182
|
-
// The parked `off-canvas` rail used to need `overflow-x-clip` here. The
|
|
183
|
-
// design system now clips this slot itself while the rail is collapsed
|
|
184
|
-
// (see `styles.css`), which an unconditional class could not do safely:
|
|
185
|
-
// a filled rail widens past the slot and would be cropped. The two are
|
|
186
|
-
// therefore coupled — reverting that rule alone brings the horizontal
|
|
187
|
-
// scroll back here, so restore this class if you ever do.
|
|
138
|
+
// `Sidebar` brings width, border, scroll and resize; `flush` avoids a second surface.
|
|
139
|
+
// `styles.css` clips this slot while collapsed; without that rule, add `overflow-x-clip`.
|
|
188
140
|
<AppShell.Aside flush>
|
|
189
141
|
<Sidebar side="right" container resizable>
|
|
190
142
|
<AssistantPanel />
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
|
-
import type { RefObject } from 'react';
|
|
2
|
+
import type { CSSProperties, RefObject } from 'react';
|
|
3
3
|
|
|
4
4
|
import { Button } from '@keboola/design';
|
|
5
5
|
|
|
@@ -78,8 +78,15 @@ export function SelectionFloater({
|
|
|
78
78
|
<Button
|
|
79
79
|
variant="secondary"
|
|
80
80
|
size="small"
|
|
81
|
-
className="tw:fixed tw:z-50"
|
|
82
|
-
|
|
81
|
+
className="tw:fixed tw:top-(--floater-top) tw:left-(--floater-left) tw:z-50"
|
|
82
|
+
// Custom properties, not inline `top`/`left`: the utilities above read them,
|
|
83
|
+
// and the position is measured from the live selection at runtime.
|
|
84
|
+
style={
|
|
85
|
+
{
|
|
86
|
+
'--floater-top': `${selection.top - 40}px`,
|
|
87
|
+
'--floater-left': `${selection.left}px`,
|
|
88
|
+
} as CSSProperties
|
|
89
|
+
}
|
|
83
90
|
onClick={handleClick}
|
|
84
91
|
>
|
|
85
92
|
Explain with Kai
|