feat(frontend): compact FilterBar with disclosure toggle and quick pills
- 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
This commit is contained in:
191
frontend/src/lib/components/reports/FilterBar.svelte
Normal file
191
frontend/src/lib/components/reports/FilterBar.svelte
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<!-- #region FilterBar [C:3] [TYPE Component] [SEMANTICS reports,filter,task-status-center] -->
|
||||||
|
<!-- @BRIEF Compact filter toolbar: quick status pills + search in the always-visible row, expandable full-filter panel behind an explicit "Фильтры" disclosure button. -->
|
||||||
|
<!-- @LAYER UI -->
|
||||||
|
<!-- @RELATION BINDS_TO -> [TaskCenter.Model] -->
|
||||||
|
<!-- @PRE filters is a TaskCenterFilter object. -->
|
||||||
|
<!-- @POST User changes call onApplyFilter(partial); clear calls onClearFilters(); quick-status clicks call onQuickStatusToggle. -->
|
||||||
|
<!-- @UX_STATE default: no active badges, all tasks visible, full-filter panel collapsed. -->
|
||||||
|
<!-- @UX_STATE filtered: active badge count and clear button visible. -->
|
||||||
|
<script lang="ts">
|
||||||
|
import { Button, Icon, Input, Select } from '$lib/ui';
|
||||||
|
import type { TaskCenterFilter, TaskType, ReportStatus } from '$types/reports';
|
||||||
|
|
||||||
|
let {
|
||||||
|
filters,
|
||||||
|
onApplyFilter = (_partial: Partial<TaskCenterFilter>) => {},
|
||||||
|
onClearFilters = () => {},
|
||||||
|
quickStatusCounts = { failed: 0, in_progress: 0, success: 0 },
|
||||||
|
onQuickStatusToggle = (_status: 'failed' | 'in_progress' | 'success') => {},
|
||||||
|
}: {
|
||||||
|
filters: TaskCenterFilter;
|
||||||
|
onApplyFilter?: (_partial: Partial<TaskCenterFilter>) => void;
|
||||||
|
onClearFilters?: () => void;
|
||||||
|
quickStatusCounts?: { failed: number; in_progress: number; success: number };
|
||||||
|
onQuickStatusToggle?: (_status: 'failed' | 'in_progress' | 'success') => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const taskTypeOptions = [
|
||||||
|
{ value: '', label: 'Все типы' },
|
||||||
|
{ value: 'llm_verification', label: 'LLM' },
|
||||||
|
{ value: 'backup', label: 'Бекапы' },
|
||||||
|
{ value: 'migration', label: 'Миграции' },
|
||||||
|
{ value: 'documentation', label: 'Документация' },
|
||||||
|
{ value: 'clean_release', label: 'Clean Release' },
|
||||||
|
{ value: 'unknown', label: 'Прочие' },
|
||||||
|
];
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: '', label: 'Все статусы' },
|
||||||
|
{ value: 'in_progress', label: 'В работе' },
|
||||||
|
{ value: 'success', label: 'Успешно' },
|
||||||
|
{ value: 'failed', label: 'Упало' },
|
||||||
|
{ value: 'partial', label: 'Частично' },
|
||||||
|
];
|
||||||
|
const sortOptions = [
|
||||||
|
{ value: 'updated_at', label: 'По обновлению' },
|
||||||
|
{ value: 'created_at', label: 'По созданию' },
|
||||||
|
{ value: 'status', label: 'По статусу' },
|
||||||
|
{ value: 'task_type', label: 'По типу' },
|
||||||
|
];
|
||||||
|
const timeRangeOptions = [
|
||||||
|
{ value: 'all', label: 'Всё время' },
|
||||||
|
{ value: '1h', label: 'Час' },
|
||||||
|
{ value: '24h', label: '24 часа' },
|
||||||
|
{ value: '7d', label: '7 дней' },
|
||||||
|
{ value: '30d', label: '30 дней' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let expanded: boolean = $state(false);
|
||||||
|
const selectedType = $derived(filters.task_types[0] ?? '');
|
||||||
|
const selectedStatus = $derived(filters.statuses[0] ?? '');
|
||||||
|
const activeFilterCount = $derived(
|
||||||
|
filters.task_types.length +
|
||||||
|
filters.statuses.length +
|
||||||
|
(filters.search.trim() ? 1 : 0) +
|
||||||
|
(filters.time_range !== 'all' ? 1 : 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSearchInput(event: Event & { currentTarget: HTMLInputElement }): void {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
const search = event.currentTarget.value;
|
||||||
|
searchTimeout = setTimeout(() => onApplyFilter({ search }), 350);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTaskType(event: Event & { currentTarget: HTMLSelectElement }): void {
|
||||||
|
const value = event.currentTarget.value as TaskType | '';
|
||||||
|
onApplyFilter({ task_types: value ? [value] : [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(event: Event & { currentTarget: HTMLSelectElement }): void {
|
||||||
|
const value = event.currentTarget.value as ReportStatus | '';
|
||||||
|
onApplyFilter({ statuses: value ? [value] : [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSort(event: Event & { currentTarget: HTMLSelectElement }): void {
|
||||||
|
onApplyFilter({ sort_by: event.currentTarget.value as TaskCenterFilter['sort_by'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTimeRange(event: Event & { currentTarget: HTMLSelectElement }): void {
|
||||||
|
onApplyFilter({ time_range: event.currentTarget.value as TaskCenterFilter['time_range'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSortOrder(): void {
|
||||||
|
onApplyFilter({ sort_order: filters.sort_order === 'asc' ? 'desc' : 'asc' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpanded(): void {
|
||||||
|
expanded = !expanded;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="rounded-lg border border-border bg-surface-card p-3" aria-label="Фильтры задач">
|
||||||
|
<!-- Always-visible compact row: quick pills ··· search ··· full-filter disclosure -->
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<!-- Quick status pills -->
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="min-h-[2rem] rounded-full border px-3 text-xs {filters.statuses.includes('failed') ? 'border-destructive-ring bg-destructive-light text-destructive ring-1 ring-destructive-ring/40' : 'border-border text-text-muted hover:border-destructive-ring hover:text-destructive'}"
|
||||||
|
aria-pressed={filters.statuses.includes('failed')}
|
||||||
|
onclick={() => onQuickStatusToggle('failed')}
|
||||||
|
>
|
||||||
|
Упавшие {quickStatusCounts.failed}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="min-h-[2rem] rounded-full border px-3 text-xs {filters.statuses.includes('in_progress') ? 'border-primary-ring bg-primary-light text-primary ring-1 ring-primary-ring/40' : 'border-border text-text-muted hover:border-primary-ring hover:text-primary'}"
|
||||||
|
aria-pressed={filters.statuses.includes('in_progress')}
|
||||||
|
onclick={() => onQuickStatusToggle('in_progress')}
|
||||||
|
>
|
||||||
|
В работе {quickStatusCounts.in_progress}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="min-h-[2rem] rounded-full border px-3 text-xs {filters.statuses.includes('success') ? 'border-success-ring bg-success-light text-success ring-1 ring-success-ring/40' : 'border-border text-text-muted hover:border-success-ring hover:text-success'}"
|
||||||
|
aria-pressed={filters.statuses.includes('success')}
|
||||||
|
onclick={() => onQuickStatusToggle('success')}
|
||||||
|
>
|
||||||
|
Успешные {quickStatusCounts.success}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- Spacer + search -->
|
||||||
|
<div class="flex-1 min-w-[10rem] ml-auto flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
id="reports-task-search"
|
||||||
|
name="reports-task-search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Поиск по названию или ID..."
|
||||||
|
value={filters.search}
|
||||||
|
oninput={handleSearchInput}
|
||||||
|
/>
|
||||||
|
<!-- Full-filter disclosure -->
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-9 shrink-0 gap-1.5 rounded-lg border px-2.5 font-semibold {expanded ? 'border-primary-ring text-primary bg-primary-light' : 'border-border-strong text-text hover:bg-surface-muted'}"
|
||||||
|
aria-label={expanded ? 'Скрыть фильтры' : 'Показать фильтры'}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-controls="reports-full-filter-panel"
|
||||||
|
onclick={toggleExpanded}
|
||||||
|
>
|
||||||
|
<Icon name="filter" size={18} strokeWidth={2.2} />
|
||||||
|
<span>Фильтры</span>
|
||||||
|
{#if activeFilterCount > 0}
|
||||||
|
<span class="ml-0.5 rounded-full bg-primary px-1.5 py-0.5 text-[10px] leading-none text-white" aria-hidden="true">
|
||||||
|
{activeFilterCount}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<Icon name="chevronDown" size={14} class="transition-transform {expanded ? 'rotate-180' : ''}" strokeWidth={2.2} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expandable full-filter panel -->
|
||||||
|
{#if expanded}
|
||||||
|
<div id="reports-full-filter-panel" class="mt-2 flex flex-wrap items-end gap-2 pt-2 border-t border-border">
|
||||||
|
<div class="w-full sm:w-auto min-w-[10rem]">
|
||||||
|
<Select id="task-type-filter" name="task-type-filter" label="Тип" value={selectedType} options={taskTypeOptions} onchange={setTaskType} />
|
||||||
|
</div>
|
||||||
|
<div class="w-full sm:w-auto min-w-[10rem]">
|
||||||
|
<Select id="status-filter" name="status-filter" label="Статус" value={selectedStatus} options={statusOptions} onchange={setStatus} />
|
||||||
|
</div>
|
||||||
|
<div class="w-full sm:w-auto min-w-[10rem]">
|
||||||
|
<Select id="time-range-filter" name="time-range-filter" label="Период" value={filters.time_range} options={timeRangeOptions} onchange={setTimeRange} />
|
||||||
|
</div>
|
||||||
|
<div class="w-full sm:w-auto min-w-[9rem]">
|
||||||
|
<Select id="sort-filter" name="sort-filter" label="Сортировка" value={filters.sort_by} options={sortOptions} onchange={setSort} />
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" class="border border-border" aria-label="Изменить направление сортировки" onclick={toggleSortOrder}>
|
||||||
|
{filters.sort_order === 'asc' ? '↑' : '↓'}
|
||||||
|
</Button>
|
||||||
|
{#if activeFilterCount > 0}
|
||||||
|
<Button variant="ghost" size="sm" onclick={onClearFilters}>Сбросить</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
|
</section>
|
||||||
|
<!-- #endregion FilterBar -->
|
||||||
102
frontend/src/lib/components/reports/__tests__/FilterBar.test.ts
Normal file
102
frontend/src/lib/components/reports/__tests__/FilterBar.test.ts
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user