fix(web): колонки доски — одна высота, прокрутка в окне, скрытие колонок (#112, T10.3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f0bdb2bc63
commit
2cd69bd4e8
@ -1,15 +1,16 @@
|
||||
import type { ColumnView } from "./columns.ts";
|
||||
import { effectiveHidden, type ColumnView } from "./columns.ts";
|
||||
import { AgileCard } from "./AgileCard.tsx";
|
||||
|
||||
// колонки доски; контейнер прокручивается по горизонтали внутри себя, страница не растягивается
|
||||
export function BoardColumns({ columns, lastCaption }: { columns: ColumnView[]; lastCaption?: string }) {
|
||||
// колонки доски; контейнер прокручивается по горизонтали внутри себя; колонки одной высоты, у каждой прокручивается список
|
||||
export function BoardColumns({ columns, lastCaption, hidden = [] }: { columns: ColumnView[]; lastCaption?: string; hidden?: string[] }) {
|
||||
const lastIdx = columns.reduce((acc, c, i) => (c.extra ? acc : i), -1);
|
||||
const gone = effectiveHidden(columns.map((c) => c.name), hidden);
|
||||
return (
|
||||
<div className="agile-columns">
|
||||
{columns.map((c, i) => (
|
||||
{columns.map((c, i) => gone.includes(c.name) ? null : (
|
||||
<section key={c.name + i} className={"agile-col" + (c.extra ? " is-extra" : "")} aria-label={c.name}>
|
||||
<header className="agile-col__head">
|
||||
<h2 className="agile-col__name">{c.name}</h2>
|
||||
<h3 className="agile-col__name">{c.name}</h3>
|
||||
<span className="agile-col__count">{c.issues.length}</span>
|
||||
</header>
|
||||
{lastCaption && i === lastIdx && <p className="agile-col__caption">{lastCaption}</p>}
|
||||
|
||||
26
web/src/modules/jira/agile/ColumnsMenu.tsx
Normal file
26
web/src/modules/jira/agile/ColumnsMenu.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
// меню «Колонки» экранов «Спринт» и «Kanban»: скрыть колонки доски; последнюю видимую скрыть нельзя
|
||||
import { Badge, Checkbox, Dropdown, IconChevronDown } from "../../../ui/index.ts";
|
||||
import { effectiveHidden } from "./columns.ts";
|
||||
|
||||
export function ColumnsMenu({ names, hidden, onChange }: { names: string[]; hidden: string[]; onChange(hidden: string[]): void }) {
|
||||
const gone = effectiveHidden(names, hidden); // имена, которых на доске больше нет, не показываются и не считаются
|
||||
const visible = names.filter((n) => !gone.includes(n));
|
||||
const flip = (name: string, show: boolean) => onChange(names.filter((n) => (n === name ? !show : gone.includes(n))));
|
||||
return (
|
||||
<Dropdown className="agile-columns-menu" button={({ open, toggle }) => (
|
||||
<button type="button" className="ui-input multi-btn" aria-haspopup="true" aria-expanded={open} onClick={toggle}>
|
||||
<span className="lbl">Колонки</span>{gone.length > 0 && <Badge>{gone.length}</Badge>}<IconChevronDown size={16} />
|
||||
</button>
|
||||
)}>
|
||||
{() => (
|
||||
<div className="dd-list" role="group" aria-label="Колонки">
|
||||
{names.map((n) => {
|
||||
const on = !gone.includes(n);
|
||||
return <Checkbox key={n} className="dd-item" label={n} checked={on} disabled={on && visible.length === 1}
|
||||
onChange={(e) => flip(n, e.target.checked)} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createMemoryRouter } from "react-router";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { App } from "../../../App.tsx";
|
||||
@ -110,4 +111,48 @@ describe("«Kanban»", () => {
|
||||
expect(p.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links?project=AWPCA");
|
||||
expect(urls).not.toContain("GET /agile");
|
||||
});
|
||||
|
||||
it("поиск и исполнитель фильтруют колонки и «Бэклог (N)»; флажка «Скрыть выполненные» нет, меню «Колонки» есть", async () => {
|
||||
const d = agile(true);
|
||||
d.backlog = [iss("AURORA-1", { assignee: "Иванов" }), iss("AURORA-2")];
|
||||
d.board_issues = [iss("AURORA-3", { status: "В работе", assignee: "Иванов" }), iss("AURORA-4", { status: "Готово", done: true })];
|
||||
hub(d);
|
||||
open("/app/jira/kanban?project=KAN");
|
||||
const p = await page();
|
||||
await within(p).findByText("Бэклог (2)");
|
||||
expect(within(p).queryByRole("checkbox", { name: "Скрыть выполненные" })).toBeNull();
|
||||
expect(within(p).getByRole("button", { name: /^Колонки/ })).toBeInTheDocument();
|
||||
await userEvent.selectOptions(within(p).getByLabelText("Исполнитель"), "Иванов");
|
||||
expect(within(p).getByText("Бэклог (1)")).toBeInTheDocument();
|
||||
expect(within(p).queryByText("Тема AURORA-4")).toBeNull();
|
||||
expect(within(p).getByText("Тема AURORA-3")).toBeInTheDocument();
|
||||
await userEvent.selectOptions(within(p).getByLabelText("Исполнитель"), "");
|
||||
await userEvent.type(within(p).getByRole("searchbox"), "AURORA-4");
|
||||
expect(within(p).getByText("Бэклог (0)")).toBeInTheDocument();
|
||||
expect(within(p).getByText("Тема AURORA-4")).toBeInTheDocument();
|
||||
expect(within(p).queryByText("Тема AURORA-3")).toBeNull();
|
||||
});
|
||||
|
||||
it("hideDone=true в хранилище на «Kanban» не применяется: выполненные остаются", async () => {
|
||||
localStorage.setItem("sdlc-hub-agile", JSON.stringify({ KAN: { kanban: { hideDone: true } } }));
|
||||
hub(agile(false));
|
||||
open("/app/jira/kanban?project=KAN");
|
||||
const p = await page();
|
||||
expect(await within(p).findByText("Тема AURORA-4")).toBeInTheDocument();
|
||||
expect(within(p).queryByRole("button", { name: "Сбросить фильтры" })).toBeNull();
|
||||
});
|
||||
|
||||
it("скрытая колонка: снять «Готово» — колонки нет; имя колонки, которой нет, в хранилище не ломает меню", async () => {
|
||||
localStorage.setItem("sdlc-hub-agile", JSON.stringify({ KAN: { kanban: { hiddenColumns: ["Старая"] } } }));
|
||||
hub(agile(false));
|
||||
open("/app/jira/kanban?project=KAN");
|
||||
const p = await page();
|
||||
await within(p).findByText("Тема AURORA-3");
|
||||
const btn = within(p).getByRole("button", { name: /^Колонки/ });
|
||||
expect(btn).toHaveTextContent(/^Колонки$/);
|
||||
await userEvent.click(btn);
|
||||
await userEvent.click(within(screen.getByRole("group", { name: "Колонки" })).getByRole("checkbox", { name: "Готово" }));
|
||||
expect(colNames(p)).toEqual(["Бэклог", "В работе", "Без колонки"]);
|
||||
expect(btn).toHaveTextContent("Колонки1");
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,13 +2,20 @@ import type { Project } from "../../../api/types.ts";
|
||||
import { PageHeader } from "../../../ui/index.ts";
|
||||
import { RefreshButton } from "../RefreshButton.tsx";
|
||||
import { AgileGate } from "./AgileGate.tsx";
|
||||
import { AgileToolbar } from "./AgileToolbar.tsx";
|
||||
import { BoardColumns } from "./BoardColumns.tsx";
|
||||
import { ColumnsMenu } from "./ColumnsMenu.tsx";
|
||||
import { columnsOf } from "./columns.ts";
|
||||
import { assigneeOptions, matches } from "./filters.ts";
|
||||
import { AgileIssueRow } from "./IssueRow.tsx";
|
||||
import { useAgileView } from "./useAgileView.ts";
|
||||
import "./agile.css";
|
||||
|
||||
// «Kanban» (spec web-app): колонки доски; при включённом бэклоге Jira первая колонка не рисуется, её задачи — в «Бэклог (N)» (R-18)
|
||||
// «Kanban» (spec web-app): колонки доски; при включённом бэклоге Jira первая колонка не рисуется, её задачи — в «Бэклог (N)» (R-18).
|
||||
// «Скрыть выполненные» здесь нет: даже сохранённое hideDone не применяется (последняя колонка и так ограничена «за 14 дней»)
|
||||
export function KanbanPage({ project }: { project: Project }) {
|
||||
const { view, set, setQ, filter, reset } = useAgileView(project.label, "kanban");
|
||||
const kfilter = { ...filter, hideDone: false };
|
||||
return (
|
||||
<div data-testid="page-kanban" className="agile agile--wide">
|
||||
<PageHeader title="Kanban" actions={<RefreshButton />} />
|
||||
@ -16,17 +23,22 @@ export function KanbanPage({ project }: { project: Project }) {
|
||||
{(d) => {
|
||||
const withBacklog = d.board?.kanban_backlog === true;
|
||||
const cols = d.board?.columns ?? null;
|
||||
const issues = d.board_issues ?? []; // сервер уже убрал задачи первой колонки, если бэклог включён
|
||||
const backlog = d.backlog ?? [];
|
||||
const issues = (d.board_issues ?? []).filter((i) => matches(i, kfilter)); // сервер уже убрал задачи первой колонки, если бэклог включён
|
||||
const backlog = (d.backlog ?? []).filter((i) => matches(i, kfilter));
|
||||
const views = columnsOf(withBacklog && cols ? cols.slice(1) : cols, issues);
|
||||
return (
|
||||
<>
|
||||
<AgileToolbar filter={kfilter} setQ={setQ} set={set} onReset={reset} showHideDone={false}
|
||||
assignees={assigneeOptions([...(d.board_issues ?? []), ...(d.backlog ?? [])], view.assignee)}>
|
||||
<ColumnsMenu names={views.map((c) => c.name)} hidden={view.hiddenColumns} onChange={(hiddenColumns) => set({ hiddenColumns })} />
|
||||
</AgileToolbar>
|
||||
{withBacklog && (
|
||||
<details className="agile-past">
|
||||
<summary>{`Бэклог (${backlog.length})`}</summary>
|
||||
<ul className="agile-list">{backlog.map((i) => <AgileIssueRow key={i.key} issue={i} />)}</ul>
|
||||
</details>
|
||||
)}
|
||||
<BoardColumns columns={columnsOf(withBacklog && cols ? cols.slice(1) : cols, issues)} lastCaption="за 14 дней" />
|
||||
<BoardColumns columns={views} lastCaption="за 14 дней" hidden={view.hiddenColumns} />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
|
||||
@ -228,6 +228,57 @@ describe("«Спринт»", () => {
|
||||
expect(cols.map((c) => c.querySelector(".agile-col__count")!.textContent)).toEqual(["1", "0", "1"]);
|
||||
});
|
||||
|
||||
it("заголовки колонок — h3 (T10.3)", async () => {
|
||||
hub(agile());
|
||||
open("/app/jira/sprint?project=AWPCA");
|
||||
const p = await page();
|
||||
await within(p).findByText("Тема AURORA-1");
|
||||
expect(within(p).getAllByRole("heading", { level: 3 }).map((h) => h.textContent)).toEqual(["К выполнению", "Готово", "Без колонки"]);
|
||||
});
|
||||
|
||||
it("«Скрытая колонка»: снять «Готово» — колонки нет, на «Колонки» — 1; после повторного монтажа так же", async () => {
|
||||
hub(agile());
|
||||
open("/app/jira/sprint?project=AWPCA");
|
||||
let p = await page();
|
||||
await within(p).findByText("Тема AURORA-1");
|
||||
await userEvent.click(within(p).getByRole("button", { name: /^Колонки/ }));
|
||||
await userEvent.click(within(screen.getByRole("group", { name: "Колонки" })).getByRole("checkbox", { name: "Готово" }));
|
||||
expect(colNames(p)).toEqual(["К выполнению", "Без колонки"]);
|
||||
expect(within(p).getByRole("button", { name: /^Колонки/ })).toHaveTextContent("Колонки1");
|
||||
cleanup();
|
||||
open("/app/jira/sprint?project=AWPCA");
|
||||
p = await page();
|
||||
await within(p).findByText("Тема AURORA-1");
|
||||
expect(colNames(p)).toEqual(["К выполнению", "Без колонки"]);
|
||||
expect(within(p).getByRole("button", { name: /^Колонки/ })).toHaveTextContent("Колонки1");
|
||||
});
|
||||
|
||||
it("последнюю видимую колонку снять нельзя", async () => {
|
||||
hub(agile());
|
||||
open("/app/jira/sprint?project=AWPCA");
|
||||
const p = await page();
|
||||
await within(p).findByText("Тема AURORA-1");
|
||||
await userEvent.click(within(p).getByRole("button", { name: /^Колонки/ }));
|
||||
const g = within(screen.getByRole("group", { name: "Колонки" }));
|
||||
await userEvent.click(g.getByRole("checkbox", { name: "Готово" }));
|
||||
await userEvent.click(g.getByRole("checkbox", { name: "Без колонки" }));
|
||||
expect(colNames(p)).toEqual(["К выполнению"]);
|
||||
expect(g.getByRole("checkbox", { name: "К выполнению" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("скрытая колонка, которой больше нет, не ломает меню и не считается", async () => {
|
||||
localStorage.setItem("sdlc-hub-agile", JSON.stringify({ AWPCA: { sprint: { hiddenColumns: ["Удалённая"] } } }));
|
||||
hub(agile());
|
||||
open("/app/jira/sprint?project=AWPCA");
|
||||
const p = await page();
|
||||
await within(p).findByText("Тема AURORA-1");
|
||||
expect(colNames(p)).toEqual(["К выполнению", "Готово", "Без колонки"]);
|
||||
const btn = within(p).getByRole("button", { name: /^Колонки/ });
|
||||
expect(btn).toHaveTextContent(/^Колонки$/);
|
||||
await userEvent.click(btn);
|
||||
expect(within(screen.getByRole("group", { name: "Колонки" })).getAllByRole("checkbox")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("эпик на доске спринта — карточка, учитывается в числе колонки", async () => {
|
||||
hub(agile([s102({ issues: [iss("AURORA-1"), iss("AURORA-E", { issue_type: "Эпик" })] })]));
|
||||
open("/app/jira/sprint?project=AWPCA");
|
||||
|
||||
@ -6,6 +6,7 @@ import { RefreshButton } from "../RefreshButton.tsx";
|
||||
import { AgileGate } from "./AgileGate.tsx";
|
||||
import { BoardColumns } from "./BoardColumns.tsx";
|
||||
import { AgileToolbar } from "./AgileToolbar.tsx";
|
||||
import { ColumnsMenu } from "./ColumnsMenu.tsx";
|
||||
import { columnsOf } from "./columns.ts";
|
||||
import { assigneeOptions, matches, parseSprintIds, selectableSprints, sprintIssues } from "./filters.ts";
|
||||
import { SprintPicker } from "./SprintPicker.tsx";
|
||||
@ -41,12 +42,14 @@ export function SprintPage({ project }: { project: Project }) {
|
||||
const chosen = selectable.filter((s) => ids.includes(s.id));
|
||||
const issues = sprintIssues(chosen);
|
||||
const shown = issues.filter((i) => matches(i, filter));
|
||||
const cols = columnsOf(d.board?.columns ?? null, shown);
|
||||
// выбор спринтов — в адресе, без новой записи истории
|
||||
const pick = (next: number[]) => setParams((p) => { const n = new URLSearchParams(p); n.set("sprint", next.join(",")); return n; }, { replace: true });
|
||||
return (
|
||||
<>
|
||||
<AgileToolbar filter={filter} setQ={setQ} set={set} onReset={reset} assignees={assigneeOptions(issues, view.assignee)} showHideDone>
|
||||
{selectable.length > 0 && <SprintPicker sprints={selectable} selected={ids} onChange={pick} />}
|
||||
<ColumnsMenu names={cols.map((c) => c.name)} hidden={view.hiddenColumns} onChange={(hiddenColumns) => set({ hiddenColumns })} />
|
||||
</AgileToolbar>
|
||||
{chosen.length === 0 ? (
|
||||
<Panel className="agile-section">
|
||||
@ -56,7 +59,7 @@ export function SprintPage({ project }: { project: Project }) {
|
||||
) : (
|
||||
<>
|
||||
{chosen.map((s) => <SprintHead key={s.id} s={s} />)}
|
||||
<BoardColumns columns={columnsOf(d.board?.columns ?? null, shown)} />
|
||||
<BoardColumns columns={cols} hidden={view.hiddenColumns} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -37,17 +37,22 @@
|
||||
.agile-row{flex-wrap:wrap}
|
||||
.agile-row__assignee{flex-basis:100%}
|
||||
}
|
||||
/* «Спринт»: страница во всю ширину, колонки прокручиваются внутри контейнера */
|
||||
.agile--wide{min-width:0}
|
||||
.agile-columns{display:flex;align-items:flex-start;gap:var(--space-3);overflow-x:auto;padding-bottom:var(--space-2);min-width:0}
|
||||
.agile-col{flex:0 0 280px;min-width:0;display:flex;flex-direction:column;gap:var(--space-2);
|
||||
/* «Спринт» и «Kanban»: доска занимает высоту окна под шапкой и панелью фильтров (ограничения max-width у .page--board нет);
|
||||
колонки одной высоты, список колонки прокручивается внутри, горизонтальная прокрутка — у .agile-columns и в окне (T10.3) */
|
||||
.agile--wide{box-sizing:border-box;min-width:0;height:calc(100dvh - 32px)} /* 32px — нижний отступ .page */
|
||||
.agile--wide>*{flex:0 0 auto}
|
||||
.agile--wide>.agile-columns{flex:1 1 0}
|
||||
.agile--wide>.agile-past[open]{max-height:40dvh;overflow:auto}
|
||||
.agile-columns{display:flex;align-items:stretch;gap:var(--space-3);overflow-x:auto;padding-bottom:var(--space-2);min-width:0;min-height:360px}
|
||||
.agile-col{flex:0 0 280px;min-width:0;min-height:0;display:flex;flex-direction:column;gap:var(--space-2);
|
||||
background:var(--color-bg-subtle);border:1px solid var(--color-border-default);border-radius:var(--radius-xl);padding:var(--space-3)}
|
||||
@media (max-width:639px){.agile--wide{height:calc(100dvh - 56px - 32px)}} /* 56px — закреплённая строка «Меню» */
|
||||
.agile-col.is-extra{border-style:dashed}
|
||||
.agile-col__head{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2)}
|
||||
.agile-col__name{margin:0;font-size:var(--fs-body);line-height:var(--lh-body);font-weight:700;overflow-wrap:anywhere}
|
||||
.agile-col__count{font-size:var(--fs-caption);color:var(--color-text-secondary)}
|
||||
.agile-col__caption{margin:0;font-size:var(--fs-caption);color:var(--color-text-secondary)}
|
||||
.agile-col__list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--space-2)}
|
||||
.agile-col__list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--space-2);flex:1 1 auto;min-height:0;overflow-y:auto}
|
||||
.agile-card{display:flex;flex-direction:column;gap:var(--space-2);min-width:0;padding:var(--space-3);cursor:pointer;
|
||||
background:var(--color-bg-surface);border:1px solid var(--color-border-default);border-radius:var(--radius-lg);
|
||||
font-size:var(--fs-body);line-height:var(--lh-body)}
|
||||
|
||||
@ -19,3 +19,9 @@ export function columnsOf(columns: BoardColumn[] | null, issues: AgileIssue[]):
|
||||
if (rest.length) out.push({ name: NO_COLUMN, issues: rest, extra: true });
|
||||
return out;
|
||||
}
|
||||
|
||||
// скрытые колонки, которые есть на доске; если скрыты все (колонки переименовали) — не скрыто ничего, доска не пустеет молча
|
||||
export function effectiveHidden(names: string[], hidden: string[]): string[] {
|
||||
const gone = names.filter((n) => hidden.includes(n));
|
||||
return gone.length >= names.length ? [] : gone;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user