@svadmin/ui 0.33.1 → 0.34.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.
@@ -1,56 +1,211 @@
1
1
  <script lang="ts">
2
- import type { Snippet } from 'svelte';
3
- import { Loader2, CheckCircle2, XCircle, Clock, Download, X, ListTodo } from '@lucide/svelte';
2
+ import { ListTodo, Loader2, Plus, Search, RefreshCw } from '@lucide/svelte';
3
+ import { getTaskProvider, useSubmitTask, useTaskList } from '@svadmin/core';
4
+ import { t } from '@svadmin/core/i18n';
5
+ import type { TaskProvider, TaskRecord } from '@svadmin/core';
4
6
  import { Button } from './ui/button/index.js';
5
7
  import { Badge } from './ui/badge/index.js';
6
- import type { BackgroundTask, TaskStatus } from '../types.js';
8
+ import { Input } from './ui/input/index.js';
9
+ import { Select } from './ui/select/index.js';
10
+ import { Textarea } from './ui/textarea/index.js';
11
+ import { Label } from './ui/label/index.js';
12
+ import * as Sheet from './ui/sheet/index.js';
13
+ import * as Tabs from './ui/tabs/index.js';
14
+ import * as Card from './ui/card/index.js';
15
+ import TaskList from './TaskList.svelte';
16
+ import TaskDetails from './TaskDetails.svelte';
17
+
18
+ type AutoRefreshValue = '0' | '5000' | '15000' | '30000';
19
+ type TaskTab = 'tasks' | 'dlq';
7
20
 
8
21
  let {
9
22
  open = $bindable(false),
10
- tasks = $bindable<BackgroundTask[]>([]),
11
- onDismiss,
12
- onRetry,
13
- onDownload,
23
+ taskProvider = getTaskProvider({ optional: true }) as TaskProvider<TaskRecord> | undefined,
24
+ title,
25
+ initialTab = 'tasks',
14
26
  }: {
15
27
  open?: boolean;
16
- tasks?: BackgroundTask[];
17
- onDismiss?: (taskId: string) => void;
18
- onRetry?: (taskId: string) => void;
19
- onDownload?: (taskId: string, url: string) => void;
28
+ taskProvider?: TaskProvider<TaskRecord>;
29
+ title?: string;
30
+ initialTab?: TaskTab;
20
31
  } = $props();
21
32
 
