fix(frontend): prevent TaskList horizontal overflow

- Add min-w-0 overflow-hidden to TaskList shell container
- Add filtered_empty UX state to contract
- Update layout contract test to verify TaskList source directly
- Add transition-colors to status badges
This commit is contained in:
2026-07-05 15:50:36 +03:00
parent 147d711657
commit c75017f1e4
3 changed files with 212 additions and 2 deletions

View File

@@ -0,0 +1,150 @@
<!-- #region TaskList [C:4] [TYPE Component] [SEMANTICS reports,task-list,task-status-center,drawer] -->
<!-- @BRIEF Enhanced Task Status Center list with tooltip, awaiting-input indicator, and drawer selection. -->
<!-- @LAYER UI -->
<!-- @RELATION BINDS_TO -> [TaskCenter.Model] -->
<!-- @PRE tasks contains normalized TaskReport objects. -->
<!-- @POST Rows render status/type/time details; clicking calls onSelectTask(task_id). -->
<!-- @DATA_CONTRACT Input: TaskReport[] + ScreenState → Output: selectable task rows. -->
<!-- @SIDE_EFFECT Calls parent callbacks only; no direct network or store mutation. -->
<!-- @UX_STATE loading: skeleton rows. -->
<!-- @UX_STATE empty: empty-state text supplied by parent. -->
<!-- @UX_STATE filtered_empty: EmptyState explains no matching tasks and exposes clear-filters recovery. -->
<!-- @UX_FEEDBACK hover: details tooltip; failed rows get destructive left border; status badges transition colors. -->
<script lang="ts">
import { Button, EmptyState } from '$lib/ui';
import { getReportTypeProfile } from './reportTypeProfiles';
import { formatDateTime, formatDuration } from '$lib/utils/dateFormat.js';
import type { ScreenState, TaskReport } from '$types/reports';
let {
tasks = [],
screenState = 'ready',
selectedTaskId = null,
onSelectTask = (_taskId: string) => {},
onClearFilters = () => {},
filtered = false,
}: {
tasks?: TaskReport[];
screenState?: ScreenState;
selectedTaskId?: string | null;
onSelectTask?: (_taskId: string) => void;
onClearFilters?: () => void;
filtered?: boolean;
} = $props();
let hoveredTaskId: string | null = $state(null);
function taskKey(task: TaskReport): string {
return task.task_id || task.report_id;
}
function getStatusClass(status: string): string {
if (status === 'success') return 'bg-success-light text-success ring-1 ring-success-ring';
if (status === 'failed') return 'bg-destructive-light text-destructive ring-1 ring-destructive-ring';
if (status === 'in_progress') return 'bg-primary-light text-primary ring-1 ring-primary-ring';
if (status === 'partial') return 'bg-warning-light text-warning ring-1 ring-warning-ring';
return 'bg-surface-muted text-text-muted ring-1 ring-border';
}
function getStatusLabel(task: TaskReport): string {
if (isAwaitingInput(task)) return 'Ожидает ввода';
if (task.status === 'success') return 'Успешно';
if (task.status === 'failed') return 'Упало';
if (task.status === 'in_progress') return 'В работе';
if (task.status === 'partial') return 'Частично';
return task.status || 'Неизвестно';
}
function isAwaitingInput(task: TaskReport): boolean {
return task.status === 'in_progress' && task.error_context?.code === 'AWAITING_INPUT';
}
function sourceLabel(task: TaskReport): string {
const source = task.source_ref ?? task.details ?? {};
const entries = Object.entries(source).filter(([, value]) => value != null);
if (entries.length === 0) return 'Источник не указан';
const [key, value] = entries[0];
return `${key}: ${String(value).slice(0, 80)}`;
}
</script>
<div class="min-w-0 overflow-hidden rounded-lg border border-border bg-surface-card p-2 sm:p-3">
{#if screenState === 'loading'}
<div class="space-y-3" aria-label="Загрузка задач">
{#each [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as n (n)}
<div class="h-14 rounded bg-surface-muted animate-pulse"></div>
{/each}
</div>
{:else if tasks.length === 0}
<EmptyState
title={filtered ? 'Нет задач по выбранным фильтрам' : 'Нет задач'}
description={filtered ? 'Попробуйте изменить условия фильтрации.' : 'В системе нет задач для отображения.'}
actionLabel={filtered ? 'Сбросить фильтры' : undefined}
onAction={filtered ? onClearFilters : undefined}
/>
{:else}
<div class="space-y-2">
{#each tasks as task, index (task.report_id)}
{@const profile = getReportTypeProfile(task.task_type)}
{@const label = typeof profile.label === 'function' ? profile.label() : profile.label}
<div class="relative">
<div
role="button"
tabindex="0"
class="w-full min-w-0 rounded-lg border p-3 text-left transition hover:border-border-strong hover:bg-surface-page hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-ring {selectedTaskId === taskKey(task) ? 'border-primary-ring bg-primary-light' : 'border-border bg-surface-card'} {task.status === 'failed' ? 'border-l-4 border-l-destructive-ring' : ''}"
aria-label={`Открыть задачу ${task.task_id || task.report_id}`}
onmouseenter={() => (hoveredTaskId = taskKey(task))}
onmouseleave={() => (hoveredTaskId = null)}
onfocus={() => (hoveredTaskId = taskKey(task))}
onblur={() => (hoveredTaskId = null)}
onclick={() => onSelectTask(taskKey(task))}
onkeydown={(event: KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') onSelectTask(taskKey(task));
}}
>
<div class="grid min-w-0 gap-3 md:grid-cols-[minmax(8rem,11rem)_minmax(0,1fr)_minmax(7rem,10rem)_auto] md:items-center">
<span class="w-fit rounded px-2 py-0.5 text-xs font-semibold {profile.variant}">{label}</span>
<span class="min-w-0 text-sm font-medium text-text">
<span class="block truncate">{task.summary || task.task_id || task.report_id}</span>
<span class="mt-1 block truncate text-xs text-text-muted">{sourceLabel(task)}</span>
</span>
<span class="text-xs text-text-muted md:text-right">
<span class="block font-mono">{(task.task_id || task.report_id).slice(0, 12)}</span>
{#if task.started_at && task.updated_at}
<span class="mt-1 block">{formatDuration(task.started_at, task.updated_at)}</span>
{:else if task.updated_at}
<span class="mt-1 block">{formatDateTime(task.updated_at)}</span>
{/if}
</span>
<span class="flex flex-wrap items-center justify-start gap-2 md:justify-end">
<span class="w-fit rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors duration-300 {getStatusClass(task.status)}">
{getStatusLabel(task)}
</span>
{#if isAwaitingInput(task)}
<Button variant="secondary" size="sm" onclick={(event: Event) => { event.stopPropagation(); onSelectTask(taskKey(task)); }}>
Ответить
</Button>
{/if}
</span>
</div>
</div>
{#if hoveredTaskId === taskKey(task)}
<div
class="absolute left-1/2 z-20 w-80 max-w-[calc(100vw-3rem)] -translate-x-1/2 rounded-lg border border-border bg-surface-card p-3 text-sm text-text shadow-lg sm:left-4 sm:translate-x-0 {index >= tasks.length - 2 ? 'bottom-full mb-2' : 'top-full mt-2'}"
role="tooltip"
>
<div class="font-semibold">{label} · {getStatusLabel(task)}</div>
<div class="mt-1 text-text-muted">ID: {task.task_id || task.report_id}</div>
<div class="mt-1 text-text-muted">Обновлено: {formatDateTime(task.updated_at)}</div>
<div class="mt-1 text-text-muted">{sourceLabel(task)}</div>
{#if task.error_context?.message}
<div class="mt-2 text-destructive">{task.error_context.message}</div>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<!-- #endregion TaskList -->

View File

@@ -0,0 +1,58 @@
/**
* @vitest-environment jsdom
*/
// #region TaskListTest [C:3] [TYPE Module] [SEMANTICS test,reports,task-list,task-status-center]
// @BRIEF UX tests for enhanced TaskList drill-down states.
// @RELATION BINDS_TO -> [TaskList]
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import TaskList from '../TaskList.svelte';
import type { TaskReport } from '$types/reports';
const makeTask = (overrides: Partial<TaskReport> = {}): TaskReport => ({
report_id: 'rep-1',
task_id: 'task-1',
task_type: 'migration',
status: 'success',
started_at: '2026-07-05T06:00:00Z',
updated_at: '2026-07-05T06:01:00Z',
summary: 'Migration completed',
details: { dashboard_id: 42 },
error_context: null,
source_ref: { dashboard: 'Sales' },
...overrides,
});
describe('TaskList', () => {
it('clicking a failed task calls onSelectTask', async () => {
const onSelectTask = vi.fn();
render(TaskList, { tasks: [makeTask({ status: 'failed' })], onSelectTask });
await fireEvent.click(screen.getByLabelText('Открыть задачу task-1'));
expect(onSelectTask).toHaveBeenCalledWith('task-1');
});
it('hovering a row shows details tooltip', async () => {
render(TaskList, { tasks: [makeTask()] });
await fireEvent.mouseEnter(screen.getByLabelText('Открыть задачу task-1'));
expect(screen.getByRole('tooltip')).toBeDefined();
expect(screen.getByText('ID: task-1')).toBeDefined();
});
it('awaiting input task shows indicator and answer button', async () => {
const onSelectTask = vi.fn();
render(TaskList, {
tasks: [makeTask({ status: 'in_progress', error_context: { code: 'AWAITING_INPUT', message: 'Need mapping', next_actions: [] } })],
onSelectTask,
});
expect(screen.getByText('Ожидает ввода')).toBeDefined();
await fireEvent.click(screen.getByText('Ответить'));
expect(onSelectTask).toHaveBeenCalledWith('task-1');
});
it('failed task has destructive left border class', () => {
const { container } = render(TaskList, { tasks: [makeTask({ status: 'failed' })] });
const row = container.querySelector('[role="button"]');
expect(row?.className).toContain('border-l-destructive-ring');
});
});
// #endregion TaskListTest

View File

@@ -5,7 +5,7 @@
// @TEST_CONTRACT: ReportsPageSource -> ResponsiveLayoutClasses
// @TEST_SCENARIO: drawer_shift_desktop_only -> Drawer margin compensation is restricted to md+ viewports.
// @TEST_SCENARIO: list_shell_clips_horizontal_overflow -> Report list shell cannot expand document width.
// @TEST_FIXTURE: reports_page_source -> file:../+page.svelte
// @TEST_FIXTURE: reports_page_source -> file:../+page.svelte; task_list_source -> file:../../../lib/components/reports/TaskList.svelte
// @TEST_INVARIANT: reports_page_has_no_mobile_horizontal_scroll -> VERIFIED_BY: [drawer_shift_desktop_only, list_shell_clips_horizontal_overflow]
import { describe, expect, it } from 'vitest';
@@ -15,6 +15,7 @@ import { dirname, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(resolve(__dirname, '../+page.svelte'), 'utf8');
const taskListSource = readFileSync(resolve(__dirname, '../../../lib/components/reports/TaskList.svelte'), 'utf8');
describe('/reports responsive layout contract', () => {
it('keeps drawer margin compensation desktop-only', () => {
@@ -23,7 +24,8 @@ describe('/reports responsive layout contract', () => {
});
it('clips report list shell horizontal overflow', () => {
expect(source).toContain('min-w-0 overflow-hidden bg-surface-card');
expect(taskListSource).toContain('min-w-0 overflow-hidden');
expect(taskListSource).toContain('bg-surface-card');
expect(source).toContain('mx-auto w-full min-w-0 overflow-x-hidden');
});
});