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,91 @@
1
+ import type { CreateTokenInput, Token } from './-types';
2
+
3
+ // Hand-rolled in-memory store — replaces a real API until @keboola/api-client
4
+ // gains a typed Tokens namespace. Swap fetchTokens / createToken for real API
5
+ // calls (e.g. `api.tokens.list(signal)`) and delete this file when that lands.
6
+ // Each call takes an optional AbortSignal and honors it, so the query hooks can
7
+ // thread cancellation exactly as they would against a real client — see -hooks.ts.
8
+ const INITIAL_TOKENS: Token[] = [
9
+ {
10
+ id: 'tok-1',
11
+ description: 'Master token',
12
+ isMasterToken: true,
13
+ created: '2025-01-10T08:00:00Z',
14
+ expiresAt: null,
15
+ },
16
+ {
17
+ id: 'tok-2',
18
+ description: 'Read-only token',
19
+ isMasterToken: false,
20
+ created: '2025-03-15T12:30:00Z',
21
+ expiresAt: '2026-03-15T12:30:00Z',
22
+ },
23
+ {
24
+ id: 'tok-3',
25
+ description: 'CI/CD pipeline token',
26
+ isMasterToken: false,
27
+ created: '2025-04-01T09:00:00Z',
28
+ expiresAt: '2025-10-01T09:00:00Z',
29
+ },
30
+ ];
31
+
32
+ // Mutable in-memory store
33
+ let tokens: Token[] = [...INITIAL_TOKENS];
34
+
35
+ /** Resets the in-memory store to its initial seed — call from test `beforeEach`. */
36
+ export const resetFixtures = (): void => {
37
+ tokens = [...INITIAL_TOKENS];
38
+ };
39
+
40
+ // Simulates a network round-trip that resolves after `ms`, or rejects the
41
+ // moment an AbortSignal fires — mirroring how a real fetch-based client aborts
42
+ // in-flight when TanStack Query cancels the query.
43
+ const delay = (ms: number, signal?: AbortSignal): Promise<void> =>
44
+ new Promise((resolve, reject) => {
45
+ if (signal?.aborted) {
46
+ reject(signal.reason);
47
+ return;
48
+ }
49
+ const timer = setTimeout(() => {
50
+ signal?.removeEventListener('abort', onAbort);
51
+ resolve();
52
+ }, ms);
53
+ const onAbort = () => {
54
+ clearTimeout(timer);
55
+ reject(signal?.reason);
56
+ };
57
+ signal?.addEventListener('abort', onAbort, { once: true });
58
+ });
59
+
60
+ /** Resolves with all tokens after a simulated round-trip; rejects if aborted. */
61
+ export const fetchTokens = async (signal?: AbortSignal): Promise<Token[]> => {
62
+ await delay(200, signal);
63
+ return [...tokens];
64
+ };
65
+
66
+ /** Resolves to a single token by id; rejects if not found or aborted. */
67
+ export const fetchToken = async (id: string, signal?: AbortSignal): Promise<Token> => {
68
+ await delay(200, signal);
69
+ const token = tokens.find((t) => t.id === id);
70
+ if (token === undefined) throw new Error(`Token \`${id}\` not found`);
71
+ return { ...token };
72
+ };
73
+
74
+ export const createToken = async (
75
+ input: CreateTokenInput,
76
+ signal?: AbortSignal,
77
+ ): Promise<Token> => {
78
+ await delay(200, signal);
79
+ const newToken: Token = {
80
+ id: `tok-${Date.now()}`,
81
+ description: input.description,
82
+ isMasterToken: false,
83
+ created: new Date().toISOString(),
84
+ expiresAt:
85
+ input.expiresIn === 'never'
86
+ ? null
87
+ : new Date(Date.now() + Number(input.expiresIn) * 1000).toISOString(),
88
+ };
89
+ tokens = [...tokens, newToken];
90
+ return newToken;
91
+ };
@@ -0,0 +1,37 @@
1
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2
+
3
+ import { createToken, fetchToken, fetchTokens } from './-fixtures';
4
+ import type { CreateTokenInput } from './-types';
5
+
6
+ const queryKeys = {
7
+ all: ['tokens'] as const,
8
+ detail: (id: string) => ['tokens', id] as const,
9
+ } as const;
10
+
11
+ // Query keys are co-located with the module rather than in a central registry:
12
+ // everything about `tokens/` lives under this directory. Larger apps may prefer a
13
+ // shared `queryKeys.ts` — both are valid; keep keys `as const` tuples either way.
14
+ export const useTokens = () =>
15
+ useQuery({
16
+ queryKey: queryKeys.all,
17
+ // Always thread the query's AbortSignal to the data call — TanStack Query
18
+ // aborts it on unmount / refetch. Swap `fetchTokens` for `api.tokens.list`.
19
+ queryFn: ({ signal }) => fetchTokens(signal),
20
+ });
21
+
22
+ export const useToken = (id: string) =>
23
+ useQuery({
24
+ queryKey: queryKeys.detail(id),
25
+ queryFn: ({ signal }) => fetchToken(id, signal),
26
+ });
27
+
28
+ export const useCreateToken = () => {
29
+ const queryClient = useQueryClient();
30
+
31
+ return useMutation({
32
+ mutationFn: (input: CreateTokenInput) => createToken(input),
33
+ onSuccess: () => {
34
+ void queryClient.invalidateQueries({ queryKey: queryKeys.all, exact: false });
35
+ },
36
+ });
37
+ };
@@ -0,0 +1,75 @@
1
+ import type { ReactNode } from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ import type { Token } from './-types';
7
+
8
+ // Render the page without a RouterProvider: mock the two router exports it uses
9
+ // (`createFileRoute` returns the Route object; `Link` renders a plain anchor).
10
+ vi.mock('@tanstack/react-router', () => ({
11
+ createFileRoute: () => (options: unknown) => ({ options }),
12
+ Link: ({ to, children }: { to: string; children: ReactNode }) => <a href={to}>{children}</a>,
13
+ }));
14
+
15
+ // Drive each async branch deterministically by mocking the query hook boundary.
16
+ const { useTokensMock } = vi.hoisted(() => ({ useTokensMock: vi.fn() }));
17
+ vi.mock('./-hooks', () => ({ useTokens: () => useTokensMock() }));
18
+
19
+ import { Route } from './index';
20
+
21
+ const TokensListPage = Route.options.component!;
22
+
23
+ const TOKENS: Token[] = [
24
+ {
25
+ id: 'tok-1',
26
+ description: 'Master token',
27
+ isMasterToken: true,
28
+ created: '2025-01-10T08:00:00Z',
29
+ expiresAt: null,
30
+ },
31
+ {
32
+ id: 'tok-2',
33
+ description: 'Read-only token',
34
+ isMasterToken: false,
35
+ created: '2025-03-15T12:30:00Z',
36
+ expiresAt: '2026-03-15T12:30:00Z',
37
+ },
38
+ ];
39
+
40
+ describe('TokensListPage', () => {
41
+ beforeEach(() => {
42
+ useTokensMock.mockReturnValue({ data: TOKENS, isLoading: false, isError: false, error: null });
43
+ });
44
+
45
+ it('renders a row per token when data loads', () => {
46
+ render(<TokensListPage />);
47
+ expect(screen.getByText('Master token')).toBeInTheDocument();
48
+ expect(screen.getByText('Read-only token')).toBeInTheDocument();
49
+ });
50
+
51
+ it('shows the empty state when there are no tokens', () => {
52
+ useTokensMock.mockReturnValue({ data: [], isLoading: false, isError: false, error: null });
53
+ render(<TokensListPage />);
54
+ expect(screen.getByText('No tokens yet')).toBeInTheDocument();
55
+ });
56
+
57
+ it('surfaces the error message when the query fails', () => {
58
+ useTokensMock.mockReturnValue({
59
+ data: undefined,
60
+ isLoading: false,
61
+ isError: true,
62
+ error: new Error('Network down'),
63
+ });
64
+ render(<TokensListPage />);
65
+ expect(screen.getByText('Failed to load tokens')).toBeInTheDocument();
66
+ expect(screen.getByText('Network down')).toBeInTheDocument();
67
+ });
68
+
69
+ it('shows a search-specific empty state when the filter matches nothing', async () => {
70
+ render(<TokensListPage />);
71
+ await userEvent.type(screen.getByPlaceholderText('Search tokens'), 'zzz');
72
+ expect(await screen.findByText('No matching tokens')).toBeInTheDocument();
73
+ expect(screen.queryByText('Master token')).not.toBeInTheDocument();
74
+ });
75
+ });
@@ -0,0 +1,17 @@
1
+ import { KeyRound } from '@keboola/design/icons';
2
+
3
+ import type { NavItem } from '../../nav';
4
+
5
+ /**
6
+ * This module's entry in the rail.
7
+ *
8
+ * - `as const` keeps `to` a literal: `Link`'s `to` is typed against the generated route tree,
9
+ * and a widened `string` fails type-check where `NAV` is rendered.
10
+ * - `satisfies` checks the shape without erasing that literal.
11
+ * - `-` prefix keeps the file out of `routeTree.gen.ts`.
12
+ */
13
+ export const tokensNavItem = {
14
+ to: '/tokens',
15
+ label: 'Tokens',
16
+ icon: KeyRound,
17
+ } as const satisfies NavItem;
@@ -0,0 +1,22 @@
1
+ export const EXPIRY_OPTIONS = [
2
+ { value: 'never', label: 'Never' },
3
+ { value: '86400', label: '1 day' },
4
+ { value: '604800', label: '7 days' },
5
+ { value: '2592000', label: '30 days' },
6
+ { value: '31536000', label: '1 year' },
7
+ ] as const;
8
+
9
+ export type TokenExpiry = (typeof EXPIRY_OPTIONS)[number]['value'];
10
+
11
+ export type Token = {
12
+ id: string;
13
+ description: string;
14
+ isMasterToken: boolean;
15
+ created: string;
16
+ expiresAt: string | null;
17
+ };
18
+
19
+ export type CreateTokenInput = {
20
+ description: string;
21
+ expiresIn: TokenExpiry;
22
+ };
@@ -0,0 +1,182 @@
1
+ import { useMemo, useState } from 'react';
2
+ import { createFileRoute, Link } from '@tanstack/react-router';
3
+
4
+ import {
5
+ Button,
6
+ EmptyState,
7
+ ErrorState,
8
+ PageHeader,
9
+ PageLayout,
10
+ PageToolbar,
11
+ SearchInput,
12
+ Skeleton,
13
+ Table,
14
+ } from '@keboola/design';
15
+
16
+ import { useAppStore } from '../../store';
17
+
18
+ import { useTokens } from './-hooks';
19
+ import type { Token } from './-types';
20
+
21
+ export const Route = createFileRoute('/tokens/')({
22
+ component: TokensListPage,
23
+ });
24
+
25
+ function TokensListPage() {
26
+ const { data: tokens, isLoading, isError, error } = useTokens();
27
+ const helpVisible = useAppStore((s) => s.helpVisible);
28
+ const [search, setSearch] = useState('');
29
+
30
+ const filteredTokens = useMemo(() => {
31
+ if (!tokens) return tokens;
32
+ const query = search.trim().toLowerCase();
33
+ if (!query) return tokens;
34
+ return tokens.filter((token) => token.description.toLowerCase().includes(query));
35
+ }, [tokens, search]);
36
+
37
+ return (
38
+ <PageLayout>
39
+ <PageLayout.Header>
40
+ <PageHeader.Index
41
+ title="Tokens"
42
+ description={helpVisible ? 'Manage API tokens for your project' : undefined}
43
+ action={
44
+ <Button asChild variant="primary">
45
+ <Link to="/tokens/new">New Token</Link>
46
+ </Button>
47
+ }
48
+ />
49
+ </PageLayout.Header>
50
+
51
+ <PageToolbar>
52
+ <PageToolbar.Search>
53
+ <SearchInput
54
+ allowClear
55
+ placeholder="Search tokens"
56
+ value={search}
57
+ onValueChange={setSearch}
58
+ />
59
+ </PageToolbar.Search>
60
+ </PageToolbar>
61
+
62
+ <PageLayout.Content>
63
+ {isLoading && <TokensListSkeleton />}
64
+ {isError && <TokensListError error={error} />}
65
+ {!isLoading && !isError && filteredTokens !== undefined && (
66
+ <>
67
+ {filteredTokens.length === 0 ? (
68
+ search.trim() ? (
69
+ <TokensSearchEmpty />
70
+ ) : (
71
+ <TokensListEmpty />
72
+ )
73
+ ) : (
74
+ <TokensTable tokens={filteredTokens} />
75
+ )}
76
+ </>
77
+ )}
78
+ </PageLayout.Content>
79
+ </PageLayout>
80
+ );
81
+ }
82
+
83
+ function TokensListSkeleton() {
84
+ return (
85
+ <div className="tw:flex tw:flex-col tw:gap-3">
86
+ <Skeleton className="tw:h-10 tw:w-full" />
87
+ <Skeleton className="tw:h-48 tw:w-full tw:rounded-card" />
88
+ </div>
89
+ );
90
+ }
91
+
92
+ function TokensListError({ error }: { error: Error }) {
93
+ return (
94
+ <ErrorState>
95
+ <ErrorState.Icon>⚠</ErrorState.Icon>
96
+ <ErrorState.Title>Failed to load tokens</ErrorState.Title>
97
+ <ErrorState.Description>{error.message}</ErrorState.Description>
98
+ </ErrorState>
99
+ );
100
+ }
101
+
102
+ function TokensListEmpty() {
103
+ return (
104
+ <EmptyState>
105
+ <EmptyState.Icon>🔑</EmptyState.Icon>
106
+ <EmptyState.Title>No tokens yet</EmptyState.Title>
107
+ <EmptyState.Description>Create your first API token to get started.</EmptyState.Description>
108
+ <EmptyState.Actions>
109
+ <Button asChild variant="primary">
110
+ <Link to="/tokens/new">New Token</Link>
111
+ </Button>
112
+ </EmptyState.Actions>
113
+ </EmptyState>
114
+ );
115
+ }
116
+
117
+ function TokensSearchEmpty() {
118
+ return (
119
+ <EmptyState>
120
+ <EmptyState.Icon>🔍</EmptyState.Icon>
121
+ <EmptyState.Title>No matching tokens</EmptyState.Title>
122
+ <EmptyState.Description>Try a different search term.</EmptyState.Description>
123
+ </EmptyState>
124
+ );
125
+ }
126
+
127
+ function TokensTable({ tokens }: { tokens: Token[] }) {
128
+ return (
129
+ <Table bordered>
130
+ <Table.Header>
131
+ <Table.Row>
132
+ <Table.Head>Description</Table.Head>
133
+ <Table.Head>Type</Table.Head>
134
+ <Table.Head>Created</Table.Head>
135
+ <Table.Head>Expires</Table.Head>
136
+ {/* `w-0` collapses the actions column to its content, but a visually empty
137
+ header still needs an accessible name — axe flags an empty <th>. */}
138
+ <Table.Head className="tw:w-0">
139
+ <span className="tw:sr-only">Actions</span>
140
+ </Table.Head>
141
+ </Table.Row>
142
+ </Table.Header>
143
+ <Table.Body>
144
+ {tokens.map((token) => (
145
+ <Table.Row key={token.id}>
146
+ <Table.Cell className="tw:font-medium tw:text-foreground">
147
+ {token.description}
148
+ </Table.Cell>
149
+ <Table.Cell>
150
+ <span
151
+ className={
152
+ token.isMasterToken
153
+ ? 'tw:text-xs tw:font-medium tw:text-warning-700'
154
+ : 'tw:text-xs tw:text-muted-foreground'
155
+ }
156
+ >
157
+ {token.isMasterToken ? 'Master' : 'Limited'}
158
+ </span>
159
+ </Table.Cell>
160
+ <Table.Cell className="tw:text-sm tw:text-muted-foreground">
161
+ {new Date(token.created).toLocaleDateString('en-US')}
162
+ </Table.Cell>
163
+ <Table.Cell className="tw:text-sm tw:text-muted-foreground">
164
+ {token.expiresAt ? new Date(token.expiresAt).toLocaleDateString('en-US') : '—'}
165
+ </Table.Cell>
166
+ <Table.Cell className="tw:text-right">
167
+ <Link
168
+ to="/tokens/$id"
169
+ params={{ id: token.id }}
170
+ // `text-secondary-500` does not clear 4.5:1 on the row background; the
171
+ // semantic `text-primary` token is AA-safe under every registered brand.
172
+ className="tw:text-sm tw:text-primary tw:no-underline tw:hover:underline"
173
+ >
174
+ View
175
+ </Link>
176
+ </Table.Cell>
177
+ </Table.Row>
178
+ ))}
179
+ </Table.Body>
180
+ </Table>
181
+ );
182
+ }
@@ -0,0 +1,163 @@
1
+ import { useForm } from '@tanstack/react-form';
2
+ import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
3
+ import { z } from 'zod';
4
+
5
+ import {
6
+ Alert,
7
+ Breadcrumb,
8
+ Button,
9
+ FormGroup,
10
+ Label,
11
+ PageHeader,
12
+ Select,
13
+ toast,
14
+ } from '@keboola/design';
15
+
16
+ import { useCreateToken } from './-hooks';
17
+ import { type CreateTokenInput, EXPIRY_OPTIONS, type TokenExpiry } from './-types';
18
+
19
+ export const Route = createFileRoute('/tokens/new')({
20
+ component: NewTokenPage,
21
+ });
22
+
23
+ const EXPIRY_VALUES = EXPIRY_OPTIONS.map((o) => o.value) as [TokenExpiry, ...TokenExpiry[]];
24
+
25
+ const createTokenSchema = z.object({
26
+ description: z
27
+ .string()
28
+ .min(1, 'Description is required')
29
+ .max(255, 'Description must be 255 characters or fewer'),
30
+ // Zod v4: use `message` instead of `errorMap`
31
+ expiresIn: z.enum(EXPIRY_VALUES, {
32
+ message: 'Select an expiry option',
33
+ }),
34
+ });
35
+
36
+ const validateDescription = (value: string): string | undefined => {
37
+ const result = createTokenSchema.shape.description.safeParse(value);
38
+ if (result.success) return undefined;
39
+ return result.error.issues[0]?.message;
40
+ };
41
+
42
+ function NewTokenPage() {
43
+ const navigate = useNavigate();
44
+ const { mutateAsync: createToken, isError, error } = useCreateToken();
45
+
46
+ // Annotate `defaultValues` so TanStack Form preserves the full TokenExpiry
47
+ // union instead of narrowing to the literal `'never'`.
48
+ const defaultValues: CreateTokenInput = {
49
+ description: '',
50
+ expiresIn: 'never',
51
+ };
52
+
53
+ const form = useForm({
54
+ defaultValues,
55
+ onSubmit: async ({ value }) => {
56
+ await createToken(value);
57
+ toast.success('Token created');
58
+ void navigate({ to: '/tokens' });
59
+ },
60
+ });
61
+
62
+ return (
63
+ <div className="tw:mx-auto tw:flex tw:max-w-lg tw:flex-col tw:gap-6">
64
+ <PageHeader.Detail
65
+ breadcrumbs={
66
+ <Breadcrumb>
67
+ <Breadcrumb.List>
68
+ <Breadcrumb.Item>
69
+ <Breadcrumb.Link asChild>
70
+ <Link to="/tokens">Tokens</Link>
71
+ </Breadcrumb.Link>
72
+ </Breadcrumb.Item>
73
+ <Breadcrumb.Separator />
74
+ <Breadcrumb.Item>
75
+ <Breadcrumb.Page>New Token</Breadcrumb.Page>
76
+ </Breadcrumb.Item>
77
+ </Breadcrumb.List>
78
+ </Breadcrumb>
79
+ }
80
+ title="New Token"
81
+ description="Create a new API token with optional expiry."
82
+ />
83
+
84
+ {isError && (
85
+ <Alert variant="error">
86
+ <span>{(error as Error)?.message ?? 'Failed to create token'}</span>
87
+ </Alert>
88
+ )}
89
+
90
+ <form
91
+ className="tw:flex tw:flex-col tw:gap-5"
92
+ onSubmit={(e) => {
93
+ e.preventDefault();
94
+ e.stopPropagation();
95
+ void form.handleSubmit();
96
+ }}
97
+ >
98
+ {/* Description field */}
99
+ <form.Field
100
+ name="description"
101
+ validators={{ onBlur: ({ value }) => validateDescription(value) }}
102
+ >
103
+ {(field) => {
104
+ const errorMsg =
105
+ field.state.meta.isTouched && field.state.meta.errors.length > 0
106
+ ? String(field.state.meta.errors[0])
107
+ : undefined;
108
+ return (
109
+ <FormGroup state={errorMsg ? 'error' : undefined}>
110
+ <Label htmlFor="token-description">
111
+ Description <span className="tw:text-secondary-500">*</span>
112
+ </Label>
113
+ <FormGroup.TextInput
114
+ id="token-description"
115
+ placeholder="e.g. CI/CD pipeline token"
116
+ value={field.state.value}
117
+ onChange={(e) => field.handleChange(e.target.value)}
118
+ onBlur={field.handleBlur}
119
+ />
120
+ {errorMsg && <FormGroup.Help>{errorMsg}</FormGroup.Help>}
121
+ </FormGroup>
122
+ );
123
+ }}
124
+ </form.Field>
125
+
126
+ {/* Expiry field */}
127
+ <form.Field name="expiresIn">
128
+ {(field) => (
129
+ <FormGroup>
130
+ <Label htmlFor="token-expiresIn">Expiry</Label>
131
+ <Select
132
+ id="token-expiresIn"
133
+ className="tw:w-full"
134
+ placeholder="Select expiry"
135
+ value={field.state.value}
136
+ onValueChange={(v) => field.handleChange(v as TokenExpiry)}
137
+ items={EXPIRY_OPTIONS}
138
+ />
139
+ </FormGroup>
140
+ )}
141
+ </form.Field>
142
+
143
+ <form.Subscribe selector={(s) => [s.canSubmit, s.isSubmitting] as const}>
144
+ {([canSubmit, isSubmitting]) => (
145
+ <div className="tw:flex tw:gap-3">
146
+ <Button
147
+ type="submit"
148
+ variant="primary"
149
+ isLoading={isSubmitting}
150
+ disabled={!canSubmit}
151
+ >
152
+ Create Token
153
+ </Button>
154
+ <Button asChild variant="outline">
155
+ <Link to="/tokens">Cancel</Link>
156
+ </Button>
157
+ </div>
158
+ )}
159
+ </form.Subscribe>
160
+ </form>
161
+ </div>
162
+ );
163
+ }
@@ -0,0 +1,34 @@
1
+ import { Skeleton } from '@keboola/design';
2
+
3
+ import type { OverviewSection } from '../overview-sections';
4
+ import { LineChartCard } from '../recipes/Chart';
5
+
6
+ /**
7
+ * The overview's credit trend: used against budgeted, per month.
8
+ *
9
+ * - Both nodes sit inside a plain wrapper because `data-section` is the registry's
10
+ * anchor and neither `LineChartCard` nor `Skeleton` takes DOM attributes.
11
+ * - Series colours are `--chart-N` palette slots, never literal colours, so the chart
12
+ * re-themes with the active brand.
13
+ */
14
+ export const chartSection: OverviewSection = {
15
+ id: 'chart',
16
+ render: (overview) => (
17
+ <div data-section="chart">
18
+ <LineChartCard
19
+ title="Credits: used vs budgeted"
20
+ xKey="month"
21
+ series={[
22
+ { key: 'used', label: 'Used', tokenVar: '--chart-1' },
23
+ { key: 'budgeted', label: 'Budgeted', tokenVar: '--chart-2' },
24
+ ]}
25
+ data={overview.credits}
26
+ />
27
+ </div>
28
+ ),
29
+ renderSkeleton: () => (
30
+ <div data-section-skeleton="chart">
31
+ <Skeleton className="tw:h-72 tw:w-full tw:rounded-card" />
32
+ </div>
33
+ ),
34
+ };
@@ -0,0 +1,21 @@
1
+ import { Skeleton } from '@keboola/design';
2
+
3
+ import type { OverviewSection } from '../overview-sections';
4
+ import { CreditBudgetGrid } from '../recipes/DataGrid';
5
+
6
+ /** The overview's per-component budget, editable in place. */
7
+ export const gridSection: OverviewSection = {
8
+ id: 'grid',
9
+ // `max-w-full` + `overflow-x-auto` is what keeps a wide grid inside the column:
10
+ // without it the table widens the page and the whole layout scrolls sideways.
11
+ render: (overview) => (
12
+ <div data-section="grid" className="tw:max-w-full tw:overflow-x-auto">
13
+ <CreditBudgetGrid rows={overview.budget} />
14
+ </div>
15
+ ),
16
+ renderSkeleton: () => (
17
+ <div data-section-skeleton="grid">
18
+ <Skeleton className="tw:h-56 tw:w-full tw:rounded-card" />
19
+ </div>
20
+ ),
21
+ };
@@ -0,0 +1,36 @@
1
+ import { Skeleton } from '@keboola/design';
2
+
3
+ import { KpiCard } from '../components/KpiCard';
4
+ import type { OverviewSection } from '../overview-sections';
5
+
6
+ /** The overview's KPI row: one card per figure. */
7
+ export const kpiSection: OverviewSection = {
8
+ id: 'kpi',
9
+ // Reflows on this column's own width, not the viewport's. A `lg:grid-cols-4` here
10
+ // would keep four columns while the assistant rail squeezes the page to a third of
11
+ // it — the rail narrows content without changing the viewport, so viewport
12
+ // breakpoints lie. `auto-fit` + a `minmax` floor drops to three, two, then one
13
+ // column as room runs out.
14
+ render: (overview) => (
15
+ <div
16
+ data-section="kpi"
17
+ className="tw:grid tw:[grid-template-columns:repeat(auto-fit,minmax(10rem,1fr))] tw:gap-4"
18
+ >
19
+ {overview.kpis.map((kpi) => (
20
+ <KpiCard key={kpi.label} label={kpi.label} value={kpi.value} deltaPct={kpi.deltaPct} />
21
+ ))}
22
+ </div>
23
+ ),
24
+ // Four placeholders because four KPIs are coming — a skeleton that does not match
25
+ // the shape it stands in for makes the page jump on load.
26
+ renderSkeleton: () => (
27
+ <div
28
+ data-section-skeleton="kpi"
29
+ className="tw:grid tw:[grid-template-columns:repeat(auto-fit,minmax(10rem,1fr))] tw:gap-4"
30
+ >
31
+ {[0, 1, 2, 3].map((slot) => (
32
+ <Skeleton key={slot} className="tw:h-28 tw:w-full tw:rounded-card" />
33
+ ))}
34
+ </div>
35
+ ),
36
+ };