22
- let runningCount = $derived(tasks.filter(t => t.status === 'running' || t.status === 'pending').length);
33
+ let activeTab = $state<TaskTab>('tasks');
34
+ let selectedTaskId = $state<string | null>(null);
35
+ let searchQuery = $state('');
36
+ let statusFilter = $state('all');
37
+ let autoRefresh = $state<AutoRefreshValue>('15000');
38
+ let submitTaskName = $state('');
39
+ let submitIdempotencyKey = $state('');
40
+ let submitBodyText = $state(t('task.bodyPlaceholder'));
41
+ let submitError = $state<string | null>(null);
42
+ let submitOpen = $state(false);
43
+
44
+ const refreshInterval = $derived(autoRefresh === '0' ? false : Number(autoRefresh));
45
+ const resolvedTitle = $derived(title ?? t('task.drawerTitle'));
46
+
47
+ const taskQuery = useTaskList({
48
+ get taskProvider() {
49
+ return taskProvider;
50
+ },
51
+ get queryOptions() {
52
+ return {
53
+ enabled: !!taskProvider,
54
+ refetchInterval: refreshInterval,
55
+ refetchIntervalInBackground: true,
56
+ };
57
+ },
58
+ });
59
+
60
+ const dlqQuery = useTaskList({
61
+ get dlq() {
62
+ return true;
63
+ },
64
+ get taskProvider() {
65
+ return taskProvider;
66
+ },
67
+ get queryOptions() {
68
+ return {
69
+ enabled: !!taskProvider && !!taskProvider?.listDlq,
70
+ refetchInterval: refreshInterval,
71
+ refetchIntervalInBackground: true,
72
+ };
73
+ },
74
+ });
75
+
76
+ const submitTask = useSubmitTask();
77
+
78
+ const allTasks = $derived(taskQuery.data?.data ?? []);
79
+ const dlqTasks = $derived(dlqQuery.data?.data ?? []);
80
+
81
+ const availableStatuses = $derived.by(() => {
82
+ const source = activeTab === 'dlq' ? dlqTasks : allTasks;
83
+ const unique = Array.from(new Set(source.map((task) => String(task.status ?? 'pending').toLowerCase()).filter(Boolean)));
84
+ return unique.sort();
85
+ });
86
+
87
+ function matchesSearch(task: TaskRecord, query: string) {
88
+ if (!query) return true;
89
+ const haystack = [
90
+ task.id,
91
+ task.title,
92
+ task.name,
93
+ task.status,
94
+ task.message,
95
+ ]
96
+ .filter(Boolean)
97
+ .join(' ')
98
+ .toLowerCase();
99
+ return haystack.includes(query.toLowerCase());
100
+ }
101
+
102
+ function matchesStatus(task: TaskRecord, status: string) {
103
+ if (status === 'all') return true;
104
+ return String(task.status ?? 'pending').toLowerCase() === status;
105
+ }
106
+
107
+ const filteredTasks = $derived.by(() => {
108
+ const source = activeTab === 'dlq' ? dlqTasks : allTasks;
109
+ return source.filter((task) => matchesSearch(task, searchQuery) && matchesStatus(task, statusFilter));
110
+ });
111
+
112
+ const runningCount = $derived(
113
+ allTasks.filter((task) => ['pending', 'queued', 'running', 'processing'].includes(String(task.status ?? '').toLowerCase())).length,
114
+ );
115
+
116
+ $effect(() => {
117
+ activeTab = initialTab;
118
+ });
119
+
120
+ $effect(() => {
121
+ if (statusFilter !== 'all' && !availableStatuses.includes(statusFilter)) {
122
+ statusFilter = 'all';
123
+ }
124
+ });
125
+
126
+ $effect(() => {
127
+ const current = filteredTasks;
128
+ if (current.length === 0) {
129
+ selectedTaskId = null;
130
+ return;
131
+ }
132
+
133
+ if (!selectedTaskId) {
134
+ selectedTaskId = current[0].id;
135
+ return;
136
+ }
23
137
 
24
- const statusIcon: Record<TaskStatus, typeof Loader2> = {
25
- pending: Clock,
26
- running: Loader2,
27
- completed: CheckCircle2,
28
- failed: XCircle,
29
- };
138
+ if (!current.some((task) => task.id === selectedTaskId)) {
139
+ selectedTaskId = current[0].id;
140
+ }
141
+ });
30
142
 
