create-keboola-app 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +79 -0
  2. package/dist/blocks-DYU5Ke29.js +147 -0
  3. package/dist/cli.js +451 -0
  4. package/dist/extract.js +133 -0
  5. package/dist/regions-B3r5LhXl.js +95 -0
  6. package/dist/sanitize-CKs8pAzZ.js +158 -0
  7. package/dist/sanitize.js +2 -0
  8. package/dist/template/AGENTS.md +133 -0
  9. package/dist/template/README.md +56 -0
  10. package/dist/template/_env.example +6 -0
  11. package/dist/template/_gitignore +7 -0
  12. package/dist/template/_oxfmtrc.json +18 -0
  13. package/dist/template/index.html +13 -0
  14. package/dist/template/oxlint.config.ts +43 -0
  15. package/dist/template/package.json +53 -0
  16. package/dist/template/public/favicon.svg +10 -0
  17. package/dist/template/src/components/AppChrome.tsx +201 -0
  18. package/dist/template/src/components/AssistantPanel.test.tsx +65 -0
  19. package/dist/template/src/components/AssistantPanel.tsx +96 -0
  20. package/dist/template/src/components/AssistantToggle.tsx +37 -0
  21. package/dist/template/src/components/ColorModeToggle.tsx +15 -0
  22. package/dist/template/src/components/HomePrompt.tsx +35 -0
  23. package/dist/template/src/components/KpiCard.test.tsx +20 -0
  24. package/dist/template/src/components/KpiCard.tsx +24 -0
  25. package/dist/template/src/components/SelectionFloater.test.tsx +73 -0
  26. package/dist/template/src/components/SelectionFloater.tsx +88 -0
  27. package/dist/template/src/components/SidebarModeMenu.test.tsx +68 -0
  28. package/dist/template/src/components/SidebarModeMenu.tsx +54 -0
  29. package/dist/template/src/index.css +26 -0
  30. package/dist/template/src/main.tsx +57 -0
  31. package/dist/template/src/nav.test.ts +25 -0
  32. package/dist/template/src/nav.ts +38 -0
  33. package/dist/template/src/overview-sections.test.tsx +36 -0
  34. package/dist/template/src/overview-sections.tsx +40 -0
  35. package/dist/template/src/recipes/Chart.test.tsx +24 -0
  36. package/dist/template/src/recipes/Chart.tsx +56 -0
  37. package/dist/template/src/recipes/DataGrid.test.tsx +47 -0
  38. package/dist/template/src/recipes/DataGrid.tsx +151 -0
  39. package/dist/template/src/routes/-fixtures.ts +113 -0
  40. package/dist/template/src/routes/-hooks.ts +15 -0
  41. package/dist/template/src/routes/-index.test.tsx +123 -0
  42. package/dist/template/src/routes/__root.tsx +20 -0
  43. package/dist/template/src/routes/index.tsx +95 -0
  44. package/dist/template/src/routes/tokens/$id.tsx +95 -0
  45. package/dist/template/src/routes/tokens/-fixtures.test.ts +60 -0
  46. package/dist/template/src/routes/tokens/-fixtures.ts +91 -0
  47. package/dist/template/src/routes/tokens/-hooks.ts +37 -0
  48. package/dist/template/src/routes/tokens/-index.test.tsx +75 -0
  49. package/dist/template/src/routes/tokens/-nav.ts +17 -0
  50. package/dist/template/src/routes/tokens/-types.ts +22 -0
  51. package/dist/template/src/routes/tokens/index.tsx +182 -0
  52. package/dist/template/src/routes/tokens/new.tsx +163 -0
  53. package/dist/template/src/sections/chart.tsx +34 -0
  54. package/dist/template/src/sections/grid.tsx +21 -0
  55. package/dist/template/src/sections/kpi.tsx +36 -0
  56. package/dist/template/src/slices/assistant.ts +68 -0
  57. package/dist/template/src/slices/shell.ts +54 -0
  58. package/dist/template/src/store.test.tsx +92 -0
  59. package/dist/template/src/store.ts +67 -0
  60. package/dist/template/src/test-setup.ts +11 -0
  61. package/dist/template/src/vite-env.d.ts +1 -0
  62. package/dist/template/templates/agent-safety/README.md +14 -0
  63. package/dist/template/templates/agent-safety/aiexclude +25 -0
  64. package/dist/template/templates/agent-safety/claude-code.settings.json +45 -0
  65. package/dist/template/templates/agent-safety/cursorignore +25 -0
  66. package/dist/template/tsconfig.json +10 -0
  67. package/dist/template/vercel.json +4 -0
  68. package/dist/template/vite.config.ts +21 -0
  69. package/dist/template/vitest.config.ts +21 -0
  70. package/dist/template/workspace-deps.json +7 -0
  71. package/package.json +64 -0
