create-keboola-app 0.2.0 → 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 +11 -0
- package/dist/{blocks-D_-NWhwg.js → blocks-BdJaEmz1.js} +5 -8
- package/dist/cli.js +26 -52
- package/dist/extract.js +8 -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/oxlint.config.ts +10 -14
- package/dist/template/package.json +20 -19
- package/dist/template/src/auth/AuthScreen.tsx +6 -26
- package/dist/template/src/auth/session.ts +7 -23
- package/dist/template/src/auth/states.tsx +5 -23
- 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 +5 -10
- package/dist/template/src/overview-fixtures.ts +6 -18
- 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.tsx +5 -19
- package/dist/template/src/routes/__root.tsx +6 -13
- package/dist/template/src/routes/signed-out.tsx +3 -7
- 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/vite.config.ts +1 -4
- package/dist/template/vitest.config.ts +2 -5
- package/package.json +12 -11
|
@@ -3,23 +3,14 @@ import { useRouterState } from '@tanstack/react-router';
|
|
|
3
3
|
|
|
4
4
|
import { Expired, Restoring, SignInForm, Unavailable } from './states';
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* The signed-out screen's path.
|
|
8
|
-
*
|
|
9
|
-
* - Spelled again as a literal in `createFileRoute`. TanStack's generator reads that argument as
|
|
10
|
-
* source text, so it cannot be a reference.
|
|
11
|
-
*/
|
|
6
|
+
/** Keep in sync with the literal in `createFileRoute` — TanStack's generator needs source text. */
|
|
12
7
|
export const AUTH_ROUTE = '/signed-out';
|
|
13
8
|
|
|
14
9
|
/**
|
|
15
10
|
* Whether the router is on the signed-out screen.
|
|
16
11
|
*
|
|
17
|
-
* -
|
|
18
|
-
*
|
|
19
|
-
* - `select` returns the boolean, not the pathname, so navigating between two chromed routes does
|
|
20
|
-
* not re-render the root.
|
|
21
|
-
* - A real app asks its session instead. This screen belongs to *having no session*, not to a path
|
|
22
|
-
* a visitor can type — the path exists so the screen is reachable without a backend.
|
|
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.
|
|
23
14
|
*/
|
|
24
15
|
export const useIsAuthRoute = (): boolean =>
|
|
25
16
|
useRouterState({ select: (state) => state.location.pathname === AUTH_ROUTE });
|
|
@@ -27,22 +18,15 @@ export const useIsAuthRoute = (): boolean =>
|
|
|
27
18
|
/** One pre-project state: the card's heading, and the body that goes under it. */
|
|
28
19
|
type AuthStateView = {
|
|
29
20
|
title: string;
|
|
30
|
-
/**
|
|
31
|
-
* simply ignore it. */
|
|
21
|
+
/** `onRetry` returns the screen to the sign-in form. */
|
|
32
22
|
body: ComponentType<{ onRetry: () => void }>;
|
|
33
23
|
};
|
|
34
24
|
|
|
35
25
|
/**
|
|
36
26
|
* The pre-project states, keyed by the session status that produces each.
|
|
37
27
|
*
|
|
38
|
-
* -
|
|
39
|
-
*
|
|
40
|
-
* - Every state renders inside the same `AuthScreen`, so the common path does not jump when one
|
|
41
|
-
* replaces another.
|
|
42
|
-
* - The keys are the contract — `AuthState` is derived from them, so adding a state here is all it
|
|
43
|
-
* takes for the route to accept and render it.
|
|
44
|
-
* - Components, not render functions, so this module holds no JSX and stays the one place the
|
|
45
|
-
* route's search param is validated.
|
|
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.
|
|
46
30
|
*/
|
|
47
31
|
export const AUTH_STATES = {
|
|
48
32
|
'signed-out': { title: 'Sign in', body: SignInForm },
|
|
@@ -53,6 +37,6 @@ export const AUTH_STATES = {
|
|
|
53
37
|
|
|
54
38
|
export type AuthState = keyof typeof AUTH_STATES;
|
|
55
39
|
|
|
56
|
-
/** Narrows an unvalidated search param.
|
|
40
|
+
/** Narrows an unvalidated search param. */
|
|
57
41
|
export const isAuthState = (value: unknown): value is AuthState =>
|
|
58
42
|
typeof value === 'string' && Object.hasOwn(AUTH_STATES, value);
|
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
import { Alert, Button, DotsLoader, useAppForm } from '@keboola/design';
|
|
2
2
|
|
|
3
|
-
/** What every state's body is handed. `onRetry` returns the screen to the sign-in form. */
|
|
4
3
|
type AuthStateProps = { onRetry: () => void };
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
|
-
* The credential form — the one part that
|
|
6
|
+
* The credential form — the one part that differs per app.
|
|
8
7
|
*
|
|
9
|
-
* -
|
|
10
|
-
* authenticates against (password, PAT, OAuth) and what to do with the answer. Replace the body
|
|
11
|
-
* of `onSubmit`; nothing else in this directory has to move.
|
|
12
|
-
* - `useAppForm` rather than a bare `useForm`: the connectors carry the label, the required marker
|
|
13
|
-
* and the error slot, so the field markup does not restate them.
|
|
14
|
-
* - `w-full` on the submit: it is the card's only action, so a content-width button reads as one
|
|
15
|
-
* option among several.
|
|
8
|
+
* - `onSubmit` reaches no network; each app replaces its body with its own auth.
|
|
16
9
|
*/
|
|
17
10
|
export const SignInForm = () => {
|
|
18
11
|
const form = useAppForm({
|
|
@@ -37,12 +30,7 @@ export const SignInForm = () => {
|
|
|
37
30
|
);
|
|
38
31
|
};
|
|
39
32
|
|
|
40
|
-
/**
|
|
41
|
-
* A persisted session being rehydrated.
|
|
42
|
-
*
|
|
43
|
-
* `aria-live="polite"`: the wait resolves without the reader acting, so the outcome has to be
|
|
44
|
-
* announced rather than found.
|
|
45
|
-
*/
|
|
33
|
+
/** A persisted session being rehydrated. */
|
|
46
34
|
export const Restoring = () => (
|
|
47
35
|
<div
|
|
48
36
|
className="tw:flex tw:items-center tw:gap-3 tw:text-sm tw:text-muted-foreground"
|
|
@@ -53,12 +41,7 @@ export const Restoring = () => (
|
|
|
53
41
|
</div>
|
|
54
42
|
);
|
|
55
43
|
|
|
56
|
-
/**
|
|
57
|
-
* A credential that was accepted once and is not any more.
|
|
58
|
-
*
|
|
59
|
-
* `warning`, not `error`: nothing malfunctioned and there is nothing to report — the session ran
|
|
60
|
-
* out, and the fix is one click away.
|
|
61
|
-
*/
|
|
44
|
+
/** A credential that was accepted once and is not any more. */
|
|
62
45
|
export const Expired = ({ onRetry }: AuthStateProps) => (
|
|
63
46
|
<>
|
|
64
47
|
<Alert variant="warning">
|
|
@@ -73,8 +56,7 @@ export const Expired = ({ onRetry }: AuthStateProps) => (
|
|
|
73
56
|
/**
|
|
74
57
|
* The service could not be reached, so the credential was never judged.
|
|
75
58
|
*
|
|
76
|
-
*
|
|
77
|
-
* down sends them to re-enter a password that would have worked.
|
|
59
|
+
* - Not `Expired`: that would send the user to re-enter a password that works.
|
|
78
60
|
*/
|
|
79
61
|
export const Unavailable = ({ onRetry }: AuthStateProps) => (
|
|
80
62
|
<>
|
|
@@ -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
|
|
@@ -9,14 +9,12 @@ const MODES: { value: SidebarMode; label: string }[] = [
|
|
|
9
9
|
];
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Picks the left rail's mode.
|
|
12
|
+
* Picks the left rail's mode.
|
|
13
13
|
*
|
|
14
|
-
* - `expand-on-hover` is
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* -
|
|
18
|
-
* shows which is active without clicking through.
|
|
19
|
-
* - `setMode` writes through `onLeftModeChange` into the app store, which persists it.
|
|
14
|
+
* - `expand-on-hover` is its own mode: `state` flips on hover, so styling that tells it from
|
|
15
|
+
* `collapsed` reads `mode`.
|
|
16
|
+
* - A radio group, not a cycling button: three modes have no natural next.
|
|
17
|
+
* - `setMode` persists through `onLeftModeChange` into the app store.
|
|
20
18
|
*/
|
|
21
19
|
export function SidebarModeMenu() {
|
|
22
20
|
const { setMode, mode } = useSidebar('left');
|
|
@@ -25,8 +23,7 @@ export function SidebarModeMenu() {
|
|
|
25
23
|
<Sidebar.MenuItem>
|
|
26
24
|
<DropdownMenu>
|
|
27
25
|
<DropdownMenu.Trigger asChild>
|
|
28
|
-
{/* `w-fit`
|
|
29
|
-
position when the rail collapses to icons. */}
|
|
26
|
+
{/* `w-fit` holds the trigger in place when the rail collapses. */}
|
|
30
27
|
<Sidebar.MenuButton
|
|
31
28
|
tooltip="Sidebar mode"
|
|
32
29
|
className="tw:w-fit"
|
|
@@ -34,12 +31,10 @@ export function SidebarModeMenu() {
|
|
|
34
31
|
aria-label="Sidebar mode"
|
|
35
32
|
/>
|
|
36
33
|
</DropdownMenu.Trigger>
|
|
37
|
-
{/*
|
|
38
|
-
menu would hang off the viewport when the rail is collapsed. */}
|
|
34
|
+
{/* A centered menu would hang off the viewport when the rail is collapsed. */}
|
|
39
35
|
<DropdownMenu.Content align="start">
|
|
40
36
|
<DropdownMenu.Label>Sidebar mode</DropdownMenu.Label>
|
|
41
|
-
{/* `
|
|
42
|
-
`SidebarMode`, so `setMode` slots in without a cast. */}
|
|
37
|
+
{/* `value={mode}` types the group as `SidebarMode`, so `setMode` needs no cast. */}
|
|
43
38
|
<DropdownMenu.RadioGroup value={mode} onValueChange={setMode}>
|
|
44
39
|
{MODES.map(({ value, label }) => (
|
|
45
40
|
<DropdownMenu.RadioItem key={value} closeOnSelect indicatorAlign="end" value={value}>
|
package/dist/template/src/nav.ts
CHANGED
|
@@ -11,12 +11,9 @@ export type NavItem = { to: string; label: string; icon: ComponentType };
|
|
|
11
11
|
/**
|
|
12
12
|
* The left rail's destinations, in render order.
|
|
13
13
|
*
|
|
14
|
-
* - `as const` keeps each `to`
|
|
15
|
-
*
|
|
16
|
-
* -
|
|
17
|
-
* - Icons render with no props (`<item.icon />`).
|
|
18
|
-
* - Each block contributes its own entry from a module the block owns, so a deselected block
|
|
19
|
-
* takes its import with it.
|
|
14
|
+
* - `as const` keeps each `to` literal for `Link`'s route-typed `to`; `satisfies` won't widen it.
|
|
15
|
+
* - Icons render with no props.
|
|
16
|
+
* - Each block owns its entry, so a deselected block takes its import with it.
|
|
20
17
|
*/
|
|
21
18
|
export const NAV = [
|
|
22
19
|
{ to: '/', label: 'Overview', icon: LayoutDashboard },
|
|
@@ -29,10 +26,8 @@ export const NAV = [
|
|
|
29
26
|
* Whether `to` is the active destination for `pathname`.
|
|
30
27
|
*
|
|
31
28
|
* - The root matches exactly.
|
|
32
|
-
* -
|
|
33
|
-
*
|
|
34
|
-
* - A sibling that merely shares the prefix (`/tokens-archive` under `/tokens`) is not
|
|
35
|
-
* nested — hence the `/` in the boundary check.
|
|
29
|
+
* - Others stay active on nested routes, like Router's non-exact `Link`.
|
|
30
|
+
* - The `/` boundary keeps `/tokens-archive` from matching `/tokens`.
|
|
36
31
|
*/
|
|
37
32
|
export const isActive = (pathname: string, to: string) =>
|
|
38
33
|
to === '/' ? pathname === '/' : pathname === to || pathname.startsWith(`${to}/`);
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
// In-memory stand-in for
|
|
2
|
-
//
|
|
3
|
-
// The call takes an AbortSignal and honors it, so the query hook threads
|
|
4
|
-
// cancellation exactly as it would against a real client — see overview-hooks.ts.
|
|
1
|
+
// In-memory stand-in for an overview endpoint; swap `fetchOverview` for the api-client call.
|
|
2
|
+
// It honors its AbortSignal like a real client — see overview-hooks.ts.
|
|
5
3
|
|
|
6
4
|
export type Kpi = {
|
|
7
5
|
label: string;
|
|
@@ -33,16 +31,10 @@ const OVERVIEW: Overview = {
|
|
|
33
31
|
{ label: 'Job success rate', value: '98.7%', deltaPct: 0.3 },
|
|
34
32
|
{ label: 'Storage used', value: '1.4 TB', deltaPct: 4.2 },
|
|
35
33
|
{ label: 'Active configurations', value: '312', deltaPct: 2.1 },
|
|
36
|
-
// No delta
|
|
37
|
-
// signal for a cost. Leaving it off also exercises the optional branch.
|
|
34
|
+
// No delta: `KpiCard` paints a rise green, wrong for a cost.
|
|
38
35
|
{ label: 'Credits this month', value: '8,420' },
|
|
39
36
|
],
|
|
40
|
-
//
|
|
41
|
-
// axis. Pairing a ~60k series with a ~1k one renders the small one as a flat
|
|
42
|
-
// line on the floor — a chart that looks informative and isn't.
|
|
43
|
-
//
|
|
44
|
-
// Consumption climbs from a low base, so a zero baseline is honest *and* uses
|
|
45
|
-
// the plot: the story is the gap to the ceiling closing month over month.
|
|
37
|
+
// Same order of magnitude, so both read on one linear axis from zero.
|
|
46
38
|
credits: [
|
|
47
39
|
{ month: 'Jan', used: 4200, budgeted: 8000 },
|
|
48
40
|
{ month: 'Feb', used: 5100, budgeted: 8000 },
|
|
@@ -51,9 +43,7 @@ const OVERVIEW: Overview = {
|
|
|
51
43
|
{ month: 'May', used: 7600, budgeted: 8500 },
|
|
52
44
|
{ month: 'Jun', used: 8420, budgeted: 8500 },
|
|
53
45
|
],
|
|
54
|
-
//
|
|
55
|
-
// month's `budgeted` value above (8000 ×3, then 8500 ×3). Fixtures that don't
|
|
56
|
-
// reconcile are the fastest way to make a demo look fake.
|
|
46
|
+
// Each month's column sums to that month's `budgeted` above.
|
|
57
47
|
budget: [
|
|
58
48
|
{
|
|
59
49
|
id: 'ex-db',
|
|
@@ -82,9 +72,7 @@ const OVERVIEW: Overview = {
|
|
|
82
72
|
],
|
|
83
73
|
};
|
|
84
74
|
|
|
85
|
-
//
|
|
86
|
-
// an AbortSignal fires — mirroring how a real fetch-based client aborts in-flight
|
|
87
|
-
// when TanStack Query cancels the query.
|
|
75
|
+
// Resolves after `ms`; rejects when the signal aborts, like a fetch-based client.
|
|
88
76
|
const delay = (ms: number, signal?: AbortSignal): Promise<void> =>
|
|
89
77
|
new Promise((resolve, reject) => {
|
|
90
78
|
if (signal?.aborted) {
|
|
@@ -13,9 +13,7 @@ it('renders the chart card title and legend labels', () => {
|
|
|
13
13
|
{ month: 'Jan', mrr: 10 },
|
|
14
14
|
{ month: 'Feb', mrr: 12 },
|
|
15
15
|
]}
|
|
16
|
-
// recharts' <ResponsiveContainer
|
|
17
|
-
// non-zero size via ResizeObserver, which jsdom never provides — an
|
|
18
|
-
// explicit width keeps the chart (and its legend) deterministic.
|
|
16
|
+
// jsdom never sizes recharts' <ResponsiveContainer>, so it would render nothing.
|
|
19
17
|
width={400}
|
|
20
18
|
/>,
|
|
21
19
|
);
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { Card } from '@keboola/design';
|
|
2
2
|
import { type ChartColorToken, LineChart } from '@keboola/design/charts';
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// app-level card wrapper over the DS `@keboola/design/charts` `LineChart`.
|
|
7
|
-
// The chart palette slots the DS exposes; narrowed so a typo is a compile error.
|
|
4
|
+
// Colors come from DS palette tokens, never hex, so the chart re-themes with the brand.
|
|
5
|
+
// Narrowed so a typo is a compile error.
|
|
8
6
|
type ChartTokenVar = '--chart-1' | '--chart-2' | '--chart-3' | '--chart-4' | '--chart-5';
|
|
9
7
|
|
|
10
8
|
type Series = { key: string; label: string; tokenVar: ChartTokenVar };
|
|
@@ -14,16 +12,11 @@ type LineChartCardProps = {
|
|
|
14
12
|
data: readonly Record<string, number | string>[];
|
|
15
13
|
xKey: string;
|
|
16
14
|
series: readonly Series[];
|
|
17
|
-
//
|
|
18
|
-
// `width="100%"` default applies. Tests pass an explicit size — recharts'
|
|
19
|
-
// <ResponsiveContainer> only renders once it measures a non-zero size via
|
|
20
|
-
// ResizeObserver, which jsdom never provides.
|
|
15
|
+
// Unset → responsive `width="100%"`. Tests set it: jsdom never sizes <ResponsiveContainer>.
|
|
21
16
|
width?: number;
|
|
22
17
|
};
|
|
23
18
|
|
|
24
|
-
//
|
|
25
|
-
// palette slot (1–5). `LineChart` resolves colors from this slot internally —
|
|
26
|
-
// it never accepts a raw CSS var or hex value.
|
|
19
|
+
// `LineChart` takes a palette slot (1–5), never a raw CSS var or hex.
|
|
27
20
|
const CHART_COLOR_SLOTS: Record<ChartTokenVar, ChartColorToken> = {
|
|
28
21
|
'--chart-1': 1,
|
|
29
22
|
'--chart-2': 2,
|
|
@@ -11,19 +11,14 @@ import { NumberInput, Table } from '@keboola/design';
|
|
|
11
11
|
import type { BudgetRow } from '../overview-fixtures';
|
|
12
12
|
import { BUDGET_MONTHS } from '../overview-fixtures';
|
|
13
13
|
|
|
14
|
-
//
|
|
15
|
-
// `EditableDataGrid`): TanStack Table v8 (headless) for the column/row model,
|
|
16
|
-
// design `Table` for styling, design `NumberInput` as the editable cell, with a
|
|
17
|
-
// live-computed row total column and footer grand total.
|
|
14
|
+
// Instance of the DS editable-grid recipe (`Table.DataGridRecipe` story, `EditableDataGrid`).
|
|
18
15
|
const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0);
|
|
19
16
|
|
|
20
17
|
const columnHelper = createColumnHelper<BudgetRow>();
|
|
21
18
|
|
|
22
19
|
export function CreditBudgetGrid({ rows }: { rows: BudgetRow[] }) {
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// stale rows first and repaint, and React documents this as the way to reset
|
|
26
|
-
// state on a prop change (design's own SidebarProvider does the same).
|
|
20
|
+
// Re-seeds edit state when `rows` changes.
|
|
21
|
+
// During render, not in an effect: an effect would paint the stale rows first.
|
|
27
22
|
const [data, setData] = useState<BudgetRow[]>(rows);
|
|
28
23
|
const [seed, setSeed] = useState<BudgetRow[]>(rows);
|
|
29
24
|
if (seed !== rows) {
|
|
@@ -113,17 +108,8 @@ export function CreditBudgetGrid({ rows }: { rows: BudgetRow[] }) {
|
|
|
113
108
|
? 'tw:min-w-52'
|
|
114
109
|
: cell.column.id === 'total'
|
|
115
110
|
? 'tw:text-right tw:tabular-nums'
|
|
116
|
-
: //
|
|
117
|
-
//
|
|
118
|
-
// minus the `px-3` gutter left ~22px of text box — enough to
|
|
119
|
-
// render "8" and silently hide the "00" of 800.
|
|
120
|
-
//
|
|
121
|
-
// `min-w`, not `w`: a width is only a hint, and a `w-full` table
|
|
122
|
-
// squeezed by the assistant rail compresses past it and clips the
|
|
123
|
-
// inputs again. A floor makes the table outgrow its container
|
|
124
|
-
// instead, which the wrapper's `overflow-x-auto` then scrolls — a
|
|
125
|
-
// single-line input can't be scrolled by eye, so its value has to
|
|
126
|
-
// stay fully visible at any container width.
|
|
111
|
+
: // Fits `NumberInput`'s 16px side padding; narrower clips "800" to "8".
|
|
112
|
+
// `min-w`, not `w`: a squeezed table then scrolls instead of clipping.
|
|
127
113
|
'tw:min-w-28'
|
|
128
114
|
}
|
|
129
115
|
>
|
|
@@ -13,19 +13,12 @@ export const Route = createRootRoute({
|
|
|
13
13
|
|
|
14
14
|
function RootLayout() {
|
|
15
15
|
// #region block:auth
|
|
16
|
-
// The signed-out screen replaces the chrome instead of
|
|
17
|
-
//
|
|
18
|
-
// -
|
|
19
|
-
// -
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// TanStack nests every file route under it.
|
|
23
|
-
// - The preflight scope is restated even though `AuthScreen` carries its own: that scope is a
|
|
24
|
-
// class on an element, so it does not reach `Toaster` beside it, and design's preflight is
|
|
25
|
-
// opt-in. Measured outside it, a bare `button` gets a 2px outset border and Arial 13.3px.
|
|
26
|
-
// - The blank line above the region's closing marker is inside the region on purpose. Left
|
|
27
|
-
// outside, it becomes the whole body of an app generated without this block, and that app's
|
|
28
|
-
// `oxfmt --check` fails on a file nobody wrote.
|
|
16
|
+
// The signed-out screen replaces the chrome instead of nesting in it.
|
|
17
|
+
// - No session → no rail, breadcrumb or history.
|
|
18
|
+
// - Nesting would duplicate AppChrome's `header`/`main` landmarks (axe).
|
|
19
|
+
// - Preflight is restated: `AuthScreen`'s own scope does not reach `Toaster` beside it.
|
|
20
|
+
// - The blank line before the closing marker must stay inside the region, or an app generated
|
|
21
|
+
// without this block fails `oxfmt --check`.
|
|
29
22
|
const isAuthRoute = useIsAuthRoute();
|
|
30
23
|
if (isAuthRoute) {
|
|
31
24
|
return (
|
|
@@ -7,13 +7,9 @@ import { AUTH_ROUTE, AUTH_STATES, isAuthState } from '../auth/session';
|
|
|
7
7
|
/**
|
|
8
8
|
* `/signed-out` — the pre-project screen.
|
|
9
9
|
*
|
|
10
|
-
* - `?state=` stands in for the
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* - `validateSearch` narrows by hand rather than through a schema. One optional enum does not
|
|
14
|
-
* need a validation library, and the block deliberately carries no dependency of its own.
|
|
15
|
-
* - An unknown or missing `state` falls back to the sign-in form rather than throwing: this is the
|
|
16
|
-
* screen a visitor lands on with no session, so it has to render whatever the URL says.
|
|
10
|
+
* - `?state=` stands in for the session hook, so every state renders without a backend.
|
|
11
|
+
* - `validateSearch` narrows by hand: the block carries no validation dependency.
|
|
12
|
+
* - An unknown or missing `state` falls back to sign-in rather than throwing.
|
|
17
13
|
*/
|
|
18
14
|
export const Route = createFileRoute('/signed-out')({
|
|
19
15
|
validateSearch: (search: Record<string, unknown>): { state: AuthState } => ({
|
|
@@ -6,11 +6,8 @@ import type { OverviewSection } from '../overview-sections';
|
|
|
6
6
|
/** The overview's KPI row: one card per figure. */
|
|
7
7
|
export const kpiSection: OverviewSection = {
|
|
8
8
|
id: 'kpi',
|
|
9
|
-
// Reflows on
|
|
10
|
-
//
|
|
11
|
-
// it — the rail narrows content without changing the viewport, so viewport
|
|
12
|
-
// breakpoints lie. `auto-fit` + a `minmax` floor drops to three, two, then one
|
|
13
|
-
// column as room runs out.
|
|
9
|
+
// Reflows on column width, not viewport: the assistant rail narrows the page
|
|
10
|
+
// without changing the viewport, so `lg:` breakpoints would lie.
|
|
14
11
|
render: (overview) => (
|
|
15
12
|
<div
|
|
16
13
|
data-section="kpi"
|
|
@@ -21,8 +18,7 @@ export const kpiSection: OverviewSection = {
|
|
|
21
18
|
))}
|
|
22
19
|
</div>
|
|
23
20
|
),
|
|
24
|
-
// Four
|
|
25
|
-
// the shape it stands in for makes the page jump on load.
|
|
21
|
+
// Four slots to match the four KPIs, so the page does not jump on load.
|
|
26
22
|
renderSkeleton: () => (
|
|
27
23
|
<div
|
|
28
24
|
data-section-skeleton="kpi"
|