31
- const statusColor: Record<TaskStatus, string> = {
32
- pending: 'text-muted-foreground',
33
- running: 'text-primary',
34
- completed: 'text-green-500',
35
- failed: 'text-destructive',
36
- };
143
+ function formatTime(value: unknown) {
144
+ if (!value) return '—';
145
+ return new Date(value as string | Date).toLocaleString();
146
+ }
147
+
148
+ function resetSubmitForm() {
149
+ submitTaskName = '';
150
+ submitIdempotencyKey = '';
151
+ submitBodyText = t('task.bodyPlaceholder');
152
+ submitError = null;
153
+ }
154
+
155
+ async function handleSubmitTask() {
156
+ submitError = null;
157
+ if (!submitTaskName.trim()) {
158
+ submitError = t('validation.required');
159
+ return;
160
+ }
161
+
162
+ let body: Record<string, unknown> | undefined;
163
+ if (submitBodyText.trim()) {
164
+ try {
165
+ body = JSON.parse(submitBodyText) as Record<string, unknown>;
166
+ } catch {
167
+ submitError = t('task.invalidJson');
168
+ return;
169
+ }
170
+ }
37
171
 
38
- function formatTime(d: string | Date) {
39
- return new Date(d).toLocaleTimeString();
172
+ try {
173
+ const handle = await submitTask.mutation.mutateAsync({
174
+ taskName: submitTaskName.trim(),
175
+ taskProvider,
176
+ options: {
177
+ body,
178
+ idempotencyKey: submitIdempotencyKey.trim() || undefined,
179
+ },
180
+ });
181
+ activeTab = 'tasks';
182
+ selectedTaskId = handle.id ?? null;
183
+ submitOpen = false;
184
+ resetSubmitForm();
185
+ await Promise.all([
186
+ taskQuery.refetch?.(),
187
+ dlqQuery.refetch?.(),
188
+ ]);
189
+ } catch (error) {
190
+ submitError = error instanceof Error ? error.message : t('task.submitFailed');
191
+ }
40
192
  }
41
193
 
42
- function dismissTask(id: string) {
43
- tasks = tasks.filter(t => t.id !== id);
44
- onDismiss?.(id);
194
+ async function refreshCurrentTab() {
195
+ if (activeTab === 'dlq') {
196
+ await dlqQuery.refetch?.();
197
+ return;
198
+ }
199
+ await taskQuery.refetch?.();
45
200
  }
46
201
  </script>
47
202
 
48
- <!-- Trigger Button (to be placed in Header) -->
49
203
  <Button
50
204
  variant="ghost"
51
205
  size="icon"
52
206
  class="relative"
53
207
  onclick={() => open = !open}
208
+ aria-label={resolvedTitle}
54
209
  >
55
210
  <ListTodo class="h-4 w-4" />
56
211
  {#if runningCount > 0}
@@ -60,99 +215,149 @@
60
215
  {/if}
61
216
  </Button>
62
217
 
63
- <!-- Task Queue Drawer -->
64
- {#if open}
65
- <!-- svelte-ignore a11y_no_static_element_interactions -->
66
- <!-- svelte-ignore a11y_click_events_have_key_events -->
67
- <div class="fixed inset-0 z-[90] flex justify-end" onclick={() => open = false}>
68
- <!-- svelte-ignore a11y_no_static_element_interactions -->
69
- <!-- svelte-ignore a11y_click_events_have_key_events -->
70
- <div
71
- class="bg-card shadow-2xl w-full max-w-sm h-full flex flex-col border-l border-border animate-in slide-in-from-right duration-200"
72
- onclick={(e) => e.stopPropagation()}
73
- >
74
- <!-- Header -->
75
- <div class="p-4 border-b border-border flex items-center justify-between bg-muted/30">
76
- <div class="flex items-center gap-2">
218
+ <Sheet.Root bind:open side="right" class="w-full max-w-6xl border-l border-border bg-card p-0" onClose={() => open = false}>
219
+ <Sheet.Content class="h-full gap-0 p-0">
220
+ <div class="flex h-full flex-col">
221
+ <div class="border-b border-border bg-muted/30 px-4 py-3">
222
+ <div class="flex flex-wrap items-center gap-2">
77
223
  <ListTodo class="h-5 w-5 text-muted-foreground" />
78
- <h2 class="text-sm font-semibold">Background Tasks</h2>
79
- {#if runningCount > 0}
80
- <Badge variant="secondary" class="text-xs">{runningCount} active</Badge>
224
+ <h2 class="text-sm font-semibold">{resolvedTitle}</h2>
225
+ <Badge variant="secondary" class="text-xs">{t('task.activeCount', { count: runningCount })}</Badge>
226
+ {#if allTasks[0]?.createdAt}
227
+ <span class="text-xs text-muted-foreground">{t('task.lastQueuedAt', { time: formatTime(allTasks[0].createdAt) })}</span>
81
228
  {/if}
82
229
  </div>
83
- <Button variant="ghost" size="icon" class="h-7 w-7" onclick={() => open = false}>
84
- <X class="h-4 w-4" />
85
- </Button>
86
230
  </div>
87
231
 
88
- <!-- Task List -->
89
- <div class="flex-1 overflow-y-auto">
90
- {#each tasks as task}
91
- {@const Icon = statusIcon[task.status]}
92
- <div class="p-4 border-b border-border hover:bg-muted/30 transition-colors">
93
- <div class="flex items-start justify-between gap-2">
94
- <div class="flex items-start gap-3 flex-1 min-w-0">
95
- <div class="{statusColor[task.status]} mt-0.5 shrink-0">
96
- <Icon class="h-4 w-4 {task.status === 'running' ? 'animate-spin' : ''}" />
232
+ {#if !taskProvider}
233
+ <div class="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
234
+ {t('common.configRequired')}
235
+ </div>
236
+ {:else}
237
+ <div class="grid min-h-0 flex-1 gap-0 lg:grid-cols-[minmax(0,1.2fr)_minmax(340px,0.8fr)]">
238
+ <div class="min-h-0 overflow-auto border-b border-border lg:border-b-0 lg:border-r">
239
+ <div class="space-y-4 p-4">
240
+ <div class="flex flex-col gap-3">
241
+ <div class="flex flex-wrap items-center gap-2">
242
+ <Tabs.Root value={activeTab} class="w-full">
243
+ <Tabs.List class="inline-flex rounded-lg border border-border bg-muted/40 p-1">
244
+ <Tabs.Trigger value="tasks" active={activeTab === 'tasks'} onclick={() => activeTab = 'tasks'}>
245
+ {t('task.tabTasks')}
246
+ <Badge variant="outline" class="ml-1 text-[10px]">{taskQuery.data?.total ?? allTasks.length}</Badge>
247
+ </Tabs.Trigger>
248
+ <Tabs.Trigger value="dlq" active={activeTab === 'dlq'} onclick={() => activeTab = 'dlq'}>
249
+ {t('task.tabDlq')}
250
+ <Badge variant="outline" class="ml-1 text-[10px]">{dlqQuery.data?.total ?? dlqTasks.length}</Badge>
251
+ </Tabs.Trigger>
252
+ </Tabs.List>
253
+ </Tabs.Root>
254
+ <div class="ml-auto flex items-center gap-2">
255
+ <Button variant="outline" size="sm" onclick={() => void refreshCurrentTab()}>
256
+ <RefreshCw class="mr-1.5 h-3.5 w-3.5" />
257
+ {t('common.refresh')}
258
+ </Button>
259
+ <Button size="sm" onclick={() => submitOpen = !submitOpen}>
260
+ <Plus class="mr-1.5 h-3.5 w-3.5" />
261
+ {t('task.submitAction')}
262
+ </Button>
263
+ </div>
97
264
  </div>
98
- <div class="flex-1 min-w-0">
99
- <p class="text-sm font-medium truncate text-foreground">{task.title}</p>
100
- {#if task.message}
101
- <p class="text-xs text-muted-foreground mt-0.5">{task.message}</p>
102
- {/if}
103
- <p class="text-[10px] text-muted-foreground/60 mt-1 font-mono">{formatTime(task.createdAt)}</p>
265
+
266
+ <div class="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px_160px]">
267
+ <div class="relative">
268
+ <Search class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
269
+ <Input
270
+ class="pl-9"
271
+ placeholder={t('task.searchPlaceholder')}
272
+ bind:value={searchQuery}
273
+ />
274
+ </div>
275
+ <Select bind:value={statusFilter} aria-label={t('task.statusFilterLabel')}>
276
+ <option value="all">{t('task.statusAll')}</option>
277
+ {#each availableStatuses as status}
278
+ <option value={status}>{status}</option>
279
+ {/each}
280
+ </Select>
281
+ <Select bind:value={autoRefresh} aria-label={t('task.autoRefreshLabel')}>
282
+ <option value="0">{t('task.refreshOff')}</option>
283
+ <option value="5000">{t('task.refresh5s')}</option>
284
+ <option value="15000">{t('task.refresh15s')}</option>
285
+ <option value="30000">{t('task.refresh30s')}</option>
286
+ </Select>
104
287
  </div>
105
288
  </div>
106
- <button
107
- class="text-muted-foreground/40 hover:text-muted-foreground transition shrink-0"
108
- onclick={() => dismissTask(task.id)}
109
- >
110
- <X class="h-3.5 w-3.5" />
111
- </button>
289
+
290
+ {#if submitOpen}
291
+ <Card.Root class="border-border/60 shadow-none">
292
+ <Card.Header class="pb-3">
293
+ <Card.Title class="text-base">{t('task.submitTitle')}</Card.Title>
294
+ <Card.Description>{t('task.submitDescription')}</Card.Description>
295
+ </Card.Header>
296
+ <Card.Content class="space-y-4 pt-0">
297
+ <div class="space-y-2">
298
+ <Label for="task-name">{t('task.taskNameLabel')}</Label>
299
+ <Input id="task-name" bind:value={submitTaskName} placeholder="image.generate" />
300
+ </div>
301
+ <div class="space-y-2">
302
+ <Label for="task-idempotency-key">{t('task.idempotencyKeyLabel')}</Label>
303
+ <Input id="task-idempotency-key" bind:value={submitIdempotencyKey} placeholder="poster-2026-04-19" />
304
+ </div>
305
+ <div class="space-y-2">
306
+ <Label for="task-body">{t('task.bodyLabel')}</Label>
307
+ <Textarea id="task-body" bind:value={submitBodyText} class="min-h-[140px] font-mono text-xs" />
308
+ </div>
309
+ {#if submitError}
310
+ <p class="text-sm text-destructive">{submitError}</p>
311
+ {/if}
312
+ <div class="flex flex-wrap justify-end gap-2">
313
+ <Button variant="ghost" onclick={() => { submitOpen = false; submitError = null; }}>
314
+ {t('common.cancel')}
315
+ </Button>
316
+ <Button onclick={() => void handleSubmitTask()} disabled={submitTask.mutation.isPending}>
317
+ {#if submitTask.mutation.isPending}
318
+ <Loader2 class="mr-1.5 h-3.5 w-3.5 animate-spin" />
319
+ {/if}
320
+ {t('task.submitAction')}
321
+ </Button>
322
+ </div>
323
+ </Card.Content>
324
+ </Card.Root>
325
+ {/if}
326
+
327
+ <TaskList
328
+ tasks={filteredTasks}
329
+ {taskProvider}
330
+ dlq={activeTab === 'dlq'}
331
+ title={activeTab === 'dlq' ? t('task.dlqTitle') : t('task.listTitle')}
332
+ emptyText={activeTab === 'dlq' ? t('task.noDlq') : t('task.noTasks')}
333
+ onSelect={(task) => {
334
+ selectedTaskId = task.id;
335
+ }}
336
+ />
112
337
  </div>
338
+ </div>
113
339
 
114
- <!-- Progress bar -->
115
- {#if task.status === 'running' && task.progress != null}
116
- <div class="mt-3 ml-7">
117
- <div class="h-1.5 w-full bg-muted rounded-full overflow-hidden">
118
- <div
119
- class="h-full bg-primary rounded-full transition-all duration-500 ease-out"
120
- style="width: {task.progress}%"
121
- ></div>
340
+ <div class="min-h-0 overflow-auto bg-muted/10">
341
+ <div class="p-4">
342
+ {#if selectedTaskId}
343
+ <TaskDetails
344
+ taskId={selectedTaskId}
345
+ {taskProvider}
346
+ useProviderData
347
+ queryOptions={{
348
+ refetchInterval: refreshInterval,
349
+ refetchIntervalInBackground: true,
350
+ }}
351
+ />
352
+ {:else}
353
+ <div class="flex h-full min-h-[280px] items-center justify-center rounded-lg border border-dashed border-border/60 bg-background/70 text-center text-sm text-muted-foreground">
354
+ {t('task.selectHint')}
122
355
  </div>
123
- <p class="text-[10px] text-muted-foreground mt-1">{task.progress}%</p>
124
- </div>
125
- {/if}
126
-
127
- <!-- Actions -->
128
- {#if task.status === 'completed' && task.downloadUrl}
129
- <div class="mt-2 ml-7">
130
- <Button
131
- variant="outline" size="sm" class="h-7 text-xs"
132
- onclick={() => onDownload?.(task.id, task.downloadUrl!)}
133
- >
134
- <Download class="h-3 w-3 mr-1" />
135
- Download
136
- </Button>
137
- </div>
138
- {:else if task.status === 'failed' && onRetry}
139
- <div class="mt-2 ml-7">
140
- <Button
141
- variant="outline" size="sm" class="h-7 text-xs text-destructive"
142
- onclick={() => onRetry?.(task.id)}
143
- >
144
- Retry
145
- </Button>
146
- </div>
147
- {/if}
148
- </div>
149
- {:else}
150
- <div class="flex flex-col items-center justify-center h-48 text-muted-foreground">
151
- <ListTodo class="h-8 w-8 mb-2 opacity-30" />
152
- <p class="text-sm">No background tasks</p>
356
+ {/if}
357
+ </div>
153
358
  </div>
154
- {/each}
155
- </div>
359
+ </div>
360
+ {/if}
156
361
  </div>
157
- </div>
158
- {/if}
362
+ </Sheet.Content>
363
+ </Sheet.Root>
@@ -1,11 +1,11 @@
1
- import type { BackgroundTask } from '../types.js';
1
+ import type { TaskProvider, TaskRecord } from '@svadmin/core';
2
+ type TaskTab = 'tasks' | 'dlq';
2
3
  type $$ComponentProps = {
3
4
  open?: boolean;
4
- tasks?: BackgroundTask[];
5
- onDismiss?: (taskId: string) => void;
6
- onRetry?: (taskId: string) => void;
7
- onDownload?: (taskId: string, url: string) => void;
5
+ taskProvider?: TaskProvider<TaskRecord>;
6
+ title?: string;
7
+ initialTab?: TaskTab;
8
8
  };
9
- declare const TaskQueueDrawer: import("svelte").Component<$$ComponentProps, {}, "open" | "tasks">;
9
+ declare const TaskQueueDrawer: import("svelte").Component<$$ComponentProps, {}, "open">;
10
10
  type TaskQueueDrawer = ReturnType<typeof TaskQueueDrawer>;
11
11
  export default TaskQueueDrawer;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,143 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
3
+ import TaskQueueDrawer from './TaskQueueDrawer.svelte';
4
+ const taskFixtures = [
5
+ {
6
+ id: 'task-1',
7
+ title: 'Generate poster',
8
+ status: 'running',
9
+ message: 'Rendering hero artwork',
10
+ createdAt: '2026-04-19T10:00:00.000Z',
11
+ updatedAt: '2026-04-19T10:05:00.000Z',
12
+ progress: 42,
13
+ },
14
+ {
15
+ id: 'task-2',
16
+ title: 'Send email batch',
17
+ status: 'queued',
18
+ message: 'Waiting for worker',
19
+ createdAt: '2026-04-19T10:10:00.000Z',
20
+ updatedAt: '2026-04-19T10:12:00.000Z',
21
+ },
22
+ ];
23
+ const dlqFixtures = [
24
+ {
25
+ id: 'task-dlq-1',
26
+ title: 'Nightly sync',
27
+ status: 'failed',
28
+ message: 'Remote API timeout',
29
+ createdAt: '2026-04-18T23:00:00.000Z',
30
+ updatedAt: '2026-04-18T23:05:00.000Z',
31
+ error: { message: 'Remote API timeout' },
32
+ },
33
+ ];
34
+ const mockTaskProvider = {
35
+ submit: vi.fn(async () => ({
36
+ id: 'submitted-image.generate',
37
+ wait: async () => ({
38
+ id: 'submitted-image.generate',
39
+ title: 'image.generate',
40
+ status: 'queued',
41
+ createdAt: '2026-04-19T11:00:00.000Z',
42
+ updatedAt: '2026-04-19T11:00:00.000Z',
43
+ }),
44
+ })),
45
+ get: vi.fn(async (taskId) => {
46
+ return [...taskFixtures, ...dlqFixtures].find((task) => task.id === taskId) ?? taskFixtures[0];
47
+ }),
48
+ list: vi.fn(async () => ({ data: taskFixtures, total: taskFixtures.length })),
49
+ listDlq: vi.fn(async () => ({ data: dlqFixtures, total: dlqFixtures.length })),
50
+ retry: vi.fn(async () => undefined),
51
+ cancel: vi.fn(async () => undefined),
52
+ };
53
+ const refetchTasks = vi.fn(async () => ({ data: { data: taskFixtures, total: taskFixtures.length } }));
54
+ const refetchDlq = vi.fn(async () => ({ data: { data: dlqFixtures, total: dlqFixtures.length } }));
55
+ const mutateAsync = vi.fn(async (params) => ({
56
+ id: `submitted-${params.taskName}`,
57
+ wait: async () => ({
58
+ id: `submitted-${params.taskName}`,
59
+ title: params.taskName,
60
+ status: 'queued',
61
+ }),
62
+ }));
63
+ vi.mock('@svadmin/core', () => ({
64
+ getTaskProvider: () => mockTaskProvider,
65
+ useTaskList: ({ dlq }) => ({
66
+ data: dlq
67
+ ? { data: dlqFixtures, total: dlqFixtures.length }
68
+ : { data: taskFixtures, total: taskFixtures.length },
69
+ isLoading: false,
70
+ refetch: dlq ? refetchDlq : refetchTasks,
71
+ }),
72
+ useTask: ({ taskId }) => ({
73
+ data: [...taskFixtures, ...dlqFixtures].find((task) => task.id === taskId) ?? taskFixtures[0],
74
+ isLoading: false,
75
+ }),
76
+ useSubmitTask: () => ({
77
+ mutation: {
78
+ mutateAsync,
79
+ isPending: false,
80
+ },
81
+ }),
82
+ }));
83
+ vi.mock('@tanstack/svelte-query', () => ({
84
+ useQueryClient: () => ({
85
+ invalidateQueries: vi.fn(async () => undefined),
86
+ }),
87
+ }));
88
+ describe('TaskQueueDrawer', () => {
89
+ beforeEach(() => {
90
+ refetchTasks.mockClear();
91
+ refetchDlq.mockClear();
92
+ mutateAsync.mockClear();
93
+ mockTaskProvider.submit.mockClear();
94
+ mockTaskProvider.get.mockClear();
95
+ mockTaskProvider.list.mockClear();
96
+ mockTaskProvider.listDlq.mockClear();
97
+ mockTaskProvider.retry.mockClear();
98
+ mockTaskProvider.cancel.mockClear();
99
+ });
100
+ it('supports tab switching, filtering, and detail selection', async () => {
101
+ render(TaskQueueDrawer, { open: true, taskProvider: mockTaskProvider });
102
+ expect(screen.getByText('Task Center')).toBeTruthy();
103
+ expect(screen.getAllByText('Generate poster').length).toBeGreaterThan(0);
104
+ expect(screen.getAllByText('Rendering hero artwork').length).toBeGreaterThan(0);
105
+ const searchInput = screen.getByPlaceholderText('Filter tasks by name, ID, status, or message...');
106
+ await fireEvent.input(searchInput, { target: { value: 'email' } });
107
+ await waitFor(() => {
108
+ expect(screen.queryAllByText('Generate poster').length).toBeLessThan(2);
109
+ });
110
+ expect(screen.getByText('Send email batch')).toBeTruthy();
111
+ await fireEvent.input(searchInput, { target: { value: '' } });
112
+ await fireEvent.click(screen.getByRole('tab', { name: /DLQ/i }));
113
+ expect(screen.getByText('Nightly sync')).toBeTruthy();
114
+ const dlqRow = screen.getByText('Nightly sync').closest('tr');
115
+ expect(dlqRow).toBeTruthy();
116
+ await fireEvent.click(dlqRow);
117
+ await waitFor(() => {
118
+ expect(screen.getAllByText('Remote API timeout').length).toBeGreaterThan(0);
119
+ });
120
+ });
121
+ it('submits a task from the inline form', async () => {
122
+ render(TaskQueueDrawer, { open: true, taskProvider: mockTaskProvider });
123
+ await fireEvent.click(screen.getAllByRole('button', { name: 'Submit Task' })[0]);
124
+ await fireEvent.input(screen.getByLabelText('Task name'), {
125
+ target: { value: 'image.generate' },
126
+ });
127
+ await fireEvent.input(screen.getByLabelText('Task payload (JSON)'), {
128
+ target: { value: '{"prompt":"poster"}' },
129
+ });
130
+ const submitButtons = screen.getAllByRole('button', { name: 'Submit Task' });
131
+ await fireEvent.click(submitButtons[submitButtons.length - 1]);
132
+ await waitFor(() => {
133
+ expect(mutateAsync).toHaveBeenCalledWith({
134
+ taskName: 'image.generate',
135
+ taskProvider: mockTaskProvider,
136
+ options: {
137
+ body: { prompt: 'poster' },
138
+ idempotencyKey: undefined,
139
+ },
140
+ });
141
+ });
142
+ });
143
+ });