Доска, проекты, поиск: два переключения не теряются, спринт эпика, «Повторить», фокус после «Очистить» (#124, #35 T4.1, #64 T9.1, M-6, #68 T3.3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-26 01:19:20 +04:00
parent 43c1f830df
commit 1fc1a326a9
8 changed files with 87 additions and 16 deletions

View File

@ -27,6 +27,6 @@ export function fromHub(snap: BoardSnapshot, dicts: Dicts): BoardModel {
const missing = addStubs(nodes, links, NOT_IN_HUB); const missing = addStubs(nodes, links, NOT_IN_HUB);
const problems = snap.last_sync ? [] : ["в хабе ещё не было полного обновления из Jira — нажмите «Обновить из Jira»"]; 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 }; return { nodes, links, sprints, fileName: "Хаб", problems, hasSprint, missing, stubLane: NOT_IN_HUB };
} }

View File

@ -32,6 +32,11 @@ describe("модель доски = links.html@3931a88", () => {
expect(fromHub(neighborOnly, dicts).hasSprint).toBe(false); expect(fromHub(neighborOnly, dicts).hasSprint).toBe(false);
expect(fromHub(snap, dicts).hasSprint).toBe(true); 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, кавычки, повторные колонки, даты разных локалей", () => { it("parseCsv и fromCsv — BOM, CRLF, кавычки, повторные колонки, даты разных локалей", () => {
const text = fx("jira-export.csv"); const text = fx("jira-export.csv");
const g = golden("jira-export.expected.json"); const g = golden("jira-export.expected.json");

View File

@ -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");
});
});

View File

@ -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 { initialSprints, initialWho } from "./analysis/facets.ts";
import type { LayoutStore } from "./components/BoardCanvas.tsx"; import type { LayoutStore } from "./components/BoardCanvas.tsx";
import * as st from "./storage.ts"; 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(); } if (st.isLegacyStore()) { st.migrateMode(); st.migrateStore(); }
return st.readPrefs(); return st.readPrefs();
}); });
const prefsRef = useRef(prefs); // последнее значение: два вызова в одном такте не теряют первый
const [store, setStore] = useState<LayoutStore>(() => readStore(0)); const [store, setStore] = useState<LayoutStore>(() => readStore(0));
const [sel, setSel] = useState(() => pick(model, prefs.filters)); const [sel, setSel] = useState(() => pick(model, prefs.filters));
if (sel.model !== model) setSel(pick(model, prefs.filters)); // новая модель — выбор заново, как fillSprints/fillWho 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<Filters>(() => ({ ...prefs.filters, sprints: sel.sprints, who: sel.who }), [prefs.filters, sel]); const filters = useMemo<Filters>(() => ({ ...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 toggle = useCallback((name: st.FlagName | "snap" | "table" | "problems") => {
const p = prefsRef.current;
const isView = name === "snap" || name === "table" || name === "problems"; 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); st.writeFlag(name, on);
setPrefs((p) => (isView ? { ...p, [name]: on } : { ...p, filters: { ...p.filters, [name]: on } })); commit(isView ? { ...p, [name]: on } : { ...p, filters: { ...p.filters, [name]: on } });
}, [prefs]); }, [commit]);
const setSprints = useCallback((s: Set<string>) => { st.writeSprints(s); setSel((x) => ({ ...x, sprints: new Set(s) })); }, []); const setSprints = useCallback((s: Set<string>) => { st.writeSprints(s); setSel((x) => ({ ...x, sprints: new Set(s) })); }, []);
const setWho = useCallback((s: Set<string>) => { st.writeWho(s); setSel((x) => ({ ...x, who: new Set(s) })); }, []); const setWho = useCallback((s: Set<string>) => { st.writeWho(s); setSel((x) => ({ ...x, who: new Set(s) })); }, []);
const toggleHidden = useCallback((what: "kind" | "edge", value: string) => { const toggleHidden = useCallback((what: "kind" | "edge", value: string) => {
const p = prefsRef.current;
const key = what === "kind" ? "hideKind" : "hideEdge"; const key = what === "kind" ? "hideKind" : "hideEdge";
const next = new Set(prefs.filters[key]); const set = new Set(p.filters[key]);
if (next.has(value)) next.delete(value); else next.add(value); if (set.has(value)) set.delete(value); else set.add(value);
const filters = { ...prefs.filters, [key]: next }; const filters = { ...p.filters, [key]: set };
st.writeHidden(filters.hideKind, filters.hideEdge); st.writeHidden(filters.hideKind, filters.hideEdge);
setPrefs((p) => ({ ...p, filters: { ...p.filters, [key]: next } })); commit({ ...p, filters });
}, [prefs]); }, [commit]);
const toggleLane = useCallback((name: string) => { st.toggleLane(name); bump(); }, [bump]); 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 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 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 resetLayout = useCallback(() => { st.resetLayout(prefs.filters.byDate); bump(); }, [prefs.filters.byDate, bump]);
const applyJson = useCallback((r: JsonImport) => { const applyJson = useCallback((r: JsonImport) => {
const patch = st.applyJsonSettings(r); const patch = st.applyJsonSettings(r);
setPrefs((p) => ({ ...p, filters: { ...p.filters, ...patch } })); commit({ ...prefsRef.current, filters: { ...prefsRef.current.filters, ...patch } });
setSel(pick(model, { ...prefs.filters, ...patch })); setSel(pick(model, { ...prefsRef.current.filters, ...patch }));
bump(); bump();
}, [model, prefs.filters, bump]); }, [model, commit, bump]);
return { filters, snap: prefs.snap, table: prefs.table, problems: prefs.problems, store, return { filters, snap: prefs.snap, table: prefs.table, problems: prefs.problems, store,
toggle, setSprints, setWho, toggleHidden, toggleLane, moveLane, setColWidth, resetLayout, applyJson }; toggle, setSprints, setWho, toggleHidden, toggleLane, moveLane, setColWidth, resetLayout, applyJson };

