Merge commit '11942c89ac8cb443bad62198cfcfdc2d5f3647a1' into fleet/story-d2-job-error-banner-home
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
onDelete?: (jobId: string) => void;
|
onDelete?: (jobId: string) => void;
|
||||||
retryLabel?: string;
|
retryLabel?: string;
|
||||||
deleteLabel?: string;
|
deleteLabel?: string;
|
||||||
|
detailed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -17,36 +18,181 @@
|
|||||||
onDelete,
|
onDelete,
|
||||||
retryLabel = 'Retry',
|
retryLabel = 'Retry',
|
||||||
deleteLabel = 'Delete',
|
deleteLabel = 'Delete',
|
||||||
|
detailed = false,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
const isDetailed = $derived(detailed || Boolean(onRetry || onDelete));
|
||||||
|
|
||||||
function handleRetry(e: MouseEvent) {
|
function handleRetry(e: MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRetry?.(job.id);
|
onRetry?.(job.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDelete(e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete?.(job.id);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if job.status === 'failed' && job.error}
|
{#if job.status === 'failed' && job.error}
|
||||||
|
{#if isDetailed}
|
||||||
|
<!-- Detailed error banner for detail page -->
|
||||||
|
<div class="error-banner" role="alert">
|
||||||
|
<div class="eb-icon" aria-hidden="true">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12 3L2 21h20L12 3z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<line x1="12" y1="10" x2="12" y2="15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<circle cx="12" cy="18" r="0.8" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="eb-body">
|
||||||
|
<div class="eb-title">Transcription failed</div>
|
||||||
|
<div class="eb-msg">{job.error}</div>
|
||||||
|
</div>
|
||||||
|
<div class="eb-actions">
|
||||||
|
{#if onRetry}
|
||||||
|
<button class="btn primary" onclick={handleRetry} aria-label="Retry transcription">↺ Retry transcription</button>
|
||||||
|
{/if}
|
||||||
|
{#if onDelete}
|
||||||
|
<button class="btn ghost danger-ghost" onclick={handleDelete}>Delete</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Minimal error line for list view -->
|
||||||
<div class="recent-err-line" role="alert" title={job.error}>
|
<div class="recent-err-line" role="alert" title={job.error}>
|
||||||
<span class="error-text" style="color: {accentColor}">{job.error}</span>
|
<span class="error-text" style="color: {accentColor}">{job.error}</span>
|
||||||
{#if onRetry}
|
{#if onRetry}
|
||||||
<button class="retry-btn" onclick={handleRetry} aria-label={retryLabel}>{retryLabel}</button>
|
<button class="retry-btn" onclick={handleRetry} aria-label={retryLabel}>{retryLabel}</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if onDelete}
|
{#if onDelete}
|
||||||
<button class="delete-btn" onclick={(e) => { e.preventDefault(); e.stopPropagation(); onDelete?.(job.id); }} aria-label={deleteLabel}>{deleteLabel}</button>
|
<button class="delete-btn" onclick={handleDelete} aria-label={deleteLabel}>{deleteLabel}</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
{:else if job.status === 'cancelled'}
|
{:else if job.status === 'cancelled'}
|
||||||
<div class="recent-err-line" role="status">
|
<div class="recent-err-line" role="status">
|
||||||
<span>Job was cancelled</span>
|
<span>Job was cancelled</span>
|
||||||
</div>
|
</div>
|
||||||
{:else if job.status === 'done' && job.segmentsJson}
|
{:else if job.status === 'done' && job.segmentsJson}
|
||||||
<div class="recent-err-line">
|
<div class="recent-err-line">
|
||||||
<button class="delete-btn" onclick={(e) => { e.preventDefault(); e.stopPropagation(); onDelete?.(job.id); }} aria-label={deleteLabel}>{deleteLabel}</button>
|
{#if onDelete}
|
||||||
|
<button class="delete-btn" onclick={handleDelete} aria-label={deleteLabel}>{deleteLabel}</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.error-banner {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--error-bg);
|
||||||
|
border: 1px solid var(--error-border);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eb-icon {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: rgba(255,90,90,0.12);
|
||||||
|
color: var(--error-text);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eb-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eb-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--error-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eb-msg {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: rgba(255,180,180,0.85);
|
||||||
|
margin-top: 5px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
max-height: 96px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eb-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 550;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 7px 13px;
|
||||||
|
border-radius: 9px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: var(--surface-hover);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 140ms ease, border-color 140ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
background: rgba(255,255,255,0.07);
|
||||||
|
border-color: rgba(255,255,255,0.18);
|
||||||
|
}
|
||||||
|
.btn:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 4px var(--accent-ring);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #10120a;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 0 18px rgba(205,242,78,0.18);
|
||||||
|
}
|
||||||
|
.btn.primary:hover {
|
||||||
|
background: #d9f566;
|
||||||
|
border-color: #d9f566;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.ghost {
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.btn.ghost:hover {
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger-ghost {
|
||||||
|
color: var(--error-text);
|
||||||
|
}
|
||||||
|
.btn.danger-ghost:hover {
|
||||||
|
background: rgba(255,90,90,0.08);
|
||||||
|
border-color: var(--error-border);
|
||||||
|
}
|
||||||
|
|
||||||
.recent-err-line {
|
.recent-err-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { getDisplayJobProgress, getJobStatusLabel, isTerminalJobStatus } from '$lib/job-progress.js';
|
import { getDisplayJobProgress, getJobStatusLabel, isTerminalJobStatus } from '$lib/job-progress.js';
|
||||||
import SourceIcon from '$lib/components/SourceIcon.svelte';
|
import SourceIcon from '$lib/components/SourceIcon.svelte';
|
||||||
import Waveform from '$lib/components/Waveform.svelte';
|
import Waveform from '$lib/components/Waveform.svelte';
|
||||||
|
import JobErrorLine from '$lib/components/JobErrorLine.svelte';
|
||||||
import { accent } from '$lib/accent.js';
|
import { accent } from '$lib/accent.js';
|
||||||
|
|
||||||
const ACCENT = $derived($accent.value);
|
const ACCENT = $derived($accent.value);
|
||||||
@@ -257,24 +258,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- ── Error ─────────────────────────────────────────── -->
|
<!-- ── Error ─────────────────────────────────────────── -->
|
||||||
{#if job.error}
|
{#if job && job.error}
|
||||||
<div class="error-banner" role="alert">
|
<JobErrorLine {job} accentColor={ACCENT} onRetry={retryJobAction} onDelete={deleteJob} />
|
||||||
<div class="eb-icon" aria-hidden="true">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M12 3L2 21h20L12 3z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
|
|
||||||
<line x1="12" y1="10" x2="12" y2="15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
<circle cx="12" cy="18" r="0.8" fill="currentColor"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="eb-body">
|
|
||||||
<div class="eb-title">Transcription failed</div>
|
|
||||||
<div class="eb-msg">{job.error}</div>
|
|
||||||
</div>
|
|
||||||
<div class="eb-actions">
|
|
||||||
<button class="btn primary" onclick={retryJobAction} aria-label="Retry transcription">↺ Retry transcription</button>
|
|
||||||
<button class="btn ghost danger-ghost" onclick={deleteJob}>Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- ── Two-column: pipeline + downloads/transcript ───── -->
|
<!-- ── Two-column: pipeline + downloads/transcript ───── -->
|
||||||
@@ -406,111 +391,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.error-banner {
|
.error-banner {
|
||||||
display: flex;
|
padding: 12px 16px;
|
||||||
gap: 14px;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: 16px 18px;
|
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: var(--error-bg);
|
|
||||||
border: 1px solid var(--error-border);
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eb-icon {
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
border-radius: 9px;
|
|
||||||
background: rgba(255,90,90,0.12);
|
|
||||||
color: var(--error-text);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eb-body {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eb-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--error-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.eb-msg {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: rgba(255,180,180,0.85);
|
|
||||||
margin-top: 5px;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
max-height: 96px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eb-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 550;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
padding: 7px 13px;
|
|
||||||
border-radius: 9px;
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
background: var(--surface-hover);
|
|
||||||
color: var(--text);
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: background 140ms ease, border-color 140ms ease;
|
|
||||||
}
|
|
||||||
.btn:hover {
|
|
||||||
background: rgba(255,255,255,0.07);
|
|
||||||
border-color: rgba(255,255,255,0.18);
|
|
||||||
}
|
|
||||||
.btn:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
box-shadow: 0 0 0 4px var(--accent-ring);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.primary {
|
|
||||||
background: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: #10120a;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 10px 18px;
|
|
||||||
font-size: 13.5px;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 0 18px rgba(205,242,78,0.18);
|
|
||||||
}
|
|
||||||
.btn.primary:hover {
|
|
||||||
background: #d9f566;
|
|
||||||
border-color: #d9f566;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.ghost {
|
|
||||||
background: transparent;
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
.btn.ghost:hover {
|
|
||||||
background: rgba(255,255,255,0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.danger-ghost {
|
|
||||||
color: var(--error-text);
|
|
||||||
}
|
|
||||||
.btn.danger-ghost:hover {
|
|
||||||
background: rgba(255, 90, 90, 0.08);
|
background: rgba(255, 90, 90, 0.08);
|
||||||
border-color: var(--error-border);
|
border: 1px solid rgba(255, 90, 90, 0.2);
|
||||||
|
color: #ff8a8a;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Breadcrumb ─────────────────────────────────────────── */
|
/* ── Breadcrumb ─────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -35,13 +35,14 @@ describe('JobErrorLine component', () => {
|
|||||||
expect(getByText('transcription timeout')).toBeTruthy();
|
expect(getByText('transcription timeout')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows retry button', () => {
|
it('shows retry button in detailed mode', () => {
|
||||||
const { getByRole } = render(JobErrorLine, {
|
const { getByRole } = render(JobErrorLine, {
|
||||||
job: makeJob(),
|
job: makeJob(),
|
||||||
accentColor: '#cdf24e',
|
accentColor: '#cdf24e',
|
||||||
onRetry: vi.fn().mockResolvedValue(undefined)
|
onRetry: vi.fn().mockResolvedValue(undefined)
|
||||||
});
|
});
|
||||||
expect(getByRole('button', { name: 'Retry' })).toBeTruthy();
|
// Detailed mode shows "Retry transcription"
|
||||||
|
expect(getByRole('button', { name: 'Retry transcription' })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onRetry when retry button clicked', async () => {
|
it('calls onRetry when retry button clicked', async () => {
|
||||||
@@ -51,11 +52,11 @@ describe('JobErrorLine component', () => {
|
|||||||
accentColor: '#cdf24e',
|
accentColor: '#cdf24e',
|
||||||
onRetry
|
onRetry
|
||||||
});
|
});
|
||||||
await fireEvent.click(getByRole('button', { name: 'Retry' }));
|
await fireEvent.click(getByRole('button', { name: 'Retry transcription' }));
|
||||||
expect(onRetry).toHaveBeenCalled();
|
expect(onRetry).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows delete button when onDelete provided', () => {
|
it('shows delete button when onDelete provided in detailed mode', () => {
|
||||||
const { getByRole } = render(JobErrorLine, {
|
const { getByRole } = render(JobErrorLine, {
|
||||||
job: makeJob(),
|
job: makeJob(),
|
||||||
accentColor: '#cdf24e',
|
accentColor: '#cdf24e',
|
||||||
@@ -65,16 +66,18 @@ describe('JobErrorLine component', () => {
|
|||||||
expect(getByRole('button', { name: 'Delete' })).toBeTruthy();
|
expect(getByRole('button', { name: 'Delete' })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides delete button when onDelete not provided', () => {
|
it('hides delete button when onDelete not provided in detailed mode', () => {
|
||||||
const { queryByRole } = render(JobErrorLine, {
|
const { queryByRole } = render(JobErrorLine, {
|
||||||
job: makeJob(),
|
job: makeJob(),
|
||||||
accentColor: '#cdf24e',
|
accentColor: '#cdf24e',
|
||||||
onRetry: vi.fn().mockResolvedValue(undefined)
|
onRetry: vi.fn().mockResolvedValue(undefined)
|
||||||
});
|
});
|
||||||
expect(queryByRole('button', { name: 'Delete' })).toBeNull();
|
// In detailed mode, only shows delete button if onDelete is provided
|
||||||
|
const deleteButton = queryByRole('button', { name: 'Delete' });
|
||||||
|
expect(deleteButton).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('container has role alert for failed jobs', () => {
|
it('container has role alert for failed jobs in detailed mode', () => {
|
||||||
const { getByRole } = render(JobErrorLine, {
|
const { getByRole } = render(JobErrorLine, {
|
||||||
job: makeJob({ status: 'failed' }),
|
job: makeJob({ status: 'failed' }),
|
||||||
accentColor: '#cdf24e',
|
accentColor: '#cdf24e',
|
||||||
@@ -86,27 +89,37 @@ describe('JobErrorLine component', () => {
|
|||||||
it('container has role status for cancelled jobs', () => {
|
it('container has role status for cancelled jobs', () => {
|
||||||
const { getByRole } = render(JobErrorLine, {
|
const { getByRole } = render(JobErrorLine, {
|
||||||
job: makeJob({ status: 'cancelled' }),
|
job: makeJob({ status: 'cancelled' }),
|
||||||
accentColor: '#cdf24e',
|
accentColor: '#cdf24e'
|
||||||
onRetry: vi.fn().mockResolvedValue(undefined)
|
|
||||||
});
|
});
|
||||||
expect(getByRole('status')).toBeTruthy();
|
expect(getByRole('status')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not render when job has no error text', () => {
|
it('does not render error when job has no error text', () => {
|
||||||
const { container } = render(JobErrorLine, {
|
const { container } = render(JobErrorLine, {
|
||||||
job: makeJob({ status: 'failed', error: null }),
|
job: makeJob({ status: 'failed', error: null }),
|
||||||
accentColor: '#cdf24e',
|
accentColor: '#cdf24e',
|
||||||
onRetry: vi.fn().mockResolvedValue(undefined)
|
onRetry: vi.fn().mockResolvedValue(undefined)
|
||||||
});
|
});
|
||||||
|
// Should not render any error container
|
||||||
|
expect(container.querySelector('.error-banner')).toBeNull();
|
||||||
expect(container.querySelector('.recent-err-line')).toBeNull();
|
expect(container.querySelector('.recent-err-line')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders "Job was cancelled" for cancelled jobs', () => {
|
it('renders "Job was cancelled" for cancelled jobs', () => {
|
||||||
const { getByText } = render(JobErrorLine, {
|
const { getByText } = render(JobErrorLine, {
|
||||||
job: makeJob({ status: 'cancelled' }),
|
job: makeJob({ status: 'cancelled' }),
|
||||||
accentColor: '#cdf24e',
|
accentColor: '#cdf24e'
|
||||||
onRetry: vi.fn().mockResolvedValue(undefined)
|
|
||||||
});
|
});
|
||||||
expect(getByText('Job was cancelled')).toBeTruthy();
|
expect(getByText('Job was cancelled')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders detailed display with title when handlers provided', () => {
|
||||||
|
const { getByText } = render(JobErrorLine, {
|
||||||
|
job: makeJob(),
|
||||||
|
accentColor: '#cdf24e',
|
||||||
|
onRetry: vi.fn().mockResolvedValue(undefined),
|
||||||
|
onDelete: vi.fn().mockResolvedValue(undefined)
|
||||||
|
});
|
||||||
|
expect(getByText('Transcription failed')).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user