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