Веб: поля проекта от модулей (#91)
Окно «Новый проект» собирает поля из MODULES[].projectFields; Project — ядро плюс поля модулей;
useUpdateProject (PATCH /projects/{label}); метка «долг hub-modules» снята; e2e читают токен агента из tokens.agents.jira.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b3055deebe
commit
867875daf6
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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<string, string> };
|
||||
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();
|
||||
}
|
||||
|
||||
@ -72,6 +72,7 @@ async function call<T>(method: string, path: string, body?: unknown, token = get
|
||||
export const api = {
|
||||
get: <T>(path: string, params?: Params) => call<T>("GET", withParams(path, params)),
|
||||
post: <T>(path: string, body: unknown = {}) => call<T>("POST", path, body),
|
||||
patch: <T>(path: string, body: unknown) => call<T>("PATCH", path, body),
|
||||
put: <T>(path: string, body: unknown) => call<T>("PUT", path, body),
|
||||
// тело ответа как есть (содержимое вложения): те же токен и ошибки, JSON не разбирается
|
||||
blob: async (path: string) => (await send("GET", path, "*/*")).blob(),
|
||||
|
||||
@ -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<CreatedProject>("/projects", { ...f, author: "viewer" }),
|
||||
mutationFn: (fields: Record<string, unknown>) => api.post<CreatedProject>("/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<string, unknown> }) =>
|
||||
api.patch<UpdatedProject>(`/projects/${encodeURIComponent(label)}`, { ...fields, author: "viewer" }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.projects }),
|
||||
});
|
||||
}
|
||||
|
||||
@ -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<string, unknown>;
|
||||
export interface CreatedProject { project: Project; batch_id: number }
|
||||
export interface UpdatedProject { project: Project; batch_ids: number[] }
|
||||
|
||||
@ -33,5 +33,6 @@ export const jiraModule: HubModule = {
|
||||
SidebarStatus,
|
||||
TopbarNotice,
|
||||
useOverlay: useIssueOverlay,
|
||||
projectFields: [{ name: "board_id", label: "Номер доски Jira", hint: "scrum или kanban определится сам", kind: "int" }],
|
||||
useOnProjectCreated,
|
||||
};
|
||||
|
||||
4
web/src/modules/jira/projectFields.ts
Normal file
4
web/src/modules/jira/projectFields.ts
Normal file
@ -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 };
|
||||
@ -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;
|
||||
};
|
||||
|
||||
68
web/src/projects/NewProjectDialog.test.tsx
Normal file
68
web/src/projects/NewProjectDialog.test.tsx
Normal file
@ -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(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<NewProjectDialog onClose={() => {}} onCreated={onCreated} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@ -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): <dialog> держит фокус; 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<Record<string, string>>({});
|
||||
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<string, unknown> = { 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
|
||||
<h2 className="project-form__title">Новый проект</h2>
|
||||
<Field label="Метка"><TextInput value={label} onChange={(e) => setLabel(e.target.value)} required /></Field>
|
||||
<Field label="Название"><TextInput value={name} onChange={(e) => setName(e.target.value)} required /></Field>
|
||||
{/* долг hub-modules: проекты → модуль jira (change wiki) */}
|
||||
<Field label="Номер доски Jira">
|
||||
<TextInput type="number" min={1} value={board} onChange={(e) => setBoard(e.target.value)} placeholder="scrum или kanban определится сам" />
|
||||
</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>
|
||||
))}
|
||||
{text && <Notice tone="error">{text}</Notice>}
|
||||
<div className="project-form__actions">
|
||||
<Button variant="secondary" disabled={create.isPending} onClick={onClose}>Отмена</Button>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user