diff --git a/web/src/issues/IssueCard.test.tsx b/web/src/issues/IssueCard.test.tsx index 452c79d..6d3c4f8 100644 --- a/web/src/issues/IssueCard.test.tsx +++ b/web/src/issues/IssueCard.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClientProvider } from "@tanstack/react-query"; import { createMemoryRouter, RouterProvider, useNavigate, useParams } from "react-router"; @@ -31,16 +31,109 @@ function open(path: string, client = makeQueryClient()) { describe("карточка задачи", () => { beforeEach(() => setToken("tok")); - it("поля, описание с переносами, связи с подписями, команды", async () => { + it("шапка: ключ, статусы, тема, исполнитель · срок · спринт · эпик по имени; ссылка в Jira", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { planned_end: "2020-01-10" }))); + open("/issues/AURORA-9001"); + expect(await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" })).toBeInTheDocument(); + const head = screen.getByTestId("issue-head"); + expect(within(head).getByText("AURORA-9001")).toBeInTheDocument(); + expect(within(head).getByText("В работе", { selector: ".badge.status" })).toBeInTheDocument(); + expect(within(head).getByText("В работе", { selector: ".badge.fact" })).toHaveClass("fact-in-progress"); + expect(within(head).getByTestId("issue-facts")).toHaveTextContent("Иванов Иван Иванович · срок 10.01 просрочено · AWPCA_Спринт 2 · эпик: Тестовый эпик"); + expect(within(head).getByText("просрочено")).toHaveClass("overdue"); + expect(screen.getByRole("link", { name: "Открыть в Jira ↗" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001"); + }); + + it("связи по группам с темой и статусом; задача вне зеркала; прочий тип с направлением", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { + links_out: [{ target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала", summary: "API свода", status: "Новая" }, + { target_key: "lower-1", link_type: "ASUPR_Связанные", summary: null, status: null }], + links_in: [{ source_key: "AURORA-9003", link_type: "ASUPR_Блокер_Завершения", summary: "Макет экрана", status: "Готово" }, + { source_key: "AURORA-9008", link_type: "Duplicate", summary: "Актуализация ТЗ", status: "В работе" }], + }))); + open("/issues/AURORA-9001"); + const depends = await screen.findByRole("group", { name: "Зависит от" }); + expect(within(depends).getByRole("listitem")).toHaveTextContent("завершение AURORA-9003 Макет экрана Готово"); + const blocks = screen.getByRole("group", { name: "Блокирует" }); + expect(within(blocks).getByRole("link", { name: "AURORA-9002" })).toHaveAttribute("href", "/issues/AURORA-9002"); + expect(within(blocks).getByRole("listitem")).toHaveTextContent("начало AURORA-9002 API свода Новая"); + expect(within(screen.getByRole("group", { name: "Связана с" })).getByRole("listitem")).toHaveTextContent("lower-1 нет в зеркале"); + expect(within(screen.getByRole("group", { name: "Прочие" })).getByRole("listitem")).toHaveTextContent("← Duplicate AURORA-9008"); + expect(screen.getByRole("link", { name: "AURORA-9002" }).closest(".issue")).not.toBeNull(); // O-10: цвет темы + }); + + it("без связей — «связей нет»", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [], links_in: [] }))); + open("/issues/AURORA-9001"); + expect(await screen.findByText("связей нет")).toBeInTheDocument(); + }); + + it("описание свыше 8 строк свёрнуто, «развернуть»/«свернуть»; короткое — без кнопки", async () => { + const long = Array.from({ length: 10 }, (_, n) => `строка ${n + 1}`).join("\n"); + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: long }))); + open("/issues/AURORA-9001"); + const pre = await screen.findByText(long, { normalizer: (s) => s }); + expect(pre).toHaveClass("description", "clamped"); + await userEvent.click(screen.getByRole("button", { name: "развернуть" })); + expect(pre).not.toHaveClass("clamped"); + await userEvent.click(screen.getByRole("button", { name: "свернуть" })); + expect(pre).toHaveClass("clamped"); + }); + + it("короткое описание — без кнопки; нет описания — нет раздела", async () => { + let d: string | null = "строка 1\nстрока 2"; + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: d }))); + open("/issues/AURORA-9001"); + expect(await screen.findByText("строка 1\nстрока 2", { normalizer: (s) => s })).toHaveClass("description"); + expect(screen.queryByRole("button", { name: "развернуть" })).toBeNull(); + d = null; + cleanup(); + open("/issues/AURORA-9001"); + await screen.findByRole("heading", { level: 2 }); + expect(screen.queryByRole("heading", { name: "Описание" })).toBeNull(); + }); + + it("комментарии новые сверху; «Скрыть» запоминается для следующей карточки", async () => { + const comments = [ + { comment_id: "2", author: "Петров Пётр", created: "2026-10-15T12:00:00.000+0300", updated: null, body: "новый\nвторая строка" }, + { comment_id: "1", author: "Иванов Иван", created: "2026-10-14T12:00:00.000+0300", updated: null, body: "старый" }]; + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { comments }))); + open("/issues/AURORA-9001"); + expect(await screen.findByRole("heading", { name: "Комментарии (2)" })).toBeInTheDocument(); + const items = within(screen.getByTestId("comments")).getAllByRole("listitem"); + expect(items[0]).toHaveTextContent("Петров Пётр"); + expect(within(items[0]).getByText("новый\nвторая строка", { normalizer: (s) => s })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Скрыть" })); + expect(screen.queryByText("старый")).toBeNull(); + expect(localStorage.getItem("jira-hub-comments-hidden")).toBe("1"); + cleanup(); + open("/issues/AURORA-9001"); + expect(await screen.findByRole("button", { name: "Показать" })).toBeInTheDocument(); + expect(screen.queryByText("старый")).toBeNull(); + }); + + it("комментарий без автора и текста не ломает карточку; без комментариев — «комментариев нет»", async () => { + let comments = [{ comment_id: "1", author: null, created: null, updated: null, body: "" }]; + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { comments }))); + open("/issues/AURORA-9001"); + expect(await screen.findByRole("heading", { name: "Комментарии (1)" })).toBeInTheDocument(); + expect(within(screen.getByTestId("comments")).getByRole("listitem")).toHaveTextContent("—"); + comments = []; + cleanup(); + open("/issues/AURORA-9001"); + expect(await screen.findByText("комментариев нет")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Скрыть" })).toBeNull(); + }); + + it("прочие поля и команды моста свёрнуты", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); open("/issues/AURORA-9001"); - expect(await screen.findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument(); - expect(screen.getByText("строка 1\nстрока 2", { normalizer: (s) => s })).toHaveClass("description"); - expect(await screen.findByText("блокирует начало")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "AURORA-9002" })).toHaveAttribute("href", "/issues/AURORA-9002"); - expect(screen.getByText("Duplicate")).toBeInTheDocument(); // неизвестный тип — имя Jira - expect(screen.getByRole("cell", { name: "ensure_fields" })).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Открыть в Jira" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001"); + const other = await screen.findByRole("region", { name: "Прочие поля" }); + expect(other).toHaveTextContent("Приоритет"); + expect(other).toHaveTextContent("Высокий"); + const details = screen.getByText("Команды моста (1)").closest("details")!; + expect(details).not.toHaveAttribute("open"); + expect(within(details).getByRole("cell", { name: "ensure_fields" })).toBeInTheDocument(); }); it("заголовок карточки — h2 с id для aria-labelledby окна", async () => { @@ -65,13 +158,15 @@ describe("карточка задачи", () => { return json(200, issue("AURORA-9001")); }); open("/issues/AURORA-9001"); + await userEvent.click(await screen.findByRole("button", { name: "Править" })); const rest = await screen.findByLabelText("Осталось"); await userEvent.clear(rest); await userEvent.type(rest, "тесты"); await userEvent.clear(screen.getByLabelText("Сделано")); await userEvent.click(screen.getByRole("button", { name: "Сохранить" })); await waitFor(() => expect(put).toEqual({ rest_text: "тесты", done_text: null, updated_by: "web" })); - expect(await screen.findByText(/сохранено .*web/)).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Сохранить" })).toBeDisabled(); + expect(await screen.findByText(/изменено .*web/)).toBeInTheDocument(); // снова режим чтения + expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Сохранить" })).toBeNull(); }); it("422 — текст ошибки у формы, введённое на месте", async () => { @@ -81,6 +176,7 @@ describe("карточка задачи", () => { return json(200, issue("AURORA-9001")); }); open("/issues/AURORA-9001"); + await userEvent.click(await screen.findByRole("button", { name: "Править" })); const owner = await screen.findByLabelText("Заметка владельца"); await userEvent.type(owner, "проверить"); await userEvent.click(screen.getByRole("button", { name: "Сохранить" })); @@ -91,6 +187,7 @@ describe("карточка задачи", () => { it("уход с несохранёнными правками требует подтверждения", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); const router = open("/issues/AURORA-9001"); + await userEvent.click(await screen.findByRole("button", { name: "Править" })); await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик"); await act(async () => { await router.navigate("/board"); }); expect(await screen.findByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeInTheDocument(); @@ -128,6 +225,7 @@ describe("карточка задачи", () => { const client = makeQueryClient(); client.setQueryData(qk.issue("AURORA-9002"), issue("AURORA-9002")); const router = open("/issues/AURORA-9001", client); + await userEvent.click(await screen.findByRole("button", { name: "Править" })); await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик"); await userEvent.click(screen.getByRole("button", { name: "Сохранить" })); await act(async () => { await router.navigate("/issues/AURORA-9002"); }); @@ -136,8 +234,8 @@ describe("карточка задачи", () => { slowPut.resolve(json(200, note({ owner_note: "черновик", updated_by: "web" }))); await waitFor(() => expect(client.isMutating()).toBe(0)); expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); - expect(screen.getByLabelText("Заметка владельца")).toHaveValue(""); - expect(screen.getByRole("button", { name: "Сохранить" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument(); // новая карточка — в режиме чтения + expect(screen.queryByText("черновик")).toBeNull(); }); it("T14.1: поля заметки заблокированы на время сохранения, ответ не затирает ввод", async () => { @@ -148,13 +246,14 @@ describe("карточка задачи", () => { return json(200, issue("AURORA-9001")); }); open("/issues/AURORA-9001"); + await userEvent.click(await screen.findByRole("button", { name: "Править" })); await userEvent.type(await screen.findByLabelText("Заметка владельца"), "раз"); await userEvent.click(screen.getByRole("button", { name: "Сохранить" })); const box = screen.getByLabelText("Заметка владельца"); await waitFor(() => expect(box).toBeDisabled()); slowPut.resolve(json(200, note({ owner_note: "раз", updated_by: "web" }))); - await waitFor(() => expect(box).toBeEnabled()); - expect(box).toHaveValue("раз"); + await screen.findByRole("button", { name: "Править" }); // режим чтения (findByText("раз") ловил бы ещё живой textarea) + expect(screen.getByText("раз")).toBeInTheDocument(); // с сохранённым значением }); it("M-2: ошибка повторной загрузки при уже имеющихся данных — данные остаются, строка с «Повторить»", async () => { @@ -186,9 +285,46 @@ describe("карточка задачи", () => { it("T14.3: id подсказок «статуса по факту» уникален и связан с полем", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); open("/issues/AURORA-9001"); + await userEvent.click(await screen.findByRole("button", { name: "Править" })); const input = await screen.findByLabelText("Статус по факту"); const id = input.getAttribute("list")!; expect(id).not.toBe("fact-statuses"); expect(document.getElementById(id)?.tagName).toBe("DATALIST"); }); + + it("заметки в режиме чтения: пустые — «—», время и автор; «Отмена» без правок — сразу", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); + open("/issues/AURORA-9001"); + const view = await screen.findByRole("region", { name: "Служебные заметки" }); + expect(view).toHaveTextContent("Сделано"); + expect(view).toHaveTextContent("схема"); + expect(within(view).getByText("Заметка владельца").nextElementSibling).toHaveTextContent("—"); + expect(view).toHaveTextContent(/изменено .*chat/); + await userEvent.click(within(view).getByRole("button", { name: "Править" })); + await userEvent.click(screen.getByRole("button", { name: "Отмена" })); + expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument(); + }); + + it("«Отмена» с правками спрашивает; «Отменить правки» возвращает чтение без изменений", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); + open("/issues/AURORA-9001"); + await userEvent.click(await screen.findByRole("button", { name: "Править" })); + await userEvent.type(screen.getByLabelText("Заметка владельца"), "черновик"); + await userEvent.click(screen.getByRole("button", { name: "Отмена" })); + expect(screen.getByText("Отменить правки заметки?")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Продолжить правку" })); + expect(screen.getByLabelText("Заметка владельца")).toHaveValue("черновик"); + await userEvent.click(screen.getByRole("button", { name: "Отмена" })); + await userEvent.click(screen.getByRole("button", { name: "Отменить правки" })); + expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument(); + expect(screen.queryByText("черновик")).toBeNull(); + }); + + it("заметки ещё нет — режим чтения с «—» и «Править»", async () => { + mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { note: null }))); + open("/issues/AURORA-9001"); + const view = await screen.findByRole("region", { name: "Служебные заметки" }); + expect(within(view).getAllByText("—")).toHaveLength(5); + expect(within(view).getByRole("button", { name: "Править" })).toBeInTheDocument(); + }); }); diff --git a/web/src/issues/IssueCard.tsx b/web/src/issues/IssueCard.tsx index 222e4da..f25bca2 100644 --- a/web/src/issues/IssueCard.tsx +++ b/web/src/issues/IssueCard.tsx @@ -1,68 +1,177 @@ -import { IssueLink } from "./IssueLink.tsx"; +import { useLayoutEffect, useRef, useState } from "react"; import { ApiError } from "../api/client.ts"; import { useIssue, useSettings } from "../api/queries.ts"; +import type { IssueComment, IssueDetail } from "../api/types.ts"; import { JIRA_URL } from "../board/constants.ts"; -import { fmtStamp } from "../board/model/util.ts"; -import { NoteForm } from "./NoteForm.tsx"; +import { fmtDate, fmtStamp } from "../board/model/util.ts"; +import { factClass, groupLinks, isOverdue, todayISO } from "./card.ts"; +import { useCommentsHidden } from "./commentsPref.ts"; +import { IssueLink } from "./IssueLink.tsx"; +import { Notes } from "./Notes.tsx"; import { dueOf } from "./options.ts"; import "./issues.css"; +const DESC_LINES = 8; + export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClose: () => void }) { const issue = useIssue(key); const settings = useSettings(); - // подпись типа связи: логический тип из link_types настроек, иначе имя типа Jira - const typeName = (jira: string) => - Object.entries(settings.data?.link_types ?? {}).find(([, j]) => j === jira)?.[0] ?? jira; - - if (issue.isPending) return

