История изменений в карточке задачи (#77)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 11:31:39 +04:00
parent 37f28c3812
commit 066cb79c4f
5 changed files with 58 additions and 5 deletions

View File

@ -24,8 +24,9 @@
| `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` |
| `cli` | `init`, `serve`, `import`, `mcp`, `tokens`, `agent publish` | `api`, `mcp_server`, `importer`, `release`, `settings`, `db`, `paths` |
`web/` — веб-приложение хаба (React + TypeScript + Vite): вход по клиентскому токену, страницы «Доска» (`/app/board`)
и «Задачи» (`/app/issues`); текущий проект — `?project=<метка>` в адресе (`web/src/projects/`: контекст проекта, выпадающий список «Проект», окно «Новый проект», заглушка «проект не найден»; страницы пересоздаются по `key={project}`); карточка `/app/issues/{key}` — окно поверх страницы, с которой её открыли: `shell/Layout.tsx` рисует
`web/` — веб-приложение хаба (React + TypeScript + Vite): вход по клиентскому токену, страницы «Доска» (`/app/board`),
«Задачи» (`/app/issues`) и «Лента» (`/app/feed`: журнал изменений `GET /changes` за период; `web/src/feed/` — чистые
функции `events.ts` (период, виды, текст события, группировка) и `EventList.tsx`, общий с блоком «История» карточки); текущий проект — `?project=<метка>` в адресе (`web/src/projects/`: контекст проекта, выпадающий список «Проект», окно «Новый проект», заглушка «проект не найден»; страницы пересоздаются по `key={project}`); карточка `/app/issues/{key}` — окно поверх страницы, с которой её открыли: `shell/Layout.tsx` рисует
страницу по адресу из `location.state.background` (`useRoutes` с явным адресом) и `issues/IssueModal.tsx` поверх неё. Собирается `make web` в `src/jira_hub/web/app/`; `api` отдаёт сборку
без токена (`/app/assets/*` — файлы, прочие `/app…` — `index.html`, 503 без сборки), `/` и `/links` перенаправляет на
`/app/board`. Данные — только через HTTP API с клиентским токеном; `mirror.board` отдаёт зеркало без преобразований,

View File

@ -24,7 +24,7 @@
- `uv sync` — установка окружения.
- `uv run jira-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов.
- `uv run jira-hub serve [--host] [--port]` — локальный запуск хаба.
- Веб-приложение — `http://127.0.0.1:8790/app/` (доска — `/app/board?project=<метка>`, задачи — `/app/issues?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на доску.
- Веб-приложение — `http://127.0.0.1:8790/app/` (доска — `/app/board?project=<метка>`, задачи — `/app/issues?project=<метка>`, лента изменений — `/app/feed?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на доску.
- `make web` — сборка приложения (после `git pull` на Mac mini, до перезапуска хаба); `make check` — pytest + lint и тесты фронта; `cd web && npm run dev` — разработка с прокси на тестовый хаб `bash web/scripts/test-hub.sh` (:8791); `cd web && npm run e2e` — Playwright; `cd web && npm run golden` — перегенерировать эталоны доски из `links.html@3931a88` (только при смене фикстур).
- `uv run jira-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run jira-hub mcp [--project <метка>]` — MCP по stdio (с привязкой сессии к проекту); `uv run jira-hub tokens` — показать токены.
- `uv run jira-hub agent publish PATH` — опубликовать версию агента для самообновления (только вверх).

View File

@ -16,7 +16,7 @@ async function seedHistory() {
expect(r.ok).toBe(true);
}
test("лента: событие за сегодня, карточка окном поверх ленты", async ({ page }) => {
test("лента: событие за сегодня, карточка окном поверх ленты, история в карточке", async ({ page }) => {
await seedHistory();
await login(page, "/app/feed?project=AWPCA");
const group = page.getByTestId("feed-group").filter({ has: page.getByRole("link", { name: "AURORA-9002", exact: true }) });
@ -29,6 +29,8 @@ test("лента: событие за сегодня, карточка окно
await group.getByRole("link", { name: "AURORA-9002", exact: true }).click();
const d = page.getByRole("dialog");
await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible();
await expect(d.getByRole("heading", { name: /^История \(\d+\)$/ })).toBeVisible();
await expect(d.getByTestId("history").getByText("статус «В работе» → «Готово»").first()).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page.getByTestId("page-feed")).toBeVisible();

View File

@ -450,3 +450,35 @@ describe("карточка задачи", () => {
err.mockRestore();
});
});
describe("история в карточке", () => {
beforeEach(() => { setToken("tok"); localStorage.clear(); });
const hist = (n: number) => Array.from({ length: n }, (_, i) => ({
at: new Date(2026, 9, 20, 9, i).toISOString(), kind: "status" as const, field: "status", author: "Петров",
from: "a", to: `s${i}`, from_value: null, to_value: null,
}));
it("12 событий: видны 10 последних и «Показать все (12)», после нажатия — все", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { changes: hist(12) })));
open("/issues/AURORA-9001");
const sec = await screen.findByTestId("history");
expect(within(sec).getByRole("heading", { name: "История (12)" })).toBeInTheDocument();
expect(within(sec).getAllByRole("listitem")).toHaveLength(10);
expect(within(sec).getAllByRole("listitem")[0]).toHaveTextContent(/^20\.10 09:00/); // с датой всегда
await userEvent.click(within(sec).getByRole("button", { name: "Показать все (12)" }));
expect(within(sec).getAllByRole("listitem")).toHaveLength(12);
});
it("пустая история — «изменений нет»; без поля changes (старый хаб) — блока нет", async () => {
let body = issue("AURORA-9001", { changes: [] });
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, body));
open("/issues/AURORA-9001");
const sec = await screen.findByTestId("history");
expect(within(sec).getByText("изменений нет")).toBeInTheDocument();
cleanup();
body = issue("AURORA-9002");
open("/issues/AURORA-9002");
await screen.findByRole("heading", { name: "Тема AURORA-9002" });
expect(screen.queryByTestId("history")).toBeNull();
});
});

View File

@ -2,9 +2,10 @@ import { type RefObject, useLayoutEffect, useRef, useState } from "react";
import { ApiError } from "../api/client.ts";
import { useClosedStatuses } from "../api/closed.ts";
import { useIssue, useSettings } from "../api/queries.ts";
import type { IssueComment, IssueDetail } from "../api/types.ts";
import type { ChangeEvent, IssueComment, IssueDetail } from "../api/types.ts";
import { JIRA_URL } from "../board/constants.ts";
import { fmtDate, fmtStamp } from "../board/model/util.ts";
import { EventList } from "../feed/EventList.tsx";
import { groupLinks, isOverdue, todayISO } from "./card.ts";
import { useCommentsHidden } from "./commentsPref.ts";
import { IssueLink } from "./IssueLink.tsx";
@ -69,6 +70,7 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
<Links issue={i} linkTypes={(settings.data?.link_types ?? {}) as Record<string, string>} />
{i.description && <Description text={i.description} projectKey={settings.data?.project_key} />}
<Comments items={i.comments ?? []} projectKey={settings.data?.project_key} />
{i.changes && <History items={i.changes} />}
</div>
<Panel className="side">
<Notes key={i.key} issue={i} />
@ -178,6 +180,22 @@ function Comments({ items, projectKey }: { items: IssueComment[]; projectKey?: s
);
}
const HISTORY_SHOWN = 10;
// журнал изменений задачи: 10 последних, остальное — по «Показать все» (spec web-app «История в карточке задачи»)
function History({ items }: { items: ChangeEvent[] }) {
const [all, setAll] = useState(false);
const shown = all ? items : items.slice(0, HISTORY_SHOWN);
return (
<section className="history" data-testid="history">
<div className="sec-head"><h3>История ({items.length})</h3></div>
{!items.length && <p className="hint">изменений нет</p>}
{items.length > 0 && <EventList events={shown} now={new Date()} alwaysDate />}
{!all && items.length > HISTORY_SHOWN && <Button variant="ghost" className="more" onClick={() => setAll(true)}>{`Показать все (${items.length})`}</Button>}
</section>
);
}
function OtherFields({ issue: i }: { issue: IssueDetail }) {
const rows: [string, string | null][] = [
["Тип", i.issue_type], ["Приоритет", i.priority], ["Автор", i.reporter], ["Метки", i.labels.join(", ") || null],