Доска, проекты, поиск: два переключения не теряются, спринт эпика, «Повторить», фокус после «Очистить» (#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:
parent
43c1f830df
commit
1fc1a326a9
@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
35
web/src/modules/jira/board/useBoardPrefs.test.tsx
Normal file
35
web/src/modules/jira/board/useBoardPrefs.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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 };
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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="Поиск" />);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user