Skip to content

Commit 3bf94ed

Browse files
authored
chore: frontend tests (#42)
* more src/components tests * - extracted repeated code from adminsagas - excluded tests from build - instead of svg's using the icons from @lucide/svelte * AdminUsers refactoring * tests for AdminEvents, refactoring of said component, also rollout and url paths fixes * updated tests, unwrap+unwrapOr and such + docs * interceptors' mocks + fixed tests
1 parent f432a59 commit 3bf94ed

26 files changed

+3764
-2630
lines changed

docs/frontend/error-handling.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Error handling
2+
3+
The frontend uses a centralized error handling pattern built around two helpers: `unwrap` and `unwrapOr`. These work with the `{ data, error }` result structure returned by the generated API client.
4+
5+
## Why this exists
6+
7+
The API client returns results in a discriminated union format where every call gives you `{ data, error }`. The naive approach leads to repetitive boilerplate scattered across every component:
8+
9+
```typescript
10+
const { data, error } = await someApiCall({});
11+
if (error) return;
12+
doSomething(data);
13+
```
14+
15+
This `if (error) return` pattern appeared 17+ times across admin panels and the editor. It's noisy, easy to forget, and obscures the actual logic. The helpers eliminate it while keeping the code readable.
16+
17+
## How it works
18+
19+
Both helpers live in `lib/api-interceptors.ts` alongside the centralized error interceptor that shows toast notifications for API failures.
20+
21+
`unwrap` extracts the data or throws if there's an error:
22+
23+
```typescript
24+
const data = unwrap(await listUsersApiV1AdminUsersGet({}));
25+
// data is guaranteed to exist here - if there was an error, we threw
26+
users = data;
27+
```
28+
29+
When the API call fails, the interceptor has already shown a toast to the user. The throw stops the function execution - any code after `unwrap` only runs on success. The thrown error bubbles up as an unhandled rejection, gets logged to console, and that's it.
30+
31+
`unwrapOr` returns a fallback value instead of throwing:
32+
33+
```typescript
34+
const data = unwrapOr(await listUsersApiV1AdminUsersGet({}), null);
35+
users = data ? data : [];
36+
```
37+
38+
This is useful when you want to continue execution with a default value rather than bail out entirely. Common for load functions where an empty array is a reasonable fallback.
39+
40+
## Choosing between them
41+
42+
Use `unwrap` when the function should stop on error. Delete operations, save operations, anything where subsequent code assumes success:
43+
44+
```typescript
45+
async function deleteUser(): Promise<void> {
46+
deletingUser = true;
47+
const result = await deleteUserApiV1AdminUsersUserIdDelete({ path: { user_id } });
48+
deletingUser = false;
49+
unwrap(result);
50+
// only runs if delete succeeded
51+
await loadUsers();
52+
showModal = false;
53+
}
54+
```
55+
56+
Use `unwrapOr` when you can gracefully handle failure with a default. Load operations where showing an empty state is acceptable:
57+
58+
```typescript
59+
async function loadEvents() {
60+
loading = true;
61+
const data = unwrapOr(await browseEventsApiV1AdminEventsBrowsePost({ body: filters }), null);
62+
loading = false;
63+
events = data?.events || [];
64+
}
65+
```
66+
67+
## Cleanup before unwrap
68+
69+
If you need cleanup to run regardless of success or failure (like resetting a loading flag), do it before calling `unwrap`:
70+
71+
```typescript
72+
async function saveRateLimits(): Promise<void> {
73+
savingRateLimits = true;
74+
const result = await updateRateLimitsApi({ body: config });
75+
savingRateLimits = false; // runs whether or not there's an error
76+
unwrap(result); // throws if error, stopping execution
77+
showModal = false; // only runs on success
78+
}
79+
```
80+
81+
The pattern is: await the call, do cleanup, then unwrap. This keeps the loading state correct even when errors occur.
82+
83+
## What happens on error
84+
85+
The error interceptor in `api-interceptors.ts` handles user feedback. When an API call fails, it shows an appropriate toast based on the status code (401 redirects to login, 403 shows access denied, 422 formats validation errors, 429 warns about rate limits, 5xx shows server error). By the time your component code sees the error, the user has already been notified.
86+
87+
The `unwrap` throw becomes an unhandled promise rejection. The global handler in `main.ts` logs it to console and suppresses the default browser error. No error page, no duplicate notifications - just a clean exit from the function.

frontend/package-lock.json

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@codemirror/state": "^6.4.1",
2424
"@codemirror/theme-one-dark": "^6.1.2",
2525
"@codemirror/view": "^6.34.1",
26+
"@lucide/svelte": "^0.562.0",
2627
"@mateothegreat/svelte5-router": "^2.16.19",
2728
"@rollup/plugin-commonjs": "^29.0.0",
2829
"@rollup/plugin-json": "^6.1.0",

frontend/rollup.config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,8 @@ export default {
181181
preferBuiltins: false,
182182
// Prefer ES modules
183183
mainFields: ['svelte', 'module', 'browser', 'main'],
184-
exportConditions: ['svelte']
184+
exportConditions: ['svelte'],
185+
extensions: ['.mjs', '.js', '.json', '.node', '.svelte']
185186
}),
186187
commonjs(),
187188
!production && {

frontend/src/App.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181
{ path: "/admin/sagas", component: AdminSagas, hooks: { pre: requireAuth } },
8282
{ path: "/admin/users", component: AdminUsers, hooks: { pre: requireAuth } },
8383
{ path: "/admin/settings", component: AdminSettings, hooks: { pre: requireAuth } },
84-
{ path: "/admin", component: AdminEvents, hooks: { pre: requireAuth } },
84+
{ path: "^/admin$", component: AdminEvents, hooks: { pre: requireAuth } },
8585
];
8686
</script>
8787

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<script lang="ts">
2+
import {
3+
PlayCircle,
4+
CheckCircle,
5+
XCircle,
6+
Clock,
7+
FileInput,
8+
Zap,
9+
Check,
10+
X,
11+
MinusCircle,
12+
HelpCircle,
13+
} from '@lucide/svelte';
14+
15+
interface Props {
16+
eventType: string;
17+
size?: number;
18+
}
19+
20+
let { eventType, size = 20 }: Props = $props();
21+
22+
// Map event types to icon components
23+
const iconMap: Record<string, typeof PlayCircle> = {
24+
// Execution events
25+
'execution.requested': FileInput,
26+
'execution_requested': FileInput,
27+
'execution.started': PlayCircle,
28+
'execution_started': PlayCircle,
29+
'execution.completed': CheckCircle,
30+
'execution_completed': CheckCircle,
31+
'execution.failed': XCircle,
32+
'execution_failed': XCircle,
33+
'execution.timeout': Clock,
34+
'execution_timeout': Clock,
35+
// Pod events
36+
'pod.created': Zap,
37+
'pod_created': Zap,
38+
'pod.running': Zap,
39+
'pod_running': Zap,
40+
'pod.succeeded': Check,
41+
'pod_succeeded': Check,
42+
'pod.failed': X,
43+
'pod_failed': X,
44+
'pod.terminated': MinusCircle,
45+
'pod_terminated': MinusCircle,
46+
};
47+
48+
const Icon = $derived(iconMap[eventType] || HelpCircle);
49+
</script>
50+
51+
<Icon {size} />
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<script lang="ts">
2+
import { X } from '@lucide/svelte';
3+
import { fade } from 'svelte/transition';
4+
import type { Snippet } from 'svelte';
5+
6+
interface Props {
7+
open: boolean;
8+
title: string;
9+
onClose: () => void;
10+
size?: 'sm' | 'md' | 'lg' | 'xl';
11+
children: Snippet;
12+
footer?: Snippet;
13+
}
14+
15+
let { open, title, onClose, size = 'lg', children, footer }: Props = $props();
16+
17+
const sizeClasses = {
18+
sm: 'max-w-md',
19+
md: 'max-w-2xl',
20+
lg: 'max-w-4xl',
21+
xl: 'max-w-6xl',
22+
};
23+
24+
// Only listen for Escape when modal is open
25+
$effect(() => {
26+
if (!open) return;
27+
28+
function handleEscape(e: KeyboardEvent) {
29+
if (e.key === 'Escape') onClose();
30+
}
31+
32+
window.addEventListener('keydown', handleEscape);
33+
return () => window.removeEventListener('keydown', handleEscape);
34+
});
35+
36+
function handleBackdropClick(e: MouseEvent) {
37+
if (e.target === e.currentTarget) onClose();
38+
}
39+
</script>
40+
41+
{#if open}
42+
<div
43+
class="modal-backdrop"
44+
transition:fade={{ duration: 150 }}
45+
onclick={handleBackdropClick}
46+
onkeydown={(e) => e.key === 'Escape' && onClose()}
47+
role="dialog"
48+
aria-modal="true"
49+
aria-labelledby="modal-title"
50+
tabindex="-1"
51+
>
52+
<div class="modal-container {sizeClasses[size]}">
53+
<div class="modal-header">
54+
<h2 id="modal-title" class="modal-title">{title}</h2>
55+
<button onclick={onClose} class="modal-close" aria-label="Close modal">
56+
<X size={24} />
57+
</button>
58+
</div>
59+
<div class="modal-body">
60+
{@render children()}
61+
</div>
62+
{#if footer}
63+
<div class="modal-footer">
64+
{@render footer()}
65+
</div>
66+
{/if}
67+
</div>
68+
</div>
69+
{/if}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<script lang="ts">
2+
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from '@lucide/svelte';
3+
4+
interface Props {
5+
currentPage: number;
6+
totalPages: number;
7+
totalItems: number;
8+
pageSize: number;
9+
onPageChange: (page: number) => void;
10+
onPageSizeChange?: (size: number) => void;
11+
pageSizeOptions?: number[];
12+
itemName?: string;
13+
}
14+
15+
let {
16+
currentPage,
17+
totalPages,
18+
totalItems,
19+
pageSize,
20+
onPageChange,
21+
onPageSizeChange,
22+
pageSizeOptions = [10, 25, 50, 100],
23+
itemName = 'items',
24+
}: Props = $props();
25+
26+
const start = $derived(Math.min((currentPage - 1) * pageSize + 1, totalItems));
27+
const end = $derived(Math.min(currentPage * pageSize, totalItems));
28+
</script>
29+
30+
{#if totalPages > 1 || onPageSizeChange}
31+
<div class="pagination-container">
32+
<div class="pagination-info">
33+
Showing {start} - {end} of {totalItems} {itemName}
34+
</div>
35+
36+
<div class="flex items-center gap-4">
37+
{#if onPageSizeChange}
38+
<select
39+
class="pagination-selector"
40+
value={pageSize}
41+
onchange={(e) => onPageSizeChange?.(Number(e.currentTarget.value))}
42+
>
43+
{#each pageSizeOptions as size}
44+
<option value={size}>{size} / page</option>
45+
{/each}
46+
</select>
47+
{/if}
48+
49+
{#if totalPages > 1}
50+
<div class="pagination-controls">
51+
<button
52+
class="pagination-button"
53+
onclick={() => onPageChange(1)}
54+
disabled={currentPage === 1}
55+
aria-label="First page"
56+
>
57+
<ChevronsLeft size={16} />
58+
</button>
59+
<button
60+
class="pagination-button"
61+
onclick={() => onPageChange(currentPage - 1)}
62+
disabled={currentPage === 1}
63+
aria-label="Previous page"
64+
>
65+
<ChevronLeft size={16} />
66+
</button>
67+
<span class="pagination-text">
68+
<span class="font-medium">{currentPage}</span> / <span class="font-medium">{totalPages}</span>
69+
</span>
70+
<button
71+
class="pagination-button"
72+
onclick={() => onPageChange(currentPage + 1)}
73+
disabled={currentPage === totalPages}
74+
aria-label="Next page"
75+
>
76+
<ChevronRight size={16} />
77+
</button>
78+
<button
79+
class="pagination-button"
80+
onclick={() => onPageChange(totalPages)}
81+
disabled={currentPage === totalPages}
82+
aria-label="Last page"
83+
>
84+
<ChevronsRight size={16} />
85+
</button>
86+
</div>
87+
{/if}
88+
</div>
89+
</div>
90+
{/if}

0 commit comments

Comments
 (0)