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.
Files changed (49) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +16 -11
  3. package/dist/{blocks-DYU5Ke29.js → blocks-BdJaEmz1.js} +36 -20
  4. package/dist/cli.js +29 -52
  5. package/dist/extract.js +9 -14
  6. package/dist/{regions-B3r5LhXl.js → regions-BNl4QsPP.js} +12 -22
  7. package/dist/{sanitize-CKs8pAzZ.js → sanitize-DVFBkv8b.js} +14 -24
  8. package/dist/sanitize.js +1 -2
  9. package/dist/template/AGENTS.md +20 -6
  10. package/dist/template/README.md +5 -0
  11. package/dist/template/oxlint.config.ts +10 -14
  12. package/dist/template/package.json +21 -20
  13. package/dist/template/src/auth/AuthScreen.test.tsx +44 -0
  14. package/dist/template/src/auth/AuthScreen.tsx +43 -0
  15. package/dist/template/src/auth/session.test.tsx +64 -0
  16. package/dist/template/src/auth/session.ts +42 -0
  17. package/dist/template/src/auth/states.tsx +70 -0
  18. package/dist/template/src/components/AppChrome.tsx +26 -74
  19. package/dist/template/src/components/SelectionFloater.tsx +10 -3
  20. package/dist/template/src/components/SidebarModeMenu.tsx +8 -13
  21. package/dist/template/src/nav.ts +6 -11
  22. package/dist/template/src/{routes/-fixtures.ts → overview-fixtures.ts} +6 -18
  23. package/dist/template/src/{routes/-hooks.ts → overview-hooks.ts} +1 -1
  24. package/dist/template/src/overview-sections.tsx +4 -1
  25. package/dist/template/src/recipes/Chart.test.tsx +1 -3
  26. package/dist/template/src/recipes/Chart.tsx +4 -11
  27. package/dist/template/src/recipes/DataGrid.test.tsx +1 -1
  28. package/dist/template/src/recipes/DataGrid.tsx +7 -21
  29. package/dist/template/src/routes/__root.tsx +21 -0
  30. package/dist/template/src/routes/{-index.test.tsx → index.test.tsx} +1 -1
  31. package/dist/template/src/routes/index.tsx +2 -3
  32. package/dist/template/src/routes/signed-out.test.tsx +56 -0
  33. package/dist/template/src/routes/signed-out.tsx +31 -0
  34. package/dist/template/src/routes/tokens/$id.tsx +2 -2
  35. package/dist/template/src/routes/tokens/{-index.test.tsx → index.test.tsx} +2 -2
  36. package/dist/template/src/routes/tokens/index.tsx +2 -3
  37. package/dist/template/src/routes/tokens/new.tsx +2 -2
  38. package/dist/template/src/sections/kpi.tsx +3 -7
  39. package/dist/template/src/slices/assistant.ts +3 -9
  40. package/dist/template/src/slices/shell.ts +4 -11
  41. package/dist/template/src/store.ts +9 -24
  42. package/dist/template/src/{routes/tokens/-fixtures.test.ts → tokens/fixtures.test.ts} +1 -1
  43. package/dist/template/src/{routes/tokens/-fixtures.ts → tokens/fixtures.ts} +1 -1
  44. package/dist/template/src/{routes/tokens/-hooks.ts → tokens/hooks.ts} +2 -2
  45. package/dist/template/src/{routes/tokens/-nav.ts → tokens/nav.ts} +1 -1
  46. package/dist/template/vite.config.ts +5 -0
  47. package/dist/template/vitest.config.ts +2 -5
  48. package/package.json +13 -12
  49. /package/dist/template/src/{routes/tokens/-types.ts → tokens/types.ts} +0 -0
@@ -9,14 +9,12 @@ const MODES: { value: SidebarMode; label: string }[] = [
9
9
  ];
10
10
 
