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
|
@@ -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
|
@@ -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 './
|
|
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`
|
|
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 -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) {
|
|
@@ -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
|
|
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,
|
|
@@ -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 '../
|
|
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 '../
|
|
12
|
-
import { BUDGET_MONTHS } from '../
|
|
11
|
+
import type { BudgetRow } from '../overview-fixtures';
|
|
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
|
>
|
|
@@ -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 '
|
|
6
|
-
import type { Token } from '
|
|
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 '
|
|
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('
|
|
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 {
|
|
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 '
|
|
17
|
-
import { type CreateTokenInput, EXPIRY_OPTIONS, type TokenExpiry } from '
|
|
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
|
|
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"
|
|
@@ -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
|
-
//
|
|
57
|
-
//
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
20
|
-
//
|
|
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
|
|
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
|
|
40
|
-
//
|
|
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
|
-
//
|
|
48
|
-
// -
|
|
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
|
-
?
|
|
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 '
|
|
3
|
+
import { createToken, fetchToken, fetchTokens, resetFixtures } from './fixtures';
|
|
4
4
|
|
|
5
5
|
describe('fixtures', () => {
|
|
6
6
|
beforeEach(() => {
|