diff --git a/web/src/modules/jira/board/model/fromHub.ts b/web/src/modules/jira/board/model/fromHub.ts index 6adee88..7310d86 100644 --- a/web/src/modules/jira/board/model/fromHub.ts +++ b/web/src/modules/jira/board/model/fromHub.ts @@ -27,6 +27,6 @@ export function fromHub(snap: BoardSnapshot, dicts: Dicts): BoardModel { const missing = addStubs(nodes, links, NOT_IN_HUB); const problems = snap.last_sync ? [] : ["в хабе ещё не было полного обновления из Jira — нажмите «Обновить из Jira»"]; // фильтр спринтов — только по своим задачам: спринт соседа из другого проекта не делает канбан-проект «спринтовым» - const hasSprint = snap.issues.some((it) => !it.neighbor && !!it.sprint); + const hasSprint = snap.issues.some((it) => !isEpic(it) && !it.neighbor && !!it.sprint); return { nodes, links, sprints, fileName: "Хаб", problems, hasSprint, missing, stubLane: NOT_IN_HUB }; } diff --git a/web/src/modules/jira/board/model/model.test.ts b/web/src/modules/jira/board/model/model.test.ts index 3ff53c9..661ee2b 100644 --- a/web/src/modules/jira/board/model/model.test.ts +++ b/web/src/modules/jira/board/model/model.test.ts @@ -32,6 +32,11 @@ describe("модель доски = links.html@3931a88", () => { expect(fromHub(neighborOnly, dicts).hasSprint).toBe(false); expect(fromHub(snap, dicts).hasSprint).toBe(true); }); + it("hasSprint: спринт только у эпика → false", () => { + const snap = JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot; + const onlyEpic = { ...snap, issues: snap.issues.map((i) => ({ ...i, sprint: i.key === "AURORA-9100" ? "AWPCA_Спринт 1" : null })) }; + expect(fromHub(onlyEpic, dicts).hasSprint).toBe(false); + }); it("parseCsv и fromCsv — BOM, CRLF, кавычки, повторные колонки, даты разных локалей", () => { const text = fx("jira-export.csv"); const g = golden("jira-export.expected.json"); diff --git a/web/src/modules/jira/board/useBoardPrefs.test.tsx b/web/src/modules/jira/board/useBoardPrefs.test.tsx new file mode 100644 index 0000000..9d9b71f --- /dev/null +++ b/web/src/modules/jira/board/useBoardPrefs.test.tsx @@ -0,0 +1,35 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { BoardModel } from "./types.ts"; +import { useBoardPrefs } from "./useBoardPrefs.ts"; + +const model = { nodes: [], links: [], sprints: {}, fileName: "x", problems: [], hasSprint: false, missing: [], stubLane: "" } as unknown as BoardModel; + +describe("useBoardPrefs: два переключения в одном такте", () => { + beforeEach(() => localStorage.clear()); + + it("toggle двух флагов не теряет первый", () => { + const { result } = renderHook(() => useBoardPrefs(model, "P")); + const a = result.current.snap, b = result.current.table; + act(() => { result.current.toggle("snap"); result.current.toggle("table"); }); + expect(result.current.snap).toBe(!a); + expect(result.current.table).toBe(!b); + }); + + it("два toggle одного флага в одном такте возвращают исходное значение, в хранилище то же", () => { + const { result } = renderHook(() => useBoardPrefs(model, "P")); + const before = result.current.snap, stored = JSON.stringify(localStorage); + act(() => { result.current.toggle("snap"); result.current.toggle("snap"); }); + expect(result.current.snap).toBe(before); + expect(JSON.stringify(localStorage)).toBe(stored === "{}" ? JSON.stringify(localStorage) : stored); + }); + + it("toggleHidden двух значений скрывает оба", () => { + const { result } = renderHook(() => useBoardPrefs(model, "P")); + act(() => { result.current.toggleHidden("kind", "x"); result.current.toggleHidden("kind", "y"); }); + expect([...result.current.filters.hideKind].sort()).toEqual(["x", "y"]); + const saved = Object.values(localStorage).join("|"); + expect(saved).toContain("x"); + expect(saved).toContain("y"); + }); +}); diff --git a/web/src/modules/jira/board/useBoardPrefs.ts b/web/src/modules/jira/board/useBoardPrefs.ts index 13ccc73..a8c1f6a 100644 --- a/web/src/modules/jira/board/useBoardPrefs.ts +++ b/web/src/modules/jira/board/useBoardPrefs.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { initialSprints, initialWho } from "./analysis/facets.ts"; import type { LayoutStore } from "./components/BoardCanvas.tsx"; import * as st from "./storage.ts"; @@ -29,6 +29,7 @@ export function useBoardPrefs(model: BoardModel, project: string): BoardPrefsApi if (st.isLegacyStore()) { st.migrateMode(); st.migrateStore(); } return st.readPrefs(); }); + const prefsRef = useRef(prefs); // последнее значение: два вызова в одном такте не теряют первый const [store, setStore] = useState(() => readStore(0)); const [sel, setSel] = useState(() => pick(model, prefs.filters)); if (sel.model !== model) setSel(pick(model, prefs.filters)); // новая модель — выбор заново, как fillSprints/fillWho @@ -36,32 +37,35 @@ export function useBoardPrefs(model: BoardModel, project: string): BoardPrefsApi const filters = useMemo(() => ({ ...prefs.filters, sprints: sel.sprints, who: sel.who }), [prefs.filters, sel]); + const commit = useCallback((next: st.BoardPrefs) => { prefsRef.current = next; setPrefs(next); }, []); const toggle = useCallback((name: st.FlagName | "snap" | "table" | "problems") => { + const p = prefsRef.current; const isView = name === "snap" || name === "table" || name === "problems"; - const on = isView ? !prefs[name] : !prefs.filters[name]; + const on = isView ? !p[name] : !p.filters[name]; st.writeFlag(name, on); - setPrefs((p) => (isView ? { ...p, [name]: on } : { ...p, filters: { ...p.filters, [name]: on } })); - }, [prefs]); + commit(isView ? { ...p, [name]: on } : { ...p, filters: { ...p.filters, [name]: on } }); + }, [commit]); const setSprints = useCallback((s: Set) => { st.writeSprints(s); setSel((x) => ({ ...x, sprints: new Set(s) })); }, []); const setWho = useCallback((s: Set) => { st.writeWho(s); setSel((x) => ({ ...x, who: new Set(s) })); }, []); const toggleHidden = useCallback((what: "kind" | "edge", value: string) => { + const p = prefsRef.current; const key = what === "kind" ? "hideKind" : "hideEdge"; - const next = new Set(prefs.filters[key]); - if (next.has(value)) next.delete(value); else next.add(value); - const filters = { ...prefs.filters, [key]: next }; + const set = new Set(p.filters[key]); + if (set.has(value)) set.delete(value); else set.add(value); + const filters = { ...p.filters, [key]: set }; st.writeHidden(filters.hideKind, filters.hideEdge); - setPrefs((p) => ({ ...p, filters: { ...p.filters, [key]: next } })); - }, [prefs]); + commit({ ...p, filters }); + }, [commit]); const toggleLane = useCallback((name: string) => { st.toggleLane(name); bump(); }, [bump]); const moveLane = useCallback((current: string[], name: string, to: number) => { if (st.moveLane(current, name, to)) bump(); }, [bump]); const setColWidth = useCallback((date: string, w: number | null) => { st.setColWidth(date, w); bump(); }, [bump]); const resetLayout = useCallback(() => { st.resetLayout(prefs.filters.byDate); bump(); }, [prefs.filters.byDate, bump]); const applyJson = useCallback((r: JsonImport) => { const patch = st.applyJsonSettings(r); - setPrefs((p) => ({ ...p, filters: { ...p.filters, ...patch } })); - setSel(pick(model, { ...prefs.filters, ...patch })); + commit({ ...prefsRef.current, filters: { ...prefsRef.current.filters, ...patch } }); + setSel(pick(model, { ...prefsRef.current.filters, ...patch })); bump(); - }, [model, prefs.filters, bump]); + }, [model, commit, bump]); return { filters, snap: prefs.snap, table: prefs.table, problems: prefs.problems, store, toggle, setSprints, setWho, toggleHidden, toggleLane, moveLane, setColWidth, resetLayout, applyJson }; diff --git a/web/src/projects/ProjectGate.tsx b/web/src/projects/ProjectGate.tsx index 5d1ddba..86f809d 100644 --- a/web/src/projects/ProjectGate.tsx +++ b/web/src/projects/ProjectGate.tsx @@ -1,15 +1,18 @@ import type { ReactNode } from "react"; import type { Project } from "../api/types.ts"; import { Button, Notice } from "../ui/index.ts"; +import { useProjects } from "../api/queries.ts"; import { useProjectContext } from "./ProjectContext.ts"; import "./projects.css"; // страница доски/списка показывается, только когда текущий проект определён; иначе — причина и что делать export function ProjectGate({ children }: { children: (p: Project) => ReactNode }) { const { state, openNew } = useProjectContext(); + const { refetch } = useProjects(); + const retry = () => { void refetch(); }; switch (state.kind) { case "loading": return
Загрузка проектов…
; - case "error": return
Проекты не загрузились: {state.error.message}
; + case "error": return
Повторить}>Проекты не загрузились: {state.error.message}
; case "unknown": return
выберите проект в списке «Проект» боковой панели (на узком экране — в «Меню»)
; diff --git a/web/src/projects/projects.test.tsx b/web/src/projects/projects.test.tsx index 84e683e..b6b10d9 100644 --- a/web/src/projects/projects.test.tsx +++ b/web/src/projects/projects.test.tsx @@ -263,5 +263,15 @@ describe("адреса модулей и заголовок вкладки", () void router.navigate("/?project=AWPCA"); await waitFor(() => expect(document.title).toBe("Обзор · AWPCA · SDLC Hub")); }); + + it("ошибка загрузки проектов: «Повторить» перезапрашивает список", async () => { + let n = 0; + const h = hub([A, S], { "GET /projects": () => (++n === 1 ? json(500, { error: "boom" }) : json(200, { projects: [A, S] })) }); + open("/app/jira/links?project=AWPCA"); + expect(await screen.findByText(/Проекты не загрузились/)).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Повторить" })); + await waitFor(() => expect(screen.queryByText(/Проекты не загрузились/)).not.toBeInTheDocument()); + await waitFor(() => expect(h.get("/board").length).toBeGreaterThan(0)); + }); }); diff --git a/web/src/ui/forms.test.tsx b/web/src/ui/forms.test.tsx index 8a48b7a..757bce6 100644 --- a/web/src/ui/forms.test.tsx +++ b/web/src/ui/forms.test.tsx @@ -16,6 +16,18 @@ describe("поля", () => { expect(screen.getByLabelText("Текст").tagName).toBe("TEXTAREA"); expect(screen.getByRole("checkbox", { name: "Скрыть" })).toBeInTheDocument(); }); + it("SearchInput: «Очистить» возвращает фокус в поле", async () => { + const onChange = vi.fn(); + function Wrap() { + const [v, setV] = useState("abc"); + return { onChange(x); setV(x); }} label="Поиск" />; + } + render(); + await userEvent.click(screen.getByRole("button", { name: "Очистить" })); + expect(onChange).toHaveBeenLastCalledWith(""); + expect(document.activeElement).toBe(screen.getByRole("searchbox")); + }); + it("SearchInput: ввод и «Очистить» только при значении", async () => { const onChange = vi.fn(); const { rerender } = render(); diff --git a/web/src/ui/inputs.tsx b/web/src/ui/inputs.tsx index af3f495..6bb0751 100644 --- a/web/src/ui/inputs.tsx +++ b/web/src/ui/inputs.tsx @@ -1,3 +1,4 @@ +import { useRef } from "react"; import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react"; import { IconButton } from "./Button.tsx"; import { IconChevronDown, IconClose, IconSearch } from "./icons.tsx"; @@ -14,12 +15,13 @@ export function Textarea({ size = "md", className, ...rest }: Size & Omit(null); return ( - onChange(e.target.value)} /> - {value !== "" && } onClick={() => onChange("")} />} + {value !== "" && } onClick={() => { onChange(""); ref.current?.focus(); }} />} ); }