11
11
  /**
12
- * Picks the left rail's mode. All three come from `SidebarProvider`.
12
+ * Picks the left rail's mode.
13
13
  *
14
- * - `expand-on-hover` is a mode of its own, not `collapsed` plus a hover handler: the
15
- * rail reports `mode="expand-on-hover"` while its `state` flips on hover. Styling that
16
- * distinguishes "the user collapsed this" from "collapsed until hovered" reads `mode`.
17
- * - A radio group, not a cycling button: three modes have no natural next, and the menu
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` keeps the trigger icon-sized rather than rail-wide, so it holds its
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
- {/* `align="start"` — the trigger sits at the rail's left edge, so a centered
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
- {/* `RadioGroup` is generic over its value: `value={mode}` pins it to
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}>
@@ -3,7 +3,7 @@ import type { ComponentType } from 'react';
3
3
  import { LayoutDashboard } from '@keboola/design/icons';
4
4
 
5
5
  // #region block:crud
6
- import { tokensNavItem } from './routes/tokens/-nav';
6
+ import { tokensNavItem } from './tokens/nav';
7
7
  // #endregion block:crud
8
8
 
9
9
  export type NavItem = { to: string; label: string; icon: ComponentType };
@@ -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` a literal. `Link`'s `to` is typed against the generated
15
- * route tree, so a widened `string` fails type-check at the call site.
16
- * - `satisfies` checks the shape without erasing those literals.
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
- * - Any other item stays active on its nested routes, matching Router's default
33
- * non-exact `Link` behaviour.
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 a real overview endpoint. Swap `fetchOverview` for the
2
- // api-client call (e.g. `api.metrics.overview(signal)`) and delete this file.
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 -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 on purpose: `KpiCard` paints a rise green, which is the wrong
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
- // Two series of the same order of magnitude, so they read against one linear
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
- // The grid *is* the budget the chart plots: each month's column sums to that
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
- // Simulates a network round-trip that resolves after `ms`, or rejects the moment
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) {
@@ -1,6 +1,6 @@
1
1
  import { useQuery } from '@tanstack/react-query';
2
2
 
3
- import { fetchOverview } from './-fixtures';
3
+ import { fetchOverview } from './overview-fixtures';
4
4
 
5
5
  const queryKeys = {
6
6
  overview: ['overview'] as const,
@@ -1,6 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
2
 
3
- import type { Overview } from './routes/-fixtures';
4
3
  // #region block:chart
5
4
  import { chartSection } from './sections/chart';
6
5
  // #endregion block:chart
@@ -10,6 +9,10 @@ import { gridSection } from './sections/grid';
10
9
  // #region block:kpi
11
10
  import { kpiSection } from './sections/kpi';
12
11
  // #endregion block:kpi
12
+ // `simple-import-sort` orders a sibling path after every nested one, so this import belongs
13
+ // past the final section region rather than inside it — the shell needs it whether or not a
14
+ // scaffold keeps that block.
15
+ import type { Overview } from './overview-fixtures';
13
16
 
14
17
  /**
15
18
  * One block's contribution to the overview.
@@ -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> only renders once it measures a
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
- // Token-aware chart recipe — colors come from CSS vars (semantic/brand tokens),
5
- // never hex, so it re-themes with the active brand. `LineChartCard` is a thin
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
- // Forwarded to the DS `LineChart` only when set; otherwise its responsive
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
- // Maps a `--chart-N` token var name to the design system's `--color-chart-N`
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,
@@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { expect, it } from 'vitest';
4
4
 
5
- import type { BudgetRow } from '../routes/-fixtures';
5
+ import type { BudgetRow } from '../overview-fixtures';
6
6
 
7
7
  import { CreditBudgetGrid } from './DataGrid';
8
8
 
@@ -8,22 +8,17 @@ import {
8
8
 
9
9
  import { NumberInput, Table } from '@keboola/design';
10
10
 
11
- import type { BudgetRow } from '../routes/-fixtures';
12
- import { BUDGET_MONTHS } from '../routes/-fixtures';
11
+ import type { BudgetRow } from '../overview-fixtures';
12
+ import { BUDGET_MONTHS } from '../overview-fixtures';
13
13
 
14
- // Instantiates the DS editable-grid recipe (`Table.DataGridRecipe` story,
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
- // Server data seeds local edit state, re-seeding when a fetch returns new rows.
24
- // Adjusted during render rather than in an effect: an effect would commit the
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
- : // Wide enough for the editor, not just the number. `NumberInput`
117
- // carries 16px of padding on each side, so a `w-20` (80px) cell
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
  >
@@ -2,6 +2,9 @@ import { createRootRoute, Outlet } from '@tanstack/react-router';
2
2
 
3
3
  import { Toaster } from '@keboola/design';
4
4
 
5
+ // #region block:auth
6
+ import { useIsAuthRoute } from '../auth/session';
7
+ // #endregion block:auth
5
8
  import { AppChrome } from '../components/AppChrome';
6
9
 
7
10
  export const Route = createRootRoute({
@@ -9,6 +12,24 @@ export const Route = createRootRoute({
9
12
  });
10
13
 
11
14
  function RootLayout() {
15
+ // #region block:auth
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`.
22
+ const isAuthRoute = useIsAuthRoute();
23
+ if (isAuthRoute) {
24
+ return (
25
+ <div className="tailwindcss-preflight">
26
+ <Outlet />
27
+ <Toaster />
28
+ </div>
29
+ );
30
+ }
31
+
32
+ // #endregion block:auth
12
33
  return (
13
34
  <div className="tailwindcss-preflight">
14
35
  <AppChrome>
@@ -6,9 +6,9 @@ import userEvent from '@testing-library/user-event';
6
6
  // #endregion block:assistant
7
7
  import { beforeEach, describe, expect, it, vi } from 'vitest';
8
8
 
9
+ import * as fixtures from '../overview-fixtures';
9
10
  import { useAppStore } from '../store';
10
11
 
11
- import * as fixtures from './-fixtures';
12
12
  import { HomePage } from './index';
13
13
 
14
14
  const renderPage = () => {
@@ -7,12 +7,11 @@ import { ErrorState, PageHeader, PageLayout } from '@keboola/design';
7
7
  import { HomePrompt } from '../components/HomePrompt';
8
8
  import { SelectionFloater } from '../components/SelectionFloater';
9
9
  // #endregion block:assistant
10
+ import type { Overview } from '../overview-fixtures';
11
+ import { useOverview } from '../overview-hooks';
10
12
  import { OVERVIEW_SECTIONS } from '../overview-sections';
11
13
  import { useAppStore } from '../store';
12
14
 
13
- import type { Overview } from './-fixtures';
14
- import { useOverview } from './-hooks';
15
-
16
15
  export const Route = createFileRoute('/')({
17
16
  component: HomePage,
18
17
  });
@@ -0,0 +1,56 @@
1
+ import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { keboola } from '@keboola/brand-registry';
6
+ import { ThemeProvider } from '@keboola/design';
7
+
8
+ import { routeTree } from '../routeTree.gen';
9
+
10
+ // jsdom implements no scrolling, and Router's scroll restoration calls this on every navigation —
11
+ // unstubbed it floods the run with `Not implemented: window.scrollTo`.
12
+ beforeEach(() => {
13
+ vi.stubGlobal('scrollTo', vi.fn());
14
+ });
15
+
16
+ afterEach(() => {
17
+ vi.unstubAllGlobals();
18
+ });
19
+
20
+ // The real tree, not a stub: the behaviour under test is the root layout choosing between
21
+ // AppChrome and the bare outlet, and a hand-built tree would not exercise that choice.
22
+ const renderAt = (path: string) => {
23
+ const router = createRouter({
24
+ routeTree,
25
+ history: createMemoryHistory({ initialEntries: [path] }),
26
+ });
27
+ return render(
28
+ <ThemeProvider brands={[keboola]} defaultBrandId={keboola.id} defaultScheme="light">
29
+ <RouterProvider router={router} />
30
+ </ThemeProvider>,
31
+ );
32
+ };
33
+
34
+ describe('/signed-out', () => {
35
+ it('renders without the app chrome', async () => {
36
+ renderAt('/signed-out');
37
+
38
+ expect(await screen.findByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument();
39
+ // The rail is the tell: a signed-out visitor must reach no destination in the app.
40
+ expect(screen.queryByRole('navigation', { name: 'Sections' })).not.toBeInTheDocument();
41
+ });
42
+
43
+ it('picks the state named by the search param', async () => {
44
+ renderAt('/signed-out?state=expired');
45
+
46
+ expect(
47
+ await screen.findByRole('heading', { level: 1, name: 'Session expired' }),
48
+ ).toBeInTheDocument();
49
+ });
50
+
51
+ it('falls back to the sign-in form for a state it does not know', async () => {
52
+ renderAt('/signed-out?state=nonsense');
53
+
54
+ expect(await screen.findByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument();
55
+ });
56
+ });
@@ -0,0 +1,31 @@
1
+ import { createFileRoute, useNavigate } from '@tanstack/react-router';
2
+
3
+ import { AuthScreen } from '../auth/AuthScreen';
4
+ import type { AuthState } from '../auth/session';
5
+ import { AUTH_ROUTE, AUTH_STATES, isAuthState } from '../auth/session';
6
+
7
+ /**
8
+ * `/signed-out` — the pre-project screen.
9
+ *
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.
13
+ */
14
+ export const Route = createFileRoute('/signed-out')({
15
+ validateSearch: (search: Record<string, unknown>): { state: AuthState } => ({
16
+ state: isAuthState(search.state) ? search.state : 'signed-out',
17
+ }),
18
+ component: SignedOutPage,
19
+ });
20
+
21
+ function SignedOutPage() {
22
+ const { state } = Route.useSearch();
23
+ const navigate = useNavigate();
24
+ const { title, body: Body } = AUTH_STATES[state];
25
+
26
+ return (
27
+ <AuthScreen title={title}>
28
+ <Body onRetry={() => void navigate({ to: AUTH_ROUTE, search: { state: 'signed-out' } })} />
29
+ </AuthScreen>
30
+ );
31
+ }
@@ -2,8 +2,8 @@ import { createFileRoute, Link } from '@tanstack/react-router';
2
2
 
