diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e9abf2e..ec2c6be 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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`), страница diff --git a/openspec/changes/agile-web-polish/specs/web-app/spec.md b/openspec/changes/agile-web-polish/specs/web-app/spec.md index e203284..bde860c 100644 --- a/openspec/changes/agile-web-polish/specs/web-app/spec.md +++ b/openspec/changes/agile-web-polish/specs/web-app/spec.md @@ -19,7 +19,7 @@ **Значок типа** SHALL различать по `issue_type` без учёта регистра: эпик («Эпик», «Epic», «Фича»), история («История», «Story»), задача («Задача», «Task»), ошибка («Ошибка», «Bug»), подзадача («Подзадача», «Sub-task», -«Subtask»); прочие и пустой тип — нейтральный значок. Значок — цветной квадрат с белым символом, у каждого вида свои +«Subtask»); прочие и пустой тип — нейтральный значок. Значок — цветной квадрат с символом цвета `--color-text-on-primary`, контрастным в обеих темах, у каждого вида свои цвет и символ (эпик — молния, история — закладка, задача — галочка, ошибка — круг, подзадача — два квадрата), в обеих темах; название типа — в `aria-label` и подсказке. diff --git a/web/e2e/agile.spec.ts b/web/e2e/agile.spec.ts index b181d49..20235de 100644 --- a/web/e2e/agile.spec.ts +++ b/web/e2e/agile.spec.ts @@ -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); +}); diff --git a/web/src/modules/jira/agile/AgileToolbar.tsx b/web/src/modules/jira/agile/AgileToolbar.tsx index 83880a9..c0b93c1 100644 --- a/web/src/modules/jira/agile/AgileToolbar.tsx +++ b/web/src/modules/jira/agile/AgileToolbar.tsx @@ -12,9 +12,12 @@ export function AgileToolbar({ filter, setQ, set, onReset, assignees, showHideDo
- + {showHideDone && ( set({ hideDone: e.target.checked })} /> )} diff --git a/web/src/modules/jira/agile/BacklogPage.test.tsx b/web/src/modules/jira/agile/BacklogPage.test.tsx index e3afc77..96c7718 100644 --- a/web/src/modules/jira/agile/BacklogPage.test.tsx +++ b/web/src/modules/jira/agile/BacklogPage.test.tsx @@ -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"); diff --git a/web/src/modules/jira/agile/BacklogPage.tsx b/web/src/modules/jira/agile/BacklogPage.tsx index 26e7cc2..1d52170 100644 --- a/web/src/modules/jira/agile/BacklogPage.tsx +++ b/web/src/modules/jira/agile/BacklogPage.tsx @@ -94,7 +94,7 @@ export function BacklogPage({ project }: { project: Project }) { return ( <> - +
diff --git a/web/src/modules/jira/agile/ColumnsMenu.tsx b/web/src/modules/jira/agile/ColumnsMenu.tsx index 438ec50..460fa6d 100644 --- a/web/src/modules/jira/agile/ColumnsMenu.tsx +++ b/web/src/modules/jira/agile/ColumnsMenu.tsx @@ -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 ( ( - )}> diff --git a/web/src/modules/jira/agile/EpicsPanel.tsx b/web/src/modules/jira/agile/EpicsPanel.tsx index 1be1fb4..ff73fe0 100644 --- a/web/src/modules/jira/agile/EpicsPanel.tsx +++ b/web/src/modules/jira/agile/EpicsPanel.tsx @@ -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 ( - props.onChange(e.target.value)}> {listed(props).map((e) => )} diff --git a/web/src/modules/jira/agile/KanbanPage.tsx b/web/src/modules/jira/agile/KanbanPage.tsx index 50fcd21..feb7734 100644 --- a/web/src/modules/jira/agile/KanbanPage.tsx +++ b/web/src/modules/jira/agile/KanbanPage.tsx @@ -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 ( <> - c.name)} hidden={view.hiddenColumns} onChange={(hiddenColumns) => set({ hiddenColumns })} /> + {withBacklog && (
diff --git a/web/src/modules/jira/agile/SprintPage.test.tsx b/web/src/modules/jira/agile/SprintPage.test.tsx index 0844206..348c543 100644 --- a/web/src/modules/jira/agile/SprintPage.test.tsx +++ b/web/src/modules/jira/agile/SprintPage.test.tsx @@ -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 () => { diff --git a/web/src/modules/jira/agile/SprintPage.tsx b/web/src/modules/jira/agile/SprintPage.tsx index 8ac904f..c943003 100644 --- a/web/src/modules/jira/agile/SprintPage.tsx +++ b/web/src/modules/jira/agile/SprintPage.tsx @@ -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 ( <> {selectable.length > 0 && } - c.name)} hidden={view.hiddenColumns} onChange={(hiddenColumns) => set({ hiddenColumns })} /> + {chosen.length > 0 && {chosen.length === 0 ? ( diff --git a/web/src/modules/jira/agile/SprintPicker.tsx b/web/src/modules/jira/agile/SprintPicker.tsx index fec0ada..8bbe145 100644 --- a/web/src/modules/jira/agile/SprintPicker.tsx +++ b/web/src/modules/jira/agile/SprintPicker.tsx @@ -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 ( ( - )}> diff --git a/web/src/modules/jira/agile/TypeIcon.test.tsx b/web/src/modules/jira/agile/TypeIcon.test.tsx index 11a657e..9d22781 100644 --- a/web/src/modules/jira/agile/TypeIcon.test.tsx +++ b/web/src/modules/jira/agile/TypeIcon.test.tsx @@ -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(); 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(); + const el = screen.getByRole("img", { name: "Тип не указан" }); + expect(el).toHaveClass("agile-type--other"); + render(); + expect(screen.getAllByRole("img", { name: "Тип не указан" })).toHaveLength(2); + }); + it("null — «Тип не указан», не «Задача»", () => { render(); const el = screen.getByRole("img", { name: "Тип не указан" }); diff --git a/web/src/modules/jira/agile/TypeIcon.tsx b/web/src/modules/jira/agile/TypeIcon.tsx index 6eba73b..dd5f822 100644 --- a/web/src/modules/jira/agile/TypeIcon.tsx +++ b/web/src/modules/jira/agile/TypeIcon.tsx @@ -9,7 +9,7 @@ const ICONS: Record = { // тип — в 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 ( diff --git a/web/src/modules/jira/agile/agile.css b/web/src/modules/jira/agile/agile.css index c5fcb2e..737bbf5 100644 --- a/web/src/modules/jira/agile/agile.css +++ b/web/src/modules/jira/agile/agile.css @@ -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} }