fix(web): фильтр из адреса вне вариантов, ссылки цветом темы, «команд по задаче не было», тесты без пауз (T13.1, O-10, O-11, T14.2, T14.3, #29)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 01:21:50 +04:00
parent 091acda286
commit 6940da5a20
7 changed files with 65 additions and 17 deletions

View File

@ -104,17 +104,18 @@ describe("карточка задачи", () => {
});
it("поздний ответ прежней карточки не подменяет новую", async () => {
const client = makeQueryClient();
const slow = deferred<Response>();
mockFetch((_m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (url.pathname === "/issues/AURORA-9001") return slow.promise;
return json(200, issue("AURORA-9002"));
});
const router = open("/issues/AURORA-9001");
const router = open("/issues/AURORA-9001", client);
await act(async () => { await router.navigate("/issues/AURORA-9002"); });
expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
slow.resolve(json(200, issue("AURORA-9001")));
await new Promise((r) => setTimeout(r, 20));
await waitFor(() => expect(client.getQueryData(qk.issue("AURORA-9001"))).toBeDefined());
expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: /AURORA-9001/ })).toBeNull();
});
@ -137,7 +138,7 @@ describe("карточка задачи", () => {
await userEvent.click(await screen.findByRole("button", { name: "Уйти" }));
expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
slowPut.resolve(json(200, note({ owner_note: "черновик", updated_by: "web" })));
await new Promise((r) => setTimeout(r, 20));
await waitFor(() => expect(client.isMutating()).toBe(0));
expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
expect(screen.getByLabelText("Заметка владельца")).toHaveValue("");
expect(screen.getByRole("button", { name: "Сохранить" })).toBeDisabled();
@ -178,4 +179,20 @@ describe("карточка задачи", () => {
await userEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
});
it("O-11: задача без команд — текст вместо пустой таблицы", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { commands: [] })));
open("/issues/AURORA-9001");
expect(await screen.findByText("команд по задаче не было")).toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: "Команда" })).toBeNull();
});
it("T14.3: id подсказок «статуса по факту» уникален и связан с полем", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
open("/issues/AURORA-9001");
const input = await screen.findByLabelText("Статус по факту");
const id = input.getAttribute("list")!;
expect(id).not.toBe("fact-statuses");
expect(document.getElementById(id)?.tagName).toBe("DATALIST");
});
});

View File

