From 86ac20961533f6b99e92a21eaaf109817f0a63e3 Mon Sep 17 00:00:00 2001 From: busya Date: Sun, 5 Jul 2026 15:50:46 +0300 Subject: [PATCH] feat(frontend): compact FilterBar with disclosure toggle and quick pills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Quick status pills (Упавшие/В работе/Успешные) always visible - Search input with flex spacer in same row - Explicit "Фильтры" disclosure button with filter icon, badge count, chevron - aria-expanded / aria-controls for accessibility - Expandable panel: type, status, time range, sort controls - Remove bottom status bar ("Показано X из Y", "Активно фильтров") - Update tests for expanded state and quick pill interactions --- .../lib/components/reports/FilterBar.svelte | 191 ++++++++++++++++++ .../reports/__tests__/FilterBar.test.ts | 102 ++++++++++ 2 files changed, 293 insertions(+) create mode 100644 frontend/src/lib/components/reports/FilterBar.svelte create mode 100644 frontend/src/lib/components/reports/__tests__/FilterBar.test.ts diff --git a/frontend/src/lib/components/reports/FilterBar.svelte b/frontend/src/lib/components/reports/FilterBar.svelte new file mode 100644 index 00000000..8fd870eb --- /dev/null +++ b/frontend/src/lib/components/reports/FilterBar.svelte @@ -0,0 +1,191 @@ + + + + + + + + + + +
+ +
+ + + + + + +
+ + + +
+
+ + + {#if expanded} +
+
+ +
+
+ +
+ + {#if activeFilterCount > 0} + + {/if} +
+ {/if} + + +
+ diff --git a/frontend/src/lib/components/reports/__tests__/FilterBar.test.ts b/frontend/src/lib/components/reports/__tests__/FilterBar.test.ts new file mode 100644 index 00000000..4e6b38b1 --- /dev/null +++ b/frontend/src/lib/components/reports/__tests__/FilterBar.test.ts @@ -0,0 +1,102 @@ +/** + * @vitest-environment jsdom + */ +// #region FilterBarTest [C:3] [TYPE Module] [SEMANTICS test,reports,filter,task-status-center] +// @BRIEF UX tests for Task Status Center FilterBar with compact expandable layout. +// @RELATION BINDS_TO -> [FilterBar] +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import FilterBar from '../FilterBar.svelte'; +import type { TaskCenterFilter } from '$types/reports'; + +const baseFilters = (): TaskCenterFilter => ({ + task_types: [], + statuses: [], + search: '', + time_range: 'all', + sort_by: 'updated_at', + sort_order: 'desc', +}); + +const baseProps = () => ({ + filters: baseFilters(), + quickStatusCounts: { failed: 0, in_progress: 0, success: 0 }, +}); + +/** Opens the full-filter panel by clicking the ⚙ toggle. */ +async function expandPanel() { + await fireEvent.click(screen.getByLabelText('Показать фильтры')); +} + +describe('FilterBar', () => { + beforeEach(() => vi.clearAllMocks()); + + it('selecting type calls onApplyFilter with task_types', async () => { + const onApplyFilter = vi.fn(); + render(FilterBar, { ...baseProps(), onApplyFilter }); + await expandPanel(); + await fireEvent.change(screen.getByLabelText('Тип'), { target: { value: 'backup' } }); + expect(onApplyFilter).toHaveBeenCalledWith({ task_types: ['backup'] }); + }); + + it('selecting status calls onApplyFilter with statuses', async () => { + const onApplyFilter = vi.fn(); + render(FilterBar, { ...baseProps(), onApplyFilter }); + await expandPanel(); + await fireEvent.change(screen.getByLabelText('Статус'), { target: { value: 'failed' } }); + expect(onApplyFilter).toHaveBeenCalledWith({ statuses: ['failed'] }); + }); + + it('typing search calls onApplyFilter after debounce', async () => { + vi.useFakeTimers(); + const onApplyFilter = vi.fn(); + render(FilterBar, { ...baseProps(), onApplyFilter }); + await fireEvent.input(screen.getByPlaceholderText('Поиск по названию или ID...'), { target: { value: 'migration' } }); + vi.advanceTimersByTime(351); + expect(onApplyFilter).toHaveBeenCalledWith({ search: 'migration' }); + vi.useRealTimers(); + }); + + it('clear button calls onClearFilters with active filters', async () => { + const onClearFilters = vi.fn(); + render(FilterBar, { + ...baseProps(), + filters: { ...baseFilters(), task_types: ['backup'], statuses: ['failed'] }, + onClearFilters, + }); + await expandPanel(); + expect(screen.getByText('Сбросить')).toBeDefined(); + await fireEvent.click(screen.getByText('Сбросить')); + expect(onClearFilters).toHaveBeenCalledOnce(); + }); + + it('clicking quick status pill calls onQuickStatusToggle', async () => { + const onQuickStatusToggle = vi.fn(); + render(FilterBar, { ...baseProps(), onQuickStatusToggle }); + await fireEvent.click(screen.getByText(/Упавшие/)); + expect(onQuickStatusToggle).toHaveBeenCalledWith('failed'); + }); + + it('quick pills show correct counts', () => { + render(FilterBar, { + ...baseProps(), + quickStatusCounts: { failed: 5, in_progress: 3, success: 12 }, + }); + expect(screen.getByText(/Упавшие 5/)).toBeDefined(); + expect(screen.getByText(/В работе 3/)).toBeDefined(); + expect(screen.getByText(/Успешные 12/)).toBeDefined(); + }); + + it('toggle ⚙ shows and hides the full-filter panel', async () => { + render(FilterBar, { ...baseProps() }); + // Panel is collapsed — Тип select should not be in the DOM + expect(screen.queryByLabelText('Тип')).toBeNull(); + // Expand + await expandPanel(); + expect(screen.getByLabelText('Тип')).toBeDefined(); + // Collapse + await fireEvent.click(screen.getByLabelText('Скрыть фильтры')); + expect(screen.queryByLabelText('Тип')).toBeNull(); + }); +}); +// #endregion FilterBarTest