Доска, проекты, поиск: два переключения не теряются, спринт эпика, «Повторить», фокус после «Очистить» (#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 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 };
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
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 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<LayoutStore>(() => 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<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 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<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 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 };
|
||||
|
||||
@ -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 <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":
|
||||
return <div className="project-gate" data-testid="project-unknown">
|
||||
<Notice tone="warning" title={`проект ${state.label} не найден`}>выберите проект в списке «Проект» боковой панели (на узком экране — в «Меню»)</Notice></div>;
|
||||
|
||||
@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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 <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 () => {
|
||||
const onChange = vi.fn();
|
||||
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 { IconButton } from "./Button.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 }) {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<span className="ui-search">
|
||||
<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 !== "" && <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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user