feat(web): панель фильтров экранов доски (#112)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-25 20:26:01 +04:00
parent 7fbc6bd574
commit 3ba8f06637
4 changed files with 153 additions and 0 deletions

View File

@ -0,0 +1,82 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AGILE_KEY } from "./agilePref.ts";
import { assigneeOptions, type AgilePage } from "./filters.ts";
import { AgileToolbar } from "./AgileToolbar.tsx";
import { useAgileView } from "./useAgileView.ts";
import type { AgileIssue } from "../api/types.ts";
const issues = [{ assignee: "ivanov" }, { assignee: "petrov" }] as AgileIssue[];
function Harness({ label = "AWPCA", page = "backlog", showHideDone = true }: { label?: string; page?: AgilePage; showHideDone?: boolean }) {
const { view, set, setQ, filter, reset } = useAgileView(label, page);
return (
<>
<AgileToolbar filter={filter} setQ={setQ} set={set} onReset={reset} assignees={assigneeOptions(issues, view.assignee)} showHideDone={showHideDone}>
<span>слот</span>
</AgileToolbar>
<output data-testid="q">{filter.q}</output>
</>
);
}
const stored = () => JSON.parse(localStorage.getItem(AGILE_KEY) ?? "null");
const search = () => screen.getByLabelText("Поиск по ключу или теме") as HTMLInputElement;
const assignee = () => screen.getByLabelText("Исполнитель") as HTMLSelectElement;
beforeEach(() => localStorage.clear());
afterEach(cleanup);
describe("AgileToolbar", () => {
it("ввод в поиск меняет filter.q и не сохраняется", () => {
render(<Harness />);
fireEvent.change(search(), { target: { value: "abc" } });
expect(screen.getByTestId("q").textContent).toBe("abc");
expect(localStorage.getItem(AGILE_KEY)).toBeNull();
});
it("исполнитель сохраняется по метке и странице", () => {
render(<Harness />);
fireEvent.change(assignee(), { target: { value: "petrov" } });
expect(stored().AWPCA.backlog.assignee).toBe("petrov");
expect(stored().AWPCA.backlog).not.toHaveProperty("q");
});
it("«Сбросить фильтры» появляется после ввода и снимает всё", () => {
render(<Harness />);
expect(screen.queryByText("Сбросить фильтры")).toBeNull();
fireEvent.change(search(), { target: { value: "abc" } });
fireEvent.change(assignee(), { target: { value: "petrov" } });
fireEvent.click(screen.getByLabelText("Скрыть выполненные"));
fireEvent.click(screen.getByText("Сбросить фильтры"));
expect(search().value).toBe("");
expect(assignee().value).toBe("");
expect((screen.getByLabelText("Скрыть выполненные") as HTMLInputElement).checked).toBe(false);
expect(screen.queryByText("Сбросить фильтры")).toBeNull();
expect(stored().AWPCA.backlog).toMatchObject({ assignee: "", epic: "", hideDone: false });
});
it("showHideDone=false — флажка нет; слот показан", () => {
render(<Harness showHideDone={false} />);
expect(screen.queryByLabelText("Скрыть выполненные")).toBeNull();
expect(screen.getByText("слот")).toBeTruthy();
});
it("повторный монтаж с той же меткой и страницей восстанавливает исполнителя и флажок", () => {
const a = render(<Harness />);
fireEvent.change(assignee(), { target: { value: "ivanov" } });
fireEvent.click(screen.getByLabelText("Скрыть выполненные"));
a.unmount();
render(<Harness />);
expect(assignee().value).toBe("ivanov");
expect((screen.getByLabelText("Скрыть выполненные") as HTMLInputElement).checked).toBe(true);
});
it("другая страница или метка — свои значения", () => {
const a = render(<Harness />);
fireEvent.change(assignee(), { target: { value: "ivanov" } });
a.unmount();
render(<Harness page="sprint" />);
expect(assignee().value).toBe("");
});
});

View File

@ -0,0 +1,25 @@
// панель фильтров экранов доски: поиск, исполнитель, «Скрыть выполненные», слот страницы, «Сбросить фильтры»
import type { ReactNode } from "react";
import { Button, Checkbox, Panel, SearchInput, Select } from "../../../ui/index.ts";
import { hasFilters, type AgileView, type IssueFilter, type Opt } from "./filters.ts";
export function AgileToolbar({ filter, setQ, set, onReset, assignees, showHideDone, children }: {
filter: IssueFilter; setQ(v: string): void; set(patch: Partial<AgileView>): void; onReset(): void;
assignees: Opt[]; showHideDone: boolean; children?: ReactNode;
}) {
return (
<Panel className="agile-toolbar" padding="md">
<div className="agile-toolbar__search">
<SearchInput label="Поиск по ключу или теме" placeholder="Ключ или тема" value={filter.q} onChange={setQ} />
</div>
<Select aria-label="Исполнитель" value={filter.assignee} onChange={(e) => set({ assignee: e.target.value })}>
{assignees.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</Select>
{showHideDone && (
<Checkbox label="Скрыть выполненные" checked={filter.hideDone} onChange={(e) => set({ hideDone: e.target.checked })} />
)}
{children}
{hasFilters(filter) && <Button variant="ghost" onClick={onReset}>Сбросить фильтры</Button>}
</Panel>
);
}

View File

@ -57,3 +57,6 @@
.agile-card__summary{overflow-wrap:anywhere}
.agile-card__foot{display:flex;align-items:center;gap:var(--space-2);min-width:0}
.agile-card__assignee{flex:1 1 auto;min-width:0;color:var(--color-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* панель фильтров */
.agile-toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-3)}
.agile-toolbar__search{flex:1 1 220px;min-width:0}

View File

@ -0,0 +1,43 @@
// состояние фильтров страницы доски: вид (хранится по метке и странице) + поиск q (только в памяти)
import { useCallback, useMemo, useRef, useState } from "react";
import { readView, saveView } from "./agilePref.ts";
import type { AgilePage, AgileView, IssueFilter } from "./filters.ts";
export function useAgileView(label: string, page: AgilePage) {
const id = `${label}\u0000${page}`;
const [state, setState] = useState(() => ({ id, view: readView(label, page), q: "" }));
// метка или страница сменились — читаем вид заново, поиск сбрасывается
let cur = state;
if (state.id !== id) {
cur = { id, view: readView(label, page), q: "" };
setState(cur);
}
const ref = useRef(cur);
ref.current = cur;
const commit = useCallback((next: typeof state) => {
ref.current = next;
setState(next);
}, []);
const set = useCallback((patch: Partial<AgileView>) => {
const next = { ...ref.current, view: { ...ref.current.view, ...patch } };
saveView(label, page, next.view); // запись вне функции-обновителя setState (T8.1 #69)
commit(next);
}, [label, page, commit]);
const setQ = useCallback((q: string) => commit({ ...ref.current, q }), [commit]);
const reset = useCallback(() => {
const next = { ...ref.current, q: "", view: { ...ref.current.view, assignee: "", epic: "", hideDone: false } };
saveView(label, page, next.view);
commit(next);
}, [label, page, commit]);
const { view, q } = cur;
const filter: IssueFilter = useMemo(
() => ({ q, assignee: view.assignee, epic: view.epic, hideDone: view.hideDone }),
[q, view.assignee, view.epic, view.hideDone],
);
return { view, set, q, setQ, filter, reset };
}