Карточка задачи: без статуса по факту, «просрочено» по статусу Jira (#57)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
dbda1ad77c
commit
c5e33a9cc4
@ -28,10 +28,11 @@ export interface IssueRow {
|
||||
neighbor: boolean; fact_status?: string | null; work_kind?: string | null;
|
||||
}
|
||||
|
||||
export const NOTE_FIELDS = ["fact_status", "work_kind", "done_text", "rest_text", "owner_note"] as const;
|
||||
// поля заметки, которые приложение показывает и правит; fact_status хаб ещё отдаёт, приложение его не читает (hide-closed)
|
||||
export const NOTE_FIELDS = ["work_kind", "done_text", "rest_text", "owner_note"] as const;
|
||||
export type NoteField = (typeof NOTE_FIELDS)[number];
|
||||
export type NoteFields = Partial<Record<NoteField, string | null>>;
|
||||
export type Note = Record<NoteField, string | null> & { key: string; updated_at: string; updated_by: string };
|
||||
export type Note = Record<NoteField, string | null> & { key: string; fact_status?: string | null; updated_at: string; updated_by: string };
|
||||
|
||||
export interface IssueCommand {
|
||||
id: number; batch_id: number; type: string; status: string; finished_at: string | null; error: string | null;
|
||||
|
||||
@ -40,14 +40,15 @@ function overflow(scroll: number, client: number) {
|
||||
describe("карточка задачи", () => {
|
||||
beforeEach(() => setToken("tok"));
|
||||
|
||||
it("шапка: ключ, статусы, тема, исполнитель · срок · спринт · эпик по имени; ссылка в Jira", async () => {
|
||||
it("шапка: ключ, статус Jira, тема, исполнитель · срок · спринт · эпик по имени; ссылка в 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"); // как в макете раздела 1
|
||||
expect(head.querySelector(".badge.fact")).toBeNull(); // статуса по факту в карточке нет (hide-closed)
|
||||
expect(within(head).queryByText(/факт:/)).toBeNull();
|
||||
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");
|
||||
@ -383,14 +384,24 @@ describe("карточка задачи", () => {
|
||||
expect(screen.queryByRole("columnheader", { name: "Команда" })).toBeNull();
|
||||
});
|
||||
|
||||
it("T14.3: id подсказок «статуса по факту» уникален и связан с полем", async () => {
|
||||
it("закрытая задача со сроком в прошлом — без пометки «просрочено»", async () => {
|
||||
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { planned_end: "2020-01-10", status: "Готово" })));
|
||||
open("/issues/AURORA-9001");
|
||||
const facts = await screen.findByTestId("issue-facts");
|
||||
expect(facts).toHaveTextContent("срок 10.01");
|
||||
expect(within(facts).queryByText("просрочено")).toBeNull();
|
||||
});
|
||||
|
||||
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: "Править" }));
|
||||
const input = await screen.findByLabelText("Статус по факту");
|
||||
const id = input.getAttribute("list")!;
|
||||
expect(id).not.toBe("fact-statuses");
|
||||
expect(document.getElementById(id)?.tagName).toBe("DATALIST");
|
||||
const view = await screen.findByRole("region", { name: "Служебные заметки" });
|
||||
expect(view).not.toHaveTextContent("Статус по факту");
|
||||
expect(view).toHaveTextContent("Вид работы");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Править" }));
|
||||
await screen.findByLabelText("Осталось");
|
||||
expect(screen.queryByLabelText("Статус по факту")).toBeNull();
|
||||
expect(document.querySelector("datalist")).toBeNull();
|
||||
});
|
||||
|
||||
it("заметки в режиме чтения: пустые — «—», время и автор; «Отмена» без правок — сразу", async () => {
|
||||
@ -425,7 +436,7 @@ describe("карточка задачи", () => {
|
||||
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).getAllByText("—")).toHaveLength(4); // вид работы, сделано, осталось, заметка владельца
|
||||
expect(within(view).getByRole("button", { name: "Править" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { type RefObject, useLayoutEffect, useRef, useState } from "react";
|
||||
import { ApiError } from "../api/client.ts";
|
||||
import { useClosedStatuses } from "../api/closed.ts";
|
||||
import { useIssue, useSettings } from "../api/queries.ts";
|
||||
import type { IssueComment, IssueDetail } from "../api/types.ts";
|
||||
import { JIRA_URL } from "../board/constants.ts";
|
||||
import { fmtDate, fmtStamp } from "../board/model/util.ts";
|
||||
import { factClass, groupLinks, isOverdue, todayISO } from "./card.ts";
|
||||
import { groupLinks, isOverdue, todayISO } from "./card.ts";
|
||||
import { useCommentsHidden } from "./commentsPref.ts";
|
||||
import { IssueLink } from "./IssueLink.tsx";
|
||||
import { Notes } from "./Notes.tsx";
|
||||
@ -17,6 +18,7 @@ const DESC_LINES = 8;
|
||||
export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClose: () => void }) {
|
||||
const issue = useIssue(key);
|
||||
const settings = useSettings();
|
||||
const closed = useClosedStatuses();
|
||||
const close = <button className="close" type="button" aria-label="Закрыть" onClick={onClose}>✕</button>;
|
||||
if (issue.isPending) return <div className="issue" data-testid="issue-card"><div className="issue-head"><p className="hint">загрузка {key}…</p>{close}</div></div>;
|
||||
if (issue.error instanceof ApiError && issue.error.status === 404) {
|
||||
@ -32,13 +34,10 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
|
||||
);
|
||||
}
|
||||
const i = issue.data!;
|
||||
const labels = settings.data?.status_labels as Record<string, string> | undefined;
|
||||
const fact = i.note?.fact_status ?? null;
|
||||
const cls = factClass(fact, labels);
|
||||
const due = dueOf(i);
|
||||
const facts = [
|
||||
i.assignee && <span key="who">{i.assignee}</span>,
|
||||
due && <span key="due">срок {fmtDate(due)}{isOverdue(due, fact, labels, todayISO()) && <> <span className="overdue">просрочено</span></>}</span>,
|
||||
due && <span key="due">срок {fmtDate(due)}{isOverdue(due, i.status, closed, todayISO()) && <> <span className="overdue">просрочено</span></>}</span>,
|
||||
i.sprint && <span key="sprint">{i.sprint}</span>,
|
||||
i.epic_key && <span key="epic">эпик: {i.epic_summary ?? i.epic_key}</span>,
|
||||
].filter(Boolean);
|
||||
@ -48,7 +47,6 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
|
||||
<div className="row1">
|
||||
<span className="key">{i.key}</span>
|
||||
{i.status && <span className="badge status">{i.status}</span>}
|
||||
{fact && <span className={"badge fact" + (cls ? " " + cls : "")}>факт: {fact}</span>}
|
||||
<span className="spacer" />
|
||||
<a href={`${JIRA_URL}/browse/${i.key}`} target="_blank" rel="noopener">Открыть в Jira ↗</a>
|
||||
{close}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useBlocker } from "react-router";
|
||||
import { useCloseAbort } from "./closeAbort.ts";
|
||||
import { ApiError } from "../api/client.ts";
|
||||
@ -41,8 +41,6 @@ export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () =>
|
||||
|
||||
const kinds = Object.keys(settings.data?.kind_labels ?? {});
|
||||
const kindOptions = values.work_kind && !kinds.includes(values.work_kind) ? [values.work_kind, ...kinds] : kinds;
|
||||
const facts = Object.keys(settings.data?.status_labels ?? {});
|
||||
const listId = useId();
|
||||
const set = (f: NoteField) => (e: { target: { value: string } }) => setValues((v) => ({ ...v, [f]: e.target.value }));
|
||||
|
||||
const submit = () => save.mutate(changed, {
|
||||
@ -60,10 +58,6 @@ export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () =>
|
||||
{kindOptions.map((k) => <option key={k} value={k}>{k}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>Статус по факту
|
||||
<input list={listId} value={values.fact_status} onChange={set("fact_status")} />
|
||||
<datalist id={listId}>{facts.map((f) => <option key={f} value={f} />)}</datalist>
|
||||
</label>
|
||||
{TEXTS.map(([f, label]) => (
|
||||
<label key={f}>{label}<textarea rows={3} value={values[f]} onChange={set(f)} /></label>
|
||||
))}
|
||||
|
||||
@ -3,7 +3,7 @@ import type { IssueDetail, NoteField } from "../api/types.ts";
|
||||
import { fmtStamp } from "../board/model/util.ts";
|
||||
import { NoteForm } from "./NoteForm.tsx";
|
||||
|
||||
const ROWS: [NoteField, string][] = [["work_kind", "Вид работы"], ["fact_status", "Статус по факту"], ["done_text", "Сделано"],
|
||||
const ROWS: [NoteField, string][] = [["work_kind", "Вид работы"], ["done_text", "Сделано"],
|
||||
["rest_text", "Осталось"], ["owner_note", "Заметка владельца"]];
|
||||
|
||||
// служебные заметки (design 20): по умолчанию чтение, «Править» — форма; после сохранения или отмены — снова чтение
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import settings from "../board/__fixtures__/settings.json";
|
||||
import { factClass, groupLinks, isOverdue, todayISO } from "./card.ts";
|
||||
import { 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 closed = new Set(["Готово", "Снято"]);
|
||||
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 });
|
||||
|
||||
@ -35,19 +35,16 @@ describe("groupLinks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("просрочка и класс статуса по факту", () => {
|
||||
it("класс из status_labels", () => {
|
||||
expect(factClass("Готово — закрыть", labels)).toBe("fact-done");
|
||||
expect(factClass("свой статус", labels)).toBeNull();
|
||||
expect(factClass(null, labels)).toBeNull();
|
||||
describe("просрочка", () => {
|
||||
it("срок прошёл, статус Jira не закрытый — просрочено", () => {
|
||||
expect(isOverdue("2026-10-01", "В работе", closed, "2026-10-17")).toBe(true);
|
||||
expect(isOverdue("2026-10-01", null, closed, "2026-10-17")).toBe(true);
|
||||
});
|
||||
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("закрытая задача или срок не прошёл — не просрочено", () => {
|
||||
expect(isOverdue("2026-10-01", "Готово", closed, "2026-10-17")).toBe(false);
|
||||
expect(isOverdue("2026-10-01", "Снято", closed, "2026-10-17")).toBe(false);
|
||||
expect(isOverdue("2026-10-17", "В работе", closed, "2026-10-17")).toBe(false);
|
||||
expect(isOverdue("", "В работе", closed, "2026-10-17")).toBe(false);
|
||||
});
|
||||
it("todayISO — местная дата YYYY-MM-DD", () => {
|
||||
expect(todayISO(new Date(2026, 0, 5, 23, 30))).toBe("2026-01-05");
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { isClosed } from "../api/closed.ts";
|
||||
import type { IssueDetail } from "../api/types.ts";
|
||||
|
||||
export type LinkGroupId = "depends" | "blocks" | "related" | "other";
|
||||
@ -23,15 +24,9 @@ export function groupLinks(d: Pick<IssueDetail, "links_out" | "links_in">, linkT
|
||||
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));
|
||||
// просрочено — срок раньше сегодня, а статус Jira не из closed_statuses (hide-closed; было — по статусу по факту, R-2 #37)
|
||||
export function isOverdue(due: string, status: string | null | undefined, closed: Set<string>, today: string): boolean {
|
||||
return !!due && due < today && !isClosed(status, closed);
|
||||
}
|
||||
|
||||
export const todayISO = (d = new Date()): string =>
|
||||
|
||||
@ -16,9 +16,6 @@
|
||||
.issue .overdue{color:var(--p-crit);font-weight:600}
|
||||
.issue .close{font-size:18px;line-height:1;background:none;border:0;color:var(--muted);cursor:pointer;padding:4px 6px}
|
||||
.badge{display:inline-block;font-size:12px;line-height:18px;padding:0 8px;border-radius:9px;background:var(--soft);color:var(--fg);white-space:nowrap}
|
||||
.badge.fact{background:transparent;border:1px solid var(--accent);color:var(--accent)}
|
||||
.badge.fact.fact-done{border-color:var(--k-test);color:var(--k-test)}
|
||||
.badge.fact.fact-cancelled{border-color:var(--muted);color:var(--muted);text-decoration:line-through}
|
||||
.issue .cols{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:24px;align-items:start}
|
||||
.issue .side{display:flex;flex-direction:column;gap:16px;min-width:0}
|
||||
.issue .side .note{align-self:stretch} /* .note{align-self:start} — с прежней страницы; в колонке окна — на всю ширину */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user