@@ -0,0 +1,151 @@
1
+ import { useCallback, useMemo, useState } from 'react';
2
+ import {
3
+ createColumnHelper,
4
+ flexRender,
5
+ getCoreRowModel,
6
+ useReactTable,
7
+ } from '@tanstack/react-table';
8
+
9
+ import { NumberInput, Table } from '@keboola/design';
10
+
11
+ import type { BudgetRow } from '../routes/-fixtures';
12
+ import { BUDGET_MONTHS } from '../routes/-fixtures';
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.
18
+ const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0);
19
+
20
+ const columnHelper = createColumnHelper<BudgetRow>();
21
+
22
+ 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).
27
+ const [data, setData] = useState<BudgetRow[]>(rows);
28
+ const [seed, setSeed] = useState<BudgetRow[]>(rows);
29
+ if (seed !== rows) {
30
+ setSeed(rows);
31
+ setData(rows);
32
+ }
33
+
34
+ const updateCell = useCallback(
35
+ (rowIndex: number, monthIndex: number, value: number) =>
36
+ setData((current) =>
37
+ current.map((row, index) => {
38
+ if (index !== rowIndex) return row;
39
+ const months = [...row.months];
40
+ months[monthIndex] = value;
41
+ return { ...row, months };
42
+ }),
43
+ ),
44
+ [],
45
+ );
46
+
47
+ const columns = useMemo(
48
+ () => [
49
+ columnHelper.accessor('component', {
50
+ header: 'Component',
51
+ cell: ({ row, getValue }) => (
52
+ <>
53
+ {getValue()}{' '}
54
+ <span className="tw:text-xs tw:text-muted-foreground">{row.original.kind}</span>
55
+ </>
56
+ ),
57
+ }),
58
+ ...BUDGET_MONTHS.map((month, monthIndex) =>
59
+ columnHelper.accessor((row) => row.months[monthIndex] ?? 0, {
60
+ id: `month-${monthIndex}`,
61
+ header: month,
62
+ cell: ({ getValue, row }) => (
63
+ <NumberInput
64
+ aria-label={`${row.original.component} ${month}`}
65
+ value={getValue()}
66
+ onValueChange={(value) => updateCell(row.index, monthIndex, value ?? 0)}
67
+ />
68
+ ),
69
+ }),
70
+ ),
71
+ columnHelper.display({
72
+ id: 'total',
73
+ header: 'Total',
74
+ cell: ({ row }) => (
75
+ <span className="tw:font-semibold tw:tabular-nums">{sum(row.original.months)}</span>
76
+ ),
77
+ }),
78
+ ],
79
+ [updateCell],
80
+ );
81
+
82
+ const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() });
83
+
84
+ const monthTotals = BUDGET_MONTHS.map((_, monthIndex) =>
85
+ sum(data.map((row) => row.months[monthIndex] ?? 0)),
86
+ );
87
+ const grandTotal = sum(monthTotals);
88
+
89
+ return (
90
+ <Table bordered>
91
+ <Table.Header>
92
+ {table.getHeaderGroups().map((headerGroup) => (
93
+ <Table.Row key={headerGroup.id}>
94
+ {headerGroup.headers.map((header) => (
95
+ <Table.Head
96
+ key={header.id}
97
+ className={header.column.id === 'component' ? undefined : 'tw:text-right'}
98
+ >
99
+ {flexRender(header.column.columnDef.header, header.getContext())}
100
+ </Table.Head>
101
+ ))}
102
+ </Table.Row>
103
+ ))}
104
+ </Table.Header>
105
+ <Table.Body>
106
+ {table.getRowModel().rows.map((row) => (
107
+ <Table.Row key={row.id}>
108
+ {row.getVisibleCells().map((cell) => (
109
+ <Table.Cell
110
+ key={cell.id}
111
+ className={
112
+ cell.column.id === 'component'
113
+ ? 'tw:min-w-52'
114
+ : cell.column.id === 'total'
115
+ ? '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.
127
+ 'tw:min-w-28'
128
+ }
129
+ >
130
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
131
+ </Table.Cell>
132
+ ))}
133
+ </Table.Row>
134
+ ))}
135
+ </Table.Body>
136
+ <Table.Footer>
137
+ <Table.Row>
138
+ <Table.Cell className="tw:font-semibold">Total</Table.Cell>
139
+ {BUDGET_MONTHS.map((month, monthIndex) => (
140
+ <Table.Cell key={month} className="tw:text-right tw:font-semibold tw:tabular-nums">
141
+ {monthTotals[monthIndex]}
142
+ </Table.Cell>
143
+ ))}
144
+ <Table.Cell className="tw:text-right tw:font-semibold tw:tabular-nums">
145
+ {grandTotal}
146
+ </Table.Cell>
147
+ </Table.Row>
148
+ </Table.Footer>
149
+ </Table>
150
+ );
151
+ }
@@ -0,0 +1,113 @@
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.
5
+
6
+ export type Kpi = {
7
+ label: string;
8
+ value: string;
9
+ deltaPct?: number;
10
+ };
11
+
12
+ /** A month of credit consumption against what was budgeted for it. */
13
+ export type CreditPoint = { month: string; used: number; budgeted: number };
14
+
15
+ /** One component's monthly credit budget, editable in the grid. */
16
+ export type BudgetRow = {
17
+ id: string;
18
+ component: string;
19
+ kind: string;
20
+ months: number[];
21
+ };
22
+
23
+ export type Overview = {
24
+ kpis: Kpi[];
25
+ credits: CreditPoint[];
26
+ budget: BudgetRow[];
27
+ };
28
+
29
+ export const BUDGET_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'] as const;
30
+
31
+ const OVERVIEW: Overview = {
32
+ kpis: [
33
+ { label: 'Job success rate', value: '98.7%', deltaPct: 0.3 },
34
+ { label: 'Storage used', value: '1.4 TB', deltaPct: 4.2 },
35
+ { 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.
38
+ { label: 'Credits this month', value: '8,420' },
39
+ ],
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.
46
+ credits: [
47
+ { month: 'Jan', used: 4200, budgeted: 8000 },
48
+ { month: 'Feb', used: 5100, budgeted: 8000 },
49
+ { month: 'Mar', used: 5900, budgeted: 8000 },
50
+ { month: 'Apr', used: 6800, budgeted: 8500 },
51
+ { month: 'May', used: 7600, budgeted: 8500 },
52
+ { month: 'Jun', used: 8420, budgeted: 8500 },
53
+ ],
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.
57
+ budget: [
58
+ {
59
+ id: 'ex-db',
60
+ component: 'Database extractor',
61
+ kind: 'Extractor',
62
+ months: [4200, 4300, 4400, 4500, 4600, 4700],
63
+ },
64
+ {
65
+ id: 'tr-python',
66
+ component: 'Python transformation',
67
+ kind: 'Transformation',
68
+ months: [2100, 2150, 2200, 2250, 2300, 2350],
69
+ },
70
+ {
71
+ id: 'wr-snowflake',
72
+ component: 'Snowflake writer',
73
+ kind: 'Writer',
74
+ months: [900, 920, 950, 980, 1000, 1050],
75
+ },
76
+ {
77
+ id: 'orchestrator',
78
+ component: 'Daily orchestration',
79
+ kind: 'Flow',
80
+ months: [800, 630, 450, 770, 600, 400],
81
+ },
82
+ ],
83
+ };
84
+
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.
88
+ const delay = (ms: number, signal?: AbortSignal): Promise<void> =>
89
+ new Promise((resolve, reject) => {
90
+ if (signal?.aborted) {
91
+ reject(signal.reason);
92
+ return;
93
+ }
94
+ const timer = setTimeout(() => {
95
+ signal?.removeEventListener('abort', onAbort);
96
+ resolve();
97
+ }, ms);
98
+ const onAbort = () => {
99
+ clearTimeout(timer);
100
+ reject(signal?.reason);
101
+ };
102
+ signal?.addEventListener('abort', onAbort, { once: true });
103
+ });
104
+
105
+ /** Resolves with the project overview after a simulated round-trip; rejects if aborted. */
106
+ export const fetchOverview = async (signal?: AbortSignal): Promise<Overview> => {
107
+ await delay(200, signal);
108
+ return {
109
+ kpis: [...OVERVIEW.kpis],
110
+ credits: [...OVERVIEW.credits],
111
+ budget: OVERVIEW.budget.map((row) => ({ ...row, months: [...row.months] })),
112
+ };
113
+ };
@@ -0,0 +1,15 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+
3
+ import { fetchOverview } from './-fixtures';
4
+
5
+ const queryKeys = {
6
+ overview: ['overview'] as const,
7
+ } as const;
8
+
9
+ export const useOverview = () =>
10
+ useQuery({
11
+ queryKey: queryKeys.overview,
12
+ // Always thread the query's AbortSignal to the data call — TanStack Query
13
+ // aborts it on unmount / refetch. Swap `fetchOverview` for the real client.
14
+ queryFn: ({ signal }) => fetchOverview(signal),
15
+ });
@@ -0,0 +1,123 @@
1
+ import type { ReactNode } from 'react';
2
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
+ import { render, screen, waitFor } from '@testing-library/react';
4
+ // #region block:assistant
5
+ import userEvent from '@testing-library/user-event';
6
+ // #endregion block:assistant
7
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
8
+
9
+ import { useAppStore } from '../store';
10
+
11
+ import * as fixtures from './-fixtures';
12
+ import { HomePage } from './index';
13
+
14
+ const renderPage = () => {
15
+ // `retry: false` so the error branch is reachable without waiting out backoff.
16
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
17
+ const wrapper = ({ children }: { children: ReactNode }) => (
18
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
19
+ );
20
+ return render(<HomePage />, { wrapper });
21
+ };
22
+
23
+ describe('HomePage', () => {
24
+ beforeEach(() => {
25
+ useAppStore.setState({ helpVisible: true });
26
+ // #region block:assistant
27
+ useAppStore.setState({ messages: [], fullscreen: false });
28
+ // #endregion block:assistant
29
+ });
30
+
31
+ // #region block:kpi
32
+ it('renders the KPIs once the overview loads', async () => {
33
+ renderPage();
34
+
35
+ expect(await screen.findByText('Job success rate')).toBeInTheDocument();
36
+ expect(screen.getByText('Credits this month')).toBeInTheDocument();
37
+ });
38
+ // #endregion block:kpi
39
+
40
+ // #region block:chart
41
+ it('renders the credits chart once the overview loads', async () => {
42
+ renderPage();
43
+
44
+ expect(
45
+ await screen.findByRole('heading', { name: 'Credits: used vs budgeted' }),
46
+ ).toBeInTheDocument();
47
+ });
48
+ // #endregion block:chart
49
+
50
+ // #region block:grid
51
+ it('renders the budget grid once the overview loads', async () => {
52
+ renderPage();
53
+
54
+ expect(await screen.findByText('Component')).toBeInTheDocument();
55
+ });
56
+ // #endregion block:grid
57
+
58
+ it('shows a loading placeholder before the overview resolves', () => {
59
+ const { container } = renderPage();
60
+
61
+ // Asserted before the query settles: `[data-section]` is the success branch, and it holds
62
+ // for any block selection — a shell-only app has no KPI label to look for.
63
+ expect(container.querySelectorAll('[data-section]')).toHaveLength(0);
64
+ expect(screen.getByRole('heading', { name: 'Overview' })).toBeInTheDocument();
65
+ });
66
+
67
+ it('shows an error state when the overview fails to load', async () => {
68
+ vi.spyOn(fixtures, 'fetchOverview').mockRejectedValueOnce(new Error('network is down'));
69
+ renderPage();
70
+
71
+ expect(await screen.findByText('Failed to load the overview')).toBeInTheDocument();
72
+ expect(screen.getByText('network is down')).toBeInTheDocument();
73
+ });
74
+
75
+ // #region block:assistant
76
+ it('hands a prompt off to the assistant transcript rather than answering inline', async () => {
77
+ const user = userEvent.setup();
78
+ renderPage();
79
+
80
+ await user.type(screen.getByPlaceholderText('Ask about this page…'), 'why is churn up?');
81
+ await user.keyboard('{Enter}');
82
+
83
+ await waitFor(() => expect(useAppStore.getState().messages).toHaveLength(2));
84
+ const [question, reply] = useAppStore.getState().messages;
85
+ expect(question?.text).toBe('why is churn up?');
86
+ expect(reply?.role).toBe('assistant');
87
+ // The rail renders the reply; the page must not.
88
+ expect(screen.queryByText(/Credits tracked under budget/)).not.toBeInTheDocument();
89
+ });
90
+
91
+ it('clears the prompt after sending', async () => {
92
+ const user = userEvent.setup();
93
+ renderPage();
94
+
95
+ const prompt = screen.getByPlaceholderText('Ask about this page…');
96
+ await user.type(prompt, 'hello');
97
+ await user.keyboard('{Enter}');
98
+
99
+ expect(prompt).toHaveValue('');
100
+ });
101
+
102
+ it('ignores a whitespace-only prompt', async () => {
103
+ const user = userEvent.setup();
104
+ renderPage();
105
+
106
+ await user.type(screen.getByPlaceholderText('Ask about this page…'), ' ');
107
+ await user.keyboard('{Enter}');
108
+
109
+ expect(useAppStore.getState().messages).toEqual([]);
110
+ });
111
+ // #endregion block:assistant
112
+
113
+ it('drops the page description when help is hidden', async () => {
114
+ renderPage();
115
+ expect(await screen.findByText(/Credit consumption, job health/)).toBeInTheDocument();
116
+
117
+ useAppStore.setState({ helpVisible: false });
118
+
119
+ await waitFor(() =>
120
+ expect(screen.queryByText(/Credit consumption, job health/)).not.toBeInTheDocument(),
121
+ );
122
+ });
123
+ });
@@ -0,0 +1,20 @@
1
+ import { createRootRoute, Outlet } from '@tanstack/react-router';
2
+
3
+ import { Toaster } from '@keboola/design';
4
+
5
+ import { AppChrome } from '../components/AppChrome';
6
+
7
+ export const Route = createRootRoute({
8
+ component: RootLayout,
9
+ });
10
+
11
+ function RootLayout() {
12
+ return (
13
+ <div className="tailwindcss-preflight">
14
+ <AppChrome>
15
+ <Outlet />
16
+ </AppChrome>
17
+ <Toaster />
18
+ </div>
19
+ );
20
+ }
@@ -0,0 +1,95 @@
1
+ import { Fragment, useRef } from 'react';
2
+ import { createFileRoute } from '@tanstack/react-router';
3
+
4
+ import { ErrorState, PageHeader, PageLayout } from '@keboola/design';
5
+
6
+ // #region block:assistant
7
+ import { HomePrompt } from '../components/HomePrompt';
8
+ import { SelectionFloater } from '../components/SelectionFloater';
9
+ // #endregion block:assistant
10
+ import { OVERVIEW_SECTIONS } from '../overview-sections';
11
+ import { useAppStore } from '../store';
12
+
13
+ import type { Overview } from './-fixtures';
14
+ import { useOverview } from './-hooks';
15
+
16
+ export const Route = createFileRoute('/')({
17
+ component: HomePage,
18
+ });
19
+
20
+ export function HomePage() {
21
+ const { data, isLoading, isError, error } = useOverview();
22
+ const helpVisible = useAppStore((s) => s.helpVisible);
23
+ // Marks the page's data content, as opposed to its chrome and its composer.
24
+ // #region block:assistant
25
+ // The selection floater scopes itself to this element, so selecting your own draft text
26
+ // does not offer to explain it back to you.
27
+ // #endregion block:assistant
28
+ const contentRef = useRef<HTMLDivElement>(null);
29
+
30
+ return (
31
+ <PageLayout>
32
+ <PageLayout.Header>
33
+ <PageHeader.Index
34
+ title="Overview"
35
+ description={
36
+ helpVisible
37
+ ? 'Credit consumption, job health, and the per-component budget for this project. Select any figure to ask about it.'
38
+ : undefined
39
+ }
40
+ />
41
+ </PageLayout.Header>
42
+
43
+ <PageLayout.Content className="tw:min-w-0">
44
+ <div className="tw:flex tw:flex-col tw:gap-6">
45
+ {/* #region block:assistant */}
46
+ <HomePrompt />
47
+ {/* #endregion block:assistant */}
48
+ <div ref={contentRef} className="tw:flex tw:flex-col tw:gap-6">
49
+ {isLoading && <OverviewSkeleton />}
50
+ {isError && <OverviewError error={error} />}
51
+ {!isLoading && !isError && data !== undefined && <OverviewContent overview={data} />}
52
+ </div>
53
+ </div>
54
+ {/* #region block:assistant */}
55
+ <SelectionFloater
56
+ containerRef={contentRef}
57
+ onExplain={(text) => useAppStore.getState().send(`Explain: ${text}`)}
58
+ />
59
+ {/* #endregion block:assistant */}
60
+ </PageLayout.Content>
61
+ </PageLayout>
62
+ );
63
+ }
64
+
65
+ // The page owns the frame; `src/overview-sections.tsx` owns which sections are present
66
+ // and in what order.
67
+ function OverviewContent({ overview }: { overview: Overview }) {
68
+ return (
69
+ <>
70
+ {OVERVIEW_SECTIONS.map((section) => (
71
+ <Fragment key={section.id}>{section.render(overview)}</Fragment>
72
+ ))}
73
+ </>
74
+ );
75
+ }
76
+
77
+ function OverviewSkeleton() {
78
+ return (
79
+ <div className="tw:flex tw:flex-col tw:gap-6">
80
+ {OVERVIEW_SECTIONS.map((section) => (
81
+ <Fragment key={section.id}>{section.renderSkeleton()}</Fragment>
82
+ ))}
83
+ </div>
84
+ );
85
+ }
86
+
87
+ function OverviewError({ error }: { error: Error }) {
88
+ return (
89
+ <ErrorState>
90
+ <ErrorState.Icon>⚠</ErrorState.Icon>
91
+ <ErrorState.Title>Failed to load the overview</ErrorState.Title>
92
+ <ErrorState.Description>{error.message}</ErrorState.Description>
93
+ </ErrorState>
94
+ );
95
+ }
@@ -0,0 +1,95 @@
1
+ import { createFileRoute, Link } from '@tanstack/react-router';
2
+
3
+ import { Breadcrumb, Button, ErrorState, PageHeader, PageLayout, Skeleton } from '@keboola/design';
4
+
5
+ import { useToken } from './-hooks';
6
+ import type { Token } from './-types';
7
+
8
+ export const Route = createFileRoute('/tokens/$id')({
9
+ component: TokenDetailPage,
10
+ });
11
+
12
+ function TokenDetailPage() {
13
+ const { id } = Route.useParams();
14
+ const { data: token, isLoading, isError, error } = useToken(id);
15
+
16
+ return (
17
+ <PageLayout maxWidth="narrow">
18
+ <PageLayout.Header>
19
+ <PageHeader.Detail
20
+ breadcrumbs={
21
+ <Breadcrumb>
22
+ <Breadcrumb.List>
23
+ <Breadcrumb.Item>
24
+ <Breadcrumb.Link asChild>
25
+ <Link to="/tokens">Tokens</Link>
26
+ </Breadcrumb.Link>
27
+ </Breadcrumb.Item>
28
+ <Breadcrumb.Separator />
29
+ <Breadcrumb.Item>
30
+ <Breadcrumb.Page>{token?.description ?? id}</Breadcrumb.Page>
31
+ </Breadcrumb.Item>
32
+ </Breadcrumb.List>
33
+ </Breadcrumb>
34
+ }
35
+ title={token?.description ?? 'Token detail'}
36
+ />
37
+ </PageLayout.Header>
38
+
39
+ <PageLayout.Content>
40
+ {isLoading && <TokenDetailSkeleton />}
41
+ {isError && <TokenDetailError error={error} />}
42
+ {!isLoading && !isError && token !== undefined && <TokenFields token={token} />}
43
+
44
+ <div className="tw:mt-6">
45
+ <Button asChild variant="outline">
46
+ <Link to="/tokens">Back to tokens</Link>
47
+ </Button>
48
+ </div>
49
+ </PageLayout.Content>
50
+ </PageLayout>
51
+ );
52
+ }
53
+
54
+ function TokenDetailSkeleton() {
55
+ return (
56
+ <div className="tw:flex tw:flex-col tw:gap-3">
57
+ <Skeleton className="tw:h-8 tw:w-2/3" />
58
+ <Skeleton className="tw:h-32 tw:w-full tw:rounded-card" />
59
+ </div>
60
+ );
61
+ }
62
+
63
+ function TokenDetailError({ error }: { error: Error }) {
64
+ return (
65
+ <ErrorState>
66
+ <ErrorState.Icon>⚠</ErrorState.Icon>
67
+ <ErrorState.Title>Failed to load token</ErrorState.Title>
68
+ <ErrorState.Description>{error.message}</ErrorState.Description>
69
+ </ErrorState>
70
+ );
71
+ }
72
+
73
+ function TokenFields({ token }: { token: Token }) {
74
+ return (
75
+ <dl className="tw:grid tw:grid-cols-[max-content_1fr] tw:gap-x-6 tw:gap-y-3 tw:rounded-card tw:border tw:border-border tw:bg-background tw:p-5 tw:shadow-card">
76
+ <Field label="ID" value={token.id} />
77
+ <Field label="Description" value={token.description} />
78
+ <Field label="Type" value={token.isMasterToken ? 'Master' : 'Limited'} />
79
+ <Field label="Created" value={new Date(token.created).toLocaleString('en-US')} />
80
+ <Field
81
+ label="Expires"
82
+ value={token.expiresAt ? new Date(token.expiresAt).toLocaleString('en-US') : 'Never'}
83
+ />
84
+ </dl>
85
+ );
86
+ }
87
+
88
+ function Field({ label, value }: { label: string; value: string }) {
89
+ return (
90
+ <>
91
+ <dt className="tw:text-sm tw:font-medium tw:text-muted-foreground">{label}</dt>
92
+ <dd className="tw:m-0 tw:text-sm tw:text-foreground">{value}</dd>
93
+ </>
94
+ );
95
+ }
@@ -0,0 +1,60 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+
3
+ import { createToken, fetchToken, fetchTokens, resetFixtures } from './-fixtures';
4
+
5
+ describe('fixtures', () => {
6
+ beforeEach(() => {
7
+ resetFixtures();
8
+ });
9
+
10
+ it('fetchTokens resolves with an array of tokens', async () => {
11
+ const tokens = await fetchTokens();
12
+ expect(Array.isArray(tokens)).toBe(true);
13
+ expect(tokens.length).toBeGreaterThan(0);
14
+ expect(tokens[0]).toMatchObject({
15
+ id: expect.any(String),
16
+ description: expect.any(String),
17
+ isMasterToken: expect.any(Boolean),
18
+ created: expect.any(String),
19
+ });
20
+ });
21
+
22
+ it('createToken adds a new token and fetchTokens returns it', async () => {
23
+ const input = { description: 'Test token', expiresIn: 'never' } as const;
24
+ const created = await createToken(input);
25
+
26
+ expect(created.description).toBe(input.description);
27
+ expect(created.expiresAt).toBeNull();
28
+ expect(created.isMasterToken).toBe(false);
29
+
30
+ const tokens = await fetchTokens();
31
+ expect(tokens.some((t) => t.id === created.id)).toBe(true);
32
+ });
33
+
34
+ it('createToken sets expiresAt when expiresIn is provided', async () => {
35
+ const input = { description: 'Expiring token', expiresIn: '86400' } as const;
36
+ const created = await createToken(input);
37
+
38
+ expect(created.expiresAt).not.toBeNull();
39
+ });
40
+
41
+ it('fetchToken resolves a single token by id', async () => {
42
+ const all = await fetchTokens();
43
+ const sample = all[0];
44
+ if (sample === undefined) throw new Error('seed is empty — check INITIAL_TOKENS');
45
+
46
+ const fetched = await fetchToken(sample.id);
47
+ expect(fetched).toEqual(sample);
48
+ });
49
+
50
+ it('fetchToken rejects when the id does not exist', async () => {
51
+ await expect(fetchToken('tok-missing')).rejects.toThrow(/not found/);
52
+ });
53
+
54
+ it('fetchTokens rejects when its AbortSignal fires', async () => {
55
+ const controller = new AbortController();
56
+ const promise = fetchTokens(controller.signal);
57
+ controller.abort();
58
+ await expect(promise).rejects.toThrow();
59
+ });
60
+ });