3
3
  import { Breadcrumb, Button, ErrorState, PageHeader, PageLayout, Skeleton } from '@keboola/design';
4
4
 
5
- import { useToken } from './-hooks';
6
- import type { Token } from './-types';
5
+ import { useToken } from '../../tokens/hooks';
6
+ import type { Token } from '../../tokens/types';
7
7
 
8
8
  export const Route = createFileRoute('/tokens/$id')({
9
9
  component: TokenDetailPage,
@@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react';
3
3
  import userEvent from '@testing-library/user-event';
4
4
  import { beforeEach, describe, expect, it, vi } from 'vitest';
5
5
 
6
- import type { Token } from './-types';
6
+ import type { Token } from '../../tokens/types';
7
7
 
8
8
  // Render the page without a RouterProvider: mock the two router exports it uses
9
9
  // (`createFileRoute` returns the Route object; `Link` renders a plain anchor).
@@ -14,7 +14,7 @@ vi.mock('@tanstack/react-router', () => ({
14
14
 
15
15
  // Drive each async branch deterministically by mocking the query hook boundary.
16
16
  const { useTokensMock } = vi.hoisted(() => ({ useTokensMock: vi.fn() }));
17
- vi.mock('./-hooks', () => ({ useTokens: () => useTokensMock() }));
17
+ vi.mock('../../tokens/hooks', () => ({ useTokens: () => useTokensMock() }));
18
18
 
19
19
  import { Route } from './index';
20
20
 
@@ -14,9 +14,8 @@ import {
14
14
  } from '@keboola/design';
15
15
 
16
16
  import { useAppStore } from '../../store';
17
-
18
- import { useTokens } from './-hooks';
19
- import type { Token } from './-types';
17
+ import { useTokens } from '../../tokens/hooks';
18
+ import type { Token } from '../../tokens/types';
20
19
 
21
20
  export const Route = createFileRoute('/tokens/')({
22
21
  component: TokensListPage,
@@ -13,8 +13,8 @@ import {
13
13
  toast,
14
14
  } from '@keboola/design';
15
15
 
16
- import { useCreateToken } from './-hooks';
17
- import { type CreateTokenInput, EXPIRY_OPTIONS, type TokenExpiry } from './-types';
16
+ import { useCreateToken } from '../../tokens/hooks';
17
+ import { type CreateTokenInput, EXPIRY_OPTIONS, type TokenExpiry } from '../../tokens/types';
18
18
 
19
19
  export const Route = createFileRoute('/tokens/new')({
20
20
  component: NewTokenPage,
@@ -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 this column's own width, not the viewport's. A `lg:grid-cols-4` here
10
- // would keep four columns while the assistant rail squeezes the page to a third of
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 placeholders because four KPIs are coming — a skeleton that does not match
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"
@@ -9,11 +9,7 @@ type ChatMessage = {
9
9
  };
10
10
 
11
11
  export type AssistantSlice = {
12
- /**
13
- * One transcript for the whole app. The home page's prompt input and the rail's
14
- * own composer both call `send`, so sending from the page is a handoff into the
15
- * rail rather than a second, parallel conversation.
16
- */
12
+ /** One app-wide transcript: the home prompt and the rail's composer both `send` into it. */
17
13
  messages: ChatMessage[];
18
14
  send: (text: string) => void;
19
15
  fullscreen: boolean;
@@ -53,10 +49,8 @@ export const createAssistantSlice: StateCreator<AppState, Mutators, [], Assistan
53
49
  messages: [
54
50
  ...s.messages,
55
51
  { id: crypto.randomUUID(), role: 'user', text },
56
- // A canned reply, not a backend call — swap the whole branch for a
57
- // real request. It answers in Markdown (including a ```mermaid fence)
58
- // because that is what an assistant actually returns, and rendering
59
- // that is the rail's job: see AssistantPanel.
52
+ // Canned stand-in for a backend call; Markdown with a ```mermaid fence,
53
+ // rendered by AssistantPanel.
60
54
  { id: crypto.randomUUID(), role: 'assistant', text: cannedReply(text) },
61
55
  ],
62
56
  }),
@@ -7,9 +7,7 @@ import type { AppState } from '../store';
7
7
  /**
8
8
  * The modes `sidebarMode` may hold, as a runtime value.
9
9
  *
10
- * - Keyed by `SidebarMode`, so adding a mode to the union fails the build here until
11
- * it is listed — a hand-written array would silently start rejecting the new mode.
12
- * - The values carry nothing; the keys are the whole point.
10
+ * - Keyed by `SidebarMode`, so a new mode fails the build until it is listed here.
13
11
  */
14
12
  const SIDEBAR_MODES: Record<SidebarMode, true> = {
15
13
  expanded: true,
@@ -22,12 +20,8 @@ export const isSidebarMode = (value: unknown): value is SidebarMode =>
22
20
 
23
21
  export type ShellSlice = {
24
22
  /**
25
- * The left rail's mode.
26
- *
27
- * - Lives here, not inside `SidebarProvider`, so it survives a reload; a layout
28
- * preference that resets reads as a broken control.
29
- * - `AppChrome` drives the provider from it (`leftMode` + `onLeftModeChange`).
30
- * - `SidebarModeMenu` is the UI that writes it.
23
+ * - Stored here, not in `SidebarProvider`, so it survives a reload.
24
+ * - `AppChrome` feeds it to the provider; `SidebarModeMenu` writes it.
31
25
  */
32
26
  sidebarMode: SidebarMode;
33
27
  setSidebarMode: (mode: SidebarMode) => void;
@@ -39,8 +33,7 @@ export type ShellSlice = {
39
33
  /**
40
34
  * The mutator tuple mirrors `store.ts`'s middleware stack, outside-in.
41
35
  *
42
- * - Without it `set` loses its third argument and every `set(partial, false, 'name')`
43
- * call fails to type-check.
36
+ * - Without it `set(partial, false, 'name')` fails to type-check.
44
37
  */
45
38
  type Mutators = [['zustand/devtools', never], ['zustand/persist', unknown]];
46
39
 
@@ -16,18 +16,14 @@ export type AppState = ShellSlice &
16
16
  // #region block:assistant
17
17
  AssistantSlice &
18
18
  // #endregion block:assistant
19
- // `unknown` is the identity for `&`, so the line above can go without leaving a dangling
20
- // operator. The comment keeps this alias on three lines, which is what `oxfmt` expects of
21
- // it in an app that dropped the middle term.
19
+ // - `unknown` is the identity for `&`, so the line above can go without a dangling operator.
20
+ // - This comment keeps the alias multi-line, as `oxfmt` expects once that line is gone.
22
21
  unknown;
23
22
 
24
23
  export const useAppStore = create<AppState>()(
25
24
  devtools(
26
- // `persist` inside `devtools` so rehydration lands in the devtools timeline as an
27
- // action. Only `sidebarMode` is written out — see `partialize`.
25
+ // `persist` inside `devtools` so rehydration shows in the devtools timeline.
28
26
  persist(
29
- // Zustand's slice pattern: `(...a)` forwards `set`, `get` and the store API to
30
- // each creator without naming the three of them per slice.
31
27
  (...a) => ({
32
28
  ...createShellSlice(...a),
33
29
  // #region block:assistant
@@ -36,26 +32,15 @@ export const useAppStore = create<AppState>()(
36
32
  }),
37
33
  {
38
34
  name: UI_SETTINGS_STORAGE_KEY,
39
- // No `version` here on purpose. Zustand discards a record whose version does not
40
- // match and no `migrate` covers, so a bare bump silently resets every returning
41
- // visitor. If the stored shape ever changes, add `version` and `migrate` in the
42
- // same change. A value that is merely unrecognised is `merge`'s job, below.
43
- // The layout preference and nothing else. The default is the whole store, which
44
- // would restore a stale transcript on reload and pin `fullscreen` open across
45
- // sessions.
35
+ // - No `version`: a bump without `migrate` resets every stored record; add both together.
36
+ // - Layout only; the whole store would restore a stale transcript and pin `fullscreen`.
46
37
  partialize: (state) => ({ sidebarMode: state.sidebarMode }),
47
- // Validate at the storage boundary instead of trusting what comes back.
48
- // - `localStorage` is user-writable, and a value written by an older build
49
- // outlives the code that wrote it.
50
- // - An unrecognised mode is not inert: `SidebarProvider` derives the rail's
51
- // `state` from it, no branch matches, so the rail sticks at icon width with
52
- // nothing checked in the mode menu — recoverable only by picking a mode.
53
- // - Falling back to `current` means the in-memory default, so a junk value
54
- // reads exactly like a first visit.
38
+ // - Untrusted: `localStorage` is user-writable and outlives the build that wrote it.
39
+ // - An unknown mode sticks the rail at icon width, so junk falls back to the default.
55
40
  merge: (persisted, current) => {
56
41
  const stored: unknown =
57
- typeof persisted === 'object' && persisted !== null
58
- ? Reflect.get(persisted, 'sidebarMode')
42
+ typeof persisted === 'object' && persisted !== null && 'sidebarMode' in persisted
43
+ ? persisted.sidebarMode
59
44
  : undefined;
60
45
 
61
46
  return { ...current, sidebarMode: isSidebarMode(stored) ? stored : current.sidebarMode };
@@ -1,6 +1,6 @@
1
1
  import { beforeEach, describe, expect, it } from 'vitest';
2
2
 
3
- import { createToken, fetchToken, fetchTokens, resetFixtures } from './-fixtures';
3
+ import { createToken, fetchToken, fetchTokens, resetFixtures } from './fixtures';
4
4
 
5
5
  describe('fixtures', () => {
6
6
  beforeEach(() => {