Веб: общая «Лента» ядра с источниками модулей, источник Jira (#140)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
68c7f23410
commit
558fe7812b
@ -98,9 +98,12 @@
|
||||
(`freshness(project)`), действие обновления (`refresh`: подпись, `start`, `isOwn`) и перечитывание после пакета (`invalidate`). Рисует и ведёт
|
||||
его ядро: трекер пакетов и агентов — `web/src/shell/activity/` (`ActivityProvider`: подхват идущего пакета и `GET /agents` раз в 60 с,
|
||||
опрос пакета каждые 3 с, `useModuleActivity(module)`, тексты `events.ts`/`agentText.ts`), блок состояния панели — `ModuleStatusBlock`, а модуль
|
||||
даёт только `Summary` — карточку на «Обзоре» (`OverviewPage`: карточки модулей и «Последние пакеты проекта»). Адреса: «Обзор» — `/app/`,
|
||||
страницы модуля — `/app/<модуль>/…` (`/app/jira/links`, `/app/jira/issues[/{key}]`, `/app/jira/feed`, `/app/wiki`); прежние `/app/board`, `/app/jira/board`,
|
||||
`/app/issues`, `/app/issues/{key}`, `/app/feed` перенаправляются с сохранением `search` и `replace`. Пункты меню могут зависеть от проекта (`NavItem.when`, `visibleNav` в `shell/nav.tsx`): пока проект не определён, условный пункт скрыт; «не найдено» перечисляет только безусловные.
|
||||
даёт только `Summary` — карточку на «Обзоре» (`OverviewPage`: карточки модулей и «Последние пакеты проекта»). Контракт `HubModule` — плюс необязательное
|
||||
`feed` (`FeedSource`: источник общей ленты — `available(project)`, хук `useFeed(input)` с группами/состоянием, `Controls` — свои настройки над лентой):
|
||||
страница ядра «Лента» (`web/src/shell/feed/`: `FeedPage`, `period`, `sources`; `FEED_MODULES`/`useFeeds` в `modules/index.ts`) ведёт период, выбор
|
||||
источников (`sources=`, id источника — имя модуля) и порядок групп по времени, модуль — запросы, группы и виды событий; ядро не знает видов событий модулей. Адреса: «Обзор» — `/app/`,
|
||||
«Лента» — страница ядра `/app/feed`; страницы модуля — `/app/<модуль>/…` (`/app/jira/links`, `/app/jira/issues[/{key}]`, `/app/wiki`); прежние `/app/board`, `/app/jira/board`,
|
||||
`/app/issues`, `/app/issues/{key}`, `/app/jira/feed` (на `/app/feed`) перенаправляются с сохранением `search` и `replace`. Пункты меню могут зависеть от проекта (`NavItem.when`, `visibleNav` в `shell/nav.tsx`): пока проект не определён, условный пункт скрыт; «не найдено» перечисляет только безусловные.
|
||||
|
||||
Экраны доски модуля jira — `web/src/modules/jira/agile/`: `model` (даты Jira, «осталось N дн.», склонение, состояние данных
|
||||
`boardState`), `columns` (раскладка задач по колонкам доски, «Без колонки»), `AgileGate` (тип доски проекта, загрузка, ошибка, три
|
||||
@ -117,13 +120,13 @@
|
||||
скачивают файл через `download.ts`, `urlTransform.ts` пропускает только эти схемы и http(s)), форма корня без привязки (`RootForm`),
|
||||
сводка для «Обзора» (`Summary`) и `status` для ядра; поле «Корень вики» в окне «Новый проект» — через `projectFields`.
|
||||
|
||||
Модуль `jira` (`web/src/modules/jira/index.tsx`): страницы «Связи» (`/app/jira/links`, заголовок страницы — «Доска связей»), «Задачи» (`/app/jira/issues`), «Лента»
|
||||
(`/app/jira/feed`), карточка `/app/jira/issues/{key}` окном поверх страницы (`issues/overlay.tsx`, `IssueModal.tsx`; оболочка рисует
|
||||
Модуль `jira` (`web/src/modules/jira/index.tsx`): страницы «Связи» (`/app/jira/links`, заголовок страницы — «Доска связей»), «Задачи» (`/app/jira/issues`) (страницы «Лента» у модуля нет — она в ядре; `/app/jira/feed`
|
||||
перенаправляется на `/app/feed`), карточка `/app/jira/issues/{key}` окном поверх страницы (`issues/overlay.tsx`, `IssueModal.tsx`; оболочка рисует
|
||||
страницу по `location.state.background`). Внутри: `board/` (чистые `model`, `analysis`, `layout`, `storage.ts` — ключи
|
||||
`localStorage` прежней страницы, у проекта AWPCA прежние, у остальных с суффиксом `@<метка>`; отрисовка `components/`;
|
||||
golden-эталоны `__fixtures__/`), `issues/` (список, карточка, `wiki/` — разбор вики-разметки, `attachments/` — список,
|
||||
плашки и скачивание: `POST /attachments/{id}/fetch`, опрос `GET /attachments/{id}`, затем `api.blob` на `/content`),
|
||||
`feed/` (период, виды, группировка событий, `EventList`, общий с блоком «История» карточки), `RefreshButton.tsx` (кнопка «Обновить из Jira» в шапках страниц), `Summary`/`status` для ядра, `api/queries.ts`.
|
||||
`feed/` (источник ленты `jiraFeed` — `source.tsx`: виды событий, группировка по задаче, `FeedGroup`; `EventList`, общий с блоком «История» карточки), `RefreshButton.tsx` (кнопка «Обновить из Jira» в шапках страниц), `Summary`/`status` для ядра, `api/queries.ts`.
|
||||
|
||||
Собирается `make web` в `src/sdlc_hub/web/app/`; `core/api` отдаёт сборку без токена (`/app/assets/*` — файлы, прочие
|
||||
`/app…` — `index.html`, 503 без сборки), `/` и `/links` перенаправляет на `/app/` («Обзор»). Данные — только через HTTP API с
|
||||
|
||||
@ -18,7 +18,7 @@ SDLC-хаб с модулями jira и wiki. Мост Jira для АРМ КА:
|
||||
- `src/sdlc_hub/modules/wiki/` — модуль wiki: `schema`, `settings`, `paths`, `projects` (поле `wiki_root`), `commands` (`wiki_sync`, `wiki_fetch` и хуки очереди), `convert` (HTML → Markdown), `stage`, `sync`, `search` (FTS5), `attachments`, `tick`, `api`, `mcp`. Входная точка — `MODULE` в `modules/wiki/__init__.py`.
|
||||
- Граница: ядро не импортирует модули (кроме ленивого импорта в `registry.py` и шагах миграций `db.py`), типы команд и термины модулей (jira, wiki, eva) живут в модуле, новые имена в ядре — без названий модулей; проверяют `test_core_imports`, `test_core_has_no_jira_terms`, `test_core_has_no_module_terms` в `tests/test_registry.py`. Меток-исключений нет.
|
||||
- Цепочка ядра: `db` ← `settings`, `projects` ← `queue` ← `api` ← `cli`; `files` ← `queue`, `api`; реле `relay` — поток в `serve`; `backup` вызывается из `api` и из `db`. Модуль jira: `hooks → commands/mirror/history/refresh/attachments/fetch → core`.
|
||||
- Веб: оболочка и общее — `web/src/{shell,ui,api,projects,auth,styles}`, модули — `web/src/modules/<имя>/` (контракт `HubModule` в `modules/types.ts`, список — `modules/index.ts`); состояние модулей (агент, свежесть, обновление) ведёт ядро в `web/src/shell/activity/`, модуль даёт `status` и `Summary` для «Обзора»; модуль jira — страницы доски, задач и ленты; модуль wiki (`web/src/modules/wiki/`) — страница «Вики» (`/app/wiki?project=<метка>`).
|
||||
- Веб: оболочка и общее — `web/src/{shell,ui,api,projects,auth,styles}`, модули — `web/src/modules/<имя>/` (контракт `HubModule` в `modules/types.ts`, список — `modules/index.ts`); состояние модулей (агент, свежесть, обновление) ведёт ядро в `web/src/shell/activity/`, модуль даёт `status` и `Summary` для «Обзора»; модуль jira — страницы доски и задач; лента — страница ядра; модуль wiki (`web/src/modules/wiki/`) — страница «Вики» (`/app/wiki?project=<метка>`).
|
||||
- Подробная карта модулей и границ — `ARCHITECTURE.md`.
|
||||
|
||||
## Команды
|
||||
@ -26,7 +26,7 @@ SDLC-хаб с модулями jira и wiki. Мост Jira для АРМ КА:
|
||||
- `uv sync` — установка окружения.
|
||||
- `uv run sdlc-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов.
|
||||
- `uv run sdlc-hub serve [--host] [--port]` — локальный запуск хаба.
|
||||
- Веб-приложение — `http://127.0.0.1:8790/app/` («Обзор» — `/app/?project=<метка>`, «Связи» (доска связей) — `/app/jira/links?project=<метка>`, задачи — `/app/jira/issues?project=<метка>`, «Бэклог» — `/app/jira/backlog?project=<метка>`, «Спринт» — `/app/jira/sprint?project=<метка>`, «Kanban» — `/app/jira/kanban?project=<метка>`, лента изменений — `/app/jira/feed?project=<метка>`, вики — `/app/wiki?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на «Обзор», прежние `/app/board`, `/app/jira/board`, `/app/issues`, `/app/feed` — на страницы модуля Jira; пункты меню могут зависеть от проекта (`NavItem.when`).
|
||||
- Веб-приложение — `http://127.0.0.1:8790/app/` («Обзор» — `/app/?project=<метка>`, «Связи» (доска связей) — `/app/jira/links?project=<метка>`, задачи — `/app/jira/issues?project=<метка>`, «Бэклог» — `/app/jira/backlog?project=<метка>`, «Спринт» — `/app/jira/sprint?project=<метка>`, «Kanban» — `/app/jira/kanban?project=<метка>`, лента изменений — `/app/feed?project=<метка>` (источники Jira и Вики), вики — `/app/wiki?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на «Обзор», прежние `/app/board`, `/app/jira/board`, `/app/issues` — на страницы модуля Jira, `/app/jira/feed` — на `/app/feed`; пункты меню могут зависеть от проекта (`NavItem.when`).
|
||||
- `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 sdlc-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run sdlc-hub mcp <модуль> [--project <метка>]` — MCP модуля по stdio (модуль обязателен: `jira` — сервер `sdlc-jira`, `wiki` — сервер `sdlc-wiki`; с привязкой сессии к проекту); `uv run sdlc-hub tokens` — показать токены (клиентский и по агенту на модуль).
|
||||
- `uv run sdlc-hub agent publish [--module jira|wiki] PATH` — опубликовать версию агента модуля для самообновления (только вверх; по умолчанию `jira`; публикация лежит в `release/<модуль>/`).
|
||||
|
||||
@ -258,8 +258,8 @@ curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8790/issues?sprint=
|
||||
|
||||
## Веб-приложение
|
||||
|
||||
Адреса: `http://127.0.0.1:8790/app/` — «Обзор» `/app/?project=<метка>` (карточки модулей Jira и «Вики» с состоянием агента, свежестью данных и обновлением, последние пакеты проекта), «Связи» (доска связей) `/app/jira/links?project=<метка>`, задачи `/app/jira/issues?project=<метка>`, лента изменений `/app/jira/feed?project=<метка>` (период и виды событий, история — и в карточке задачи), вики `/app/wiki?project=<метка>`, карточка `/app/jira/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и
|
||||
`/links` перенаправляют на «Обзор»; прежние адреса `/app/board`, `/app/jira/board`, `/app/issues`, `/app/feed` ведут на страницы модуля Jira; пункты меню могут зависеть от проекта (`NavItem.when`). Вход по клиентскому токену (`uv run sdlc-hub tokens`), токен хранится в браузере;
|
||||
Адреса: `http://127.0.0.1:8790/app/` — «Обзор» `/app/?project=<метка>` (карточки модулей Jira и «Вики» с состоянием агента, свежестью данных и обновлением, последние пакеты проекта), «Связи» (доска связей) `/app/jira/links?project=<метка>`, задачи `/app/jira/issues?project=<метка>`, лента изменений `/app/feed?project=<метка>` (источники Jira и Вики, период и виды событий Jira, история — и в карточке задачи), вики `/app/wiki?project=<метка>`, карточка `/app/jira/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и
|
||||
`/links` перенаправляют на «Обзор»; прежние адреса `/app/board`, `/app/jira/board`, `/app/issues` ведут на страницы модуля Jira, `/app/jira/feed` — на `/app/feed`; пункты меню могут зависеть от проекта (`NavItem.when`). Вход по клиентскому токену (`uv run sdlc-hub tokens`), токен хранится в браузере;
|
||||
`localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои. Выкатка на Mac mini:
|
||||
|
||||
```
|
||||
|
||||
@ -18,7 +18,7 @@ async function seedHistory() {
|
||||
|
||||
test("лента: событие за сегодня, карточка окном поверх ленты, история в карточке", async ({ page }) => {
|
||||
await seedHistory();
|
||||
await login(page, "/app/jira/feed?project=AWPCA");
|
||||
await login(page, "/app/feed?project=AWPCA");
|
||||
const group = page.getByTestId("feed-group").filter({ has: page.getByRole("link", { name: "AURORA-9002", exact: true }) });
|
||||
await expect(group.getByText("статус «В работе» → «Готово»").first()).toBeVisible();
|
||||
const other = page.getByRole("checkbox", { name: "Прочие поля" });
|
||||
@ -39,7 +39,7 @@ test("лента: событие за сегодня, карточка окно
|
||||
test("лента на 390 px: нет горизонтальной прокрутки при длинных значениях", async ({ page }) => {
|
||||
await seedHistory();
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await login(page, "/app/jira/feed?project=AWPCA&kinds=status,other");
|
||||
await login(page, "/app/feed?project=AWPCA&kinds=status,other");
|
||||
await expect(page.getByTestId("feed-group").first()).toBeVisible();
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
|
||||
@ -24,7 +24,7 @@ function resolve(from: string, spec: string): string {
|
||||
}
|
||||
|
||||
// имена полей и типов модулей, которых в ядре быть не должно (типизирует сам модуль)
|
||||
const MODULE_TERMS = /\b(fact_status|closed_statuses|kind_labels|link_types|BoardIssue|IssueRow|jira_synced_at|wiki_synced_at|wiki_sync_error|wiki_root|board_id|board_type|issues_count)\b/;
|
||||
const MODULE_TERMS = /\b(fact_status|closed_statuses|kind_labels|link_types|BoardIssue|IssueRow|jira_synced_at|wiki_synced_at|wiki_sync_error|wiki_root|board_id|board_type|issues_count|ChangeKind|kinds|history_at|page_code|prev_version)\b/;
|
||||
|
||||
// классы, которые «определяет» CSS-текст: только селекторы; значения свойств, атрибуты и :not(...) не считаются
|
||||
const classesOf = (css: string): Set<string> => {
|
||||
@ -106,6 +106,7 @@ describe("детекторы границы", () => {
|
||||
expect(MODULE_TERMS.test("p.wiki_root")).toBe(true);
|
||||
expect(MODULE_TERMS.test("board_type")).toBe(true);
|
||||
expect(MODULE_TERMS.test("fact_status")).toBe(true);
|
||||
expect(MODULE_TERMS.test("kinds: string")).toBe(true);
|
||||
expect(MODULE_TERMS.test("метка задач в Jira")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { jiraModule } from "./jira/index.tsx";
|
||||
import { wikiModule } from "./wiki/index.tsx";
|
||||
import type { HubModule, Overlay } from "./types.ts";
|
||||
import type { FeedResult, FeedSourceInput, HubModule, Overlay } from "./types.ts";
|
||||
|
||||
// список модулей — константа: хуки модулей вызываются в постоянном порядке на каждой отрисовке
|
||||
export const MODULES: readonly HubModule[] = [jiraModule, wikiModule];
|
||||
@ -11,3 +11,12 @@ const OVERLAY_HOOKS = MODULES.flatMap((m) => m.useOverlay ?? []);
|
||||
export function useOverlays(): Overlay[] {
|
||||
return OVERLAY_HOOKS.map((use) => use());
|
||||
}
|
||||
|
||||
// модули с источником ленты — в порядке MODULES
|
||||
export const FEED_MODULES: readonly HubModule[] = MODULES.filter((m) => m.feed);
|
||||
const FEED_HOOKS = FEED_MODULES.map((m) => m.feed!.useFeed);
|
||||
|
||||
// результаты источников ленты; inputs — по одному на модуль из FEED_MODULES, число и порядок не меняются
|
||||
export function useFeeds(inputs: FeedSourceInput[]): FeedResult[] {
|
||||
return FEED_HOOKS.map((use, i) => use(inputs[i]));
|
||||
}
|
||||
|
||||
@ -26,7 +26,7 @@ export function Summary({ project }: { project: Project }) {
|
||||
<nav className="jira-summary__links" aria-label="Разделы Jira">
|
||||
<Link to={`/jira/links${q}`}>Связи</Link>
|
||||
<Link to={`/jira/issues${q}`}>Задачи</Link>
|
||||
<Link to={`/jira/feed${q}`}>Лента</Link>
|
||||
<Link to={`/feed${q}&sources=jira`}>Лента</Link>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -66,18 +66,18 @@ const links = () => within(jiraGroup()).getAllByRole("link").map((l) => l.textCo
|
||||
describe("«Бэклог»", () => {
|
||||
beforeEach(() => { setToken("tok"); localStorage.clear(); });
|
||||
|
||||
it("меню scrum-проекта: «Связи», «Бэклог», «Спринт», «Задачи», «Лента»", async () => {
|
||||
it("меню scrum-проекта: «Связи», «Бэклог», «Спринт», «Задачи»", async () => {
|
||||
hub();
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
await within(jiraGroup()).findByRole("link", { name: "Бэклог" });
|
||||
expect(links()).toEqual(["Связи", "Бэклог", "Спринт", "Задачи", "Лента"]);
|
||||
expect(links()).toEqual(["Связи", "Бэклог", "Спринт", "Задачи"]);
|
||||
});
|
||||
|
||||
it("проект без доски: пунктов нет, «У проекта нет scrum-доски» со ссылкой «Связи», запроса /agile нет", async () => {
|
||||
const calls = hub();
|
||||
open("/app/jira/backlog?project=STOCKS");
|
||||
expect(await screen.findByText("У проекта нет scrum-доски")).toBeInTheDocument();
|
||||
expect(links()).toEqual(["Связи", "Задачи", "Лента"]);
|
||||
expect(links()).toEqual(["Связи", "Задачи"]);
|
||||
const inPage = within(screen.getByRole("main"));
|
||||
expect(inPage.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links?project=STOCKS");
|
||||
expect(calls).toHaveLength(0);
|
||||
@ -87,7 +87,7 @@ describe("«Бэклог»", () => {
|
||||
hub({ projects: "hang" });
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
await screen.findByRole("group", { name: "Jira" });
|
||||
expect(links()).toEqual(["Связи", "Задачи", "Лента"]);
|
||||
expect(links()).toEqual(["Связи", "Задачи"]);
|
||||
});
|
||||
|
||||
it("секции: активный, будущий, «Бэклог», «Прошлые спринты (1)»; заголовок спринта с суммами", async () => {
|
||||
|
||||
@ -54,12 +54,12 @@ const colNames = (p: HTMLElement) => Array.from(p.querySelectorAll(".agile-col__
|
||||
describe("«Kanban»", () => {
|
||||
beforeEach(() => { setToken("tok"); localStorage.clear(); });
|
||||
|
||||
it("меню kanban-проекта: «Связи», «Kanban», «Задачи», «Лента»", async () => {
|
||||
it("меню kanban-проекта: «Связи», «Kanban», «Задачи»", async () => {
|
||||
hub(agile(false));
|
||||
open("/app/jira/links?project=KAN");
|
||||
const g = () => screen.getByRole("group", { name: "Jira" });
|
||||
await within(g()).findByRole("link", { name: "Kanban" });
|
||||
expect(within(g()).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Kanban", "Задачи", "Лента"]);
|
||||
expect(within(g()).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Kanban", "Задачи"]);
|
||||
});
|
||||
|
||||
it("колонки из board_issues, у последней — «за 14 дней», «Без колонки» при статусе вне доски", async () => {
|
||||
|
||||
@ -61,11 +61,11 @@ const colNames = (p: HTMLElement) => Array.from(p.querySelectorAll(".agile-col__
|
||||
describe("«Спринт»", () => {
|
||||
beforeEach(() => { setToken("tok"); localStorage.clear(); });
|
||||
|
||||
it("меню scrum-проекта: «Связи», «Бэклог», «Спринт», «Задачи», «Лента»; «Kanban» нет", async () => {
|
||||
it("меню scrum-проекта: «Связи», «Бэклог», «Спринт», «Задачи»; «Kanban» нет", async () => {
|
||||
hub(agile());
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
await within(jiraGroup()).findByRole("link", { name: "Спринт" });
|
||||
expect(within(jiraGroup()).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Бэклог", "Спринт", "Задачи", "Лента"]);
|
||||
expect(within(jiraGroup()).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Бэклог", "Спринт", "Задачи"]);
|
||||
});
|
||||
|
||||
it("колонки доски с числом задач и «Без колонки»", async () => {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { ChangeEvent } from "../api/types.ts";
|
||||
import { eventText, eventTime } from "./events.ts";
|
||||
import { eventTime } from "../../../ui/format.ts";
|
||||
import { eventText } from "./events.ts";
|
||||
import "./feed.css";
|
||||
|
||||
// строки событий журнала: время, автор, что изменилось (лента и история в карточке; spec web-app «Лента изменений»)
|
||||
|
||||
19
web/src/modules/jira/feed/FeedGroup.tsx
Normal file
19
web/src/modules/jira/feed/FeedGroup.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { Badge, Panel } from "../../../ui/index.ts";
|
||||
import { IssueLink } from "../issues/IssueLink.tsx";
|
||||
import { EventList } from "./EventList.tsx";
|
||||
import type { IssueGroup } from "./events.ts";
|
||||
import "./feed.css";
|
||||
|
||||
// группа ленты — одна задача: ключ ссылкой на карточку, тема, статус и события по времени
|
||||
export function FeedGroup({ group, now }: { group: IssueGroup; now: Date }) {
|
||||
return (
|
||||
<Panel className="feed-group" data-testid="feed-group">
|
||||
<header className="feed-group__head">
|
||||
<IssueLink issueKey={group.key} className="feed-group__key" />
|
||||
<span className="feed-group__sum">{group.summary ?? "нет в зеркале"}</span>
|
||||
{group.status && <Badge>{group.status}</Badge>}
|
||||
</header>
|
||||
<EventList events={group.events} now={now} />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@ -1,79 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useChanges } from "../api/queries.ts";
|
||||
import type { ChangeKind } from "../api/types.ts";
|
||||
import { IssueLink } from "../issues/IssueLink.tsx";
|
||||
import { Badge, Button, Checkbox, Notice, PageHeader, Panel, Segmented } from "../../../ui/index.ts";
|
||||
import { RefreshButton } from "../RefreshButton.tsx";
|
||||
import { EventList } from "./EventList.tsx";
|
||||
import { groupByIssue, KINDS, kindsParam, parseKinds, parsePeriod, PERIODS, periodRange, type Period } from "./events.ts";
|
||||
import "./feed.css";
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const stamp = (d: Date) => isNaN(d.getTime()) ? "—" : `${pad(d.getDate())}.${pad(d.getMonth() + 1)} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
|
||||
function plural(n: number): string {
|
||||
const m10 = n % 10, m100 = n % 100;
|
||||
if (m10 === 1 && m100 !== 11) return "событие";
|
||||
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return "события";
|
||||
return "событий";
|
||||
}
|
||||
|
||||
// лента изменений проекта за период (spec web-app «Лента изменений», design web-feed 1–7)
|
||||
export function FeedPage({ project }: { project: string }) {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const period = parsePeriod(params.get("period"));
|
||||
const kinds = useMemo(() => parseKinds(params.get("kinds")), [params]);
|
||||
// границы считает запрос в момент выполнения (C-1): ключ кэша — проект, период и виды
|
||||
const changes = useChanges({ project, period, kinds: kinds.join(",") }, () => periodRange(period, new Date()), kinds.length > 0);
|
||||
const now = new Date(changes.dataUpdatedAt || Date.now()); // «текущие сутки» для времени событий — на момент ответа
|
||||
|
||||
const setParam = (name: string, value: string | null) => setParams((p) => {
|
||||
const n = new URLSearchParams(p);
|
||||
if (value) n.set(name, value); else n.delete(name);
|
||||
return n;
|
||||
});
|
||||
const choosePeriod = (p: Period) => setParam("period", p === "today" ? null : p);
|
||||
const toggleKind = (k: ChangeKind, on: boolean) => {
|
||||
const next = on ? [...kinds, k] : kinds.filter((x) => x !== k);
|
||||
setParam("kinds", next.length ? kindsParam(next) : "none"); // пустой набор — явное «none», иначе адрес вернул бы умолчание
|
||||
};
|
||||
|
||||
const data = changes.data;
|
||||
const groups = useMemo(() => groupByIssue(data?.changes ?? []), [data]);
|
||||
const count = data?.changes.length ?? 0;
|
||||
// окно — из ответа хаба: подзаголовок совпадает с тем, что показано
|
||||
const subtitle = kinds.length && data ? `${count} ${plural(count)} · ${stamp(new Date(data.since))} — ${stamp(new Date(data.until))}` : undefined;
|
||||
|
||||
return (
|
||||
<div data-testid="page-feed" className="feed">
|
||||
<PageHeader title="Лента" subtitle={subtitle} actions={<RefreshButton />} />
|
||||
<div className="feed__controls">
|
||||
<Segmented label="Период" value={period} options={PERIODS} onChange={choosePeriod} />
|
||||
<div className="feed__kinds" role="group" aria-label="Виды событий">
|
||||
{KINDS.map((k) => (
|
||||
<Checkbox key={k.value} label={k.label} checked={kinds.includes(k.value)}
|
||||
onChange={(e) => toggleKind(k.value, e.currentTarget.checked)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!kinds.length && <Notice tone="info">выберите хотя бы один вид</Notice>}
|
||||
{kinds.length > 0 && changes.isError && (
|
||||
<Notice tone="error" action={<Button onClick={() => void changes.refetch()}>Повторить</Button>}>{(changes.error as Error).message}</Notice>
|
||||
)}
|
||||
{kinds.length > 0 && changes.isPending && <p className="feed__hint">Загрузка…</p>}
|
||||
{data && !count && <Notice tone="info">за период изменений нет</Notice>}
|
||||
{data?.truncated && <Notice tone="warning">{`показаны последние ${count} событий — выберите период короче`}</Notice>}
|
||||
{groups.map((g) => (
|
||||
<Panel key={g.key} className="feed-group" data-testid="feed-group">
|
||||
<header className="feed-group__head">
|
||||
<IssueLink issueKey={g.key} className="feed-group__key" />
|
||||
<span className="feed-group__sum">{g.summary ?? "нет в зеркале"}</span>
|
||||
{g.status && <Badge>{g.status}</Badge>}
|
||||
</header>
|
||||
<EventList events={g.events} now={now} />
|
||||
</Panel>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,28 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChangeEvent } from "../api/types.ts";
|
||||
import { DEFAULT_KINDS, eventText, eventTime, groupByIssue, kindsParam, parseKinds, parsePeriod, periodRange } from "./events.ts";
|
||||
import { DEFAULT_KINDS, eventText, groupByIssue, kindsParam, parseKinds } from "./events.ts";
|
||||
|
||||
const NOW = new Date(2026, 9, 22, 14, 5);
|
||||
const ev = (over: Partial<ChangeEvent> = {}): ChangeEvent => ({
|
||||
at: "2026-10-22T08:15:00Z", key: "X", summary: "Тема", status: "Готово", kind: "status", field: "status",
|
||||
author: "Петров", from: "В работе", to: "Готово", from_value: null, to_value: null, ...over,
|
||||
});
|
||||
|
||||
describe("период", () => {
|
||||
it("границы по местному времени", () => {
|
||||
expect(periodRange("today", NOW)).toEqual({ since: new Date(2026, 9, 22, 0, 0), until: NOW });
|
||||
expect(periodRange("yesterday", NOW)).toEqual({ since: new Date(2026, 9, 21, 0, 0), until: new Date(2026, 9, 22, 0, 0) });
|
||||
expect(periodRange("2h", NOW)).toEqual({ since: new Date(2026, 9, 22, 12, 5), until: NOW });
|
||||
expect(periodRange("7d", NOW)).toEqual({ since: new Date(2026, 9, 15, 14, 5), until: NOW });
|
||||
});
|
||||
|
||||
it("мусор в адресе — «Сегодня»", () => {
|
||||
expect(parsePeriod("foo")).toBe("today");
|
||||
expect(parsePeriod(null)).toBe("today");
|
||||
expect(parsePeriod("7d")).toBe("7d");
|
||||
});
|
||||
});
|
||||
|
||||
describe("виды", () => {
|
||||
it("неизвестные отброшены, пусто — по умолчанию", () => {
|
||||
expect(parseKinds("status,foo")).toEqual(["status"]);
|
||||
@ -77,16 +61,6 @@ describe("текст события", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("время события", () => {
|
||||
it("сегодня — ЧЧ:ММ, иначе с датой, без времени — прочерк", () => {
|
||||
expect(eventTime(new Date(2026, 9, 22, 11, 15).toISOString(), NOW)).toBe("11:15");
|
||||
expect(eventTime(new Date(2026, 9, 21, 9, 5).toISOString(), NOW)).toBe("21.10 09:05");
|
||||
expect(eventTime(new Date(2026, 9, 22, 11, 15).toISOString(), NOW, true)).toBe("22.10 11:15");
|
||||
expect(eventTime(null, NOW)).toBe("—");
|
||||
expect(eventTime("мусор", NOW)).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("группировка", () => {
|
||||
it("группы в порядке первого появления, у группы тема и статус", () => {
|
||||
const g = groupByIssue([ev({ key: "Y", summary: "Вторая", status: null }), ev(), ev({ key: "Y", summary: "Вторая", status: null })]);
|
||||
|
||||
@ -1,23 +1,6 @@
|
||||
import type { ChangeEvent, ChangeKind } from "../api/types.ts";
|
||||
import { wikiPlain } from "../issues/wiki/plain.ts";
|
||||
|
||||
// период ленты: границы по местному времени браузера; «Сегодня» — по умолчанию и в адрес не пишется (spec web-app «Лента изменений»)
|
||||
export type Period = "2h" | "today" | "yesterday" | "7d";
|
||||
export const PERIODS: { value: Period; label: string }[] = [
|
||||
{ value: "2h", label: "2 часа" }, { value: "today", label: "Сегодня" }, { value: "yesterday", label: "Вчера" }, { value: "7d", label: "7 дней" },
|
||||
];
|
||||
export const parsePeriod = (v: string | null): Period => (PERIODS.some((p) => p.value === v) ? (v as Period) : "today");
|
||||
|
||||
export function periodRange(p: Period, now: Date): { since: Date; until: Date } {
|
||||
const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
switch (p) {
|
||||
case "2h": return { since: new Date(now.getTime() - 2 * 3600_000), until: now };
|
||||
case "yesterday": return { since: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1), until: midnight };
|
||||
case "7d": return { since: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7, now.getHours(), now.getMinutes(), now.getSeconds(), now.getMilliseconds()), until: now };
|
||||
default: return { since: midnight, until: now };
|
||||
}
|
||||
}
|
||||
|
||||
export const KINDS: { value: ChangeKind; label: string }[] = [
|
||||
{ value: "status", label: "Статусы" }, { value: "description", label: "Описание" }, { value: "dates", label: "Сроки" },
|
||||
{ value: "comment", label: "Комментарии" }, { value: "other", label: "Прочие поля" },
|
||||
@ -38,17 +21,6 @@ export function kindsParam(kinds: ChangeKind[]): string | null {
|
||||
return ordered.join(",") === DEFAULT_KINDS.join(",") ? null : ordered.join(",");
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
// «ЧЧ:ММ» для событий текущих суток, иначе «ДД.ММ ЧЧ:ММ»; без времени — «—»
|
||||
export function eventTime(at: string | null, now: Date, alwaysDate = false): string {
|
||||
const d = at ? new Date(at) : null;
|
||||
if (!d || isNaN(d.getTime())) return "—";
|
||||
const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
const sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
|
||||
return sameDay && !alwaysDate ? hm : `${pad(d.getDate())}.${pad(d.getMonth() + 1)} ${hm}`;
|
||||
}
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
assignee: "исполнитель", Sprint: "спринт", labels: "метки", priority: "приоритет", summary: "тема", epic_link: "эпик",
|
||||
Link: "связь", Attachment: "вложение", resolution: "резолюция",
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
/* лента изменений (spec web-app «Лента изменений»): только токены дизайн-системы */
|
||||
.feed{display:flex;flex-direction:column;gap:var(--space-4);padding-top:var(--space-6)}
|
||||
.feed .ui-page-header{margin-bottom:0}
|
||||
.feed__controls{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-3) var(--space-5)}
|
||||
/* источник Jira в ленте (spec web-app «Лента изменений»): только токены дизайн-системы */
|
||||
.feed__kinds{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2) var(--space-4)}
|
||||
.feed__hint{margin:0;color:var(--color-text-secondary)}
|
||||
.feed-group{display:flex;flex-direction:column;gap:var(--space-3);min-width:0}
|
||||
.feed-group__head{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2)}
|
||||
.feed-group__key{font-family:var(--font-mono);font-size:13px;color:var(--color-action-primary);text-decoration:none}
|
||||
|
||||
59
web/src/modules/jira/feed/source.tsx
Normal file
59
web/src/modules/jira/feed/source.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { useMemo } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { Checkbox } from "../../../ui/index.ts";
|
||||
import type { FeedResult, FeedSource, FeedSourceInput } from "../../types.ts";
|
||||
import { useChanges } from "../api/queries.ts";
|
||||
import type { ChangeKind } from "../api/types.ts";
|
||||
import { groupByIssue, KINDS, kindsParam, parseKinds } from "./events.ts";
|
||||
import { FeedGroup } from "./FeedGroup.tsx";
|
||||
import "./feed.css";
|
||||
|
||||
// виды событий — настройка источника, хранится в адресе (`kinds=`); пустой набор — явное «none», иначе адрес вернул бы умолчание
|
||||
function Kinds() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const kinds = useMemo(() => parseKinds(params.get("kinds")), [params]);
|
||||
const toggle = (k: ChangeKind, on: boolean) => {
|
||||
const next = on ? [...kinds, k] : kinds.filter((x) => x !== k);
|
||||
const value = next.length ? kindsParam(next) : "none";
|
||||
setParams((p) => {
|
||||
const n = new URLSearchParams(p);
|
||||
if (value) n.set("kinds", value); else n.delete("kinds");
|
||||
return n;
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className="feed__kinds" role="group" aria-label="Виды событий">
|
||||
{KINDS.map((k) => (
|
||||
<Checkbox key={k.value} label={k.label} checked={kinds.includes(k.value)}
|
||||
onChange={(e) => toggle(k.value, e.currentTarget.checked)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY: FeedResult = { groups: [], count: 0, window: null, pending: false, error: null, truncated: false, notice: null };
|
||||
|
||||
function useJiraFeed(input: FeedSourceInput): FeedResult {
|
||||
const [params] = useSearchParams();
|
||||
const kinds = useMemo(() => parseKinds(params.get("kinds")), [params]);
|
||||
// границы считает запрос в момент выполнения (C-1): ключ кэша — проект, период и виды
|
||||
const q = useChanges({ project: input.project.label, period: input.period, kinds: kinds.join(",") }, input.bounds,
|
||||
input.enabled && kinds.length > 0);
|
||||
const data = input.enabled && kinds.length > 0 ? q.data : undefined;
|
||||
const updatedAt = q.dataUpdatedAt;
|
||||
const groups = useMemo(() => {
|
||||
const now = new Date(updatedAt || Date.now()); // «текущие сутки» для времени событий — на момент ответа
|
||||
return groupByIssue(data?.changes ?? []).map((g) => ({
|
||||
key: g.key, at: g.events[0]?.at ?? "", node: <FeedGroup group={g} now={now} />,
|
||||
}));
|
||||
}, [data, updatedAt]);
|
||||
if (!input.enabled) return EMPTY;
|
||||
if (!kinds.length) return { ...EMPTY, notice: "выберите хотя бы один вид" };
|
||||
return {
|
||||
groups, count: data?.changes.length ?? 0, window: data ? { since: data.since, until: data.until } : null,
|
||||
pending: q.isPending, truncated: !!data?.truncated,
|
||||
error: q.isError ? { message: (q.error as Error).message, retry: () => void q.refetch() } : null, notice: null,
|
||||
};
|
||||
}
|
||||
|
||||
export const jiraFeed: FeedSource = { available: () => true, useFeed: useJiraFeed, Controls: Kinds };
|
||||
@ -1,12 +1,12 @@
|
||||
import { Navigate, useLocation, useParams } from "react-router";
|
||||
import { ProjectGate } from "../../projects/ProjectGate.tsx";
|
||||
import { IconBacklog, IconBoard, IconHistory, IconKanban, IconList, IconSprint } from "../../ui/index.ts";
|
||||
import { IconBacklog, IconBoard, IconKanban, IconList, IconSprint } from "../../ui/index.ts";
|
||||
import type { HubModule } from "../types.ts";
|
||||
import { BacklogPage } from "./agile/BacklogPage.tsx";
|
||||
import { KanbanPage } from "./agile/KanbanPage.tsx";
|
||||
import { SprintPage } from "./agile/SprintPage.tsx";
|
||||
import { BoardPage } from "./board/BoardPage.tsx";
|
||||
import { FeedPage } from "./feed/FeedPage.tsx";
|
||||
import { jiraFeed } from "./feed/source.tsx";
|
||||
import { useIssueOverlay } from "./issues/overlay.tsx";
|
||||
import { IssuesPage } from "./issues/IssuesPage.tsx";
|
||||
import { boardType } from "./projectFields.ts";
|
||||
@ -30,13 +30,12 @@ export const jiraModule: HubModule = {
|
||||
{ path: "/jira/sprint", element: <ProjectGate>{(p) => <SprintPage key={p.label} project={p} />}</ProjectGate> },
|
||||
{ path: "/jira/kanban", element: <ProjectGate>{(p) => <KanbanPage key={p.label} project={p} />}</ProjectGate> },
|
||||
{ path: "/jira/issues", element: <ProjectGate>{(p) => <IssuesPage key={p.label} project={p.label} />}</ProjectGate> },
|
||||
{ path: "/jira/feed", element: <ProjectGate>{(p) => <FeedPage key={p.label} project={p.label} />}</ProjectGate> },
|
||||
// прежние адреса (закладки): перенаправление с сохранением строки запроса, без новой записи истории
|
||||
{ path: "/jira/board", element: <LegacyRedirect to="/jira/links" /> },
|
||||
{ path: "/board", element: <LegacyRedirect to="/jira/links" /> },
|
||||
{ path: "/issues", element: <LegacyRedirect to="/jira/issues" /> },
|
||||
{ path: "/issues/:key", element: <LegacyRedirect to="/jira/issues" keyParam="key" /> },
|
||||
{ path: "/feed", element: <LegacyRedirect to="/jira/feed" /> },
|
||||
{ path: "/jira/feed", element: <LegacyRedirect to="/feed" /> }, // лента — страница ядра
|
||||
],
|
||||
nav: [
|
||||
{ to: "/jira/links", label: "Связи", icon: <IconBoard /> },
|
||||
@ -44,10 +43,10 @@ export const jiraModule: HubModule = {
|
||||
{ to: "/jira/sprint", label: "Спринт", icon: <IconSprint />, when: (p) => boardType(p) === "scrum" },
|
||||
{ to: "/jira/kanban", label: "Kanban", icon: <IconKanban />, when: (p) => boardType(p) === "kanban" },
|
||||
{ to: "/jira/issues", label: "Задачи", icon: <IconList />, padded: true },
|
||||
{ to: "/jira/feed", label: "Лента", icon: <IconHistory />, padded: true, keepSearch: true },
|
||||
],
|
||||
status: jiraStatus,
|
||||
Summary,
|
||||
useOverlay: useIssueOverlay,
|
||||
feed: jiraFeed,
|
||||
projectFields: [{ name: "board_id", label: "Номер доски Jira", hint: "scrum или kanban определится сам", kind: "int" }],
|
||||
};
|
||||
|
||||
@ -6,12 +6,12 @@ import { MODULES } from "./index.ts";
|
||||
|
||||
describe("сборка оболочки из модулей", () => {
|
||||
it("страницы: «Обзор», адреса jira, перенаправления, вики, NotFound последним", () => {
|
||||
expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/jira/links", "/jira/backlog", "/jira/sprint", "/jira/kanban", "/jira/issues", "/jira/feed", "/jira/board", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]);
|
||||
expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/feed", "/jira/links", "/jira/backlog", "/jira/sprint", "/jira/kanban", "/jira/issues", "/jira/board", "/board", "/issues", "/issues/:key", "/jira/feed", "/wiki", "*"]);
|
||||
});
|
||||
|
||||
it("пункты панели: подписи прежние, адреса jira с префиксом, плюс вики", () => {
|
||||
expect(MODULES.flatMap((m) => m.nav).map((n) => [n.label, n.to])).toEqual([
|
||||
["Связи", "/jira/links"], ["Бэклог", "/jira/backlog"], ["Спринт", "/jira/sprint"], ["Kanban", "/jira/kanban"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"],
|
||||
["Связи", "/jira/links"], ["Бэклог", "/jira/backlog"], ["Спринт", "/jira/sprint"], ["Kanban", "/jira/kanban"], ["Задачи", "/jira/issues"], ["Вики", "/wiki"],
|
||||
]);
|
||||
});
|
||||
|
||||
@ -42,7 +42,7 @@ describe("NotFound при повторе адреса", () => {
|
||||
const { NotFound: NF } = await import("../shell/NotFound.tsx");
|
||||
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
render(<MemoryRouter><NF /></MemoryRouter>);
|
||||
expect(screen.getAllByRole("link")).toHaveLength(3); // «Обзор» + два раздела
|
||||
expect(screen.getAllByRole("link")).toHaveLength(4); // «Обзор», «Лента» + два раздела
|
||||
expect(err.mock.calls.flat().join(" ")).not.toMatch(/same key/);
|
||||
err.mockRestore();
|
||||
vi.doUnmock("../modules/index.ts");
|
||||
|
||||
@ -29,6 +29,31 @@ export type ModuleStatus = {
|
||||
invalidate(qc: QueryClient, project: string | null): void; // что перечитать после пакета модуля
|
||||
};
|
||||
|
||||
// источник общей ленты (страница ядра «Лента», design wiki-history): ядро ведёт период, выбор источников и порядок групп,
|
||||
// модуль — запросы, группы и свои настройки над лентой
|
||||
export type FeedBounds = { since: Date; until: Date };
|
||||
export type FeedSourceInput = {
|
||||
project: Project;
|
||||
period: string; // ключ кэша: "2h" | "today" | "yesterday" | "7d"
|
||||
bounds: () => FeedBounds; // границы на момент запроса (перечитывание берёт «до сейчас»)
|
||||
enabled: boolean; // источник выбран и доступен; false — запросов нет
|
||||
};
|
||||
export type FeedGroup = { key: string; at: string; node: ReactNode }; // at — ISO события группы, по которому группы упорядочены
|
||||
export type FeedResult = {
|
||||
groups: FeedGroup[];
|
||||
count: number; // событий в ответе — для подзаголовка
|
||||
window: { since: string; until: string } | null; // границы из ответа хаба; null — данных нет
|
||||
pending: boolean;
|
||||
error: { message: string; retry(): void } | null;
|
||||
truncated: boolean;
|
||||
notice: string | null; // своё сообщение на месте событий источника (тогда groups пусты)
|
||||
};
|
||||
export type FeedSource = {
|
||||
available(p: Project): boolean;
|
||||
useFeed(input: FeedSourceInput): FeedResult; // хук: ядро вызывает его у всех модулей с feed в постоянном порядке
|
||||
Controls?: ComponentType; // свои настройки над лентой; видны, пока источник выбран
|
||||
};
|
||||
|
||||
// описание веб-модуля (design hub-modules 10): оболочка берёт из него страницы, пункты панели, состояние данных, окна
|
||||
export type HubModule = {
|
||||
name: string;
|
||||
@ -39,5 +64,6 @@ export type HubModule = {
|
||||
status?: ModuleStatus;
|
||||
Summary?: ComponentType<{ project: Project }>; // карточка модуля на «Обзоре»
|
||||
useOverlay?: () => Overlay; // вызывается оболочкой в постоянном порядке модулей
|
||||
feed?: FeedSource; // источник ленты; id в адресе (`sources=`) — name, подпись флажка — title
|
||||
projectFields?: ProjectFieldDef[]; // поля проекта модуля: окно создания собирает их у всех модулей
|
||||
};
|
||||
|
||||
@ -75,7 +75,7 @@ describe("страница «Обзор»", () => {
|
||||
expect(await jira.findByText((_, el) => el?.classList.contains("jira-summary__counts") === true && el.textContent === "4 задач · 3 открытых · 1 просрочено")).toBeInTheDocument();
|
||||
expect(jira.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links?project=AWPCA");
|
||||
expect(jira.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/jira/issues?project=AWPCA");
|
||||
expect(jira.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/jira/feed?project=AWPCA");
|
||||
expect(jira.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/feed?project=AWPCA&sources=jira");
|
||||
});
|
||||
|
||||
it("overview card without root: объяснение и «Привязать корень вики»", async () => {
|
||||
|
||||
@ -54,10 +54,10 @@ describe("боковая панель", () => {
|
||||
|
||||
it("раздел «Лента» ведёт на /feed с проектом и отмечается как текущий", async () => {
|
||||
hub({ "GET /changes": () => json(200, { since: "", until: "", truncated: false, changes: [] }) });
|
||||
open("/app/jira/feed?project=AWPCA");
|
||||
open("/app/feed?project=AWPCA");
|
||||
expect(await screen.findByTestId("page-feed")).toBeInTheDocument();
|
||||
const nav = within(bar()).getByRole("navigation", { name: "Разделы" });
|
||||
await waitFor(() => expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/jira/feed?project=AWPCA"));
|
||||
await waitFor(() => expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/feed?project=AWPCA"));
|
||||
expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("aria-current", "page");
|
||||
expect(within(nav).getByRole("link", { name: "Задачи" })).not.toHaveAttribute("aria-current");
|
||||
});
|
||||
@ -72,7 +72,7 @@ describe("боковая панель", () => {
|
||||
const main = within(screen.getByRole("main"));
|
||||
expect(main.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links");
|
||||
expect(main.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/jira/issues");
|
||||
expect(main.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/jira/feed"); // M-2
|
||||
expect(main.getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/feed"); // M-2
|
||||
expect(main.getByRole("link", { name: "Обзор" })).toHaveAttribute("href", expect.stringMatching(/^\/app\/?$/));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Страница не найдена");
|
||||
});
|
||||
@ -133,9 +133,9 @@ describe("боковая панель", () => {
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
const nav = within(bar()).getByRole("navigation", { name: "Разделы" });
|
||||
const links = (await within(nav).findAllByRole("link")).map((l) => l.textContent);
|
||||
expect(links).toEqual(["Обзор", "Связи", "Задачи", "Лента", "Вики"]);
|
||||
expect(links).toEqual(["Обзор", "Лента", "Связи", "Задачи", "Вики"]);
|
||||
const jira = within(nav).getByRole("group", { name: "Jira" });
|
||||
expect(within(jira).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Задачи", "Лента"]);
|
||||
expect(within(jira).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Задачи"]);
|
||||
expect(within(nav).getByRole("group", { name: "Вики" })).toBeInTheDocument();
|
||||
await waitFor(() => expect(within(nav).getByRole("link", { name: "Обзор" })).toHaveAttribute("href", expect.stringMatching(/^\/app\/?\?project=AWPCA$/)));
|
||||
});
|
||||
|
||||
@ -6,7 +6,7 @@ import type { Ref } from "react";
|
||||
import { clearToken } from "../auth/token.ts";
|
||||
import { useActivitySummary } from "./activity/activity.ts";
|
||||
import { ModuleEventStrip, ModuleStatusBlock } from "./ModuleStatusBlock.tsx";
|
||||
import { GROUPS, OVERVIEW, visibleNav } from "./nav.tsx";
|
||||
import { FEED, GROUPS, OVERVIEW, visibleNav } from "./nav.tsx";
|
||||
import { useProjectContext } from "../projects/ProjectContext.ts";
|
||||
import { withProject } from "../projects/current.ts";
|
||||
import { ProjectPicker } from "../projects/ProjectPicker.tsx";
|
||||
@ -122,6 +122,7 @@ function Panel({ section, project, drawer }: Props & { drawer?: { close(): void;
|
||||
<div className="sidebar__scroll">
|
||||
<nav className="sidebar__nav" aria-label="Разделы">
|
||||
{link(OVERVIEW, first)}
|
||||
{link(FEED)}
|
||||
{GROUPS.map((g) => (
|
||||
<div key={g.name} role="group" aria-labelledby={`sidebar-group-${g.name}`} className="sidebar__group">
|
||||
<span id={`sidebar-group-${g.name}`} className={"sidebar__group-title" + (collapsed ? " visually-hidden" : "")}>{g.title}</span>
|
||||
|
||||
@ -6,16 +6,17 @@ import { createMemoryRouter } from "react-router";
|
||||
import { focusManager } from "@tanstack/react-query";
|
||||
import { act } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { App } from "../../../App.tsx";
|
||||
import type { ChangeEvent } from "../api/types.ts";
|
||||
import { setToken } from "../../../auth/token.ts";
|
||||
import settings from "../board/__fixtures__/settings.json";
|
||||
import { routes } from "../../../routes.tsx";
|
||||
import { json, mockFetch } from "../../../test/fetchMock.ts";
|
||||
import { issue } from "../../../test/issueFixtures.ts";
|
||||
import { App } from "../../App.tsx";
|
||||
import type { ChangeEvent } from "../../modules/jira/api/types.ts";
|
||||
import { setToken } from "../../auth/token.ts";
|
||||
import settings from "../../modules/jira/board/__fixtures__/settings.json";
|
||||
import { routes } from "../../routes.tsx";
|
||||
import { json, mockFetch } from "../../test/fetchMock.ts";
|
||||
import { issue } from "../../test/issueFixtures.ts";
|
||||
|
||||
const SNAP = JSON.parse(readFileSync(join(import.meta.dirname, "..", "board", "__fixtures__", "hub-basic.board.json"), "utf8"));
|
||||
const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9 };
|
||||
const SNAP = JSON.parse(readFileSync(join(import.meta.dirname, "..", "..", "modules", "jira", "board", "__fixtures__", "hub-basic.board.json"), "utf8"));
|
||||
const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9, wiki_root: "DOC-1" };
|
||||
const NOWIKI = { label: "sfm_kedr", name: "СФМ КЭДР", board_id: null, board_type: null, created_at: "", issues_count: 2, wiki_root: null };
|
||||
const B = { label: "STOCKS", name: "Запасы", board_id: null, board_type: null, created_at: "", issues_count: 1 };
|
||||
const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000).toISOString();
|
||||
const ev = (over: Partial<ChangeEvent>): ChangeEvent => ({
|
||||
@ -26,6 +27,7 @@ const ev = (over: Partial<ChangeEvent>): ChangeEvent => ({
|
||||
type Changes = { since?: string; until?: string; truncated?: boolean; changes: ChangeEvent[] } | Response;
|
||||
function hub(changes: () => Changes = () => ({ changes: [] })) {
|
||||
const calls: URL[] = [];
|
||||
const wikiCalls: URL[] = [];
|
||||
mockFetch((method, url) => {
|
||||
const k = `${method} ${url.pathname}`;
|
||||
if (k === "GET /changes") {
|
||||
@ -33,7 +35,11 @@ function hub(changes: () => Changes = () => ({ changes: [] })) {
|
||||
const r = changes();
|
||||
return r instanceof Response ? r : json(200, { since: "", until: "", truncated: false, ...r });
|
||||
}
|
||||
if (k === "GET /projects") return json(200, { projects: [A, B] });
|
||||
if (k === "GET /wiki/changes") {
|
||||
wikiCalls.push(url);
|
||||
return json(200, { since: "", until: "", synced_at: null, history_at: null, truncated: false, changes: [] });
|
||||
}
|
||||
if (k === "GET /projects") return json(200, { projects: [A, B, NOWIKI] });
|
||||
if (k === "GET /settings") return json(200, settings);
|
||||
if (k === "GET /board") return json(200, SNAP);
|
||||
if (k === "GET /issues") return json(200, { issues: [] });
|
||||
@ -42,7 +48,7 @@ function hub(changes: () => Changes = () => ({ changes: [] })) {
|
||||
if (k.startsWith("GET /issues/")) return json(200, issue(decodeURIComponent(url.pathname.split("/").pop()!)));
|
||||
return json(404, { error: "not_found" });
|
||||
});
|
||||
return calls;
|
||||
return Object.assign(calls, { wiki: wikiCalls });
|
||||
}
|
||||
const open = (path: string) => {
|
||||
const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
|
||||
@ -57,17 +63,40 @@ describe("лента изменений", () => {
|
||||
|
||||
it("шапка: вторичная кнопка «Обновить из Jira», primary нет", async () => {
|
||||
hub();
|
||||
open("/app/jira/feed?project=AWPCA");
|
||||
open("/app/feed?project=AWPCA");
|
||||
await page();
|
||||
expect(screen.getByRole("button", { name: "Обновить из Jira" })).toHaveClass("ui-btn--secondary");
|
||||
expect(document.querySelector(".ui-btn--primary")).toBeNull();
|
||||
});
|
||||
|
||||
it.todo("шапка у проекта с вики: и «Обновить вики» (П-10) — источник вики появляется в задаче 6");
|
||||
|
||||
it("«Проект без вики»: флажков источников нет, GET /wiki/changes нет", async () => {
|
||||
const calls = hub(() => ({ changes: [ev({})] }));
|
||||
open("/app/feed?project=sfm_kedr");
|
||||
expect(await screen.findAllByTestId("feed-group")).toHaveLength(1);
|
||||
expect(screen.queryByRole("group", { name: "Источники" })).toBeNull();
|
||||
expect(screen.queryByRole("checkbox", { name: "Вики" })).toBeNull();
|
||||
expect(calls.wiki).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.todo("сняты все источники — «выберите хотя бы один источник», запросов нет, адрес sources=none — флажки источников появляются в задаче 6");
|
||||
it.todo("«Что закрылось за два часа» с источником «Вики»: sources=jira в адресе, GET /wiki/changes после снятия не было — задача 6");
|
||||
it.todo("«Ошибка одного источника»: /wiki/changes 500 — события Jira показаны, на месте вики — ошибка с «Повторить» — задача 6");
|
||||
|
||||
it("прежний /app/jira/feed → /app/feed с тем же search, replace (прежние закладки)", async () => {
|
||||
hub();
|
||||
const router = open("/app/jira/feed?project=AWPCA&period=7d");
|
||||
await page();
|
||||
expect(router.state.location.pathname + router.state.location.search).toBe("/app/feed?project=AWPCA&period=7d");
|
||||
expect(router.state.historyAction).toBe("REPLACE");
|
||||
});
|
||||
|
||||
it("C-1/C-2: возврат на вкладку перечитывает ленту с границей «до» на момент запроса", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date(2026, 9, 22, 10, 0));
|
||||
const calls = hub();
|
||||
open("/app/jira/feed?project=AWPCA");
|
||||
open("/app/feed?project=AWPCA");
|
||||
await page();
|
||||
await waitFor(() => expect(calls.length).toBe(1));
|
||||
expect(new Date(calls[0].searchParams.get("until")!).getTime()).toBe(new Date(2026, 9, 22, 10, 0).getTime());
|
||||
@ -83,7 +112,7 @@ describe("лента изменений", () => {
|
||||
ev({}), ev({ key: "AURORA-9001", summary: "Схема", status: "В работе", kind: "comment", field: "comment", from: null, to: "Готово к проверке" }),
|
||||
ev({ at: minutesAgo(40), kind: "description", field: "description" }),
|
||||
] }));
|
||||
open("/app/jira/feed?project=AWPCA");
|
||||
open("/app/feed?project=AWPCA");
|
||||
await page();
|
||||
const groups = await screen.findAllByTestId("feed-group");
|
||||
expect(groups.map((g) => within(g).getByRole("link").textContent)).toEqual(["AURORA-9002", "AURORA-9001"]);
|
||||
@ -103,12 +132,13 @@ describe("лента изменений", () => {
|
||||
|
||||
it("«Что закрылось за два часа»: период и виды в адресе и в запросе", async () => {
|
||||
const calls = hub();
|
||||
const router = open("/app/jira/feed?project=AWPCA");
|
||||
const router = open("/app/feed?project=AWPCA");
|
||||
await page();
|
||||
await userEvent.click(screen.getByRole("radio", { name: "2 часа" }));
|
||||
for (const name of ["Описание", "Сроки", "Комментарии"]) await userEvent.click(screen.getByRole("checkbox", { name }));
|
||||
await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA&period=2h&kinds=status"));
|
||||
expect(router.state.location.pathname).toBe("/app/jira/feed");
|
||||
await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA&period=2h&kinds=status")); // источник один (вики — в задаче 6): sources в адрес не пишется
|
||||
expect(router.state.location.pathname).toBe("/app/feed");
|
||||
expect(calls.wiki).toHaveLength(0);
|
||||
await waitFor(() => expect(calls.at(-1)!.searchParams.get("kinds")).toBe("status"));
|
||||
const since = new Date(calls.at(-1)!.searchParams.get("since")!).getTime();
|
||||
expect(Math.abs(Date.now() - 2 * 3600_000 - since)).toBeLessThan(60_000);
|
||||
@ -116,7 +146,7 @@ describe("лента изменений", () => {
|
||||
|
||||
it("карточка из ленты — окно поверх, закрытие возвращает ленту с тем же периодом", async () => {
|
||||
hub(() => ({ changes: [ev({})] }));
|
||||
const router = open("/app/jira/feed?project=AWPCA&period=7d");
|
||||
const router = open("/app/feed?project=AWPCA&period=7d");
|
||||
await page();
|
||||
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" }));
|
||||
const d = await screen.findByRole("dialog");
|
||||
@ -124,20 +154,20 @@ describe("лента изменений", () => {
|
||||
expect(screen.getByTestId("page-feed")).toBeInTheDocument();
|
||||
await userEvent.click(within(d).getAllByRole("button", { name: "Закрыть" })[0]);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(router.state.location.pathname + router.state.location.search).toBe("/app/jira/feed?project=AWPCA&period=7d");
|
||||
expect(router.state.location.pathname + router.state.location.search).toBe("/app/feed?project=AWPCA&period=7d");
|
||||
});
|
||||
|
||||
it("M-1: /app/jira/feed без project — проект дописывается в адрес", async () => {
|
||||
it("M-1: /app/feed без project — проект дописывается в адрес", async () => {
|
||||
hub();
|
||||
const router = open("/app/jira/feed");
|
||||
const router = open("/app/feed");
|
||||
await page();
|
||||
await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA"));
|
||||
});
|
||||
|
||||
it("M-3/M-6/M-7: подсказка truncated над группами, автор без имени — «—», группы не регионы", async () => {
|
||||
hub(() => ({ truncated: true, changes: [ev({ author: null })] }));
|
||||
open("/app/jira/feed?project=AWPCA");
|
||||
const notice = await screen.findByText("показаны последние 1 событий — выберите период короче");
|
||||
open("/app/feed?project=AWPCA");
|
||||
const notice = await screen.findByText("Jira: показаны последние 1 событий — выберите период короче");
|
||||
const group = screen.getByTestId("feed-group");
|
||||
expect(notice.compareDocumentPosition(group) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(within(group).getByText("—", { selector: ".feed-event__author" })).toBeInTheDocument();
|
||||
@ -148,13 +178,14 @@ describe("лента изменений", () => {
|
||||
let mode: "empty" | "truncated" | "error" = "empty";
|
||||
hub(() => mode === "error" ? json(500, { error: "boom", detail: "сбой" })
|
||||
: mode === "truncated" ? { truncated: true, changes: [ev({})] } : { changes: [] });
|
||||
open("/app/jira/feed?project=AWPCA");
|
||||
open("/app/feed?project=AWPCA");
|
||||
expect(await screen.findByText("за период изменений нет")).toBeInTheDocument();
|
||||
mode = "truncated";
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Вчера" }));
|
||||
expect(await screen.findByText("показаны последние 1 событий — выберите период короче")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Jira: показаны последние 1 событий — выберите период короче")).toBeInTheDocument();
|
||||
mode = "error";
|
||||
await userEvent.click(screen.getByRole("radio", { name: "7 дней" }));
|
||||
expect(await screen.findByText("Jira: хаб ответил 500")).toBeInTheDocument();
|
||||
const retry = await screen.findByRole("button", { name: "Повторить" });
|
||||
mode = "empty";
|
||||
await userEvent.click(retry);
|
||||
@ -163,7 +194,7 @@ describe("лента изменений", () => {
|
||||
|
||||
it("все флажки сняты — подсказка, запроса нет", async () => {
|
||||
const calls = hub();
|
||||
open("/app/jira/feed?project=AWPCA&kinds=status");
|
||||
open("/app/feed?project=AWPCA&kinds=status");
|
||||
await page();
|
||||
await waitFor(() => expect(calls.length).toBe(1));
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "Статусы" }));
|
||||
@ -173,7 +204,7 @@ describe("лента изменений", () => {
|
||||
|
||||
it("мусор в адресе: период по умолчанию, неизвестные виды отброшены", async () => {
|
||||
const calls = hub();
|
||||
open("/app/jira/feed?project=AWPCA&period=foo&kinds=status,foo");
|
||||
open("/app/feed?project=AWPCA&period=foo&kinds=status,foo");
|
||||
await page();
|
||||
await waitFor(() => expect(calls.length).toBeGreaterThan(0));
|
||||
expect(screen.getByRole("radio", { name: "Сегодня" })).toHaveAttribute("aria-checked", "true");
|
||||
@ -182,14 +213,14 @@ describe("лента изменений", () => {
|
||||
expect(screen.getByRole("checkbox", { name: "Сроки" })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("смена проекта: запрос по новому проекту, период и виды сохраняются", async () => {
|
||||
it("смена проекта: запрос по новому проекту, period, sources и kinds сохраняются (keepSearch)", async () => {
|
||||
const calls = hub();
|
||||
const router = open("/app/jira/feed?project=AWPCA&period=2h&kinds=status");
|
||||
const router = open("/app/feed?project=AWPCA&period=2h&sources=jira&kinds=status");
|
||||
await page();
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS");
|
||||
await waitFor(() => expect(calls.at(-1)!.searchParams.get("project")).toBe("STOCKS"));
|
||||
expect(calls.at(-1)!.searchParams.get("kinds")).toBe("status");
|
||||
expect(router.state.location.pathname).toBe("/app/jira/feed");
|
||||
expect(router.state.location.search).toBe("?project=STOCKS&period=2h&kinds=status");
|
||||
expect(router.state.location.pathname).toBe("/app/feed");
|
||||
expect(router.state.location.search).toBe("?project=STOCKS&period=2h&sources=jira&kinds=status");
|
||||
});
|
||||
});
|
||||
86
web/src/shell/feed/FeedPage.tsx
Normal file
86
web/src/shell/feed/FeedPage.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import type { Project } from "../../api/types.ts";
|
||||
import { FEED_MODULES, useFeeds } from "../../modules/index.ts";
|
||||
import type { HubModule } from "../../modules/types.ts";
|
||||
import { Button, Checkbox, IconRefresh, Notice, PageHeader, Segmented } from "../../ui/index.ts";
|
||||
import { fmtStamp } from "../../ui/format.ts";
|
||||
import { useModuleActivity } from "../activity/activity.ts";
|
||||
import { parsePeriod, PERIODS, periodRange, type Period } from "./period.ts";
|
||||
import { parseSources, sourcesParam } from "./sources.ts";
|
||||
import "./feed.css";
|
||||
|
||||
const stamp = (iso: string) => (iso ? fmtStamp(iso) : "—");
|
||||
|
||||
function plural(n: number): string {
|
||||
const m10 = n % 10, m100 = n % 100;
|
||||
if (m10 === 1 && m100 !== 11) return "событие";
|
||||
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return "события";
|
||||
return "событий";
|
||||
}
|
||||
|
||||
const time = (iso: string): number => { const t = Date.parse(iso); return isNaN(t) ? 0 : t; };
|
||||
|
||||
// вторичная кнопка обновления модуля-источника; ход пакета показывает блок состояния боковой панели
|
||||
function RefreshAction({ m }: { m: HubModule }) {
|
||||
const act = useModuleActivity(m.name);
|
||||
return <Button variant="secondary" icon={<IconRefresh />} loading={act.busy} onClick={() => void act.start()}>{m.status!.refresh!.label}</Button>;
|
||||
}
|
||||
|
||||
// общая лента проекта за период из источников модулей (spec web-app «Лента изменений», design wiki-history)
|
||||
export function FeedPage({ project }: { project: Project }) {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const period = parsePeriod(params.get("period"));
|
||||
const available = useMemo(() => FEED_MODULES.filter((m) => m.feed!.available(project)).map((m) => m.name), [project]);
|
||||
const selected = useMemo(() => parseSources(params.get("sources"), available), [params, available]);
|
||||
const bounds = useCallback(() => periodRange(period, new Date()), [period]);
|
||||
const results = useFeeds(FEED_MODULES.map((m) => ({ project, period, bounds, enabled: selected.includes(m.name) })));
|
||||
const shown = FEED_MODULES.map((m, i) => ({ m, r: results[i] })).filter(({ m }) => selected.includes(m.name));
|
||||
|
||||
const setParam = (name: string, value: string | null) => setParams((p) => {
|
||||
const n = new URLSearchParams(p);
|
||||
if (value) n.set(name, value); else n.delete(name);
|
||||
return n;
|
||||
});
|
||||
const choosePeriod = (p: Period) => setParam("period", p === "today" ? null : p);
|
||||
const toggleSource = (name: string, on: boolean) =>
|
||||
setParam("sources", sourcesParam(on ? [...selected, name] : selected.filter((x) => x !== name), available));
|
||||
|
||||
const count = shown.reduce((n, { r }) => n + r.count, 0);
|
||||
const window = shown.find(({ r }) => r.window)?.r.window ?? null; // окно — из ответа хаба: подзаголовок совпадает с показанным
|
||||
const subtitle = window ? `${count} ${plural(count)} · ${stamp(window.since)} — ${stamp(window.until)}` : undefined;
|
||||
// группы всех выбранных источников одним списком по времени, новые сверху; при равенстве — порядок модулей (сортировка устойчива)
|
||||
const groups = shown.flatMap(({ m, r }) => r.groups.map((g) => ({ ...g, key: `${m.name}:${g.key}` })))
|
||||
.sort((a, b) => time(b.at) - time(a.at));
|
||||
const settled = shown.length > 0 && shown.every(({ r }) => !r.pending && !r.error && !r.notice);
|
||||
|
||||
return (
|
||||
<div data-testid="page-feed" className="feed-page">
|
||||
<PageHeader title="Лента" subtitle={subtitle}
|
||||
actions={<>{shown.filter(({ m }) => m.status?.refresh).map(({ m }) => <RefreshAction key={m.name} m={m} />)}</>} />
|
||||
<div className="feed-page__controls">
|
||||
<Segmented label="Период" value={period} options={PERIODS} onChange={choosePeriod} />
|
||||
{available.length > 1 && (
|
||||
<div className="feed-page__sources" role="group" aria-label="Источники">
|
||||
{FEED_MODULES.filter((m) => available.includes(m.name)).map((m) => (
|
||||
<Checkbox key={m.name} label={m.title} checked={selected.includes(m.name)}
|
||||
onChange={(e) => toggleSource(m.name, e.currentTarget.checked)} />
|
||||
))}
|
||||
</div>)}
|
||||
{shown.map(({ m }) => { const Controls = m.feed!.Controls; return Controls && <Controls key={m.name} />; })}
|
||||
</div>
|
||||
{!shown.length && <Notice tone="info">выберите хотя бы один источник</Notice>}
|
||||
{shown.map(({ m, r }) => (
|
||||
<div key={m.name} className="feed-page__messages">
|
||||
{r.notice && <Notice tone="info">{r.notice}</Notice>}
|
||||
{r.error && <Notice tone="error" action={<Button onClick={r.error.retry}>Повторить</Button>}>{`${m.title}: ${r.error.message}`}</Notice>}
|
||||
</div>
|
||||
))}
|
||||
{shown.some(({ r }) => r.pending) && <p className="feed-page__hint">Загрузка…</p>}
|
||||
{settled && !groups.length && <Notice tone="info">за период изменений нет</Notice>}
|
||||
{shown.map(({ m, r }) => r.truncated && (
|
||||
<Notice key={m.name} tone="warning">{`${m.title}: показаны последние ${r.count} событий — выберите период короче`}</Notice>))}
|
||||
{groups.map((g) => <div key={g.key} className="feed-page__group">{g.node}</div>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
web/src/shell/feed/feed.css
Normal file
9
web/src/shell/feed/feed.css
Normal file
@ -0,0 +1,9 @@
|
||||
/* общая лента ядра (spec web-app «Лента изменений»): только токены дизайн-системы */
|
||||
.feed-page{display:flex;flex-direction:column;gap:var(--space-4);padding-top:var(--space-6)}
|
||||
.feed-page .ui-page-header{margin-bottom:0}
|
||||
.feed-page__controls{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-3) var(--space-5)}
|
||||
.feed-page__sources{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2) var(--space-4)}
|
||||
.feed-page__messages{display:flex;flex-direction:column;gap:var(--space-2)}
|
||||
.feed-page__messages:empty{display:none}
|
||||
.feed-page__hint{margin:0;color:var(--color-text-secondary)}
|
||||
.feed-page__group{min-width:0;display:flex;flex-direction:column}
|
||||
19
web/src/shell/feed/period.test.ts
Normal file
19
web/src/shell/feed/period.test.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parsePeriod, periodRange } from "./period.ts";
|
||||
|
||||
const NOW = new Date(2026, 9, 22, 14, 5);
|
||||
|
||||
describe("период", () => {
|
||||
it("границы по местному времени", () => {
|
||||
expect(periodRange("today", NOW)).toEqual({ since: new Date(2026, 9, 22, 0, 0), until: NOW });
|
||||
expect(periodRange("yesterday", NOW)).toEqual({ since: new Date(2026, 9, 21, 0, 0), until: new Date(2026, 9, 22, 0, 0) });
|
||||
expect(periodRange("2h", NOW)).toEqual({ since: new Date(2026, 9, 22, 12, 5), until: NOW });
|
||||
expect(periodRange("7d", NOW)).toEqual({ since: new Date(2026, 9, 15, 14, 5), until: NOW });
|
||||
});
|
||||
|
||||
it("мусор в адресе — «Сегодня»", () => {
|
||||
expect(parsePeriod("foo")).toBe("today");
|
||||
expect(parsePeriod(null)).toBe("today");
|
||||
expect(parsePeriod("7d")).toBe("7d");
|
||||
});
|
||||
});
|
||||
16
web/src/shell/feed/period.ts
Normal file
16
web/src/shell/feed/period.ts
Normal file
@ -0,0 +1,16 @@
|
||||
// период ленты: границы по местному времени браузера; «Сегодня» — по умолчанию и в адрес не пишется (spec web-app «Лента изменений»)
|
||||
export type Period = "2h" | "today" | "yesterday" | "7d";
|
||||
export const PERIODS: { value: Period; label: string }[] = [
|
||||
{ value: "2h", label: "2 часа" }, { value: "today", label: "Сегодня" }, { value: "yesterday", label: "Вчера" }, { value: "7d", label: "7 дней" },
|
||||
];
|
||||
export const parsePeriod = (v: string | null): Period => (PERIODS.some((p) => p.value === v) ? (v as Period) : "today");
|
||||
|
||||
export function periodRange(p: Period, now: Date): { since: Date; until: Date } {
|
||||
const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
switch (p) {
|
||||
case "2h": return { since: new Date(now.getTime() - 2 * 3600_000), until: now };
|
||||
case "yesterday": return { since: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1), until: midnight };
|
||||
case "7d": return { since: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7, now.getHours(), now.getMinutes(), now.getSeconds(), now.getMilliseconds()), until: now };
|
||||
default: return { since: midnight, until: now };
|
||||
}
|
||||
}
|
||||
17
web/src/shell/feed/sources.test.ts
Normal file
17
web/src/shell/feed/sources.test.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseSources, sourcesParam } from "./sources.ts";
|
||||
|
||||
describe("источники ленты в адресе", () => {
|
||||
it("parseSources: умолчание, неизвестные и недоступные отброшены, none — пусто", () => {
|
||||
expect(parseSources(null, ["jira", "wiki"])).toEqual(["jira", "wiki"]);
|
||||
expect(parseSources("wiki,foo", ["jira", "wiki"])).toEqual(["wiki"]);
|
||||
expect(parseSources("wiki", ["jira"])).toEqual(["jira"]); // недоступный → умолчание
|
||||
expect(parseSources("none", ["jira", "wiki"])).toEqual([]);
|
||||
});
|
||||
|
||||
it("sourcesParam: порядок как у доступных, все доступные не пишутся, пусто — none", () => {
|
||||
expect(sourcesParam(["wiki", "jira"], ["jira", "wiki"])).toBeNull();
|
||||
expect(sourcesParam(["jira"], ["jira", "wiki"])).toBe("jira");
|
||||
expect(sourcesParam([], ["jira", "wiki"])).toBe("none");
|
||||
});
|
||||
});
|
||||
14
web/src/shell/feed/sources.ts
Normal file
14
web/src/shell/feed/sources.ts
Normal file
@ -0,0 +1,14 @@
|
||||
// источники ленты в адресе: `sources=jira,wiki`; набор по умолчанию (все доступные) в адрес не пишется, «none» — ни одного
|
||||
export function parseSources(v: string | null, available: string[]): string[] {
|
||||
if (v === "none") return [];
|
||||
const asked = (v ?? "").split(",").map((s) => s.trim());
|
||||
const known = available.filter((a) => asked.includes(a));
|
||||
return known.length ? known : [...available];
|
||||
}
|
||||
|
||||
// в адрес — в порядке `available`; все доступные — null; пусто — «none»
|
||||
export function sourcesParam(selected: string[], available: string[]): string | null {
|
||||
const ordered = available.filter((a) => selected.includes(a));
|
||||
if (!ordered.length) return "none";
|
||||
return ordered.length === available.length ? null : ordered.join(",");
|
||||
}
|
||||
@ -1,12 +1,14 @@
|
||||
import { MODULES } from "../modules/index.ts";
|
||||
import type { Project } from "../api/types.ts";
|
||||
import type { NavItem } from "../modules/types.ts";
|
||||
import { IconHome } from "../ui/index.ts";
|
||||
import { IconHistory, IconHome } from "../ui/index.ts";
|
||||
|
||||
// «Обзор» — главная страница ядра; разделы модулей — по группам
|
||||
export const OVERVIEW: NavItem = { to: "/", label: "Обзор", icon: <IconHome />, padded: true };
|
||||
// «Лента» — страница ядра: события источников модулей (design wiki-history); при смене проекта строка запроса сохраняется
|
||||
export const FEED: NavItem = { to: "/feed", label: "Лента", icon: <IconHistory />, padded: true, keepSearch: true };
|
||||
export const GROUPS = MODULES.filter((m) => m.nav.length > 0).map((m) => ({ name: m.name, title: m.title, nav: m.nav }));
|
||||
export const NAV: NavItem[] = [OVERVIEW, ...GROUPS.flatMap((g) => g.nav)];
|
||||
export const NAV: NavItem[] = [OVERVIEW, FEED, ...GROUPS.flatMap((g) => g.nav)];
|
||||
|
||||
// пункты, видимые для проекта: безусловные — всегда, условные (`when`) — когда проект известен и условие верно
|
||||
export const visibleNav = (items: NavItem[], project: Project | null): NavItem[] =>
|
||||
|
||||
@ -2,11 +2,13 @@ import type { RouteObject } from "react-router";
|
||||
import { MODULES } from "../modules/index.ts";
|
||||
import { ProjectGate } from "../projects/ProjectGate.tsx";
|
||||
import { NotFound } from "./NotFound.tsx";
|
||||
import { FeedPage } from "./feed/FeedPage.tsx";
|
||||
import { OverviewPage } from "./OverviewPage.tsx";
|
||||
|
||||
// «Обзор» ядра, страницы модулей; Layout рисует их по адресу страницы под окном карточки (design 10); NotFound — последним
|
||||
export const PAGE_ROUTES: RouteObject[] = [
|
||||
{ path: "/", element: <ProjectGate>{(p) => <OverviewPage key={p.label} project={p} />}</ProjectGate> },
|
||||
{ path: "/feed", element: <ProjectGate>{(p) => <FeedPage key={p.label} project={p} />}</ProjectGate> },
|
||||
...MODULES.flatMap((m) => m.pages),
|
||||
{ path: "*", element: <NotFound /> },
|
||||
];
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fmtDate, fmtStamp } from "./format.ts";
|
||||
import { eventTime, fmtDate, fmtStamp } from "./format.ts";
|
||||
|
||||
const NOW = new Date(2026, 9, 22, 14, 5);
|
||||
|
||||
describe("форматтеры дат ядра", () => {
|
||||
it("fmtDate: текущий год — ДД.ММ, другой — ДД.ММ.ГГГГ, null — тире", () => {
|
||||
@ -14,3 +16,13 @@ describe("форматтеры дат ядра", () => {
|
||||
expect(fmtStamp(null)).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("время события", () => {
|
||||
it("сегодня — ЧЧ:ММ, иначе с датой, без времени — прочерк", () => {
|
||||
expect(eventTime(new Date(2026, 9, 22, 11, 15).toISOString(), NOW)).toBe("11:15");
|
||||
expect(eventTime(new Date(2026, 9, 21, 9, 5).toISOString(), NOW)).toBe("21.10 09:05");
|
||||
expect(eventTime(new Date(2026, 9, 22, 11, 15).toISOString(), NOW, true)).toBe("22.10 11:15");
|
||||
expect(eventTime(null, NOW)).toBe("—");
|
||||
expect(eventTime("мусор", NOW)).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
@ -16,3 +16,12 @@ export function fmtDate(iso: string | null, now: Date = new Date()): string {
|
||||
if (!m) return iso;
|
||||
return Number(m[1]) === now.getFullYear() ? `${m[3]}.${m[2]}` : `${m[3]}.${m[2]}.${m[1]}`;
|
||||
}
|
||||
|
||||
// «ЧЧ:ММ» для событий текущих суток, иначе «ДД.ММ ЧЧ:ММ»; без времени — «—»
|
||||
export function eventTime(at: string | null, now: Date, alwaysDate = false): string {
|
||||
const d = at ? new Date(at) : null;
|
||||
if (!d || isNaN(d.getTime())) return "—";
|
||||
const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
const sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
|
||||
return sameDay && !alwaysDate ? hm : `${pad(d.getDate())}.${pad(d.getMonth() + 1)} ${hm}`;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user