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