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.
- package/README.md +79 -0
- package/dist/blocks-DYU5Ke29.js +147 -0
- package/dist/cli.js +451 -0
- package/dist/extract.js +133 -0
- package/dist/regions-B3r5LhXl.js +95 -0
- package/dist/sanitize-CKs8pAzZ.js +158 -0
- package/dist/sanitize.js +2 -0
- package/dist/template/AGENTS.md +133 -0
- package/dist/template/README.md +56 -0
- package/dist/template/_env.example +6 -0
- package/dist/template/_gitignore +7 -0
- package/dist/template/_oxfmtrc.json +18 -0
- package/dist/template/index.html +13 -0
- package/dist/template/oxlint.config.ts +43 -0
- package/dist/template/package.json +53 -0
- package/dist/template/public/favicon.svg +10 -0
- package/dist/template/src/components/AppChrome.tsx +201 -0
- package/dist/template/src/components/AssistantPanel.test.tsx +65 -0
- package/dist/template/src/components/AssistantPanel.tsx +96 -0
- package/dist/template/src/components/AssistantToggle.tsx +37 -0
- package/dist/template/src/components/ColorModeToggle.tsx +15 -0
- package/dist/template/src/components/HomePrompt.tsx +35 -0
- package/dist/template/src/components/KpiCard.test.tsx +20 -0
- package/dist/template/src/components/KpiCard.tsx +24 -0
- package/dist/template/src/components/SelectionFloater.test.tsx +73 -0
- package/dist/template/src/components/SelectionFloater.tsx +88 -0
- package/dist/template/src/components/SidebarModeMenu.test.tsx +68 -0
- package/dist/template/src/components/SidebarModeMenu.tsx +54 -0
- package/dist/template/src/index.css +26 -0
- package/dist/template/src/main.tsx +57 -0
- package/dist/template/src/nav.test.ts +25 -0
- package/dist/template/src/nav.ts +38 -0
- package/dist/template/src/overview-sections.test.tsx +36 -0
- package/dist/template/src/overview-sections.tsx +40 -0
- package/dist/template/src/recipes/Chart.test.tsx +24 -0
- package/dist/template/src/recipes/Chart.tsx +56 -0
- package/dist/template/src/recipes/DataGrid.test.tsx +47 -0
- package/dist/template/src/recipes/DataGrid.tsx +151 -0
- package/dist/template/src/routes/-fixtures.ts +113 -0
- package/dist/template/src/routes/-hooks.ts +15 -0
- package/dist/template/src/routes/-index.test.tsx +123 -0
- package/dist/template/src/routes/__root.tsx +20 -0
- package/dist/template/src/routes/index.tsx +95 -0
- package/dist/template/src/routes/tokens/$id.tsx +95 -0
- package/dist/template/src/routes/tokens/-fixtures.test.ts +60 -0
- package/dist/template/src/routes/tokens/-fixtures.ts +91 -0
- package/dist/template/src/routes/tokens/-hooks.ts +37 -0
- package/dist/template/src/routes/tokens/-index.test.tsx +75 -0
- package/dist/template/src/routes/tokens/-nav.ts +17 -0
- package/dist/template/src/routes/tokens/-types.ts +22 -0
- package/dist/template/src/routes/tokens/index.tsx +182 -0
- package/dist/template/src/routes/tokens/new.tsx +163 -0
- package/dist/template/src/sections/chart.tsx +34 -0
- package/dist/template/src/sections/grid.tsx +21 -0
- package/dist/template/src/sections/kpi.tsx +36 -0
- package/dist/template/src/slices/assistant.ts +68 -0
- package/dist/template/src/slices/shell.ts +54 -0
- package/dist/template/src/store.test.tsx +92 -0
- package/dist/template/src/store.ts +67 -0
- package/dist/template/src/test-setup.ts +11 -0
- package/dist/template/src/vite-env.d.ts +1 -0
- package/dist/template/templates/agent-safety/README.md +14 -0
- package/dist/template/templates/agent-safety/aiexclude +25 -0
- package/dist/template/templates/agent-safety/claude-code.settings.json +45 -0
- package/dist/template/templates/agent-safety/cursorignore +25 -0
- package/dist/template/tsconfig.json +10 -0
- package/dist/template/vercel.json +4 -0
- package/dist/template/vite.config.ts +21 -0
- package/dist/template/vitest.config.ts +21 -0
- package/dist/template/workspace-deps.json +7 -0
- 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
|
+
});
|