From 8eecc567dc9bfb9c59e7a9750f14433904ad0572 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 14:14:31 +0400 Subject: [PATCH] =?UTF-8?q?feat(web-modules):=20=C2=AB=D0=9D=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=C2=BB=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D0=BE=D0=BB=D1=8F=20=D0=BF=D0=BE=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=D0=BC,=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D1=81=D0=BA=D0=B0=D0=B7=D0=BA=D0=B8,=20=D0=BF=D0=B0=D0=BA?= =?UTF-8?q?=D0=B5=D1=82=D1=8B=20=D0=BA=D0=B0=D0=B6=D0=B4=D0=BE=D0=BC=D1=83?= =?UTF-8?q?=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- openspec/changes/web-modules/design.md | 2 + web/src/api/queries.test.tsx | 26 +++++++++ web/src/api/types.ts | 6 ++- web/src/modules/wiki/RootForm.test.tsx | 29 ++++++++++ web/src/modules/wiki/RootForm.tsx | 7 ++- web/src/projects/NewProjectDialog.test.tsx | 63 ++++++++++++++++++++-- web/src/projects/NewProjectDialog.tsx | 44 +++++++++------ web/src/projects/projects.css | 4 ++ web/src/projects/projects.test.tsx | 8 +-- web/src/shell/Layout.tsx | 9 +--- web/src/shell/NewProject.test.tsx | 32 +++++++++++ web/src/shell/NewProject.tsx | 12 +++++ web/src/shell/activity/activity.ts | 7 ++- 13 files changed, 215 insertions(+), 34 deletions(-) create mode 100644 web/src/api/queries.test.tsx create mode 100644 web/src/modules/wiki/RootForm.test.tsx create mode 100644 web/src/shell/NewProject.test.tsx create mode 100644 web/src/shell/NewProject.tsx diff --git a/openspec/changes/web-modules/design.md b/openspec/changes/web-modules/design.md index 4e77c81..772ee03 100644 --- a/openspec/changes/web-modules/design.md +++ b/openspec/changes/web-modules/design.md @@ -144,3 +144,5 @@ type HubModule = { | 40 | 24.10.2026 | Task 5: общий `agentText(a)` (`shell/activity/agentText.ts`) — текст, подсказка и признак «на связи» агента; им пользуются блок состояния и карточки «Обзора» | текст «агент на связи/офлайн/обновите агента» не должен разойтись между двумя местами | рефакторинг блока состояния; тесты блока его держат | | 41 | 24.10.2026 | Task 5: `useRecentBatches(project)` опрашивает `GET /batches?project=&limit=10` раз в 15 с (`refetchInterval`) | ход пакетов меняется без участия страницы; инвалидация из активности ядра усложнила бы контракт | лишние запросы раз в 15 с на «Обзоре» | | 42 | 24.10.2026 | Task 5: поле поиска вики на «Обзоре» не ведёт на пустой запрос (Enter при пустой строке ничего не делает); `q` в «Вики» — только начальное значение поля, выбор страницы убирает `q` из адреса | пустой поиск = страница «Вики» без `q`, для неё есть ссылка «Вики»; `hrefOf` страницы по контракту Task 4 не несёт поиск | пользователь, вставший на `?q=` и открывший страницу, теряет в адресе строку поиска (в поле она остаётся) | +| 43 | 24.10.2026 | Task 6: номер доски проверяется в окне сам (`/^\d+$/`, ≥ 1, текст «нужно целое число»), поле — `type="text"` с `inputMode="numeric"`, форма `noValidate` | `type="number"` даёт браузерную подсказку вместо нашего текста и пропускает `51.5`/`1e3` по-разному в браузерах; T4.1 | поле без стрелок счётчика; невалидное значение ловится только по «Создать» | +| 44 | 24.10.2026 | Task 6: в ядре `useTrackModuleBatch()` (activity.ts) — отслеживание пакета по имени модуля; окно создания вынесено в `shell/NewProject.tsx`; `useTrackBatch` остаётся для пакетов без известного модуля | пакеты нового проекта привязываются каждому своему модулю по `batches[].module` (M-14, T5.3 #68); Layout не обязан знать модули | пакет неизвестного модуля молча не отслеживается | diff --git a/web/src/api/queries.test.tsx b/web/src/api/queries.test.tsx new file mode 100644 index 0000000..8973478 --- /dev/null +++ b/web/src/api/queries.test.tsx @@ -0,0 +1,26 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { setToken } from "../auth/token.ts"; +import { json, mockFetch } from "../test/fetchMock.ts"; +import { useUpdateProject } from "./queries.ts"; + +describe("useUpdateProject (T4.4)", () => { + beforeEach(() => setToken("tok")); + + it("PATCH /projects/{метка} с полями и автором, ответ несёт batches", async () => { + const calls: { method: string; path: string; body: unknown }[] = []; + mockFetch((method, url, body) => { + calls.push({ method, path: url.pathname, body }); + return method === "PATCH" ? json(200, { project: { label: "A B", name: "x", created_at: "" }, batch_ids: [5], batches: [{ module: "wiki", id: 5 }] }) : json(200, { projects: [] }); + }); + const client = new QueryClient(); + const { result } = renderHook(() => useUpdateProject(), { wrapper: ({ children }: { children: ReactNode }) => {children} }); + let r: Awaited> | undefined; + await act(async () => { r = await result.current.mutateAsync({ label: "A B", fields: { wiki_root: "DOC-1" } }); }); + expect(calls[0]).toEqual({ method: "PATCH", path: "/projects/A%20B", body: { wiki_root: "DOC-1", author: "viewer" } }); + expect(r?.batches).toEqual([{ module: "wiki", id: 5 }]); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + }); +}); diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 295149a..b0f0213 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -30,5 +30,7 @@ export type Project = { label: string; name: string; created_at: string; jira_synced_at: string | null; batches?: Batch[]; } & Record; -export interface CreatedProject { project: Project; batch_id: number } -export interface UpdatedProject { project: Project; batch_ids: number[] } +// пакет, поставленный хабом проекту: module — модуль, чей это пакет (отслеживает его трекер этого модуля) +export interface ProjectBatch { module: string; id: number } +export interface CreatedProject { project: Project; batch_id: number; batches: ProjectBatch[] } +export interface UpdatedProject { project: Project; batch_ids: number[]; batches: ProjectBatch[] } diff --git a/web/src/modules/wiki/RootForm.test.tsx b/web/src/modules/wiki/RootForm.test.tsx new file mode 100644 index 0000000..88cd68d --- /dev/null +++ b/web/src/modules/wiki/RootForm.test.tsx @@ -0,0 +1,29 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { setToken } from "../../auth/token.ts"; +import { ActivityContext, type ActivityValue, type ModuleActivity } from "../../shell/activity/activity.ts"; +import { json, mockFetch } from "../../test/fetchMock.ts"; +import { RootForm } from "./RootForm.tsx"; + +const act = (): ModuleActivity => ({ agent: null, event: null, busy: false, running: null, start: async () => {}, track: vi.fn() }); + +describe("RootForm: пакет привязки отслеживает модуль wiki", () => { + beforeEach(() => setToken("tok")); + + it("пакет модуля wiki из batches уходит в track wiki, чужие — нет (T4.4)", async () => { + mockFetch((method) => method === "PATCH" + ? json(200, { project: { label: "P", name: "x", created_at: "" }, batch_ids: [5, 6], batches: [{ module: "wiki", id: 5 }, { module: "jira", id: 6 }] }) + : json(404, {})); + const modules = { jira: act(), wiki: act() }; + const value: ActivityValue = { agents: [], modules, summary: "online", trackBatch: vi.fn(async () => {}) }; + render( + ); + await userEvent.type(screen.getByLabelText("Корень вики (DOC-… или ссылка)"), "DOC-1"); + await userEvent.click(screen.getByRole("button", { name: "Привязать" })); + await waitFor(() => expect(modules.wiki.track).toHaveBeenCalledWith(5)); + expect(modules.wiki.track).toHaveBeenCalledTimes(1); + expect(modules.jira.track).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/modules/wiki/RootForm.tsx b/web/src/modules/wiki/RootForm.tsx index b7be670..dadf5c9 100644 --- a/web/src/modules/wiki/RootForm.tsx +++ b/web/src/modules/wiki/RootForm.tsx @@ -2,6 +2,7 @@ import { useState, type FormEvent } from "react"; import { ApiError } from "../../api/client.ts"; import { useUpdateProject } from "../../api/queries.ts"; import { useQueryClient } from "@tanstack/react-query"; +import { useTrackModuleBatch } from "../../shell/activity/activity.ts"; import { Button, Field, Panel, TextInput } from "../../ui/index.ts"; import { wikiQk } from "./api/queries.ts"; @@ -10,13 +11,17 @@ export function RootForm({ project }: { project: string }) { const [value, setValue] = useState(""); const qc = useQueryClient(); const update = useUpdateProject(); + const track = useTrackModuleBatch(); const err = update.error; const text = err ? (err instanceof ApiError ? err.detail ?? err.message : err.message) : null; const submit = (e: FormEvent) => { e.preventDefault(); if (!value.trim()) return; update.mutate({ label: project, fields: { wiki_root: value.trim() } }, { - onSuccess: () => void qc.invalidateQueries({ queryKey: wikiQk.project(project) }), + onSuccess: (r) => { + void qc.invalidateQueries({ queryKey: wikiQk.project(project) }); + for (const b of r.batches ?? []) if (b.module === "wiki") track("wiki", b.id); // пакет привязки — модулю wiki + }, }); }; return ( diff --git a/web/src/projects/NewProjectDialog.test.tsx b/web/src/projects/NewProjectDialog.test.tsx index 479c98c..b158cc3 100644 --- a/web/src/projects/NewProjectDialog.test.tsx +++ b/web/src/projects/NewProjectDialog.test.tsx @@ -1,24 +1,26 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { setToken } from "../auth/token.ts"; import { jiraModule } from "../modules/jira/index.tsx"; import type { HubModule } from "../modules/types.ts"; import { json, mockFetch } from "../test/fetchMock.ts"; +import { MODULES } from "../modules/index.ts"; import { NewProjectDialog } from "./NewProjectDialog.tsx"; // второй модуль с текстовым полем: окно собирает поля из всех модулей, а не только из jira vi.mock("../modules/index.ts", async () => { const { jiraModule: jira } = await import("../modules/jira/index.tsx"); + const { wikiModule: wiki } = await import("../modules/wiki/index.tsx"); const extra: HubModule = { name: "extra", title: "Extra", pages: [], nav: [], projectFields: [{ name: "space_key", label: "Пространство", hint: "ключ", kind: "text" }], }; - return { MODULES: [jira, extra] }; + return { MODULES: [jira, wiki, extra] }; }); -const CREATED = { project: { label: "P", name: "Пэ", created_at: "" }, batch_id: 3 }; +const CREATED = { project: { label: "P", name: "Пэ", created_at: "" }, batch_id: 3, batches: [{ module: "jira", id: 3 }, { module: "wiki", id: 4 }] }; function setup() { const posts: unknown[] = []; @@ -65,4 +67,59 @@ describe("NewProjectDialog: поля модулей", () => { await waitFor(() => expect(posts).toHaveLength(1)); expect(posts[0]).toEqual({ label: "P", name: "Пэ", board_id: 5120, space_key: "KEY", author: "viewer" }); }); + + it("поля сгруппированы по модулям: fieldset с legend «Jira» и «Вики»", () => { + setup(); + for (const [legend, field] of [["Jira", "Номер доски Jira"], ["Вики", "Корень вики"]]) { + const g = screen.getByRole("group", { name: legend }); + expect(within(g).getByLabelText(field)).toBeInTheDocument(); + } + }); + + it("подсказки — текст под полем, placeholder пустой", () => { + setup(); + const board = screen.getByLabelText("Номер доски Jira"); + expect(board).not.toHaveAttribute("placeholder"); + expect(board).toHaveAccessibleDescription("scrum или kanban определится сам"); + expect(screen.getByLabelText("Метка")).toHaveAccessibleDescription("метка задач в Jira и имя проекта в хабе"); + }); + + it("51.5 в номере доски — «нужно целое число», запроса нет (T4.1)", async () => { + const { posts } = setup(); + await fill(); + await userEvent.type(screen.getByLabelText("Номер доски Jira"), "51.5"); + await userEvent.click(screen.getByRole("button", { name: "Создать" })); + expect(await screen.findByText("нужно целое число")).toBeInTheDocument(); + expect(screen.getByLabelText("Номер доски Jira")).toHaveAttribute("aria-invalid", "true"); + expect(posts).toHaveLength(0); + }); + + it("onCreated получает пакеты с модулями (M-14)", async () => { + const { onCreated } = setup(); + await fill(); + await userEvent.click(screen.getByRole("button", { name: "Создать" })); + await waitFor(() => expect(onCreated).toHaveBeenCalled()); + expect(onCreated.mock.calls[0][1]).toEqual([{ module: "jira", id: 3 }, { module: "wiki", id: 4 }]); + }); + + it("два быстрых ввода в разные поля не теряют первое (T4.2)", async () => { + const { posts } = setup(); + await fill(); + const board = screen.getByLabelText("Номер доски Jira"), space = screen.getByLabelText("Пространство"); + fireEvent.change(board, { target: { value: "7" } }); + fireEvent.change(space, { target: { value: "K" } }); + await userEvent.click(screen.getByRole("button", { name: "Создать" })); + await waitFor(() => expect(posts).toHaveLength(1)); + expect(posts[0]).toMatchObject({ board_id: 7, space_key: "K" }); + }); + + it("поля собираются при отрисовке, а не при импорте (T4.5)", () => { + const late: HubModule = { name: "late", title: "Late", pages: [], nav: [], + projectFields: [{ name: "x", label: "Позднее поле", hint: "", kind: "text" }] }; + (MODULES as HubModule[]).push(late); + try { + setup(); + expect(screen.getByLabelText("Позднее поле")).toBeInTheDocument(); + } finally { (MODULES as HubModule[]).pop(); } + }); }); diff --git a/web/src/projects/NewProjectDialog.tsx b/web/src/projects/NewProjectDialog.tsx index 6adf2a5..f9fbdbd 100644 --- a/web/src/projects/NewProjectDialog.tsx +++ b/web/src/projects/NewProjectDialog.tsx @@ -1,42 +1,56 @@ import { useState, type FormEvent } from "react"; import { ApiError } from "../api/client.ts"; import { useCreateProject } from "../api/queries.ts"; -import type { Project } from "../api/types.ts"; +import type { Project, ProjectBatch } from "../api/types.ts"; import { MODULES } from "../modules/index.ts"; import { Button, Dialog, Field, Notice, TextInput } from "../ui/index.ts"; import "./projects.css"; -// окно «Новый проект» (как IssueModal): держит фокус; Esc и «Отмена» — onClose, ошибка хаба — над кнопками -const FIELDS = MODULES.flatMap((m) => m.projectFields ?? []); - -export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCreated(p: Project, batchId: number): void }) { +// окно «Новый проект» (как IssueModal): держит фокус; Esc и «Отмена» — onClose, ошибка хаба — над кнопками. +// Поля модулей — группы fieldset с названием модуля; подсказка — текст под полем (не placeholder) +export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCreated(p: Project, batches: ProjectBatch[]): void }) { const [label, setLabel] = useState(""), [name, setName] = useState(""); const [extra, setExtra] = useState>({}); + const [invalid, setInvalid] = useState>({}); const create = useCreateProject(); + // список собирается при отрисовке, а не при импорте (T4.5) + const groups = MODULES.map((m) => ({ title: m.title, fields: m.projectFields ?? [] })).filter((g) => g.fields.length > 0); const submit = (e: FormEvent) => { e.preventDefault(); const f: Record = { label: label.trim(), name: name.trim() }; - for (const d of FIELDS) { + const bad: Record = {}; + for (const d of groups.flatMap((g) => g.fields)) { const v = (extra[d.name] ?? "").trim(); - if (v !== "") f[d.name] = d.kind === "int" ? Number(v) : v; + if (v === "") continue; + if (d.kind === "int") { + const n = Number(v); + if (!/^\d+$/.test(v) || !Number.isSafeInteger(n) || n < 1) bad[d.name] = "нужно целое число"; else f[d.name] = n; + } else f[d.name] = v; } - create.mutate(f, { onSuccess: (r) => onCreated(r.project, r.batch_id) }); + setInvalid(bad); + if (Object.keys(bad).length > 0) return; + create.mutate(f, { onSuccess: (r) => onCreated(r.project, r.batches ?? []) }); }; const err = create.error; const text = err ? (err instanceof ApiError ? err.detail ?? err.message : err.message) : null; return ( { if (!create.isPending) onClose(); }}> -
+

Новый проект

- setLabel(e.target.value)} required /> + setLabel(e.target.value)} required /> setName(e.target.value)} required /> - {FIELDS.map((d) => ( - - setExtra({ ...extra, [d.name]: e.target.value })} placeholder={d.hint} /> - + {groups.map((g) => ( +
+ {g.title} + {g.fields.map((d) => ( + + { const v = e.target.value; setExtra((x) => ({ ...x, [d.name]: v })); }} /> + + ))} +
))} {text && {text}}
diff --git a/web/src/projects/projects.css b/web/src/projects/projects.css index b666fba..e08d77c 100644 --- a/web/src/projects/projects.css +++ b/web/src/projects/projects.css @@ -5,3 +5,7 @@ .project-form { display: flex; flex-direction: column; gap: var(--space-4); } .project-form__title { margin: 0; font-size: var(--fs-section); line-height: var(--lh-section); font-weight: 700; color: var(--color-text-primary); } .project-form__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); } +.project-form__group { display: flex; flex-direction: column; gap: var(--space-3); margin: 0; padding: var(--space-3) var(--space-4) var(--space-4); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); min-width: 0; } +.project-form__legend { padding: 0 var(--space-1); font-size: var(--fs-caption); line-height: var(--lh-caption); font-weight: 700; color: var(--color-text-secondary); } +.project-form__group { display: flex; flex-direction: column; gap: var(--space-3); margin: 0; padding: var(--space-3) var(--space-4) var(--space-4); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); min-width: 0; } +.project-form__legend { padding: 0 var(--space-1); font-size: var(--fs-caption); line-height: var(--lh-caption); font-weight: 700; color: var(--color-text-secondary); } diff --git a/web/src/projects/projects.test.tsx b/web/src/projects/projects.test.tsx index a014e39..3aa8f10 100644 --- a/web/src/projects/projects.test.tsx +++ b/web/src/projects/projects.test.tsx @@ -99,7 +99,7 @@ describe("проект в приложении", () => { it("создание проекта из формы", async () => { let created = false; const h = hub([A], { - "POST /projects": () => { created = true; return json(201, { project: S, batch_id: 7 }); }, + "POST /projects": () => { created = true; return json(201, { project: S, batch_id: 7, batches: [{ module: "jira", id: 7 }] }); }, "GET /projects": () => json(200, { projects: created ? [A, S] : [A] }), // блок состояния берёт проект из списка "GET /batches/7": () => json(200, { id: 7, author: "viewer", status: "queued", remaining_seconds: 5, counts: {}, commands: [{ type: "refresh", status: "queued" }] }), }); @@ -121,7 +121,7 @@ describe("проект в приложении", () => { }); it("с номером доски board_id уходит числом", async () => { - const h = hub([A], { "POST /projects": () => json(201, { project: S, batch_id: 7 }) }); + const h = hub([A], { "POST /projects": () => json(201, { project: S, batch_id: 7, batches: [{ module: "jira", id: 7 }] }) }); open("/app/jira/board?project=AWPCA"); await screen.findByTestId("page-board"); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); @@ -152,7 +152,7 @@ describe("проект в приложении", () => { let n = 0; const h = hub([A, S], { "GET /projects": () => (++n === 1 ? json(200, { projects: [A, S] }) : json(500, { error: "boom" })), - "POST /projects": () => json(201, { project: S, batch_id: 7 }), + "POST /projects": () => json(201, { project: S, batch_id: 7, batches: [{ module: "jira", id: 7 }] }), "GET /batches/7": () => json(200, { id: 7, author: "viewer", status: "queued", remaining_seconds: 5, counts: {}, commands: [{ type: "refresh", status: "queued" }] }), }); const router = open("/app/jira/board?project=AWPCA"); @@ -171,7 +171,7 @@ describe("проект в приложении", () => { it("окно «Новый проект» во время создания не закрывается: «Отмена» заблокирована, Esc гасится", async () => { let release!: () => void; const gate = new Promise((r) => { release = r; }); - hub([A], { "POST /projects": async () => { await gate; return json(201, { project: S, batch_id: 7 }); } }); + hub([A], { "POST /projects": async () => { await gate; return json(201, { project: S, batch_id: 7, batches: [{ module: "jira", id: 7 }] }); } }); open("/app/jira/board?project=AWPCA"); await screen.findByTestId("page-board"); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); diff --git a/web/src/shell/Layout.tsx b/web/src/shell/Layout.tsx index 7faff22..be55fae 100644 --- a/web/src/shell/Layout.tsx +++ b/web/src/shell/Layout.tsx @@ -3,10 +3,9 @@ import { useLocation, useNavigate, useRoutes } from "react-router"; import { useProjects } from "../api/queries.ts"; import { useOverlays } from "../modules/index.ts"; import { rememberedProject, rememberProject, resolveProject, withProject } from "../projects/current.ts"; -import { NewProjectDialog } from "../projects/NewProjectDialog.tsx"; +import { NewProject } from "./NewProject.tsx"; import { ProjectContext, type ProjectCtx, type ProjectState } from "../projects/ProjectContext.ts"; import { ActivityProvider } from "./activity/ActivityProvider.tsx"; -import { useTrackBatch } from "./activity/activity.ts"; import { ErrorBoundary } from "./ErrorBoundary.tsx"; import { NAV } from "./nav.tsx"; import { PAGE_ROUTES } from "./pages.tsx"; @@ -70,9 +69,3 @@ export function Layout() { ); } - -// окно «Новый проект»: пакет, поставленный хабом при создании, отслеживает трекер ядра (модуль берётся из ответа хаба) -function NewProject({ onClose, select }: { onClose(): void; select(label: string): void }) { - const trackBatch = useTrackBatch(); - return { onClose(); void trackBatch(batchId).catch(() => {}); select(p.label); }} />; -} diff --git a/web/src/shell/NewProject.test.tsx b/web/src/shell/NewProject.test.tsx new file mode 100644 index 0000000..0bfd1e9 --- /dev/null +++ b/web/src/shell/NewProject.test.tsx @@ -0,0 +1,32 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { setToken } from "../auth/token.ts"; +import { json, mockFetch } from "../test/fetchMock.ts"; +import { ActivityContext, type ActivityValue, type ModuleActivity } from "./activity/activity.ts"; +import { NewProject } from "./NewProject.tsx"; + +const act = (): ModuleActivity => ({ agent: null, event: null, busy: false, running: null, start: async () => {}, track: vi.fn() }); + +describe("NewProject: пакеты каждому своему модулю", () => { + beforeEach(() => setToken("tok")); + + it("jira и wiki получают свои id (M-14, T5.3 #68)", async () => { + mockFetch((method) => method === "POST" + ? json(201, { project: { label: "P", name: "Пэ", created_at: "" }, batch_id: 3, batches: [{ module: "jira", id: 3 }, { module: "wiki", id: 4 }] }) + : json(404, {})); + const modules = { jira: act(), wiki: act() }; + const value: ActivityValue = { agents: [], modules, summary: "online", trackBatch: vi.fn(async () => {}) }; + const onClose = vi.fn(), select = vi.fn(); + render( + ); + await userEvent.type(screen.getByLabelText("Метка"), "P"); + await userEvent.type(screen.getByLabelText("Название"), "Пэ"); + await userEvent.click(screen.getByRole("button", { name: "Создать" })); + await waitFor(() => expect(select).toHaveBeenCalledWith("P")); + expect(modules.jira.track).toHaveBeenCalledExactlyOnceWith(3); + expect(modules.wiki.track).toHaveBeenCalledExactlyOnceWith(4); + expect(value.trackBatch).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/shell/NewProject.tsx b/web/src/shell/NewProject.tsx new file mode 100644 index 0000000..daeee69 --- /dev/null +++ b/web/src/shell/NewProject.tsx @@ -0,0 +1,12 @@ +import { NewProjectDialog } from "../projects/NewProjectDialog.tsx"; +import { useTrackModuleBatch } from "./activity/activity.ts"; + +// окно «Новый проект»: каждый пакет, поставленный хабом при создании, отслеживает трекер своего модуля (поле module) +export function NewProject({ onClose, select }: { onClose(): void; select(label: string): void }) { + const track = useTrackModuleBatch(); + return { + onClose(); + for (const b of batches) track(b.module, b.id); + select(p.label); + }} />; +} diff --git a/web/src/shell/activity/activity.ts b/web/src/shell/activity/activity.ts index 1124780..057e2af 100644 --- a/web/src/shell/activity/activity.ts +++ b/web/src/shell/activity/activity.ts @@ -1,4 +1,4 @@ -import { createContext, useContext } from "react"; +import { createContext, useCallback, useContext } from "react"; import type { AgentView, Batch } from "../../api/types.ts"; import type { ModuleEvent } from "./events.ts"; @@ -33,6 +33,11 @@ export function useModuleActivity(name: string): ModuleActivity { if (!m) throw new Error(`у модуля «${name}» нет описания состояния (HubModule.status)`); return m; } +// отслеживать пакет, поставленный хабом конкретному модулю (после создания/правки проекта); неизвестный модуль пропускается +export function useTrackModuleBatch(): (module: string, batchId: number) => void { + const { modules } = useActivity(); + return useCallback((module, id) => { modules[module]?.track(id); }, [modules]); +} export const useAgents = (): AgentView[] => useActivity().agents; export const useActivitySummary = (): ActivityValue["summary"] => useActivity().summary; export const useTrackBatch = (): ActivityValue["trackBatch"] => useActivity().trackBatch;