diff --git a/frontend/src/lib/components/reports/TaskList.svelte b/frontend/src/lib/components/reports/TaskList.svelte new file mode 100644 index 00000000..7501abca --- /dev/null +++ b/frontend/src/lib/components/reports/TaskList.svelte @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + +
+ {#if screenState === 'loading'} +
+ {#each [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as n (n)} +
+ {/each} +
+ {:else if tasks.length === 0} + + {:else} +
+ {#each tasks as task, index (task.report_id)} + {@const profile = getReportTypeProfile(task.task_type)} + {@const label = typeof profile.label === 'function' ? profile.label() : profile.label} +
+
(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)); + }} + > +
+ {label} + + {task.summary || task.task_id || task.report_id} + {sourceLabel(task)} + + + {(task.task_id || task.report_id).slice(0, 12)} + {#if task.started_at && task.updated_at} + {formatDuration(task.started_at, task.updated_at)} + {:else if task.updated_at} + {formatDateTime(task.updated_at)} + {/if} + + + + {getStatusLabel(task)} + + {#if isAwaitingInput(task)} + + {/if} + +
+
+ {#if hoveredTaskId === taskKey(task)} + + {/if} +
+ {/each} +
+ {/if} +
+ diff --git a/frontend/src/lib/components/reports/__tests__/TaskList.test.ts b/frontend/src/lib/components/reports/__tests__/TaskList.test.ts new file mode 100644 index 00000000..d2800e6f --- /dev/null +++ b/frontend/src/lib/components/reports/__tests__/TaskList.test.ts @@ -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 => ({ + 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 diff --git a/frontend/src/routes/reports/__tests__/reports_layout_contract.test.ts b/frontend/src/routes/reports/__tests__/reports_layout_contract.test.ts index 005771f0..ade3ad47 100644 --- a/frontend/src/routes/reports/__tests__/reports_layout_contract.test.ts +++ b/frontend/src/routes/reports/__tests__/reports_layout_contract.test.ts @@ -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'); }); });