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,88 @@
1
+ import { useEffect, useState } from 'react';
2
+ import type { RefObject } from 'react';
3
+
4
+ import { Button } from '@keboola/design';
5
+
6
+ type FloaterSelection = {
7
+ text: string;
8
+ top: number;
9
+ left: number;
10
+ };
11
+
12
+ // Selections inside an assistant-panel instance (rail or fullscreen) never
13
+ // trigger the floater — marker kept in sync with AssistantPanel's root.
14
+ const ASSISTANT_PANEL_SELECTOR = '[data-assistant-panel]';
15
+
16
+ export function SelectionFloater({
17
+ containerRef,
18
+ onExplain,
19
+ }: {
20
+ /** Only selections anchored inside this element trigger the floater. */
21
+ containerRef: RefObject<HTMLElement | null>;
22
+ onExplain: (text: string) => void;
23
+ }) {
24
+ const [selection, setSelection] = useState<FloaterSelection | null>(null);
25
+
26
+ useEffect(() => {
27
+ const handleSelectionChange = () => {
28
+ const sel = window.getSelection();
29
+ const text = sel?.toString().trim() ?? '';
30
+ const anchorNode = sel?.anchorNode ?? null;
31
+ const focusNode = sel?.focusNode ?? null;
32
+ const container = containerRef.current;
33
+
34
+ if (!sel || sel.rangeCount === 0 || !text || !anchorNode || !focusNode || !container) {
35
+ setSelection(null);
36
+ return;
37
+ }
38
+
39
+ const toElement = (node: Node) =>
40
+ node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
41
+
42
+ const anchorElement = toElement(anchorNode);
43
+ const focusElement = toElement(focusNode);
44
+
45
+ const isValid = (element: Element | null) =>
46
+ !!element && container.contains(element) && !element.closest(ASSISTANT_PANEL_SELECTOR);
47
+
48
+ if (!isValid(anchorElement) || !isValid(focusElement)) {
49
+ setSelection(null);
50
+ return;
51
+ }
52
+
53
+ const range = sel.getRangeAt(0);
54
+ const rect =
55
+ typeof range.getBoundingClientRect === 'function'
56
+ ? range.getBoundingClientRect()
57
+ : { top: 0, left: 0 };
58
+ setSelection({ text, top: rect.top, left: rect.left });
59
+ };
60
+
61
+ document.addEventListener('mouseup', handleSelectionChange);
62
+ document.addEventListener('selectionchange', handleSelectionChange);
63
+ return () => {
64
+ document.removeEventListener('mouseup', handleSelectionChange);
65
+ document.removeEventListener('selectionchange', handleSelectionChange);
66
+ };
67
+ }, [containerRef]);
68
+
69
+ if (!selection) return null;
70
+
71
+ const handleClick = () => {
72
+ onExplain(selection.text);
73
+ window.getSelection()?.removeAllRanges();
74
+ setSelection(null);
75
+ };
76
+
77
+ return (
78
+ <Button
79
+ variant="secondary"
80
+ size="small"
81
+ className="tw:fixed tw:z-50"
82
+ style={{ top: selection.top - 40, left: selection.left }}
83
+ onClick={handleClick}
84
+ >
85
+ Explain with Kai
86
+ </Button>
87
+ );
88
+ }
@@ -0,0 +1,68 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { beforeEach, describe, expect, it } from 'vitest';
4
+
5
+ import { Sidebar, SidebarProvider } from '@keboola/design';
6
+
7
+ import { useAppStore } from '../store';
8
+
9
+ import { SidebarModeMenu } from './SidebarModeMenu';
10
+
11
+ // Mirrors AppChrome's wiring.
12
+ // - The provider is controlled from the store, so a pick travels menu -> setMode ->
13
+ // onLeftModeChange -> store. An uncontrolled provider would pass with that chain broken.
14
+ // - The `<Sidebar>` is required, not scenery: `Sidebar.MenuButton` reads
15
+ // `SidebarInstanceContext`, which `<Sidebar>` provides and `SidebarProvider` does not.
16
+ function Harness() {
17
+ const sidebarMode = useAppStore((s) => s.sidebarMode);
18
+ const setSidebarMode = useAppStore((s) => s.setSidebarMode);
19
+
20
+ return (
21
+ <SidebarProvider
22
+ leftMode={sidebarMode}
23
+ onLeftModeChange={setSidebarMode}
24
+ leftVariant="inset"
25
+ leftCollapsible="icon"
26
+ >
27
+ <Sidebar side="left">
28
+ <Sidebar.Footer>
29
+ <Sidebar.Menu>
30
+ <SidebarModeMenu />
31
+ </Sidebar.Menu>
32
+ </Sidebar.Footer>
33
+ </Sidebar>
34
+ </SidebarProvider>
35
+ );
36
+ }
37
+
38
+ describe('<SidebarModeMenu />', () => {
39
+ beforeEach(() => {
40
+ useAppStore.setState({ sidebarMode: 'expanded' });
41
+ });
42
+
43
+ it.each([
44
+ ['Collapsed', 'collapsed'],
45
+ ['Expand on hover', 'expand-on-hover'],
46
+ ])('writes %s through to the store', async (label, mode) => {
47
+ // Radix marks the closed menu's content `pointer-events: none`; userEvent's check
48
+ // targets real pointer obstruction, not this.
49
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
50
+ render(<Harness />);
51
+
52
+ await user.click(screen.getByRole('button', { name: 'Sidebar mode' }));
53
+ await user.click(screen.getByRole('menuitemradio', { name: label }));
54
+
55
+ expect(useAppStore.getState().sidebarMode).toBe(mode);
56
+ });
57
+
58
+ it('marks the active mode as checked', async () => {
59
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
60
+ useAppStore.setState({ sidebarMode: 'expand-on-hover' });
61
+ render(<Harness />);
62
+
63
+ await user.click(screen.getByRole('button', { name: 'Sidebar mode' }));
64
+
65
+ expect(screen.getByRole('menuitemradio', { name: 'Expand on hover' })).toBeChecked();
66
+ expect(screen.getByRole('menuitemradio', { name: 'Expanded' })).not.toBeChecked();
67
+ });
68
+ });
@@ -0,0 +1,54 @@
1
+ import type { SidebarMode } from '@keboola/design';
2
+ import { DropdownMenu, Sidebar, useSidebar } from '@keboola/design';
3
+ import { PanelLeft } from '@keboola/design/icons';
4
+
5
+ const MODES: { value: SidebarMode; label: string }[] = [
6
+ { value: 'expanded', label: 'Expanded' },
7
+ { value: 'collapsed', label: 'Collapsed' },
8
+ { value: 'expand-on-hover', label: 'Expand on hover' },
9
+ ];
10
+
11
+ /**
12
+ * Picks the left rail's mode. All three come from `SidebarProvider`.
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.
20
+ */
21
+ export function SidebarModeMenu() {
22
+ const { setMode, mode } = useSidebar('left');
23
+
24
+ return (
25
+ <Sidebar.MenuItem>
26
+ <DropdownMenu>
27
+ <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. */}
30
+ <Sidebar.MenuButton
31
+ tooltip="Sidebar mode"
32
+ className="tw:w-fit"
33
+ icon={<PanelLeft />}
34
+ aria-label="Sidebar mode"
35
+ />
36
+ </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. */}
39
+ <DropdownMenu.Content align="start">
40
+ <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. */}
43
+ <DropdownMenu.RadioGroup value={mode} onValueChange={setMode}>
44
+ {MODES.map(({ value, label }) => (
45
+ <DropdownMenu.RadioItem key={value} closeOnSelect indicatorAlign="end" value={value}>
46
+ {label}
47
+ </DropdownMenu.RadioItem>
48
+ ))}
49
+ </DropdownMenu.RadioGroup>
50
+ </DropdownMenu.Content>
51
+ </DropdownMenu>
52
+ </Sidebar.MenuItem>
53
+ );
54
+ }
@@ -0,0 +1,26 @@
1
+ /* Tailwind v4. Standard theme + this app's own utilities (auto-detected from its
2
+ * source). The `@keboola/design/theme` import adds design's semantic color tokens
3
+ * (portable @theme, no @config/@plugin) so the app can generate themed utilities
4
+ * like `tw:bg-background`; `@keboola/design/styles/compiled` supplies design's
5
+ * component chrome, the runtime `--*` tokens, and the scoped preflight as plain CSS. */
6
+
7
+ /* Declare the cascade-layer order up front. Everything — the app's own generated
8
+ * utilities AND design's compiled utilities — shares ONE `utilities` layer, so v3-style
9
+ * SPECIFICITY still decides conflicts. This matters: design styles component internals
10
+ * with higher-specificity descendant rules (e.g. SearchInput's `[&>input]:py-[15px]`)
11
+ * that must keep beating a plain `p-0`; a dedicated higher app-utilities layer would let
12
+ * plain app utilities override them (layer beats specificity), zeroing component padding.
13
+ * The only conflict specificity can't settle is same-specificity DUPLICATES — the app's
14
+ * `md:grid-cols-3` vs design-compiled's `grid-cols-1` (both 0,1,0). CSS breaks that tie by
15
+ * SOURCE ORDER, so design's compiled CSS is imported BEFORE the app's own `utilities.css`;
16
+ * the app's utilities land later and win the tie, fixing the responsive/grid/filter
17
+ * collapse. `base`/`components` keep design's preflight below its component chrome. */
18
+ @layer theme, base, components, utilities;
19
+ @import 'tailwindcss/theme.css' layer(theme) prefix(tw);
20
+ @import '@keboola/design/theme';
21
+ @import '@keboola/design/styles/compiled';
22
+ @import 'tailwindcss/utilities.css' layer(utilities) prefix(tw);
23
+
24
+ body {
25
+ @apply tw:m-0 tw:min-h-screen tw:bg-background;
26
+ }
@@ -0,0 +1,57 @@
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4
+ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
5
+ import { createRouter, RouterProvider } from '@tanstack/react-router';
6
+
7
+ import { keboola } from '@keboola/brand-registry';
8
+ import { ThemeProvider } from '@keboola/design';
9
+
10
+ import './index.css';
11
+
12
+ // Route tree is code-generated by @tanstack/router-plugin/vite on `pnpm dev` or
13
+ // `pnpm build`. The committed stub in routeTree.gen.ts keeps type-check + CI
14
+ // green without needing to run the dev server first.
15
+ import { routeTree } from './routeTree.gen';
16
+
17
+ const queryClient = new QueryClient({
18
+ defaultOptions: {
19
+ queries: {
20
+ staleTime: 60_000,
21
+ retry: 1,
22
+ },
23
+ },
24
+ });
25
+
26
+ const router = createRouter({ routeTree });
27
+
28
+ // Register the router instance for type-safety globally. Module augmentation
29
+ // only works through `interface` declarations, so we suppress the project-wide
30
+ // "prefer type over interface" rule here.
31
+ declare module '@tanstack/react-router' {
32
+ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
33
+ interface Register {
34
+ router: typeof router;
35
+ }
36
+ }
37
+
38
+ const root = document.getElementById('app');
39
+ if (!root) throw new Error('Root element #app not found');
40
+
41
+ createRoot(root).render(
42
+ <StrictMode>
43
+ <QueryClientProvider client={queryClient}>
44
+ {/* Register the brands and make one active. Without `brands` + `defaultBrandId`
45
+ the provider falls back to the brandless `design-system` sentinel: no brand
46
+ tokens are injected and `BrandLogo` renders nothing. */}
47
+ {/* `defaultScheme="system"` follows the OS and resolves reactively. The scheme
48
+ belongs here, not on individual components — the provider writes
49
+ `data-theme` and injects the brand's per-scheme token values together. */}
50
+ <ThemeProvider brands={[keboola]} defaultBrandId={keboola.id} defaultScheme="system">
51
+ <RouterProvider router={router} />
52
+ </ThemeProvider>
53
+ {/* eslint-disable-next-line turbo/no-undeclared-env-vars -- Vite built-in, not a real env var */}
54
+ {import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
55
+ </QueryClientProvider>
56
+ </StrictMode>,
57
+ );
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { isActive, NAV } from './nav';
4
+
5
+ describe('isActive', () => {
6
+ it('matches the root only exactly', () => {
7
+ expect(isActive('/', '/')).toBe(true);
8
+ expect(isActive('/tokens', '/')).toBe(false);
9
+ });
10
+
11
+ it('keeps a section active on its nested routes', () => {
12
+ expect(isActive('/tokens', '/tokens')).toBe(true);
13
+ expect(isActive('/tokens/new', '/tokens')).toBe(true);
14
+ });
15
+
16
+ it('does not treat a sibling that merely shares a prefix as nested', () => {
17
+ expect(isActive('/tokens-archive', '/tokens')).toBe(false);
18
+ });
19
+ });
20
+
21
+ describe('NAV', () => {
22
+ it('points every destination at a rooted path', () => {
23
+ for (const item of NAV) expect(item.to.startsWith('/')).toBe(true);
24
+ });
25
+ });
@@ -0,0 +1,38 @@
1
+ import type { ComponentType } from 'react';
2
+
3
+ import { LayoutDashboard } from '@keboola/design/icons';
4
+
5
+ // #region block:crud
6
+ import { tokensNavItem } from './routes/tokens/-nav';
7
+ // #endregion block:crud
8
+
9
+ export type NavItem = { to: string; label: string; icon: ComponentType };
10
+
11
+ /**
12
+ * The left rail's destinations, in render order.
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.
20
+ */
21
+ export const NAV = [
22
+ { to: '/', label: 'Overview', icon: LayoutDashboard },
23
+ // #region block:crud
24
+ tokensNavItem,
25
+ // #endregion block:crud
26
+ ] as const satisfies readonly NavItem[];
27
+
28
+ /**
29
+ * Whether `to` is the active destination for `pathname`.
30
+ *
31
+ * - 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.
36
+ */
37
+ export const isActive = (pathname: string, to: string) =>
38
+ to === '/' ? pathname === '/' : pathname === to || pathname.startsWith(`${to}/`);
@@ -0,0 +1,36 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { render, waitFor } from '@testing-library/react';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { HomePage } from './routes/index';
6
+ import { OVERVIEW_SECTIONS } from './overview-sections';
7
+
8
+ const renderPage = () => {
9
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
10
+ return render(
11
+ <QueryClientProvider client={client}>
12
+ <HomePage />
13
+ </QueryClientProvider>,
14
+ );
15
+ };
16
+
17
+ describe('the overview', () => {
18
+ it('shows one placeholder per registered section while loading', () => {
19
+ const { container } = renderPage();
20
+
21
+ expect(container.querySelectorAll('[data-section-skeleton]')).toHaveLength(
22
+ OVERVIEW_SECTIONS.length,
23
+ );
24
+ });
25
+
26
+ it('renders one section per registry entry once loaded', async () => {
27
+ const { container } = renderPage();
28
+
29
+ await waitFor(() =>
30
+ expect(container.querySelectorAll('[data-section]')).toHaveLength(OVERVIEW_SECTIONS.length),
31
+ );
32
+ for (const section of OVERVIEW_SECTIONS) {
33
+ expect(container.querySelector(`[data-section="${section.id}"]`)).not.toBeNull();
34
+ }
35
+ });
36
+ });
@@ -0,0 +1,40 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ import type { Overview } from './routes/-fixtures';
4
+ // #region block:chart
5
+ import { chartSection } from './sections/chart';
6
+ // #endregion block:chart
7
+ // #region block:grid
8
+ import { gridSection } from './sections/grid';
9
+ // #endregion block:grid
10
+ // #region block:kpi
11
+ import { kpiSection } from './sections/kpi';
12
+ // #endregion block:kpi
13
+
14
+ /**
15
+ * One block's contribution to the overview.
16
+ *
17
+ * - `id` is the anchor: `data-section` when rendered, `data-section-skeleton` while
18
+ * loading.
19
+ * - `renderSkeleton` lives with `render` so a section's placeholder matches its real
20
+ * shape.
21
+ */
22
+ export type OverviewSection = {
23
+ id: string;
24
+ render: (overview: Overview) => ReactNode;
25
+ renderSkeleton: () => ReactNode;
26
+ };
27
+
28
+ /** The sections this app carries, in render order. */
29
+ export const OVERVIEW_SECTIONS: readonly OverviewSection[] = [
30
+ // One entry per section block, appended in render order.
31
+ // #region block:kpi
32
+ kpiSection,
33
+ // #endregion block:kpi
34
+ // #region block:chart
35
+ chartSection,
36
+ // #endregion block:chart
37
+ // #region block:grid
38
+ gridSection,
39
+ // #endregion block:grid
40
+ ];
@@ -0,0 +1,24 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { expect, it } from 'vitest';
3
+
4
+ import { LineChartCard } from './Chart';
5
+
6
+ it('renders the chart card title and legend labels', () => {
7
+ render(
8
+ <LineChartCard
9
+ title="Revenue"
10
+ xKey="month"
11
+ series={[{ key: 'mrr', label: 'MRR', tokenVar: '--chart-1' }]}
12
+ data={[
13
+ { month: 'Jan', mrr: 10 },
14
+ { month: 'Feb', mrr: 12 },
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.
19
+ width={400}
20
+ />,
21
+ );
22
+ expect(screen.getByText('Revenue')).toBeInTheDocument();
23
+ expect(screen.getByText('MRR')).toBeInTheDocument();
24
+ });
@@ -0,0 +1,56 @@
1
+ import { Card } from '@keboola/design';
2
+ import { type ChartColorToken, LineChart } from '@keboola/design/charts';
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.
8
+ type ChartTokenVar = '--chart-1' | '--chart-2' | '--chart-3' | '--chart-4' | '--chart-5';
9
+
10
+ type Series = { key: string; label: string; tokenVar: ChartTokenVar };
11
+
12
+ type LineChartCardProps = {
13
+ title: string;
14
+ data: readonly Record<string, number | string>[];
15
+ xKey: string;
16
+ 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.
21
+ width?: number;
22
+ };
23
+
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.
27
+ const CHART_COLOR_SLOTS: Record<ChartTokenVar, ChartColorToken> = {
28
+ '--chart-1': 1,
29
+ '--chart-2': 2,
30
+ '--chart-3': 3,
31
+ '--chart-4': 4,
32
+ '--chart-5': 5,
33
+ };
34
+
35
+ export function LineChartCard({ title, data, xKey, series, width }: LineChartCardProps) {
36
+ return (
37
+ <Card>
38
+ <Card.Header>
39
+ <Card.Title as="h2">{title}</Card.Title>
40
+ </Card.Header>
41
+ <Card.Content>
42
+ <LineChart
43
+ data={[...data]}
44
+ xKey={xKey}
45
+ width={width}
46
+ series={series.map((s) => ({
47
+ dataKey: s.key,
48
+ name: s.label,
49
+ color: CHART_COLOR_SLOTS[s.tokenVar],
50
+ }))}
51
+ showLegend
52
+ />
53
+ </Card.Content>
54
+ </Card>
55
+ );
56
+ }
@@ -0,0 +1,47 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { expect, it } from 'vitest';
4
+
5
+ import type { BudgetRow } from '../routes/-fixtures';
6
+
7
+ import { CreditBudgetGrid } from './DataGrid';
8
+
9
+ const ROWS: BudgetRow[] = [
10
+ {
11
+ id: 'ex-db',
12
+ component: 'Database extractor',
13
+ kind: 'Extractor',
14
+ months: [800, 820, 850, 900, 940, 980],
15
+ },
16
+ {
17
+ id: 'tr-python',
18
+ component: 'Python transformation',
19
+ kind: 'Transformation',
20
+ months: [310, 320, 330, 350, 360, 380],
21
+ },
22
+ ];
23
+
24
+ it('recomputes the row total and footer total when a monthly cell is edited', async () => {
25
+ const user = userEvent.setup();
26
+ render(<CreditBudgetGrid rows={ROWS} />);
27
+
28
+ expect(screen.getByText('Component')).toBeInTheDocument();
29
+
30
+ const janCell = screen.getByLabelText('Database extractor Jan');
31
+ const row = janCell.closest('tr');
32
+ expect(row).not.toBeNull();
33
+ // 800 + 820 + 850 + 900 + 940 + 980 = 5290
34
+ expect(row?.textContent).toContain('5290');
35
+
36
+ await user.clear(janCell);
37
+ await user.type(janCell, '1000');
38
+ await user.tab();
39
+
40
+ // 1000 + 820 + 850 + 900 + 940 + 980 = 5490
41
+ expect(row?.textContent).toContain('5490');
42
+
43
+ const footer = document.querySelector('tfoot');
44
+ expect(footer).not.toBeNull();
45
+ // grand total across both rows shifts from 7340 to 7540 (+200 edit delta)
46
+ expect(footer?.textContent).toContain('7540');
47
+ });