загрузка {key}…

; + const close = ; + if (issue.isPending) return

загрузка {key}…

{close}
; if (issue.error instanceof ApiError && issue.error.status === 404) { return

Задачи {key} нет в зеркале хаба.

; } if (issue.isError && !issue.data) { return ( -

{issue.error.message}{" "} -

+
+

{issue.error.message}{" "} +

+ +
); } const i = issue.data!; - const fields: [string, string | null][] = [ - ["Тип", i.issue_type], ["Статус Jira", i.status], ["Приоритет", i.priority], ["Исполнитель", i.assignee], - ["Автор", i.reporter], ["Спринт", i.sprint], ["Срок", dueOf(i) || null], ["Плановое начало", i.planned_start], - ["Эпик", i.epic_key], ["Метки", i.labels.join(", ") || null], ["Сосед по связям", i.neighbor ? "да" : null], - ["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null], - ]; + const labels = settings.data?.status_labels as Record | undefined; + const fact = i.note?.fact_status ?? null; + const cls = factClass(fact, labels); + const due = dueOf(i); + const facts = [ + i.assignee && {i.assignee}, + due && срок {fmtDate(due)}{isOverdue(due, fact, labels, todayISO()) && <> просрочено}, + i.sprint && {i.sprint}, + i.epic_key && эпик: {i.epic_summary ?? i.epic_key}, + ].filter(Boolean); return ( -
- {issue.isError && ( -

{issue.error.message}{" "} -

- )} -

-

{i.key} · {i.summary}

-

Открыть в Jira

-
-
-
{fields.filter(([, v]) => v).map(([k, v]) =>
{k}
{v}
)}
- {i.description &&
{i.description}
} -

Связи

-
    - {i.links_out.map((l) =>
  • {typeName(l.link_type)} →
  • )} - {i.links_in.map((l) =>
  • → {typeName(l.link_type)} → эта задача
  • )} - {!i.links_out.length && !i.links_in.length &&
  • связей нет
  • } -
-

Последние команды

- {i.commands.length ? ( - - - {i.commands.map((c) => ( - - - ))} -
КомандаПакетТипСтатусЗавершенаОшибка
#{c.id}#{c.batch_id}{c.type}{c.status}{c.finished_at ? fmtStamp(c.finished_at) : ""}{c.error}
- ) :

команд по задаче не было

} +
+
+
+ {i.key} + {i.status && {i.status}} + {fact && {fact}} + + Открыть в Jira ↗ + {close} +
+

{i.summary || i.key}

+ {facts.length > 0 &&

{facts.flatMap((f, n) => (n ? [" · ", f] : [f]))}

} + {issue.isError && ( +

{issue.error.message}{" "} +

+ )} +
+
+
+ } /> + {i.description && } + +
+
+ + +
-
); } + +function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record }) { + const groups = groupLinks(issue, linkTypes); + return ( +
+

Связи

+ {!groups.length &&

связей нет

} + {groups.map(([id, title, links]) => ( +
+ +
    + {links.map((l) => ( +
  • + {l.tag && <>{l.tag}{" "}} + {" "} + {l.summary === null && l.status === null + ? нет в зеркале + : <>{l.summary}{l.status && <>{" "}{l.status}}} +
  • + ))} +
+
+ ))} +
+ ); +} + +function Description({ text }: { text: string }) { + const ref = useRef(null); + const [open, setOpen] = useState(false); + const [overflow, setOverflow] = useState(false); + useLayoutEffect(() => { + const el = ref.current; + if (el && !open) setOverflow(el.scrollHeight > el.clientHeight + 1); + }, [text, open]); + const long = text.split("\n").length > DESC_LINES || overflow; + return ( +
+

Описание

+
{text}
+ {(long || open) && } +
+ ); +} + +function Comments({ items }: { items: IssueComment[] }) { + const [hidden, setHidden] = useCommentsHidden(); + return ( +
+
+

Комментарии ({items.length})

+ {items.length > 0 && } +
+ {!items.length &&

комментариев нет

} + {items.length > 0 && !hidden && ( +
    + {items.map((c) => ( +
  • +
    {c.author ?? "—"}{c.created ? `, ${fmtStamp(c.created)}` : ""}
    +
    {c.body}
    +
  • + ))} +
+ )} +
+ ); +} + +function OtherFields({ issue: i }: { issue: IssueDetail }) { + const rows: [string, string | null][] = [ + ["Тип", i.issue_type], ["Приоритет", i.priority], ["Автор", i.reporter], ["Метки", i.labels.join(", ") || null], + ["Плановое начало", i.planned_start], ["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null], + ["Сосед по связям", i.neighbor ? "да" : null], + ]; + return ( +
+

Прочие поля

+
{rows.filter(([, v]) => v).map(([k, v]) =>
{k}
{v}
)}
+
+ ); +} + +function Commands({ issue: i }: { issue: IssueDetail }) { + if (!i.commands.length) { + return

Команды моста

команд по задаче не было

; + } + return ( +
+
+ Команды моста ({i.commands.length}) + + + {i.commands.map((c) => ( + + + ))} +
КомандаПакетТипСтатусЗавершенаОшибка
#{c.id}#{c.batch_id}{c.type}{c.status}{c.finished_at ? fmtStamp(c.finished_at) : ""}{c.error}
+
+
+ ); +} diff --git a/web/src/issues/IssueModal.test.tsx b/web/src/issues/IssueModal.test.tsx index 1e9f6d8..90e300f 100644 --- a/web/src/issues/IssueModal.test.tsx +++ b/web/src/issues/IssueModal.test.tsx @@ -115,6 +115,7 @@ describe("карточка окном", () => { const router = at(["/app/issues"]); render(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); + await userEvent.click(await within(dialog()).findByRole("button", { name: "Править" })); const box = await within(dialog()).findByLabelText("Заметка владельца"); await userEvent.type(box, "черновик"); fireEvent(dialog(), new Event("cancel", { cancelable: true })); diff --git a/web/src/issues/NoteForm.tsx b/web/src/issues/NoteForm.tsx index e4ea965..d2e229b 100644 --- a/web/src/issues/NoteForm.tsx +++ b/web/src/issues/NoteForm.tsx @@ -1,9 +1,8 @@ -import { useEffect, useId, useMemo, useState } from "react"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; import { useBlocker } from "react-router"; import { ApiError } from "../api/client.ts"; import { useSaveNote, useSettings } from "../api/queries.ts"; import { NOTE_FIELDS, type IssueDetail, type NoteField, type NoteFields } from "../api/types.ts"; -import { fmtStamp } from "../board/model/util.ts"; type Values = Record; const valuesOf = (n: IssueDetail["note"]): Values => @@ -18,9 +17,11 @@ export function changedFields(initial: Values, values: Values): NoteFields { const TEXTS: [NoteField, string][] = [["done_text", "Сделано"], ["rest_text", "Осталось"], ["owner_note", "Заметка владельца"]]; -export function NoteForm({ issue }: { issue: IssueDetail }) { +export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () => void }) { const settings = useSettings(); const save = useSaveNote(issue.key); + const [askCancel, setAskCancel] = useState(false); + const confirmRef = useRef(null); const [initial, setInitial] = useState(() => valuesOf(issue.note)); const [values, setValues] = useState(initial); const changed = useMemo(() => changedFields(initial, values), [initial, values]); @@ -33,6 +34,8 @@ export function NoteForm({ issue }: { issue: IssueDetail }) { window.addEventListener("beforeunload", warn); return () => window.removeEventListener("beforeunload", warn); }, [dirty]); + // подтверждение ухода может оказаться ниже видимой части окна — прокрутить к нему + useEffect(() => { if (blocker.state === "blocked") confirmRef.current?.scrollIntoView?.({ block: "nearest" }); }, [blocker.state]); const kinds = Object.keys(settings.data?.kind_labels ?? {}); const kindOptions = values.work_kind && !kinds.includes(values.work_kind) ? [values.work_kind, ...kinds] : kinds; @@ -41,14 +44,13 @@ export function NoteForm({ issue }: { issue: IssueDetail }) { const set = (f: NoteField) => (e: { target: { value: string } }) => setValues((v) => ({ ...v, [f]: e.target.value })); const submit = () => save.mutate(changed, { - onSuccess: (note) => { const v = valuesOf(note); setInitial(v); setValues(v); }, + onSuccess: (note) => { const v = valuesOf(note); setInitial(v); setValues(v); onDone(); }, }); const err = save.error instanceof ApiError ? save.error.detail ?? save.error.message : save.error?.message; - const saved = save.data ?? issue.note; return ( -
{ e.preventDefault(); submit(); }}> -

Служебные заметки

+ { e.preventDefault(); submit(); }}> +

Служебные заметки