Карточка задачи окном и комментарии из Jira (#37) #40
@ -35,11 +35,17 @@ export type Note = Record<NoteField, string | null> & { key: string; updated_at:
|
||||
export interface IssueCommand {
|
||||
id: number; batch_id: number; type: string; status: string; finished_at: string | null; error: string | null;
|
||||
}
|
||||
export interface IssueLinkOut { target_key: string; link_type: string; summary: string | null; status: string | null }
|
||||
export interface IssueLinkIn { source_key: string; link_type: string; summary: string | null; status: string | null }
|
||||
// комментарий задачи из Jira (зеркало хаба, агент v10+): текст и время — как в Jira
|
||||
export interface IssueComment { comment_id: string; author: string | null; created: string | null; updated: string | null; body: string }
|
||||
export interface IssueDetail extends IssueRow {
|
||||
links_out: { target_key: string; link_type: string }[];
|
||||
links_in: { source_key: string; link_type: string }[];
|
||||
epic_summary: string | null;
|
||||
links_out: IssueLinkOut[];
|
||||
links_in: IssueLinkIn[];
|
||||
note: Note | null;
|
||||
commands: IssueCommand[];
|
||||
comments: IssueComment[];
|
||||
}
|
||||
|
||||
export type BatchStatus = "queued" | "running" | "done" | "cancelled";
|
||||
|
||||
@ -8,20 +8,9 @@ import { makeQueryClient } from "../api/queryClient.ts";
|
||||
import { setToken } from "../auth/token.ts";
|
||||
import settings from "../board/__fixtures__/settings.json";
|
||||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||||
import { issue, note } from "../test/issueFixtures.ts";
|
||||
import { IssuePage } from "./IssuePage.tsx";
|
||||
|
||||
const note = (over = {}) => ({ key: "AURORA-9001", fact_status: "В работе", work_kind: "разработка", done_text: "схема",
|
||||
rest_text: "API", owner_note: null, updated_at: "2026-10-14T09:00:00+00:00", updated_by: "chat", ...over });
|
||||
const issue = (key: string, over = {}) => ({
|
||||
key, id: "1", issue_type: "Задача", summary: `Тема ${key}`, description: "строка 1\nстрока 2", status: "В работе",
|
||||
priority: "Высокий", assignee: "Иванов Иван Иванович", reporter: "me", duedate: null, planned_start: null,
|
||||
planned_end: "2026-10-20", epic_key: "AURORA-9100", labels: ["kind-dev"], sprint: "AWPCA_Спринт 2", created: null,
|
||||
jira_updated: null, fetched_at: "", neighbor: false,
|
||||
links_out: [{ target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала" }],
|
||||
links_in: [{ source_key: "AURORA-9003", link_type: "Duplicate" }],
|
||||
note: note({ key }), commands: [{ id: 5, batch_id: 2, type: "ensure_fields", status: "done", finished_at: "2026-10-14T09:00:00+00:00", error: null }],
|
||||
...over,
|
||||
});
|
||||
const deferred = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
|
||||
|
||||
function open(path: string, client = makeQueryClient()) {
|
||||
|
||||
55
web/src/issues/card.test.ts
Normal file
55
web/src/issues/card.test.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import settings from "../board/__fixtures__/settings.json";
|
||||
import { factClass, groupLinks, isOverdue, todayISO } from "./card.ts";
|
||||
|
||||
const types = settings.link_types as Record<string, string>;
|
||||
const labels = { ...(settings.status_labels as Record<string, string>), "Снято": "fact-cancelled" };
|
||||
const out = (k: string, t: string, s: string | null = `Тема ${k}`, st: string | null = "В работе") => ({ target_key: k, link_type: t, summary: s, status: st });
|
||||
const inn = (k: string, t: string, s: string | null = `Тема ${k}`, st: string | null = "В работе") => ({ source_key: k, link_type: t, summary: s, status: st });
|
||||
|
||||
describe("groupLinks", () => {
|
||||
it("блокеры по направлению, связанные без повторов, прочие с направлением; порядок групп и ключей", () => {
|
||||
const g = groupLinks({
|
||||
links_out: [out("A-10", "ASUPR_Блокер_Завершения"), out("A-2", "ASUPR_Блокер_Начала"), out("A-5", "ASUPR_Связанные"),
|
||||
out("A-7", "Duplicate")],
|
||||
links_in: [inn("A-3", "ASUPR_Блокер_Начала"), inn("A-5", "ASUPR_Связанные"), inn("A-8", "Duplicate", null, null)],
|
||||
}, types);
|
||||
expect(g.map(([id, title]) => [id, title])).toEqual([["depends", "Зависит от"], ["blocks", "Блокирует"],
|
||||
["related", "Связана с"], ["other", "Прочие"]]);
|
||||
expect(g[0][2]).toEqual([{ key: "A-3", summary: "Тема A-3", status: "В работе", tag: "начало" }]);
|
||||
expect(g[1][2].map((l) => [l.key, l.tag])).toEqual([["A-2", "начало"], ["A-10", "завершение"]]); // ключи как числа
|
||||
expect(g[2][2].map((l) => l.key)).toEqual(["A-5"]);
|
||||
expect(g[3][2]).toEqual([{ key: "A-7", summary: "Тема A-7", status: "В работе", tag: "→ Duplicate" },
|
||||
{ key: "A-8", summary: null, status: null, tag: "← Duplicate" }]);
|
||||
});
|
||||
|
||||
it("пустые группы не возвращаются; без link_types всё — «Прочие» с именем Jira", () => {
|
||||
expect(groupLinks({ links_out: [], links_in: [] }, types)).toEqual([]);
|
||||
const g = groupLinks({ links_out: [out("A-2", "ASUPR_Блокер_Начала")], links_in: [] }, {});
|
||||
expect(g).toEqual([["other", "Прочие", [{ key: "A-2", summary: "Тема A-2", status: "В работе", tag: "→ ASUPR_Блокер_Начала" }]]]);
|
||||
});
|
||||
|
||||
it("логический тип из настроек вне трёх известных — «Прочие» с логическим именем", () => {
|
||||
const g = groupLinks({ links_out: [out("A-2", "X_Дубль")], links_in: [] }, { ...types, "дублирует": "X_Дубль" });
|
||||
expect(g[0][2][0].tag).toBe("→ дублирует");
|
||||
});
|
||||
});
|
||||
|
||||
describe("просрочка и класс статуса по факту", () => {
|
||||
it("класс из status_labels", () => {
|
||||
expect(factClass("Готово — закрыть", labels)).toBe("fact-done");
|
||||
expect(factClass("свой статус", labels)).toBeNull();
|
||||
expect(factClass(null, labels)).toBeNull();
|
||||
});
|
||||
it("срок раньше сегодня и не закрыта по факту — просрочено (R-2)", () => {
|
||||
expect(isOverdue("2026-10-14", "В работе", labels, "2026-10-15")).toBe(true);
|
||||
expect(isOverdue("2026-10-14", null, labels, "2026-10-15")).toBe(true);
|
||||
expect(isOverdue("2026-10-15", "В работе", labels, "2026-10-15")).toBe(false);
|
||||
expect(isOverdue("2026-10-14", "Готово — закрыть", labels, "2026-10-15")).toBe(false);
|
||||
expect(isOverdue("2026-10-14", "Снято", labels, "2026-10-15")).toBe(false);
|
||||
expect(isOverdue("", "В работе", labels, "2026-10-15")).toBe(false);
|
||||
});
|
||||
it("todayISO — местная дата YYYY-MM-DD", () => {
|
||||
expect(todayISO(new Date(2026, 0, 5, 23, 30))).toBe("2026-01-05");
|
||||
});
|
||||
});
|
||||
38
web/src/issues/card.ts
Normal file
38
web/src/issues/card.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import type { IssueDetail } from "../api/types.ts";
|
||||
|
||||
export type LinkGroupId = "depends" | "blocks" | "related" | "other";
|
||||
export interface CardLink { key: string; summary: string | null; status: string | null; tag: string | null }
|
||||
|
||||
const GROUPS: [LinkGroupId, string][] = [["depends", "Зависит от"], ["blocks", "Блокирует"], ["related", "Связана с"], ["other", "Прочие"]];
|
||||
const BLOCK_TAGS: Record<string, string> = { "блокирует начало": "начало", "блокирует завершение": "завершение" };
|
||||
const byKey = (a: CardLink, b: CardLink) =>
|
||||
a.key.localeCompare(b.key, "ru", { numeric: true }) || (a.tag ?? "").localeCompare(b.tag ?? "", "ru");
|
||||
|
||||
// связи карточки по смыслу (design 17): логическое имя типа — из link_types настроек (имя типа Jira → логическое)
|
||||
export function groupLinks(d: Pick<IssueDetail, "links_out" | "links_in">, linkTypes: Record<string, string>): [LinkGroupId, string, CardLink[]][] {
|
||||
const logical = new Map(Object.entries(linkTypes).map(([name, jira]) => [jira, name]));
|
||||
const groups: Record<LinkGroupId, CardLink[]> = { depends: [], blocks: [], related: [], other: [] };
|
||||
const add = (dir: "out" | "in", key: string, jiraType: string, summary: string | null, status: string | null) => {
|
||||
const name = logical.get(jiraType);
|
||||
if (name && BLOCK_TAGS[name]) groups[dir === "in" ? "depends" : "blocks"].push({ key, summary, status, tag: BLOCK_TAGS[name] });
|
||||
else if (name === "связанные") { if (!groups.related.some((l) => l.key === key)) groups.related.push({ key, summary, status, tag: null }); }
|
||||
else groups.other.push({ key, summary, status, tag: `${dir === "out" ? "→" : "←"} ${name ?? jiraType}` });
|
||||
};
|
||||
for (const l of d.links_out) add("out", l.target_key, l.link_type, l.summary, l.status);
|
||||
for (const l of d.links_in) add("in", l.source_key, l.link_type, l.summary, l.status);
|
||||
return GROUPS.filter(([id]) => groups[id].length).map(([id, title]) => [id, title, groups[id].sort(byKey)]);
|
||||
}
|
||||
|
||||
const CLOSED_FACT = new Set(["fact-done", "fact-cancelled"]);
|
||||
export const factClass = (fact: string | null | undefined, statusLabels: Record<string, string> | undefined): string | null =>
|
||||
(fact && statusLabels?.[fact]) || null;
|
||||
|
||||
// R-2: просрочено — срок раньше сегодня, а статус по факту не «закрыт» (классы fact-done, fact-cancelled)
|
||||
export function isOverdue(due: string, fact: string | null | undefined, statusLabels: Record<string, string> | undefined, today: string): boolean {
|
||||
if (!due || due >= today) return false;
|
||||
const cls = factClass(fact, statusLabels);
|
||||
return !(cls && CLOSED_FACT.has(cls));
|
||||
}
|
||||
|
||||
export const todayISO = (d = new Date()): string =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
15
web/src/issues/commentsPref.ts
Normal file
15
web/src/issues/commentsPref.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { useState } from "react";
|
||||
|
||||
// выбор «комментарии скрыты» — один на все карточки, в браузере (design 19); без localStorage — показаны
|
||||
export const COMMENTS_HIDDEN_KEY = "jira-hub-comments-hidden";
|
||||
const read = (): boolean => { try { return localStorage.getItem(COMMENTS_HIDDEN_KEY) === "1"; } catch { return false; } };
|
||||
|
||||
export function useCommentsHidden(): [boolean, (hidden: boolean) => void] {
|
||||
const [hidden, setHidden] = useState(read);
|
||||
const set = (v: boolean) => {
|
||||
setHidden(v);
|
||||
try { if (v) localStorage.setItem(COMMENTS_HIDDEN_KEY, "1"); else localStorage.removeItem(COMMENTS_HIDDEN_KEY); }
|
||||
catch { /* хранилище недоступно: выбор живёт до закрытия карточки */ }
|
||||
};
|
||||
return [hidden, set];
|
||||
}
|
||||
22
web/src/issues/modal.test.ts
Normal file
22
web/src/issues/modal.test.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BOARD_CARD_STATE, cardState, LIST_BACKGROUND, openCardState } from "./modal.ts";
|
||||
|
||||
describe("состояние окна в истории", () => {
|
||||
it("со страницы — фон = адрес страницы, глубина 1", () => {
|
||||
expect(openCardState({ pathname: "/issues", search: "?sprint=S", state: null }))
|
||||
.toEqual({ background: { pathname: "/issues", search: "?sprint=S" }, depth: 1, direct: false });
|
||||
});
|
||||
it("из окна — тот же фон, глубина + 1, признак прямого адреса сохраняется", () => {
|
||||
const s = { background: LIST_BACKGROUND, depth: 0, direct: true };
|
||||
expect(openCardState({ pathname: "/issues/A-1", search: "", state: s })).toEqual({ background: LIST_BACKGROUND, depth: 1, direct: true });
|
||||
expect(openCardState({ pathname: "/issues/A-2", search: "", state: BOARD_CARD_STATE }))
|
||||
.toEqual({ background: { pathname: "/board", search: "" }, depth: 2, direct: false });
|
||||
});
|
||||
it("чужое или битое state — не состояние окна", () => {
|
||||
expect(cardState(null)).toBeNull();
|
||||
expect(cardState({ background: "/issues", depth: 1 })).toBeNull();
|
||||
expect(cardState({ background: { pathname: "/issues", search: "" }, depth: "1" })).toBeNull();
|
||||
expect(cardState({ background: { pathname: "/issues", search: "" }, depth: 1 }))
|
||||
.toEqual({ background: { pathname: "/issues", search: "" }, depth: 1, direct: false });
|
||||
});
|
||||
});
|
||||
22
web/src/issues/modal.ts
Normal file
22
web/src/issues/modal.ts
Normal file
@ -0,0 +1,22 @@
|
||||
// состояние окна карточки в записи истории (design 10–12): страница под окном, сколько записей заняли карточки
|
||||
// поверх неё, открыто ли окно прямым адресом
|
||||
export interface Background { pathname: string; search: string }
|
||||
export interface CardState { background: Background; depth: number; direct: boolean }
|
||||
|
||||
export const LIST_BACKGROUND: Background = { pathname: "/issues", search: "" };
|
||||
// доска не хранит состояние в адресе: «ⓘ» всегда открывает окно поверх /board
|
||||
export const BOARD_CARD_STATE: CardState = { background: { pathname: "/board", search: "" }, depth: 1, direct: false };
|
||||
|
||||
export function cardState(s: unknown): CardState | null {
|
||||
const c = s as { background?: { pathname?: unknown; search?: unknown }; depth?: unknown; direct?: unknown } | null;
|
||||
if (!c || typeof c !== "object" || !c.background || typeof c.background !== "object") return null;
|
||||
const { pathname, search } = c.background;
|
||||
if (typeof pathname !== "string" || typeof search !== "string" || typeof c.depth !== "number") return null;
|
||||
return { background: { pathname, search }, depth: c.depth, direct: c.direct === true };
|
||||
}
|
||||
|
||||
// state ссылки на карточку: со страницы — фон = её адрес, глубина 1; из окна — тот же фон, глубина + 1
|
||||
export function openCardState(loc: { pathname: string; search: string; state: unknown }): CardState {
|
||||
const cur = cardState(loc.state);
|
||||
return cur ? { ...cur, depth: cur.depth + 1 } : { background: { pathname: loc.pathname, search: loc.search }, depth: 1, direct: false };
|
||||
}
|
||||
19
web/src/test/issueFixtures.ts
Normal file
19
web/src/test/issueFixtures.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { IssueDetail, Note } from "../api/types.ts";
|
||||
|
||||
export const note = (over: Partial<Note> = {}): Note => ({
|
||||
key: "AURORA-9001", fact_status: "В работе", work_kind: "разработка", done_text: "схема", rest_text: "API",
|
||||
owner_note: null, updated_at: "2026-10-14T09:00:00+00:00", updated_by: "chat", ...over,
|
||||
});
|
||||
|
||||
export const issue = (key: string, over: Partial<IssueDetail> = {}): IssueDetail => ({
|
||||
key, id: "1", issue_type: "Задача", summary: `Тема ${key}`, description: "строка 1\nстрока 2", status: "В работе",
|
||||
priority: "Высокий", assignee: "Иванов Иван Иванович", reporter: "me", duedate: null, planned_start: null,
|
||||
planned_end: "2026-10-20", epic_key: "AURORA-9100", epic_summary: "Тестовый эпик", labels: ["kind-dev"],
|
||||
sprint: "AWPCA_Спринт 2", created: null, jira_updated: null, fetched_at: "", neighbor: false,
|
||||
links_out: [{ target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала", summary: "API свода", status: "Новая" }],
|
||||
links_in: [{ source_key: "AURORA-9003", link_type: "Duplicate", summary: "Макет экрана", status: "Готово" }],
|
||||
note: note({ key }),
|
||||
commands: [{ id: 5, batch_id: 2, type: "ensure_fields", status: "done", finished_at: "2026-10-14T09:00:00+00:00", error: null }],
|
||||
comments: [],
|
||||
...over,
|
||||
});
|
||||
@ -3,6 +3,12 @@ import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
import { saveBoardScroll } from "../board/scrollMemory.ts";
|
||||
|
||||
// jsdom 29 не умеет showModal/close у <dialog>: открытость — атрибут open, как в браузере
|
||||
if (typeof HTMLDialogElement !== "undefined" && !HTMLDialogElement.prototype.showModal) {
|
||||
HTMLDialogElement.prototype.showModal = function showModal(this: HTMLDialogElement) { this.setAttribute("open", ""); };
|
||||
HTMLDialogElement.prototype.close = function close(this: HTMLDialogElement) { this.removeAttribute("open"); };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
saveBoardScroll(null);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user