fix(web): значки типов разных цветов, панель фильтров в одну строку, правки minor финального ревью (#112, I-1, I-2, T5.1, M-1..M-4, M-7, M-8, T7.2)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-25 21:38:46 +04:00
parent cf342448b8
commit 65e0945476
15 changed files with 79 additions and 17 deletions

View File

@ -108,7 +108,7 @@
чистые функции), `agilePref` (запоминание вида в `localStorage` под ключом `sdlc-hub-agile`: `{метка: {backlog|sprint|kanban: вид}}`;
поиск и выбор спринтов не хранятся), `useAgileView` (хук состояния вида и поиска), `AgileToolbar` (панель фильтров), `EpicsPanel`
(панель «Эпики» бэклога и выбор эпика в узкой панели), `SprintPicker` (выбор спринтов), `ColumnsMenu` (скрытие колонок «Спринт» и
«Kanban»). «Бэклог», «Спринт» и «Kanban» имеют фильтры, свёртку и скрытие колонок; «Бэклог» — панель эпиков, «Спринт» — выбор спринтов.
«Kanban»). «Бэклог» — фильтры, свёртка секций, панель эпиков; «Спринт» — фильтры, выбор спринтов (в адресе `?sprint=`, не в `localStorage`), скрытие колонок; «Kanban» — поиск/исполнитель и скрытие колонок.
Данные — один запрос `GET /agile?project=<метка>` (`modules/jira/agile.py`); страницы только смотрят (фильтры и вид в Jira не пишут).
Модуль `wiki` (`web/src/modules/wiki/index.tsx`): страница «Вики» (`/app/wiki`): дерево (`Tree`) с поиском (`Search`), страница

View File

@ -19,7 +19,7 @@
**Значок типа** SHALL различать по `issue_type` без учёта регистра: эпик («Эпик», «Epic», «Фича»), история
(«История», «Story»), задача («Задача», «Task»), ошибка («Ошибка», «Bug»), подзадача («Подзадача», «Sub-task»,
«Subtask»); прочие и пустой тип — нейтральный значок. Значок — цветной квадрат с белым символом, у каждого вида свои
«Subtask»); прочие и пустой тип — нейтральный значок. Значок — цветной квадрат с символом цвета `--color-text-on-primary`, контрастным в обеих темах, у каждого вида свои
цвет и символ (эпик — молния, история — закладка, задача — галочка, ошибка — круг, подзадача — два квадрата), в обеих
темах; название типа — в `aria-label` и подсказке.

View File

@ -172,3 +172,17 @@ test("390×800: документ без горизонтальной прокр
expect(m.bottom, JSON.stringify(m)).toBeLessThanOrEqual(m.inner);
expect(m.sw).toBeGreaterThan(m.cw);
});
test("панель фильтров на 1600 px: «Исполнитель» и поиск на одной строке, подпись видна (I-2, M-2)", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
await login(page, "/app/jira/sprint?project=AWPCA");
const search = page.getByRole("searchbox", { name: "Поиск по ключу или теме" });
const select = page.getByRole("combobox", { name: "Исполнитель" });
await expect(select).toBeVisible();
await expect(page.locator(".agile-toolbar__field").getByText("Исполнитель", { exact: true })).toBeVisible();
const mid = async (l: typeof search) => { const b = (await l.boundingBox())!; return b.y + b.height / 2; };
expect(Math.abs((await mid(search)) - (await mid(select))), "центры по вертикали").toBeLessThanOrEqual(2);
const bar = (await page.locator(".agile-toolbar").boundingBox())!;
const sel = (await select.boundingBox())!;
expect(sel.width, "выбор не растянут на строку").toBeLessThan(bar.width / 2);
});

View File

