D2: JobErrorLine component + home/jobs page integration
- Add src/lib/components/JobErrorLine.svelte: reusable error banner with icon, truncated message, accent retry and danger delete buttons; Svelte 5 runes; WCAG AA (role=alert/status, aria-busy, aria-label, focus ring) - Integrate into src/routes/+page.svelte: replace .recent-err-line div with JobErrorLine; add handleRetry function updating jobs array in-place - Integrate into src/routes/jobs/+page.svelte: remove inline .job-err-inline and .row-retry-slot; add JobErrorLine inside .job-info for failed/cancelled; refactor retryJobById/deleteJobById with clean (jobId: string) signatures; done-job delete preserved in row-actions - Add src/tests/job-error-line.test.ts: 8 component rendering tests via @testing-library/svelte + happy-dom covering message, buttons, callbacks, fallback text, and ARIA roles - Install @testing-library/svelte@5 and happy-dom as devDeps for component test support; update vitest.config.ts with svelte plugin, happy-dom env match glob, and browser resolve condition for Svelte 5 client build Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
0810c223e1
commit
dd438d2a0b
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import type { Job } from '$lib/types.js';
|
||||
|
||||
interface Props {
|
||||
job: Job;
|
||||
accentColor: string;
|
||||
onRetry: (jobId: string) => Promise<void>;
|
||||
onDelete?: (jobId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
let { job, accentColor, onRetry, onDelete }: Props = $props();
|
||||
|
||||
let retrying = $state(false);
|
||||
|
||||
const errorText = $derived(job.error ?? 'Transcription failed');
|
||||
const isFailed = $derived(job.status === 'failed');
|
||||
|
||||
async function handleRetry(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
retrying = true;
|
||||
try {
|
||||
await onRetry(job.id);
|
||||
} finally {
|
||||
retrying = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onDelete) await onDelete(job.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="job-error-line"
|
||||
role={isFailed ? 'alert' : 'status'}
|
||||
style="--jel-accent: {accentColor}"
|
||||
>
|
||||
<svg
|
||||
class="err-icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.3" />
|
||||
<path d="M8 5v4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
|
||||
<circle cx="8" cy="11.5" r="0.75" fill="currentColor" />
|
||||
</svg>
|
||||
|
||||
<span class="err-msg" title={errorText}>{errorText}</span>
|
||||
|
||||
<div class="err-actions">
|
||||
<button
|
||||
class="err-btn retry-btn"
|
||||
onclick={handleRetry}
|
||||
aria-label="Retry"
|
||||
aria-busy={retrying ? 'true' : undefined}
|
||||
disabled={retrying}
|
||||
>{retrying ? '…' : '↺'}</button>
|
||||
|
||||
{#if onDelete && !retrying}
|
||||
<button
|
||||
class="err-btn delete-btn"
|
||||
onclick={handleDelete}
|
||||
aria-label="Delete"
|
||||
>✕</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.job-error-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.err-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--error-text, #ff8a8a);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.err-msg {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--error-text, #ff8a8a);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: var(--font-mono, 'JetBrains Mono', 'SF Mono', ui-monospace, monospace);
|
||||
}
|
||||
|
||||
.err-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.err-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.err-btn:focus-visible {
|
||||
outline: 2px solid var(--jel-accent, var(--accent, #cdf24e));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.err-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
background: color-mix(in oklab, var(--jel-accent, var(--accent, #cdf24e)) 10%, transparent);
|
||||
border-color: color-mix(in oklab, var(--jel-accent, var(--accent, #cdf24e)) 40%, transparent);
|
||||
color: var(--jel-accent, var(--accent, #cdf24e));
|
||||
}
|
||||
|
||||
.retry-btn:hover:not(:disabled) {
|
||||
background: color-mix(in oklab, var(--jel-accent, var(--accent, #cdf24e)) 20%, transparent);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: rgba(255, 90, 90, 0.08);
|
||||
border-color: rgba(255, 90, 90, 0.3);
|
||||
color: var(--error-text, #ff8a8a);
|
||||
}
|
||||
|
||||
.delete-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 90, 90, 0.2);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.err-btn {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+13
-12
@@ -5,6 +5,7 @@
|
||||
import SourceIcon from '$lib/components/SourceIcon.svelte';
|
||||
import Waveform from '$lib/components/Waveform.svelte';
|
||||
import RecordButton from '$lib/components/RecordButton.svelte';
|
||||
import JobErrorLine from '$lib/components/JobErrorLine.svelte';
|
||||
import { accent } from '$lib/accent.js';
|
||||
|
||||
const ACCENT = $derived($accent.value);
|
||||
@@ -100,6 +101,17 @@
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
async function handleRetry(jobId: string) {
|
||||
const res = await fetch(`/api/jobs/${jobId}/retry`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const updated = await fetch(`/api/jobs/${jobId}`);
|
||||
if (updated.ok) {
|
||||
const j = await updated.json();
|
||||
jobs = jobs.map((x) => (x.id === jobId ? j : x));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decorative waveform bars for the drop zone (80 bars)
|
||||
const DROPZONE_BARS = 80;
|
||||
</script>
|
||||
@@ -271,7 +283,7 @@
|
||||
<div class="recent-title">{job.title || job.source}</div>
|
||||
<div class="recent-meta mono">{jobMeta(job)}</div>
|
||||
{#if job.status === 'failed' && job.error}
|
||||
<div class="recent-err-line" title={job.error}>{job.error}</div>
|
||||
<JobErrorLine {job} accentColor={ACCENT} onRetry={handleRetry} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if job.status !== 'done' && job.status !== 'failed' && job.status !== 'cancelled'}
|
||||
@@ -568,17 +580,6 @@
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.recent-err-line {
|
||||
font-size: 11.5px;
|
||||
color: var(--error-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 3px;
|
||||
font-family: var(--font-mono);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ── Utilities ─────────────────────────────────────────── */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { getDisplayJobProgress, getJobStatusColor, getJobStatusLabel, isTerminalJobStatus } from '$lib/job-progress.js';
|
||||
import SourceIcon from '$lib/components/SourceIcon.svelte';
|
||||
import Waveform from '$lib/components/Waveform.svelte';
|
||||
import JobErrorLine from '$lib/components/JobErrorLine.svelte';
|
||||
import { accent } from '$lib/accent.js';
|
||||
|
||||
const ACCENT = $derived($accent.value);
|
||||
@@ -33,23 +34,27 @@
|
||||
loading = false;
|
||||
});
|
||||
|
||||
async function deleteJobById(jobId: string) {
|
||||
const job = jobs.find((j) => j.id === jobId);
|
||||
if (!job) return;
|
||||
if (!confirm(`Delete "${job.title || job.id}"?`)) return;
|
||||
const res = await fetch(`/api/jobs/${jobId}`, { method: 'DELETE' });
|
||||
if (res.ok) jobs = jobs.filter((j) => j.id !== jobId);
|
||||
}
|
||||
|
||||
async function deleteJob(e: MouseEvent, job: Job) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!confirm(`Delete "${job.title || job.id}"?`)) return;
|
||||
const res = await fetch(`/api/jobs/${job.id}`, { method: 'DELETE' });
|
||||
if (res.ok) jobs = jobs.filter((j) => j.id !== job.id);
|
||||
await deleteJobById(job.id);
|
||||
}
|
||||
|
||||
async function retryJob(e: MouseEvent, job: Job) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const res = await fetch(`/api/jobs/${job.id}/retry`, { method: 'POST' });
|
||||
async function retryJobById(jobId: string) {
|
||||
const res = await fetch(`/api/jobs/${jobId}/retry`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const updated = await fetch(`/api/jobs/${job.id}`);
|
||||
const updated = await fetch(`/api/jobs/${jobId}`);
|
||||
if (updated.ok) {
|
||||
const j = await updated.json();
|
||||
jobs = jobs.map((x) => (x.id === job.id ? j : x));
|
||||
jobs = jobs.map((x) => (x.id === jobId ? j : x));
|
||||
}
|
||||
} else {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
@@ -99,11 +104,15 @@
|
||||
<span>·</span>
|
||||
<span>{job.audioMode}</span>
|
||||
{/if}
|
||||
{#if ['failed', 'cancelled'].includes(job.status) && job.error}
|
||||
<span>·</span>
|
||||
<span class="job-err-inline" title={job.error}>{job.error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if ['failed', 'cancelled'].includes(job.status) && job.error}
|
||||
<JobErrorLine
|
||||
{job}
|
||||
accentColor={ACCENT}
|
||||
onRetry={retryJobById}
|
||||
onDelete={deleteJobById}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !isTerminalJobStatus(job.status)}
|
||||
@@ -120,27 +129,11 @@
|
||||
<div class="job-pct mono" style="color: {ACCENT}">
|
||||
{getDisplayJobProgress(job, { hasTranscript: Boolean(job.segmentsJson) })}%
|
||||
</div>
|
||||
{:else}
|
||||
<div class="job-pct mono" style="color: {getJobStatusColor(job.status)}">
|
||||
{getJobStatusLabel(job.status)}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Retry slot (always visible) -->
|
||||
{#if ['failed', 'cancelled'].includes(job.status)}
|
||||
<div class="row-retry-slot">
|
||||
<button
|
||||
class="row-btn accent"
|
||||
onclick={(e) => retryJob(e, job)}
|
||||
aria-label="Retry"
|
||||
title="Retry"
|
||||
>↺</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Row actions (hover-revealed, delete only) -->
|
||||
<!-- Row actions (hover-revealed, delete for done jobs) -->
|
||||
<div class="row-actions">
|
||||
{#if isTerminalJobStatus(job.status)}
|
||||
{#if job.status === 'done'}
|
||||
<button
|
||||
class="row-btn danger"
|
||||
onclick={(e) => deleteJob(e, job)}
|
||||
@@ -256,21 +249,6 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.row-retry-slot {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.job-err-inline {
|
||||
color: var(--error-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
max-width: 160px;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.job-row[data-status="failed"] .job-name {
|
||||
color: var(--error-text);
|
||||
}
|
||||
@@ -296,14 +274,6 @@
|
||||
.row-btn.danger:hover {
|
||||
background: rgba(255, 90, 90, 0.2);
|
||||
}
|
||||
.row-btn.accent {
|
||||
border-color: color-mix(in oklab, var(--accent) 40%, transparent);
|
||||
background: color-mix(in oklab, var(--accent) 10%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.row-btn.accent:hover {
|
||||
background: color-mix(in oklab, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, fireEvent } from '@testing-library/svelte';
|
||||
import JobErrorLine from '$lib/components/JobErrorLine.svelte';
|
||||
import type { Job } from '$lib/types.js';
|
||||
|
||||
function makeJob(overrides: Partial<Job> = {}): Job {
|
||||
return {
|
||||
id: 'job-1',
|
||||
status: 'failed',
|
||||
title: 'Test Job',
|
||||
source: 'test.mp3',
|
||||
audioMode: 'auto',
|
||||
meanVolume: null,
|
||||
whisperJobId: null,
|
||||
progress: 0,
|
||||
outputDir: null,
|
||||
segmentsJson: null,
|
||||
error: 'transcription timeout',
|
||||
retryCount: 0,
|
||||
nextRetryAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('JobErrorLine component', () => {
|
||||
it('renders error message for failed job', () => {
|
||||
const { getByText } = render(JobErrorLine, {
|
||||
job: makeJob(),
|
||||
accentColor: '#cdf24e',
|
||||
onRetry: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
expect(getByText('transcription timeout')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows retry button labeled ↺', () => {
|
||||
const { getByRole } = render(JobErrorLine, {
|
||||
job: makeJob(),
|
||||
accentColor: '#cdf24e',
|
||||
onRetry: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
expect(getByRole('button', { name: 'Retry' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls onRetry with job id when retry button clicked', async () => {
|
||||
const onRetry = vi.fn().mockResolvedValue(undefined);
|
||||
const { getByRole } = render(JobErrorLine, {
|
||||
job: makeJob({ id: 'job-abc' }),
|
||||
accentColor: '#cdf24e',
|
||||
onRetry
|
||||
});
|
||||
await fireEvent.click(getByRole('button', { name: 'Retry' }));
|
||||
expect(onRetry).toHaveBeenCalledWith('job-abc');
|
||||
});
|
||||
|
||||
it('shows fallback text when job.error is null', () => {
|
||||
const { getByText } = render(JobErrorLine, {
|
||||
job: makeJob({ error: null }),
|
||||
accentColor: '#cdf24e',
|
||||
onRetry: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
expect(getByText('Transcription failed')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows delete button when onDelete provided', () => {
|
||||
const { getByRole } = render(JobErrorLine, {
|
||||
job: makeJob(),
|
||||
accentColor: '#cdf24e',
|
||||
onRetry: vi.fn().mockResolvedValue(undefined),
|
||||
onDelete: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
expect(getByRole('button', { name: 'Delete' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hides delete button when onDelete not provided', () => {
|
||||
const { queryByRole } = render(JobErrorLine, {
|
||||
job: makeJob(),
|
||||
accentColor: '#cdf24e',
|
||||
onRetry: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
expect(queryByRole('button', { name: 'Delete' })).toBeNull();
|
||||
});
|
||||
|
||||
it('container has role alert for failed jobs', () => {
|
||||
const { getByRole } = render(JobErrorLine, {
|
||||
job: makeJob({ status: 'failed' }),
|
||||
accentColor: '#cdf24e',
|
||||
onRetry: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
expect(getByRole('alert')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('container has role status for cancelled jobs', () => {
|
||||
const { getByRole } = render(JobErrorLine, {
|
||||
job: makeJob({ status: 'cancelled' }),
|
||||
accentColor: '#cdf24e',
|
||||
onRetry: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
expect(getByRole('status')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user