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,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
|
+
});
|