feat(web): панель фильтров экранов доски (#112)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7fbc6bd574
commit
3ba8f06637
82
web/src/modules/jira/agile/AgileToolbar.test.tsx
Normal file
82
web/src/modules/jira/agile/AgileToolbar.test.tsx
Normal 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("");
|
||||
});
|
||||
});
|
||||
25
web/src/modules/jira/agile/AgileToolbar.tsx
Normal file
25
web/src/modules/jira/agile/AgileToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
43
web/src/modules/jira/agile/useAgileView.ts
Normal file
43
web/src/modules/jira/agile/useAgileView.ts
Normal 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 };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user