From 3ba8f06637a192bf83f8df70476d6b5d85a1eca2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 20:26:01 +0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=BE=D0=B2=20?= =?UTF-8?q?=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=BE=D0=B2=20=D0=B4=D0=BE=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=20(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../modules/jira/agile/AgileToolbar.test.tsx | 82 +++++++++++++++++++ web/src/modules/jira/agile/AgileToolbar.tsx | 25 ++++++ web/src/modules/jira/agile/agile.css | 3 + web/src/modules/jira/agile/useAgileView.ts | 43 ++++++++++ 4 files changed, 153 insertions(+) create mode 100644 web/src/modules/jira/agile/AgileToolbar.test.tsx create mode 100644 web/src/modules/jira/agile/AgileToolbar.tsx create mode 100644 web/src/modules/jira/agile/useAgileView.ts diff --git a/web/src/modules/jira/agile/AgileToolbar.test.tsx b/web/src/modules/jira/agile/AgileToolbar.test.tsx new file mode 100644 index 0000000..101927d --- /dev/null +++ b/web/src/modules/jira/agile/AgileToolbar.test.tsx @@ -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 ( + <> + + слот + + {filter.q} + + ); +} + +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(); + fireEvent.change(search(), { target: { value: "abc" } }); + expect(screen.getByTestId("q").textContent).toBe("abc"); + expect(localStorage.getItem(AGILE_KEY)).toBeNull(); + }); + + it("исполнитель сохраняется по метке и странице", () => { + render(); + fireEvent.change(assignee(), { target: { value: "petrov" } }); + expect(stored().AWPCA.backlog.assignee).toBe("petrov"); + expect(stored().AWPCA.backlog).not.toHaveProperty("q"); + }); + + it("«Сбросить фильтры» появляется после ввода и снимает всё", () => { + render(); + 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(); + expect(screen.queryByLabelText("Скрыть выполненные")).toBeNull(); + expect(screen.getByText("слот")).toBeTruthy(); + }); + + it("повторный монтаж с той же меткой и страницей восстанавливает исполнителя и флажок", () => { + const a = render(); + fireEvent.change(assignee(), { target: { value: "ivanov" } }); + fireEvent.click(screen.getByLabelText("Скрыть выполненные")); + a.unmount(); + render(); + expect(assignee().value).toBe("ivanov"); + expect((screen.getByLabelText("Скрыть выполненные") as HTMLInputElement).checked).toBe(true); + }); + + it("другая страница или метка — свои значения", () => { + const a = render(); + fireEvent.change(assignee(), { target: { value: "ivanov" } }); + a.unmount(); + render(); + expect(assignee().value).toBe(""); + }); +}); diff --git a/web/src/modules/jira/agile/AgileToolbar.tsx b/web/src/modules/jira/agile/AgileToolbar.tsx new file mode 100644 index 0000000..6cfbc2a --- /dev/null +++ b/web/src/modules/jira/agile/AgileToolbar.tsx @@ -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): void; onReset(): void; + assignees: Opt[]; showHideDone: boolean; children?: ReactNode; +}) { + return ( + +
+ +
+ + {showHideDone && ( + set({ hideDone: e.target.checked })} /> + )} + {children} + {hasFilters(filter) && } +
+ ); +} diff --git a/web/src/modules/jira/agile/agile.css b/web/src/modules/jira/agile/agile.css index d997592..08af104 100644 --- a/web/src/modules/jira/agile/agile.css +++ b/web/src/modules/jira/agile/agile.css @@ -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} diff --git a/web/src/modules/jira/agile/useAgileView.ts b/web/src/modules/jira/agile/useAgileView.ts new file mode 100644 index 0000000..49b72eb --- /dev/null +++ b/web/src/modules/jira/agile/useAgileView.ts @@ -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) => { + 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 }; +}