@ -12,9 +12,12 @@ export function AgileToolbar({ filter, setQ, set, onReset, assignees, showHideDo
<div className="agile-toolbar__search">
<SearchInput label="Поиск по ключу или теме" placeholder="Ключ или тема" value={filter.q} onChange={setQ} />
</div>
<Select aria-label="Исполнитель" value={filter.assignee} onChange={(e) => set({ assignee: e.target.value })}>
{assignees.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</Select>
<label className="agile-toolbar__field">
<span>Исполнитель</span>
<Select aria-label="Исполнитель" value={filter.assignee} onChange={(e) => set({ assignee: e.target.value })}>
{assignees.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</Select>
</label>
{showHideDone && (
<Checkbox label="Скрыть выполненные" checked={filter.hideDone} onChange={(e) => set({ hideDone: e.target.checked })} />
)}

View File

@ -373,6 +373,18 @@ describe("«Бэклог»", () => {
expect(screen.getByRole("complementary", { name: "Эпики" })).toBeInTheDocument();
});
it("M-3: панель «Эпики» скрыта при выбранном эпике — выбор «Эпик» показан, значение видно и снимается", async () => {
localStorage.setItem("sdlc-hub-agile", JSON.stringify({ AWPCA: { backlog: { epic: "AURORA-10", epicsPanel: false } } }));
hub({ data: epicData() });
open("/app/jira/backlog?project=AWPCA");
const p = within(await loaded());
const sel = p.getByLabelText("Эпик");
expect(sel.closest(".agile-epic-select")).toHaveClass("is-forced");
expect(sel).toHaveValue("AURORA-10");
await userEvent.selectOptions(sel, "");
expect(sel).toHaveValue("");
});
it("выбор «Эпик» в панели фильтров (узкое окно) ставит тот же фильтр", async () => {
hub({ data: epicData() });
open("/app/jira/backlog?project=AWPCA");

View File

@ -94,7 +94,7 @@ export function BacklogPage({ project }: { project: Project }) {
return (
<>
<AgileToolbar filter={filter} setQ={setQ} set={set} onReset={reset} assignees={assigneeOptions(allIssues, view.assignee)} showHideDone>
<EpicSelect {...epicProps} />
<EpicSelect {...epicProps} forced={!view.epicsPanel} />
<Button className="agile-epics-toggle" aria-pressed={view.epicsPanel} onClick={() => set({ epicsPanel: !view.epicsPanel })}>Эпики</Button>
</AgileToolbar>
<div className={`agile-backlog${view.epicsPanel ? " has-epics" : ""}`}>

View File

@ -8,7 +8,7 @@ export function ColumnsMenu({ names, hidden, onChange }: { names: string[]; hidd
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}>
<button type="button" className="ui-input multi-btn" aria-label={gone.length > 0 ? `Колонки, скрыто ${gone.length}` : "Колонки"} aria-haspopup="true" aria-expanded={open} onClick={toggle}>
<span className="lbl">Колонки</span>{gone.length > 0 && <Badge>{gone.length}</Badge>}<IconChevronDown size={16} />
</button>
)}>

View File

@ -37,9 +37,10 @@ export function EpicsPanel(props: Props) {
);
}
export function EpicSelect(props: Props) {
// forced — панель «Эпики» скрыта, а выбранный эпик должен оставаться виден и снимаем (M-3)
export function EpicSelect(props: Props & { forced?: boolean }) {
return (
<Select aria-label="Эпик" className="agile-epic-select" value={props.value} onChange={(e) => props.onChange(e.target.value)}>
<Select aria-label="Эпик" className={`agile-epic-select${props.forced ? " is-forced" : ""}`} value={props.value} onChange={(e) => props.onChange(e.target.value)}>
<option value="">Все задачи</option>
{listed(props).map((e) => <option key={e.key} value={e.key}>{e.title}</option>)}
<option value={NONE}>Без эпика</option>

View File

@ -25,12 +25,14 @@ export function KanbanPage({ project }: { project: Project }) {
const cols = d.board?.columns ?? null;
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);
const boardCols = withBacklog && cols ? cols.slice(1) : cols;
const views = columnsOf(boardCols, issues);
const allNames = columnsOf(boardCols, d.board_issues ?? []).map((c) => c.name); // меню — по задачам без фильтров (T7.2)
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 })} />
<ColumnsMenu names={allNames} hidden={view.hiddenColumns} onChange={(hiddenColumns) => set({ hiddenColumns })} />
</AgileToolbar>
{withBacklog && (
<details className="agile-past">

View File

@ -294,6 +294,23 @@ describe("«Спринт»", () => {
const p = within(await page());
expect(await p.findByText("Нет активного спринта")).toBeInTheDocument();
expect(p.getByRole("link", { name: "Бэклог" })).toHaveAttribute("href", "/app/jira/backlog?project=AWPCA");
expect(p.queryByRole("button", { name: /^Колонки/ })).toBeNull(); // M-7: доски нет — меню не нужно
});
it("T7.2: меню «Колонки» строится по задачам без фильтров — «Без колонки» не пропадает из меню вместе с поиском", async () => {
localStorage.setItem("sdlc-hub-agile", JSON.stringify({ AWPCA: { sprint: { hiddenColumns: ["Без колонки"] } } }));
hub(agile());
open("/app/jira/sprint?project=AWPCA");
const p = within(await page());
await p.findByText("Тема AURORA-1");
await userEvent.type(p.getByRole("searchbox", { name: "Поиск по ключу или теме" }), "AURORA-1");
expect(colNames(await page())).toEqual(["К выполнению", "Готово"]);
const btn = p.getByRole("button", { name: "Колонки, скрыто 1" });
await userEvent.click(btn);
const g = within(screen.getByRole("group", { name: "Колонки" }));
expect(g.getByRole("checkbox", { name: "Без колонки" })).not.toBeChecked();
await userEvent.click(g.getByRole("checkbox", { name: "Готово" }));
expect(JSON.parse(localStorage.getItem("sdlc-hub-agile")!).AWPCA.sprint.hiddenColumns).toEqual(["Готово", "Без колонки"]);
});
it("агент без досок — «Нужен агент v15 и обновление из Jira» и «Обновить из Jira»", async () => {

View File

@ -43,13 +43,14 @@ export function SprintPage({ project }: { project: Project }) {
const issues = sprintIssues(chosen);
const shown = issues.filter((i) => matches(i, filter));
const cols = columnsOf(d.board?.columns ?? null, shown);
const allNames = columnsOf(d.board?.columns ?? null, issues).map((c) => c.name); // меню — по задачам без фильтров (T7.2)
// выбор спринтов — в адресе, без новой записи истории
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 })} />
{chosen.length > 0 && <ColumnsMenu names={allNames} hidden={view.hiddenColumns} onChange={(hiddenColumns) => set({ hiddenColumns })} />}
</AgileToolbar>
{chosen.length === 0 ? (
<Panel className="agile-section">

View File

@ -10,7 +10,7 @@ export function SprintPicker({ sprints, selected, onChange }: {
const flip = (id: number, on: boolean) => onChange(sprints.map((s) => s.id).filter((x) => (x === id ? on : selected.includes(x))));
return (
<Dropdown className="agile-sprint-picker" button={({ open, toggle }) => (
<button type="button" className="ui-input multi-btn" aria-haspopup="true" aria-expanded={open} onClick={toggle}>
<button type="button" className="ui-input multi-btn" aria-label={chosen.length === 1 ? `Спринты: ${label}` : label} aria-haspopup="true" aria-expanded={open} onClick={toggle}>
<span className="lbl">{label}</span><IconChevronDown size={16} />
</button>
)}>

View File

@ -10,7 +10,7 @@ describe("TypeIcon — значок типа по образцу Jira", () => {
["Ошибка", "bug"],
["Подзадача", "subtask"],
["Риск", "other"],
])("«%s» → вид %s: role=img, aria-label — исходное имя, класс agile-type--%s", (type, kind) => {
])("«%s» → вид %s: role=img, aria-label — исходное имя, класс agile-type--<вид>", (type, kind) => {
const { container } = render(<TypeIcon type={type} />);
const el = screen.getByRole("img", { name: type });
expect(el).toHaveAttribute("aria-label", type);
@ -18,6 +18,14 @@ describe("TypeIcon — значок типа по образцу Jira", () => {
expect(container.querySelector("svg")).not.toBeNull();
});
it("пустая строка и пробелы — «Тип не указан», у значка есть доступное имя", () => {
render(<TypeIcon type=" " />);
const el = screen.getByRole("img", { name: "Тип не указан" });
expect(el).toHaveClass("agile-type--other");
render(<TypeIcon type="" />);
expect(screen.getAllByRole("img", { name: "Тип не указан" })).toHaveLength(2);
});
it("null — «Тип не указан», не «Задача»", () => {
render(<TypeIcon type={null} />);
const el = screen.getByRole("img", { name: "Тип не указан" });

View File

@ -9,7 +9,7 @@ const ICONS: Record<TypeKind, typeof IconCritical> = {
// тип — в aria-label и title; без типа — «Тип не указан»
export function TypeIcon({ type }: { type: string | null }) {
const kind = typeKind(type);
const label = type ?? "Тип не указан";
const label = type?.trim() || "Тип не указан";
const Icon = ICONS[kind];
return (
<span className={`agile-type agile-type--${kind}`} role="img" aria-label={label} title={label}>

View File

@ -27,7 +27,8 @@
color:var(--color-text-on-primary);background:var(--color-text-secondary)}
.agile-type--epic{background:var(--k-ui)}
.agile-type--story{background:var(--color-status-success)}
.agile-type--task,.agile-type--subtask{background:var(--color-action-primary)}
.agile-type--task{background:var(--k-dev)}
.agile-type--subtask{background:var(--k-analytics)}
.agile-type--bug{background:var(--color-status-error)}
.agile-past{display:flex;flex-direction:column;gap:var(--space-2)}
.agile-past>summary{cursor:pointer;font-weight:600;padding:var(--space-2) 0}
@ -65,6 +66,8 @@
/* панель фильтров */
.agile-toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-3)}
.agile-toolbar__search{flex:1 1 220px;min-width:0}
.agile-toolbar__field{display:inline-flex;align-items:center;gap:var(--space-2);margin:0;font-size:var(--fs-body);color:var(--color-text-secondary)}
.agile-toolbar .ui-select{width:auto;flex:0 1 220px;min-width:160px}
.agile-section__toggle{display:inline-flex;align-items:center;gap:var(--space-2);margin:0;padding:0;border:0;background:none;color:inherit;font:inherit;cursor:pointer}
.agile-section__toggle:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px}
/* «Бэклог»: панель «Эпики» слева; на узком окне — выбор «Эпик» в панели фильтров */
@ -82,9 +85,10 @@
.agile-epics__key{font-family:var(--font-mono);font-size:var(--fs-caption);color:var(--color-text-secondary)}
.agile-epics__count{margin-left:auto;font-size:var(--fs-caption);color:var(--color-text-secondary)}
.agile-epic-select{display:none}
.agile-epic-select.is-forced{display:inline-flex}
@container page (max-width: 899px){
.agile-backlog.has-epics{grid-template-columns:minmax(0,1fr)}
.agile-epics{display:none}
.agile-epic-select{display:inline-block}
.agile-epic-select{display:inline-flex}
.agile-epics-toggle{display:none}
}