feat(web-modules): «Новый проект» — поля по модулям, подсказки, пакеты каждому модулю
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
59f5cbef00
commit
15626aa33a
@ -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 не обязан знать модули | пакет неизвестного модуля молча не отслеживается |
|
||||
|
||||
26
web/src/api/queries.test.tsx
Normal file
26
web/src/api/queries.test.tsx
Normal file
@ -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 }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> });
|
||||
let r: Awaited<ReturnType<typeof result.current.mutateAsync>> | 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));
|
||||
});
|
||||
});
|
||||
@ -30,5 +30,7 @@ export type Project = {
|
||||
label: string; name: string; created_at: string;
|
||||
jira_synced_at: string | null; batches?: Batch[];
|
||||
} & Record<string, unknown>;
|
||||
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[] }
|
||||
|
||||
29
web/src/modules/wiki/RootForm.test.tsx
Normal file
29
web/src/modules/wiki/RootForm.test.tsx
Normal file
@ -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(<QueryClientProvider client={new QueryClient()}><ActivityContext.Provider value={value}>
|
||||
<RootForm project="P" /></ActivityContext.Provider></QueryClientProvider>);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@ -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 (
|
||||
|
||||
@ -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(); }
|
||||
});
|
||||
});
|
||||
|
||||
@ -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): <dialog> держит фокус; Esc и «Отмена» — onClose, ошибка хаба — над кнопками
|
||||
const FIELDS = MODULES.flatMap((m) => m.projectFields ?? []);
|
||||
|
||||
export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCreated(p: Project, batchId: number): void }) {
|
||||
// окно «Новый проект» (как IssueModal): <dialog> держит фокус; 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<Record<string, string>>({});
|
||||
const [invalid, setInvalid] = useState<Record<string, string>>({});
|
||||
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<string, unknown> = { label: label.trim(), name: name.trim() };
|
||||
for (const d of FIELDS) {
|
||||
const bad: Record<string, string> = {};
|
||||
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 (
|
||||
<Dialog size="sm" label="Новый проект" className="project-dialog" onClose={() => { if (!create.isPending) onClose(); }}>
|
||||
<form className="project-form" onSubmit={submit}>
|
||||
<form className="project-form" onSubmit={submit} noValidate>
|
||||
<h2 className="project-form__title">Новый проект</h2>
|
||||
<Field label="Метка"><TextInput value={label} onChange={(e) => setLabel(e.target.value)} required /></Field>
|
||||
<Field label="Метка" hint="метка задач в Jira и имя проекта в хабе"><TextInput value={label} onChange={(e) => setLabel(e.target.value)} required /></Field>
|
||||
<Field label="Название"><TextInput value={name} onChange={(e) => setName(e.target.value)} required /></Field>
|
||||
{FIELDS.map((d) => (
|
||||
<Field key={d.name} label={d.label}>
|
||||
<TextInput type={d.kind === "int" ? "number" : "text"} min={d.kind === "int" ? 1 : undefined} value={extra[d.name] ?? ""}
|
||||
onChange={(e) => setExtra({ ...extra, [d.name]: e.target.value })} placeholder={d.hint} />
|
||||
</Field>
|
||||
{groups.map((g) => (
|
||||
<fieldset key={g.title} className="project-form__group">
|
||||
<legend className="project-form__legend">{g.title}</legend>
|
||||
{g.fields.map((d) => (
|
||||
<Field key={d.name} label={d.label} hint={d.hint} error={invalid[d.name]}>
|
||||
<TextInput inputMode={d.kind === "int" ? "numeric" : undefined} value={extra[d.name] ?? ""}
|
||||
onChange={(e) => { const v = e.target.value; setExtra((x) => ({ ...x, [d.name]: v })); }} />
|
||||
</Field>
|
||||
))}
|
||||
</fieldset>
|
||||
))}
|
||||
{text && <Notice tone="error">{text}</Notice>}
|
||||
<div className="project-form__actions">
|
||||
|
||||
@ -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); }
|
||||
|
||||
@ -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<void>((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: "Проект" }), "+ Новый проект…");
|
||||
|
||||
@ -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() {
|
||||
</ProjectContext>
|
||||
);
|
||||
}
|
||||
|
||||
// окно «Новый проект»: пакет, поставленный хабом при создании, отслеживает трекер ядра (модуль берётся из ответа хаба)
|
||||
function NewProject({ onClose, select }: { onClose(): void; select(label: string): void }) {
|
||||
const trackBatch = useTrackBatch();
|
||||
return <NewProjectDialog onClose={onClose} onCreated={(p, batchId) => { onClose(); void trackBatch(batchId).catch(() => {}); select(p.label); }} />;
|
||||
}
|
||||
|
||||
32
web/src/shell/NewProject.test.tsx
Normal file
32
web/src/shell/NewProject.test.tsx
Normal file
@ -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(<QueryClientProvider client={new QueryClient()}><ActivityContext.Provider value={value}>
|
||||
<NewProject onClose={onClose} select={select} /></ActivityContext.Provider></QueryClientProvider>);
|
||||
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();
|
||||
});
|
||||
});
|
||||
12
web/src/shell/NewProject.tsx
Normal file
12
web/src/shell/NewProject.tsx
Normal file
@ -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 <NewProjectDialog onClose={onClose} onCreated={(p, batches) => {
|
||||
onClose();
|
||||
for (const b of batches) track(b.module, b.id);
|
||||
select(p.label);
|
||||
}} />;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user