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:
150
frontend/src/lib/components/reports/TaskList.svelte
Normal file
150
frontend/src/lib/components/reports/TaskList.svelte
Normal 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 -->
|
||||
@@ -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
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user