View File

@ -1,15 +1,18 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { Project } from "../api/types.ts"; import type { Project } from "../api/types.ts";
import { Button, Notice } from "../ui/index.ts"; import { Button, Notice } from "../ui/index.ts";
import { useProjects } from "../api/queries.ts";
import { useProjectContext } from "./ProjectContext.ts"; import { useProjectContext } from "./ProjectContext.ts";
import "./projects.css"; import "./projects.css";
// страница доски/списка показывается, только когда текущий проект определён; иначе — причина и что делать // страница доски/списка показывается, только когда текущий проект определён; иначе — причина и что делать
export function ProjectGate({ children }: { children: (p: Project) => ReactNode }) { export function ProjectGate({ children }: { children: (p: Project) => ReactNode }) {
const { state, openNew } = useProjectContext(); const { state, openNew } = useProjectContext();
const { refetch } = useProjects();
const retry = () => { void refetch(); };
switch (state.kind) { switch (state.kind) {
case "loading": return <div className="project-gate"><Notice tone="info">Загрузка проектов…</Notice></div>; case "loading": return <div className="project-gate"><Notice tone="info">Загрузка проектов…</Notice></div>;
case "error": return <div className="project-gate"><Notice tone="error">Проекты не загрузились: {state.error.message}</Notice></div>; case "error": return <div className="project-gate"><Notice tone="error" action={<Button onClick={retry}>Повторить</Button>}>Проекты не загрузились: {state.error.message}</Notice></div>;
case "unknown": case "unknown":
return <div className="project-gate" data-testid="project-unknown"> return <div className="project-gate" data-testid="project-unknown">
<Notice tone="warning" title={`проект ${state.label} не найден`}>выберите проект в списке «Проект» боковой панели (на узком экране — в «Меню»)</Notice></div>; <Notice tone="warning" title={`проект ${state.label} не найден`}>выберите проект в списке «Проект» боковой панели (на узком экране — в «Меню»)</Notice></div>;

View File

@ -263,5 +263,15 @@ describe("адреса модулей и заголовок вкладки", ()
void router.navigate("/?project=AWPCA"); void router.navigate("/?project=AWPCA");
await waitFor(() => expect(document.title).toBe("Обзор · AWPCA · SDLC Hub")); 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));
});
}); });

View File

@ -16,6 +16,18 @@ describe("поля", () => {
expect(screen.getByLabelText("Текст").tagName).toBe("TEXTAREA"); expect(screen.getByLabelText("Текст").tagName).toBe("TEXTAREA");
expect(screen.getByRole("checkbox", { name: "Скрыть" })).toBeInTheDocument(); expect(screen.getByRole("checkbox", { name: "Скрыть" })).toBeInTheDocument();
}); });
it("SearchInput: «Очистить» возвращает фокус в поле", async () => {
const onChange = vi.fn();
function Wrap() {
const [v, setV] = useState("abc");
return <SearchInput value={v} onChange={(x) => { onChange(x); setV(x); }} label="Поиск" />;
}
render(<Wrap />);
await userEvent.click(screen.getByRole("button", { name: "Очистить" }));
expect(onChange).toHaveBeenLastCalledWith("");
expect(document.activeElement).toBe(screen.getByRole("searchbox"));
});
it("SearchInput: ввод и «Очистить» только при значении", async () => { it("SearchInput: ввод и «Очистить» только при значении", async () => {
const onChange = vi.fn(); const onChange = vi.fn();
const { rerender } = render(<SearchInput value="" onChange={onChange} label="Поиск" />); const { rerender } = render(<SearchInput value="" onChange={onChange} label="Поиск" />);

View File

@ -1,3 +1,4 @@
import { useRef } from "react";
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react"; import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
import { IconButton } from "./Button.tsx"; import { IconButton } from "./Button.tsx";
import { IconChevronDown, IconClose, IconSearch } from "./icons.tsx"; import { IconChevronDown, IconClose, IconSearch } from "./icons.tsx";
@ -14,12 +15,13 @@ export function Textarea({ size = "md", className, ...rest }: Size & Omit<Textar
} }
export function SearchInput({ value, onChange, label, placeholder, autoFocus }: { value: string; onChange(v: string): void; label: string; placeholder?: string; autoFocus?: boolean }) { export function SearchInput({ value, onChange, label, placeholder, autoFocus }: { value: string; onChange(v: string): void; label: string; placeholder?: string; autoFocus?: boolean }) {
const ref = useRef<HTMLInputElement>(null);
return ( return (
<span className="ui-search"> <span className="ui-search">
<span className="ui-search__icon" aria-hidden="true"><IconSearch /></span> <span className="ui-search__icon" aria-hidden="true"><IconSearch /></span>
<input type="search" className="ui-input ui-input--md ui-search__input" aria-label={label} placeholder={placeholder} autoFocus={autoFocus} <input ref={ref} type="search" className="ui-input ui-input--md ui-search__input" aria-label={label} placeholder={placeholder} autoFocus={autoFocus}
value={value} onChange={(e) => onChange(e.target.value)} /> value={value} onChange={(e) => onChange(e.target.value)} />
{value !== "" && <IconButton className="ui-search__clear" label="Очистить" icon={<IconClose />} onClick={() => onChange("")} />} {value !== "" && <IconButton className="ui-search__clear" label="Очистить" icon={<IconClose />} onClick={() => { onChange(""); ref.current?.focus(); }} />}
</span> </span>
); );
} }