Список задач: «скрыть закрытые» по умолчанию, без статуса по факту (#57)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0abed70f44
commit
dbda1ad77c
@ -23,7 +23,7 @@ test("фильтр → карточка окном → заметка сохра
|
||||
test("окно поверх списка: прокрутка списка на месте при открытии и после закрытия", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 320 });
|
||||
await login(page, "/app/issues");
|
||||
await expect(page.locator("table.grid tbody tr")).toHaveCount(10);
|
||||
await expect(page.locator("table.grid tbody tr")).toHaveCount(9); // AURORA-9006 «Готово» скрыта
|
||||
const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; });
|
||||
expect(y).toBeGreaterThan(50);
|
||||
await page.locator("table.grid tbody tr").last().getByRole("link").click();
|
||||
@ -173,3 +173,15 @@ test("узкий экран: окно на всю ширину, заметки
|
||||
await expect(d.getByRole("columnheader", { name: "Источник" })).toBeAttached();
|
||||
expect(await d.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
|
||||
});
|
||||
|
||||
test("закрытые скрыты по умолчанию, снятый флажок показывает их", async ({ page }) => {
|
||||
await login(page, "/app/issues");
|
||||
await expect(page.locator("table.grid tbody tr")).toHaveCount(9);
|
||||
await expect(page.getByRole("link", { name: "AURORA-9006", exact: true })).toHaveCount(0);
|
||||
const hide = page.getByRole("checkbox", { name: "скрыть закрытые" });
|
||||
await hide.click(); // не uncheck(): адрес меняется навигацией, флажок следует за ним не сразу
|
||||
await expect(page).toHaveURL(/closed=1/);
|
||||
await expect(hide).not.toBeChecked();
|
||||
await expect(page.getByRole("link", { name: "AURORA-9006", exact: true })).toBeVisible();
|
||||
await expect(page.locator("table.grid tbody tr")).toHaveCount(10);
|
||||
});
|
||||
|
||||
25
web/src/api/closed.test.ts
Normal file
25
web/src/api/closed.test.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { closedStatuses, isClosed } from "./closed.ts";
|
||||
|
||||
describe("закрытые статусы", () => {
|
||||
const set = closedStatuses({ closed_statuses: ["Готово", "Снято"] });
|
||||
|
||||
it("набор — из closed_statuses настроек", () => {
|
||||
expect([...set]).toEqual(["Готово", "Снято"]);
|
||||
});
|
||||
|
||||
it("нет настройки или она не список — ничего не закрыто", () => {
|
||||
expect(closedStatuses({}).size).toBe(0);
|
||||
expect(closedStatuses(undefined).size).toBe(0);
|
||||
expect(closedStatuses({ closed_statuses: "Готово" }).size).toBe(0);
|
||||
expect([...closedStatuses({ closed_statuses: ["Готово", 5] })]).toEqual(["Готово"]);
|
||||
});
|
||||
|
||||
it("закрыта — только задача со статусом из набора", () => {
|
||||
expect(isClosed("Снято", set)).toBe(true);
|
||||
expect(isClosed("В работе", set)).toBe(false);
|
||||
expect(isClosed("", set)).toBe(false);
|
||||
expect(isClosed(null, set)).toBe(false);
|
||||
expect(isClosed(undefined, set)).toBe(false);
|
||||
});
|
||||
});
|
||||
14
web/src/api/closed.ts
Normal file
14
web/src/api/closed.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { useMemo } from "react";
|
||||
import { useSettings } from "./queries.ts";
|
||||
import type { HubSettings } from "./types.ts";
|
||||
|
||||
// закрытые задачи (hide-closed): статус Jira из настройки closed_statuses; нет настройки или она не список — закрытых нет
|
||||
export const closedStatuses = (s: HubSettings | undefined): Set<string> =>
|
||||
new Set(Array.isArray(s?.closed_statuses) ? s.closed_statuses.filter((x): x is string => typeof x === "string") : []);
|
||||
|
||||
export const isClosed = (status: string | null | undefined, closed: Set<string>): boolean => !!status && closed.has(status);
|
||||
|
||||
export function useClosedStatuses(): Set<string> {
|
||||
const settings = useSettings();
|
||||
return useMemo(() => closedStatuses(settings.data), [settings.data]);
|
||||
}
|
||||
@ -2,8 +2,9 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tansta
|
||||
import { api } from "./client.ts";
|
||||
import type { BoardSnapshot, HubSettings, IssueDetail, IssueRow, Note, NoteFields } from "./types.ts";
|
||||
|
||||
export const ISSUE_FILTER_NAMES = ["sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q"] as const;
|
||||
export type IssueFilters = Partial<Record<(typeof ISSUE_FILTER_NAMES)[number], string>>;
|
||||
// фильтры списка в адресе; hide_closed в адрес не пишется — его выводит список из флажка «скрыть закрытые» (closed=1 — снят)
|
||||
export const ISSUE_FILTER_NAMES = ["sprint", "assignee", "work_kind", "status", "label", "epic", "neighbor", "q"] as const;
|
||||
export type IssueFilters = Partial<Record<(typeof ISSUE_FILTER_NAMES)[number] | "hide_closed", string>>;
|
||||
export const ISSUE_PAGE = 50;
|
||||
|
||||
export const qk = {
|
||||
|
||||
@ -11,11 +11,11 @@ export interface SyncRun { started_at: string; finished_at: string; issues_count
|
||||
export interface BoardSnapshot { generated_at: string; last_sync: SyncRun | null; issues: BoardIssue[]; links: BoardLink[] }
|
||||
|
||||
// словари настроек: kind_labels — вид работы → метка Jira, link_types — логический тип → имя типа связи Jira,
|
||||
// status_labels — статус по факту → метка Jira
|
||||
// closed_statuses — статусы Jira закрытых задач
|
||||
export interface HubSettings {
|
||||
kind_labels?: Record<string, string>;
|
||||
link_types?: Record<string, string>;
|
||||
status_labels?: Record<string, string>;
|
||||
closed_statuses?: unknown;
|
||||
project_key?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
{
|
||||
"kind_labels": {"аналитика": "kind-analytics", "решение": "kind-decision", "актуализация": "kind-docs", "разработка": "kind-dev", "UI": "kind-ui", "тестирование": "kind-test", "организационная": "kind-org"},
|
||||
"link_types": {"блокирует начало": "ASUPR_Блокер_Начала", "блокирует завершение": "ASUPR_Блокер_Завершения", "связанные": "ASUPR_Связанные"},
|
||||
"status_labels": {"Готово — закрыть": "fact-done", "В работе": "fact-in-progress", "Ожидание разработки": "fact-open"}
|
||||
"status_labels": {"Готово — закрыть": "fact-done", "В работе": "fact-in-progress", "Ожидание разработки": "fact-open"},
|
||||
"closed_statuses": ["Готово", "Снято"]
|
||||
}
|
||||
|
||||
@ -150,4 +150,72 @@ describe("список задач", () => {
|
||||
expect(within(sel).queryByRole("option", { name: /нет на доске/ })).toBeNull();
|
||||
expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const hideBox = () => screen.getByRole("checkbox", { name: "скрыть закрытые" });
|
||||
|
||||
it("закрытые скрыты по умолчанию; снятый флажок — closed=1 и запрос без hide_closed", async () => {
|
||||
const fetch = hub(() => [row(1)]);
|
||||
const { router } = renderWithProviders(<IssuesPage />, { route: "/issues", path: "/issues" });
|
||||
await screen.findByRole("link", { name: "AURORA-9001" });
|
||||
expect(hideBox()).toBeChecked();
|
||||
let q = issuesCalls(fetch).at(-1)!.searchParams;
|
||||
expect(q.get("hide_closed")).toBe("1");
|
||||
expect(q.has("closed")).toBe(false);
|
||||
await userEvent.click(hideBox());
|
||||
await waitFor(() => expect(router.state.location.search).toBe("?closed=1"));
|
||||
await waitFor(() => expect(issuesCalls(fetch).at(-1)!.searchParams.has("hide_closed")).toBe(false));
|
||||
q = issuesCalls(fetch).at(-1)!.searchParams;
|
||||
expect(q.has("closed")).toBe(false);
|
||||
await userEvent.click(hideBox());
|
||||
await waitFor(() => expect(router.state.location.search).toBe(""));
|
||||
expect(hideBox()).toBeChecked(); // выборка с hide_closed=1 уже в кэше — повторного запроса может не быть
|
||||
});
|
||||
|
||||
it("адрес closed=1 — флажок снят, запрос без hide_closed", async () => {
|
||||
const fetch = hub(() => [row(1)]);
|
||||
renderWithProviders(<IssuesPage />, { route: "/issues?closed=1", path: "/issues" });
|
||||
await screen.findByRole("link", { name: "AURORA-9001" });
|
||||
expect(hideBox()).not.toBeChecked();
|
||||
expect(issuesCalls(fetch).every((u) => !u.searchParams.has("hide_closed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("выбран закрытый статус Jira — hide_closed не отправляется", async () => {
|
||||
const fetch = hub(() => [row(1)]);
|
||||
renderWithProviders(<IssuesPage />, { route: "/issues?status=Готово", path: "/issues" });
|
||||
await screen.findByRole("link", { name: "AURORA-9001" });
|
||||
const q = issuesCalls(fetch).at(-1)!.searchParams;
|
||||
expect(q.get("status")).toBe("Готово");
|
||||
expect(q.has("hide_closed")).toBe(false);
|
||||
expect(hideBox()).toBeChecked();
|
||||
});
|
||||
|
||||
it("выбран незакрытый статус Jira — hide_closed отправляется", async () => {
|
||||
const fetch = hub(() => [row(1)]);
|
||||
renderWithProviders(<IssuesPage />, { route: "/issues?status=В работе", path: "/issues" });
|
||||
await screen.findByRole("link", { name: "AURORA-9001" });
|
||||
expect(issuesCalls(fetch).at(-1)!.searchParams.get("hide_closed")).toBe("1");
|
||||
});
|
||||
|
||||
it("настройки пришли позже — запрос по закрытому статусу без hide_closed", async () => {
|
||||
const st = deferred<Response>();
|
||||
const fetch = mockFetch((_m, url) => {
|
||||
if (url.pathname === "/settings") return st.promise;
|
||||
if (url.pathname === "/board") return json(200, SNAP);
|
||||
return json(200, { issues: [row(1)] });
|
||||
});
|
||||
renderWithProviders(<IssuesPage />, { route: "/issues?status=Снято", path: "/issues" });
|
||||
st.resolve(json(200, settings));
|
||||
await screen.findByRole("link", { name: "AURORA-9001" });
|
||||
const calls = () => fetch.mock.calls.map(([u]) => new URL(String(u), "http://hub.test")).filter((u) => u.pathname === "/issues");
|
||||
await waitFor(() => expect(calls().at(-1)!.searchParams.has("hide_closed")).toBe(false));
|
||||
});
|
||||
|
||||
it("статуса по факту нет: ни фильтра, ни колонки; fact_status из адреса не уходит в запрос", async () => {
|
||||
const fetch = hub(() => [row(1)]);
|
||||
renderWithProviders(<IssuesPage />, { route: "/issues?fact_status=В работе", path: "/issues" });
|
||||
await screen.findByRole("link", { name: "AURORA-9001" });
|
||||
expect(screen.queryByLabelText("Статус по факту")).toBeNull();
|
||||
expect(screen.queryByRole("columnheader", { name: "Статус по факту" })).toBeNull();
|
||||
expect(issuesCalls(fetch).every((u) => !u.searchParams.has("fact_status"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { isClosed, useClosedStatuses } from "../api/closed.ts";
|
||||
import { ISSUE_FILTER_NAMES, useBoard, useIssues, useSettings, type IssueFilters } from "../api/queries.ts";
|
||||
import { IssueLink } from "./IssueLink.tsx";
|
||||
import { dueOf, epicNames, filterOptions, withCurrent } from "./options.ts";
|
||||
import "./issues.css";
|
||||
|
||||
const SELECTS = [
|
||||
["sprint", "Спринт"], ["assignee", "Исполнитель"], ["work_kind", "Вид работы"], ["fact_status", "Статус по факту"],
|
||||
["status", "Статус Jira"], ["label", "Метка"], ["epic", "Эпик"],
|
||||
["sprint", "Спринт"], ["assignee", "Исполнитель"], ["work_kind", "Вид работы"], ["status", "Статус Jira"],
|
||||
["label", "Метка"], ["epic", "Эпик"],
|
||||
] as const;
|
||||
const Q_DELAY = 300; // поиск — после паузы ввода (R-7)
|
||||
|
||||
@ -37,7 +38,12 @@ export function IssuesPage() {
|
||||
const board = useBoard(), settings = useSettings();
|
||||
const opts = useMemo(() => filterOptions(board.data, settings.data), [board.data, settings.data]);
|
||||
const names = useMemo(() => epicNames(board.data), [board.data]);
|
||||
const list = useIssues(filters);
|
||||
// закрытые скрыты, пока флажок не снят (closed=1); выбранный закрытый статус Jira скрытие отменяет
|
||||
const showClosed = params.get("closed") === "1";
|
||||
const closed = useClosedStatuses();
|
||||
const query = useMemo<IssueFilters>(() => (showClosed || isClosed(filters.status, closed) ? filters : { ...filters, hide_closed: "1" }),
|
||||
[filters, showClosed, closed]);
|
||||
const list = useIssues(query);
|
||||
const rows = list.data?.pages.flat() ?? [];
|
||||
|
||||
return (
|
||||
@ -53,6 +59,7 @@ export function IssuesPage() {
|
||||
</label>
|
||||
))}
|
||||
<label className="check"><input type="checkbox" checked={filters.neighbor === "1"} onChange={(e) => set("neighbor", e.target.checked ? "1" : "")} />только соседи</label>
|
||||
<label className="check"><input type="checkbox" checked={!showClosed} onChange={(e) => set("closed", e.target.checked ? "" : "1")} />скрыть закрытые</label>
|
||||
<input type="search" aria-label="Поиск по ключу или теме" placeholder="ключ или тема" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</div>
|
||||
{list.isError && (
|
||||
@ -60,13 +67,13 @@ export function IssuesPage() {
|
||||
<button className="btn sm" type="button" onClick={() => void list.refetch()}>Повторить</button></p>
|
||||
)}
|
||||
<table className="grid">
|
||||
<thead><tr><th>Ключ</th><th>Тема</th><th>Тип</th><th>Статус Jira</th><th>Исполнитель</th><th>Спринт</th><th>Срок</th><th>Эпик</th><th>Вид работы</th><th>Статус по факту</th><th>Сосед</th></tr></thead>
|
||||
<thead><tr><th>Ключ</th><th>Тема</th><th>Тип</th><th>Статус Jira</th><th>Исполнитель</th><th>Спринт</th><th>Срок</th><th>Эпик</th><th>Вид работы</th><th>Сосед</th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map((i) => (
|
||||
<tr key={i.key}>
|
||||
<td><IssueLink issueKey={i.key} /></td><td>{i.summary}</td><td>{i.issue_type}</td><td>{i.status}</td>
|
||||
<td>{i.assignee}</td><td>{i.sprint}</td><td>{dueOf(i)}</td><td>{i.epic_key ? names[i.epic_key] ?? i.epic_key : ""}</td>
|
||||
<td>{i.work_kind}</td><td>{i.fact_status}</td><td>{i.neighbor ? "да" : ""}</td>
|
||||
<td>{i.work_kind}</td><td>{i.neighbor ? "да" : ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@ -24,7 +24,6 @@ export function filterOptions(snap: BoardSnapshot | undefined, s: HubSettings |
|
||||
sprint: opt(uniq(issues.map((i) => i.sprint))),
|
||||
assignee: opt(uniq(issues.map((i) => i.assignee))),
|
||||
work_kind: opt(uniq([...Object.keys(s?.kind_labels ?? {}), ...issues.map((i) => i.work_kind)])),
|
||||
fact_status: opt(uniq([...Object.keys(s?.status_labels ?? {}), ...issues.map((i) => i.fact_status)])),
|
||||
status: opt(uniq(issues.map((i) => i.status))),
|
||||
label: opt(uniq(issues.flatMap((i) => i.labels))),
|
||||
epic: uniq(issues.map((i) => i.epic_key)).map((k) => ({ value: k, label: names[k] ?? k })),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user