diff --git a/web/e2e/feed.spec.ts b/web/e2e/feed.spec.ts index 049aba7..ab57064 100644 --- a/web/e2e/feed.spec.ts +++ b/web/e2e/feed.spec.ts @@ -12,7 +12,7 @@ async function seedHistory() { { history_id: `e2e-${run}-2`, seq: 0, key: "AURORA-9002", created, author: "Петров Пётр", field_id: "assignee", field_name: "assignee", from: null, from_string: null, to: "x".repeat(200), to_string: "Очень-длинное-имя-без-пробелов-".repeat(8) }, ]; - const r = await fetch(HUB_URL + "/agent/history", { method: "POST", headers: { Authorization: "Bearer " + tokens().agent, "Content-Type": "application/json" }, body: JSON.stringify({ changes }) }); + const r = await fetch(HUB_URL + "/agent/history", { method: "POST", headers: { Authorization: "Bearer " + tokens().agents.jira, "Content-Type": "application/json" }, body: JSON.stringify({ changes }) }); expect(r.ok).toBe(true); } diff --git a/web/e2e/hub.ts b/web/e2e/hub.ts index 5566bad..5f3eabd 100644 --- a/web/e2e/hub.ts +++ b/web/e2e/hub.ts @@ -2,7 +2,7 @@ import { expect, type Page } from "@playwright/test"; import { readFileSync } from "node:fs"; import { HUB_HOME, HUB_URL } from "../playwright.config.ts"; -export const tokens = () => JSON.parse(readFileSync(`${HUB_HOME}/tokens.json`, "utf8")) as { agent: string; client: string }; +export const tokens = () => JSON.parse(readFileSync(`${HUB_HOME}/tokens.json`, "utf8")) as { client: string; agents: Record }; const BASE = HUB_URL; export async function login(page: Page, path = "/app/board") { @@ -14,7 +14,7 @@ export async function login(page: Page, path = "/app/board") { } async function call(path: string, body: unknown) { - const r = await fetch(BASE + path, { method: "POST", headers: { Authorization: "Bearer " + tokens().agent, "Content-Type": "application/json" }, body: JSON.stringify(body) }); + const r = await fetch(BASE + path, { method: "POST", headers: { Authorization: "Bearer " + tokens().agents.jira, "Content-Type": "application/json" }, body: JSON.stringify(body) }); expect(r.ok, `${path} → ${r.status}`).toBe(true); return r.json(); } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2910491..5ff6886 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -72,6 +72,7 @@ async function call(method: string, path: string, body?: unknown, token = get export const api = { get: (path: string, params?: Params) => call("GET", withParams(path, params)), post: (path: string, body: unknown = {}) => call("POST", path, body), + patch: (path: string, body: unknown) => call("PATCH", path, body), put: (path: string, body: unknown) => call("PUT", path, body), // тело ответа как есть (содержимое вложения): те же токен и ошибки, JSON не разбирается blob: async (path: string) => (await send("GET", path, "*/*")).blob(), diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index c66d5a8..4f3353f 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "./client.ts"; -import type { CreatedProject, HubSettings, Project } from "./types.ts"; +import type { CreatedProject, HubSettings, UpdatedProject, Project } from "./types.ts"; // запросы ядра: настройки и проекты; запросы модулей — в web/src/modules/<модуль>/api/queries.ts export const qk = { @@ -14,12 +14,22 @@ export const useSettings = () => export const useProjects = () => useQuery({ queryKey: qk.projects, queryFn: () => api.get<{ projects?: Project[] }>("/projects").then((r) => r.projects ?? []) }); -// «Новый проект»: пакет обновления хаб ставит сам (batch_id), автор — viewer, чтобы доска подхватила его ход +// «Новый проект»: пакет обновления хаб ставит сам (batch_id), автор — viewer, чтобы доска подхватила его ход; +// fields — метка, название и поля модулей (имена полей задают модули, см. HubModule.projectFields) export function useCreateProject() { const qc = useQueryClient(); return useMutation({ - mutationFn: (f: { label: string; name: string; board_id?: number }) => - api.post("/projects", { ...f, author: "viewer" }), + mutationFn: (fields: Record) => api.post("/projects", { ...fields, author: "viewer" }), onSuccess: () => qc.invalidateQueries({ queryKey: qk.projects }), // ждём список: onCreated уже видит новый проект }); } + +// правка проекта: PATCH /projects/{label} с изменёнными полями; хаб ставит пакеты модулей (batch_ids) +export function useUpdateProject() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ label, fields }: { label: string; fields: Record }) => + api.patch(`/projects/${encodeURIComponent(label)}`, { ...fields, author: "viewer" }), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.projects }), + }); +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 23cf8ac..20d79bb 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -83,9 +83,8 @@ export interface Batch { export interface AgentView { online: boolean; last_seen_at: string | null; warning: string | null } export interface CreatedBatch { id: number; estimate_seconds: number; count: number } -// проект-метка (GET/POST /projects): board_type хаб определяет по отчёту агента, до него — null -export interface Project { - label: string; name: string; board_id: number | null; board_type: "scrum" | "kanban" | null; created_at: string; - issues_count: number; -} +// проект-метка (GET/POST /projects): ядро знает метку, название и дату; поля модулей (jira: board_id, board_type, +// issues_count) типизирует сам модуль — см. modules/jira/projectFields.ts +export type Project = { label: string; name: string; created_at: string } & Record; export interface CreatedProject { project: Project; batch_id: number } +export interface UpdatedProject { project: Project; batch_ids: number[] } diff --git a/web/src/modules/jira/index.tsx b/web/src/modules/jira/index.tsx index d4ceabf..bcefab3 100644 --- a/web/src/modules/jira/index.tsx +++ b/web/src/modules/jira/index.tsx @@ -33,5 +33,6 @@ export const jiraModule: HubModule = { SidebarStatus, TopbarNotice, useOverlay: useIssueOverlay, + projectFields: [{ name: "board_id", label: "Номер доски Jira", hint: "scrum или kanban определится сам", kind: "int" }], useOnProjectCreated, }; diff --git a/web/src/modules/jira/projectFields.ts b/web/src/modules/jira/projectFields.ts new file mode 100644 index 0000000..f0310c9 --- /dev/null +++ b/web/src/modules/jira/projectFields.ts @@ -0,0 +1,4 @@ +import type { Project } from "../../api/types.ts"; + +// поля проекта, которые добавляет модуль jira (GET /projects); board_type хаб определяет по отчёту агента, до него — null +export type JiraProject = Project & { board_id: number | null; board_type: "scrum" | "kanban" | null; issues_count: number }; diff --git a/web/src/modules/types.ts b/web/src/modules/types.ts index 78e7bf3..0c1bf81 100644 --- a/web/src/modules/types.ts +++ b/web/src/modules/types.ts @@ -9,6 +9,9 @@ export type NavItem = { // окно поверх страницы (карточка задачи): pageLocation — адрес страницы под окном, null — окна нет export type Overlay = { pageLocation: Location | null; element: ReactNode }; +// поле окна «Новый проект», которое вносит модуль: пустое значение в тело запроса не попадает, int уходит числом +export type ProjectFieldDef = { name: string; label: string; hint: string; kind: "int" | "text" }; + // описание веб-модуля (design hub-modules 10): оболочка берёт из него страницы, пункты панели, состояние данных, окна export type HubModule = { name: string; @@ -19,5 +22,6 @@ export type HubModule = { SidebarStatus?: ComponentType<{ project: string | null; compact: boolean }>; TopbarNotice?: ComponentType; // строка события под верхней панелью узкого экрана useOverlay?: () => Overlay; // вызывается оболочкой в постоянном порядке модулей + projectFields?: ProjectFieldDef[]; // поля проекта модуля: окно создания собирает их у всех модулей useOnProjectCreated?: () => (batchId: number) => void; }; diff --git a/web/src/projects/NewProjectDialog.test.tsx b/web/src/projects/NewProjectDialog.test.tsx new file mode 100644 index 0000000..37c2474 --- /dev/null +++ b/web/src/projects/NewProjectDialog.test.tsx @@ -0,0 +1,68 @@ +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 { jiraModule } from "../modules/jira/index.tsx"; +import type { HubModule } from "../modules/types.ts"; +import { json, mockFetch } from "../test/fetchMock.ts"; +import { NewProjectDialog } from "./NewProjectDialog.tsx"; + +// второй модуль с текстовым полем: окно собирает поля из всех модулей, а не только из jira +vi.mock("../modules/index.ts", async () => { + const { jiraModule: jira } = await import("../modules/jira/index.tsx"); + const extra: HubModule = { + name: "extra", pages: [], nav: [], + projectFields: [{ name: "space_key", label: "Пространство", hint: "ключ", kind: "text" }], + }; + return { MODULES: [jira, extra] }; +}); + +const CREATED = { project: { label: "P", name: "Пэ", created_at: "" }, batch_id: 3 }; + +function setup() { + const posts: unknown[] = []; + mockFetch((method, _url, body) => { + if (method === "POST") { posts.push(body); return json(201, CREATED); } + return json(404, { error: "not_found" }); + }); + const onCreated = vi.fn(); + render( + + {}} onCreated={onCreated} /> + , + ); + return { posts, onCreated }; +} +const fill = async () => { + await userEvent.type(screen.getByLabelText("Метка"), "P"); + await userEvent.type(screen.getByLabelText("Название"), "Пэ"); +}; + +describe("NewProjectDialog: поля модулей", () => { + beforeEach(() => setToken("tok")); + + it("jira описывает поле доски", () => { + expect(jiraModule.projectFields).toEqual([ + { name: "board_id", label: "Номер доски Jira", hint: "scrum или kanban определится сам", kind: "int" }, + ]); + }); + + it("пустые поля в тело не попадают", async () => { + const { posts } = setup(); + await fill(); + await userEvent.click(screen.getByRole("button", { name: "Создать" })); + await waitFor(() => expect(posts).toHaveLength(1)); + expect(posts[0]).toEqual({ label: "P", name: "Пэ", author: "viewer" }); + }); + + it("доска «5120» уходит числом, текстовое поле другого модуля — строкой", async () => { + const { posts } = setup(); + await fill(); + await userEvent.type(screen.getByLabelText("Номер доски Jira"), "5120"); + await userEvent.type(screen.getByLabelText("Пространство"), " KEY "); + await userEvent.click(screen.getByRole("button", { name: "Создать" })); + await waitFor(() => expect(posts).toHaveLength(1)); + expect(posts[0]).toEqual({ label: "P", name: "Пэ", board_id: 5120, space_key: "KEY", author: "viewer" }); + }); +}); diff --git a/web/src/projects/NewProjectDialog.tsx b/web/src/projects/NewProjectDialog.tsx index bc6f6ae..6adf2a5 100644 --- a/web/src/projects/NewProjectDialog.tsx +++ b/web/src/projects/NewProjectDialog.tsx @@ -2,18 +2,25 @@ 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 { 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 }) { - const [label, setLabel] = useState(""), [name, setName] = useState(""), [board, setBoard] = useState(""); + const [label, setLabel] = useState(""), [name, setName] = useState(""); + const [extra, setExtra] = useState>({}); const create = useCreateProject(); const submit = (e: FormEvent) => { e.preventDefault(); - const f: { label: string; name: string; board_id?: number } = { label: label.trim(), name: name.trim() }; - if (board.trim() !== "") f.board_id = Number(board); + const f: Record = { label: label.trim(), name: name.trim() }; + for (const d of FIELDS) { + const v = (extra[d.name] ?? "").trim(); + if (v !== "") f[d.name] = d.kind === "int" ? Number(v) : v; + } create.mutate(f, { onSuccess: (r) => onCreated(r.project, r.batch_id) }); }; const err = create.error; @@ -25,10 +32,12 @@ export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCr

Новый проект

setLabel(e.target.value)} required /> setName(e.target.value)} required /> - {/* долг hub-modules: проекты → модуль jira (change wiki) */} - - setBoard(e.target.value)} placeholder="scrum или kanban определится сам" /> - + {FIELDS.map((d) => ( + + setExtra({ ...extra, [d.name]: e.target.value })} placeholder={d.hint} /> + + ))} {text && {text}}