@ -52,6 +52,7 @@ export function IssuePage() {
{!i.links_out.length && !i.links_in.length && <li className="hint">связей нет</li>}
</ul>
<h3>Последние команды</h3>
{i.commands.length ? (
<table className="grid">
<thead><tr><th>Команда</th><th>Пакет</th><th>Тип</th><th>Статус</th><th>Завершена</th><th>Ошибка</th></tr></thead>
<tbody>{i.commands.map((c) => (
@ -59,6 +60,7 @@ export function IssuePage() {
<td>{c.finished_at ? fmtStamp(c.finished_at) : ""}</td><td>{c.error}</td></tr>
))}</tbody>
</table>
) : <p className="hint">команд по задаче не было</p>}
</div>
<NoteForm key={i.key} issue={i} />
</div>

View File

@ -1,4 +1,4 @@
import { act, screen, waitFor } from "@testing-library/react";
import { act, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { readFileSync } from "node:fs";
import { join } from "node:path";
@ -108,4 +108,25 @@ describe("список задач", () => {
expect(before).toBe("POP");
expect(router.state.historyAction).toBe("REPLACE");
});
it("T13.1: значение фильтра из адреса вне вариантов — в списке с пометкой", async () => {
hub(() => [row(1)]);
renderWithProviders(<IssuesPage />, { route: "/issues?sprint=AWPCA_Спринт 0", path: "/issues" });
const sel = await screen.findByLabelText("Спринт");
await waitFor(() => expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 0 (нет на доске)" })).toBeInTheDocument());
expect(sel).toHaveValue("AWPCA_Спринт 0");
});
it("T13.1: пока снимок доски грузится, пометки «(нет на доске)» нет", async () => {
mockFetch((_m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (url.pathname === "/board") return new Promise<Response>(() => {}); // снимок не приходит до конца теста
return json(200, { issues: [row(1)] });
});
renderWithProviders(<IssuesPage />, { route: "/issues?sprint=AWPCA_Спринт 2", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
const sel = await screen.findByLabelText("Спринт");
expect(within(sel).queryByRole("option", { name: /нет на доске/ })).toBeNull();
expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument();
});
});

View File

@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { ISSUE_FILTER_NAMES, useBoard, useIssues, useSettings, type IssueFilters } from "../api/queries.ts";
import { dueOf, epicNames, filterOptions } from "./options.ts";
import { dueOf, epicNames, filterOptions, withCurrent } from "./options.ts";
import "./issues.css";
const SELECTS = [
@ -47,7 +47,7 @@ export function IssuesPage() {
<label key={name}>{label}
<select aria-label={label} value={filters[name] ?? ""} onChange={(e) => set(name, e.target.value)}>
<option value="">все</option>
{opts[name].map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
{withCurrent(opts[name], filters[name], !!board.data).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
))}

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useId, useMemo, useState } from "react";
import { useBlocker } from "react-router";
import { ApiError } from "../api/client.ts";
import { useSaveNote, useSettings } from "../api/queries.ts";
@ -37,6 +37,7 @@ export function NoteForm({ issue }: { issue: IssueDetail }) {
const kinds = Object.keys(settings.data?.kind_labels ?? {});
const kindOptions = values.work_kind && !kinds.includes(values.work_kind) ? [values.work_kind, ...kinds] : kinds;
const facts = Object.keys(settings.data?.status_labels ?? {});
const listId = useId();
const set = (f: NoteField) => (e: { target: { value: string } }) => setValues((v) => ({ ...v, [f]: e.target.value }));
const submit = () => save.mutate(changed, {
@ -56,8 +57,8 @@ export function NoteForm({ issue }: { issue: IssueDetail }) {
</select>
</label>
<label>Статус по факту
<input list="fact-statuses" value={values.fact_status} onChange={set("fact_status")} />
<datalist id="fact-statuses">{facts.map((f) => <option key={f} value={f} />)}</datalist>
<input list={listId} value={values.fact_status} onChange={set("fact_status")} />
<datalist id={listId}>{facts.map((f) => <option key={f} value={f} />)}</datalist>
</label>
{TEXTS.map(([f, label]) => (
<label key={f}>{label}<textarea rows={3} value={values[f]} onChange={set(f)} /></label>

View File

@ -6,6 +6,7 @@
.grid th,.grid td{border-bottom:1px solid var(--line);padding:6px 8px;text-align:left;vertical-align:top}
.grid th{color:var(--muted);font-weight:500;position:sticky;top:0;background:var(--bg)}
.grid a{color:var(--accent)}
.issue a{color:var(--accent)}
.issue .cols{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:24px}
@media (max-width:900px){.issue .cols{grid-template-columns:1fr}}
.issue .fields{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:4px 16px;margin:0}

View File

@ -1,6 +1,6 @@
import type { BoardSnapshot, HubSettings } from "../api/types.ts";
type Opt = { value: string; label: string };
export type Opt = { value: string; label: string };
const uniq = (xs: (string | null | undefined)[]) => [...new Set(xs.filter((x): x is string => !!x))].sort((a, b) => a.localeCompare(b, "ru"));
const isEpic = (t: string | null) => /^(Фича|Epic|Эпик)$/i.test(t ?? "");
@ -10,6 +10,12 @@ export const epicNames = (snap: BoardSnapshot | undefined): Record<string, strin
export const dueOf = (i: { planned_end: string | null; duedate: string | null }) => (i.planned_end || i.duedate || "").slice(0, 10);
// варианты выпадающих списков фильтров: из снимка /board и словарей настроек, без новых маршрутов API
// T13.1: значение фильтра из адреса, которого нет среди вариантов (закрытый спринт, ушедший исполнитель), видно в списке;
// пока снимок доски не загружен, пометку не ставим (R-4)
export const withCurrent = (opts: Opt[], value: string | undefined, loaded: boolean): Opt[] =>
!value || opts.some((o) => o.value === value) ? opts
: [{ value, label: loaded ? `${value} (нет на доске)` : value }, ...opts];
export function filterOptions(snap: BoardSnapshot | undefined, s: HubSettings | undefined) {
const issues = snap?.issues ?? [];
const names = epicNames(snap);