Спринты и бэклог в модуле jira (#108) #109
27
web/src/modules/jira/agile/AgileCard.tsx
Normal file
27
web/src/modules/jira/agile/AgileCard.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import type { MouseEvent } from "react";
|
||||
import type { AgileIssue } from "../api/types.ts";
|
||||
import { IssueLink, useOpenIssue } from "../issues/IssueLink.tsx";
|
||||
import { fmtSp } from "./model.ts";
|
||||
import { TypeIcon } from "./TypeIcon.tsx";
|
||||
|
||||
// карточка задачи в колонке доски; щелчок (не по ссылке и не при выделении текста) открывает карточку задачи поверх страницы
|
||||
export function AgileCard({ issue }: { issue: AgileIssue }) {
|
||||
const open = useOpenIssue();
|
||||
const click = (e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest("a")) return;
|
||||
const sel = window.getSelection();
|
||||
if (sel && !sel.isCollapsed && sel.containsNode(e.currentTarget, true)) return;
|
||||
open(issue.key);
|
||||
};
|
||||
return (
|
||||
<li className={"agile-card" + (issue.done ? " is-done" : "")} onClick={click}>
|
||||
<span className="agile-card__summary">{issue.summary ?? "—"}</span>
|
||||
<span className="agile-card__foot">
|
||||
<TypeIcon type={issue.issue_type} />
|
||||
<IssueLink issueKey={issue.key} className="agile-row__key" />
|
||||
<span className="agile-card__assignee">{issue.assignee ?? "—"}</span>
|
||||
<span className="agile-row__sp">{fmtSp(issue.story_points)}</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@ -66,11 +66,11 @@ const links = () => within(jiraGroup()).getAllByRole("link").map((l) => l.textCo
|
||||
describe("«Бэклог»", () => {
|
||||
beforeEach(() => { setToken("tok"); localStorage.clear(); });
|
||||
|
||||
it("меню scrum-проекта: «Связи», «Бэклог», «Задачи», «Лента»", async () => { // задача 10 добавит «Спринт»
|
||||
it("меню scrum-проекта: «Связи», «Бэклог», «Спринт», «Задачи», «Лента»", async () => {
|
||||
hub();
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
await within(jiraGroup()).findByRole("link", { name: "Бэклог" });
|
||||
expect(links()).toEqual(["Связи", "Бэклог", "Задачи", "Лента"]);
|
||||
expect(links()).toEqual(["Связи", "Бэклог", "Спринт", "Задачи", "Лента"]);
|
||||
});
|
||||
|
||||
it("проект без доски: пунктов нет, «У проекта нет scrum-доски» со ссылкой «Связи», запроса /agile нет", async () => {
|
||||
|
||||
21
web/src/modules/jira/agile/BoardColumns.tsx
Normal file
21
web/src/modules/jira/agile/BoardColumns.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import type { ColumnView } from "./columns.ts";
|
||||
import { AgileCard } from "./AgileCard.tsx";
|
||||
|
||||
// колонки доски; контейнер прокручивается по горизонтали внутри себя, страница не растягивается
|
||||
export function BoardColumns({ columns, lastCaption }: { columns: ColumnView[]; lastCaption?: string }) {
|
||||
const lastIdx = columns.reduce((acc, c, i) => (c.extra ? acc : i), -1);
|
||||
return (
|
||||
<div className="agile-columns">
|
||||
{columns.map((c, i) => (
|
||||
<section key={c.name + i} className={"agile-col" + (c.extra ? " is-extra" : "")} aria-label={c.name}>
|
||||
<header className="agile-col__head">
|
||||
<h2 className="agile-col__name">{c.name}</h2>
|
||||
<span className="agile-col__count">{c.issues.length}</span>
|
||||
</header>
|
||||
{lastCaption && i === lastIdx && <p className="agile-col__caption">{lastCaption}</p>}
|
||||
<ul className="agile-col__list">{c.issues.map((x) => <AgileCard key={x.key} issue={x} />)}</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
web/src/modules/jira/agile/SprintPage.test.tsx
Normal file
156
web/src/modules/jira/agile/SprintPage.test.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createMemoryRouter } from "react-router";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { App } from "../../../App.tsx";
|
||||
import { setToken } from "../../../auth/token.ts";
|
||||
import settings from "../board/__fixtures__/settings.json";
|
||||
import { routes } from "../../../routes.tsx";
|
||||
import { json, mockFetch } from "../../../test/fetchMock.ts";
|
||||
import { issue } from "../../../test/issueFixtures.ts";
|
||||
import type { AgileIssue, AgileResponse, AgileSprint } from "../api/types.ts";
|
||||
|
||||
const A = { label: "AWPCA", name: "АРМ КА", board_id: 4216, board_type: "scrum", created_at: "", issues_count: 9, jira_synced_at: null };
|
||||
|
||||
const iss = (key: string, over: Partial<AgileIssue> = {}): AgileIssue => ({
|
||||
key, summary: `Тема ${key}`, issue_type: "Задача", status: "Новая", priority: null, assignee: null,
|
||||
epic_key: null, story_points: null, rank: "0|a", done: false, ...over,
|
||||
});
|
||||
const iso = (offsetDays: number) => {
|
||||
const d = new Date(Date.now() + offsetDays * 86_400_000);
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T12:00:00.000+0300`;
|
||||
};
|
||||
const sprint = (over: Partial<AgileSprint>): AgileSprint => ({
|
||||
id: 1, name: "S", state: "active", start_date: "2026-10-12T09:00:00.000+0300", end_date: iso(3),
|
||||
complete_date: null, goal: null, count: 0, count_done: 0, unestimated: 0, points: 0, points_done: 0, issues: [], ...over,
|
||||
});
|
||||
const s102 = (over: Partial<AgileSprint> = {}) => sprint({
|
||||
id: 102, name: "AWPCA_Sprint_3", goal: "Закрыть свод", count: 3, count_done: 1, points: 25, points_done: 8,
|
||||
issues: [iss("AURORA-1", { story_points: 5 }), iss("AURORA-2", { done: true, status: "Готово" }), iss("AURORA-3", { status: "Странный" })], ...over,
|
||||
});
|
||||
const agile = (sprints: AgileSprint[] = [s102()], over: Partial<AgileResponse> = {}): AgileResponse => ({
|
||||
board: { id: 4216, type: "scrum", columns: [{ name: "К выполнению", statuses: ["Новая"] }, { name: "Готово", statuses: ["Готово"] }], kanban_backlog: null, fetched_at: "2026-10-24T10:00:00+00:00" },
|
||||
sprints, backlog: [], board_issues: null, ...over,
|
||||
});
|
||||
|
||||
function hub(data: AgileResponse) {
|
||||
mockFetch((method, url) => {
|
||||
const k = `${method} ${url.pathname}`;
|
||||
if (k === "GET /agile") return json(200, data);
|
||||
if (k === "GET /projects") return json(200, { projects: [A] });
|
||||
if (k === "GET /settings") return json(200, settings);
|
||||
if (k === "GET /issues") return json(200, { issues: [] });
|
||||
if (k === "GET /batches") return json(200, { batches: [] });
|
||||
if (k === "GET /agents") return json(200, { agents: [] });
|
||||
if (k.startsWith("GET /issues/")) return json(200, issue(decodeURIComponent(url.pathname.split("/").pop()!)));
|
||||
return json(404, { error: "not_found" });
|
||||
});
|
||||
}
|
||||
const open = (path: string) => {
|
||||
const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
|
||||
render(<App router={router} />);
|
||||
return router;
|
||||
};
|
||||
const page = () => screen.findByTestId("page-sprint");
|
||||
const jiraGroup = () => screen.getByRole("group", { name: "Jira" });
|
||||
const colNames = (p: HTMLElement) => Array.from(p.querySelectorAll(".agile-col__name")).map((e) => e.textContent);
|
||||
|
||||
describe("«Спринт»", () => {
|
||||
beforeEach(() => { setToken("tok"); localStorage.clear(); });
|
||||
|
||||
it("меню scrum-проекта: «Связи», «Бэклог», «Спринт», «Задачи», «Лента»; «Kanban» нет", async () => {
|
||||
hub(agile());
|
||||
open("/app/jira/links?project=AWPCA");
|
||||
await within(jiraGroup()).findByRole("link", { name: "Спринт" });
|
||||
expect(within(jiraGroup()).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Бэклог", "Спринт", "Задачи", "Лента"]);
|
||||
});
|
||||
|
||||
it("колонки доски с числом задач и «Без колонки»", async () => {
|
||||
hub(agile());
|
||||
open("/app/jira/sprint?project=AWPCA");
|
||||
const p = await page();
|
||||
await within(p).findByText("Тема AURORA-1");
|
||||
expect(colNames(p)).toEqual(["К выполнению", "Готово", "Без колонки"]);
|
||||
const cols = Array.from(p.querySelectorAll(".agile-col"));
|
||||
expect(cols.map((c) => c.querySelector(".agile-col__count")!.textContent)).toEqual(["1", "1", "1"]);
|
||||
expect(within(cols[2] as HTMLElement).getByText("Тема AURORA-3")).toBeInTheDocument();
|
||||
expect(p.querySelector(".agile-columns")).not.toBeNull();
|
||||
const h = within(p).getByRole("heading", { name: "AWPCA_Sprint_3" });
|
||||
expect(h.parentElement).toHaveTextContent("Закрыть свод");
|
||||
expect(h.parentElement).toHaveTextContent("1 из 3 задач");
|
||||
expect(h.parentElement).toHaveTextContent("8 из 25 SP");
|
||||
});
|
||||
|
||||
it("«осталось 3 дн.» и «срок прошёл»", async () => {
|
||||
hub(agile([s102({ end_date: iso(3) })]));
|
||||
open("/app/jira/sprint?project=AWPCA");
|
||||
expect(await within(await page()).findByText("осталось 3 дн.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("срок прошёл", async () => {
|
||||
hub(agile([s102({ end_date: iso(-1) })]));
|
||||
open("/app/jira/sprint?project=AWPCA");
|
||||
expect(await within(await page()).findByText("срок прошёл")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("два активных: переключатель «Спринты», выбор — ?sprint=<id> и его задачи", async () => {
|
||||
hub(agile([s102(), sprint({ id: 104, name: "AWPCA_Sprint_5", issues: [iss("AURORA-77")], count: 1 })]));
|
||||
const router = open("/app/jira/sprint?project=AWPCA");
|
||||
const p = within(await page());
|
||||
await p.findByText("Тема AURORA-1");
|
||||
const seg = p.getByRole("navigation", { name: "Спринты" });
|
||||
await userEvent.click(within(seg).getByRole("link", { name: "AWPCA_Sprint_5" }));
|
||||
expect(await p.findByText("Тема AURORA-77")).toBeInTheDocument();
|
||||
expect(p.queryByText("Тема AURORA-1")).toBeNull();
|
||||
expect(router.state.location.search).toContain("sprint=104");
|
||||
expect(router.state.location.search).toContain("project=AWPCA");
|
||||
});
|
||||
|
||||
it("один активный: переключателя нет", async () => {
|
||||
hub(agile());
|
||||
open("/app/jira/sprint?project=AWPCA");
|
||||
const p = within(await page());
|
||||
await p.findByText("Тема AURORA-1");
|
||||
expect(p.queryByRole("navigation", { name: "Спринты" })).toBeNull();
|
||||
});
|
||||
|
||||
it("устаревший ?sprint= — первый активный", async () => {
|
||||
hub(agile([s102(), sprint({ id: 101, name: "Закрытый", state: "closed", issues: [iss("AURORA-99")] })]));
|
||||
open("/app/jira/sprint?project=AWPCA&sprint=101");
|
||||
expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Тема AURORA-99")).toBeNull();
|
||||
});
|
||||
|
||||
it("мусорный ?sprint=abc — первый активный", async () => {
|
||||
hub(agile());
|
||||
open("/app/jira/sprint?project=AWPCA&sprint=abc");
|
||||
expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("нет активного — «Нет активного спринта» и ссылка «Бэклог»", async () => {
|
||||
hub(agile([sprint({ id: 103, name: "F", state: "future" })]));
|
||||
open("/app/jira/sprint?project=AWPCA");
|
||||
const p = within(await page());
|
||||
expect(await p.findByText("Нет активного спринта")).toBeInTheDocument();
|
||||
expect(p.getByRole("link", { name: "Бэклог" })).toHaveAttribute("href", "/app/jira/backlog?project=AWPCA");
|
||||
});
|
||||
|
||||
it("агент без досок — «Нужен агент v15 и обновление из Jira» и «Обновить из Jira»", async () => {
|
||||
hub({ board: null, sprints: [], backlog: null, board_issues: null });
|
||||
open("/app/jira/sprint?project=AWPCA");
|
||||
expect(await screen.findByText("Нужен агент v15 и обновление из Jira")).toBeInTheDocument();
|
||||
expect(within(screen.getByRole("main")).getAllByRole("button", { name: "Обновить из Jira" }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("щелчок по карточке открывает окно задачи поверх «Спринта»", async () => {
|
||||
hub(agile());
|
||||
const router = open("/app/jira/sprint?project=AWPCA");
|
||||
const p = within(await page());
|
||||
await userEvent.click(await p.findByText("Тема AURORA-1"));
|
||||
await screen.findByRole("dialog");
|
||||
expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-1");
|
||||
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Закрыть" }));
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/sprint"));
|
||||
});
|
||||
});
|
||||
61
web/src/modules/jira/agile/SprintPage.tsx
Normal file
61
web/src/modules/jira/agile/SprintPage.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
import type { Project } from "../../../api/types.ts";
|
||||
import { PageHeader, Panel, Segmented } from "../../../ui/index.ts";
|
||||
import type { AgileSprint } from "../api/types.ts";
|
||||
import { RefreshButton } from "../RefreshButton.tsx";
|
||||
import { AgileGate } from "./AgileGate.tsx";
|
||||
import { BoardColumns } from "./BoardColumns.tsx";
|
||||
import { columnsOf } from "./columns.ts";
|
||||
import { daysLeft, fmtRange, fmtSp, tasksWord } from "./model.ts";
|
||||
import "./agile.css";
|
||||
|
||||
function SprintHead({ s }: { s: AgileSprint }) {
|
||||
const left = daysLeft(s.end_date, new Date());
|
||||
return (
|
||||
<header className="agile-section__head">
|
||||
<h2 className="agile-section__title">{s.name}</h2>
|
||||
<span className="agile-section__meta">{fmtRange(s.start_date, s.end_date)}</span>
|
||||
{left !== null && <span className="agile-section__meta">{left >= 0 ? `осталось ${left} дн.` : "срок прошёл"}</span>}
|
||||
{s.goal && <span className="agile-section__goal">{s.goal}</span>}
|
||||
<span className="agile-section__meta">{`${s.count_done} из ${s.count} ${tasksWord(s.count)}`}</span>
|
||||
<span className="agile-section__meta">{`${fmtSp(s.points_done)} из ${fmtSp(s.points)} SP`}</span>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// «Спринт» (spec web-app): колонки доски активного спринта; только просмотр
|
||||
export function SprintPage({ project }: { project: Project }) {
|
||||
const [params] = useSearchParams();
|
||||
const wanted = params.get("sprint");
|
||||
return (
|
||||
<div data-testid="page-sprint" className="agile agile--wide">
|
||||
<PageHeader title="Спринт" actions={<RefreshButton />} />
|
||||
<AgileGate project={project} kind="scrum">
|
||||
{(d) => {
|
||||
const active = d.sprints.filter((s) => s.state === "active");
|
||||
// устаревший или мусорный ?sprint= — первый активный (Review Focus 2)
|
||||
const sprint = active.find((s) => String(s.id) === wanted) ?? active[0];
|
||||
if (!sprint) {
|
||||
return (
|
||||
<Panel className="agile-section">
|
||||
<p className="agile__empty">Нет активного спринта</p>
|
||||
<Link className="agile__link" to={`/jira/backlog?project=${encodeURIComponent(project.label)}`}>Бэклог</Link>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
const proj = encodeURIComponent(project.label);
|
||||
return (
|
||||
<>
|
||||
{active.length > 1 && (
|
||||
<Segmented label="Спринты" value={String(sprint.id)}
|
||||
options={active.map((s) => ({ value: String(s.id), label: s.name, href: `/jira/sprint?project=${proj}&sprint=${s.id}` }))} />
|
||||
)}
|
||||
<SprintHead s={sprint} />
|
||||
<BoardColumns columns={columnsOf(d.board?.columns ?? null, sprint.issues)} />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</AgileGate>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -35,3 +35,23 @@
|
||||
.agile-row{flex-wrap:wrap}
|
||||
.agile-row__assignee{flex-basis:100%}
|
||||
}
|
||||
/* «Спринт»: страница во всю ширину, колонки прокручиваются внутри контейнера */
|
||||
.agile--wide{padding-inline:var(--space-6);min-width:0}
|
||||
.agile-columns{display:flex;align-items:flex-start;gap:var(--space-3);overflow-x:auto;padding-bottom:var(--space-2);min-width:0}
|
||||
.agile-col{flex:0 0 280px;min-width:0;display:flex;flex-direction:column;gap:var(--space-2);
|
||||
background:var(--color-bg-subtle);border:1px solid var(--color-border-default);border-radius:var(--radius-xl);padding:var(--space-3)}
|
||||
.agile-col.is-extra{border-style:dashed}
|
||||
.agile-col__head{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2)}
|
||||
.agile-col__name{margin:0;font-size:var(--fs-body);line-height:var(--lh-body);font-weight:700;overflow-wrap:anywhere}
|
||||
.agile-col__count{font-size:var(--fs-caption);color:var(--color-text-secondary)}
|
||||
.agile-col__caption{margin:0;font-size:var(--fs-caption);color:var(--color-text-secondary)}
|
||||
.agile-col__list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--space-2)}
|
||||
.agile-card{display:flex;flex-direction:column;gap:var(--space-2);min-width:0;padding:var(--space-3);cursor:pointer;
|
||||
background:var(--color-bg-surface);border:1px solid var(--color-border-default);border-radius:var(--radius-lg);
|
||||
font-size:var(--fs-body);line-height:var(--lh-body)}
|
||||
.agile-card:hover{background:var(--color-bg-hover)}
|
||||
.agile-card.is-done{color:var(--color-text-secondary)}
|
||||
.agile-card.is-done .agile-card__summary{text-decoration:line-through}
|
||||
.agile-card__summary{overflow-wrap:anywhere}
|
||||
.agile-card__foot{display:flex;align-items:center;gap:var(--space-2);min-width:0}
|
||||
.agile-card__assignee{flex:1 1 auto;min-width:0;color:var(--color-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
|
||||
24
web/src/modules/jira/agile/columns.test.ts
Normal file
24
web/src/modules/jira/agile/columns.test.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgileIssue } from "../api/types.ts";
|
||||
import { columnsOf } from "./columns.ts";
|
||||
|
||||
const iss = (key: string, status: string): AgileIssue => ({
|
||||
key, summary: key, issue_type: "Задача", status, priority: null, assignee: null, epic_key: null, story_points: null, rank: null, done: false,
|
||||
});
|
||||
const cols = [{ name: "К выполнению", statuses: ["Backlog"] }, { name: "В работе", statuses: ["В работе"] }, { name: "Готово", statuses: ["Готово"] }];
|
||||
|
||||
describe("columnsOf", () => {
|
||||
it("по колонкам в порядке ранга, «Без колонки» — только с задачами", () => {
|
||||
const v = columnsOf(cols, [iss("A", "Готово"), iss("B", "Backlog"), iss("C", "Странный"), iss("D", "Backlog")]);
|
||||
expect(v.map((c) => [c.name, c.issues.map((i) => i.key)])).toEqual([["К выполнению", ["B", "D"]], ["В работе", []], ["Готово", ["A"]], ["Без колонки", ["C"]]]);
|
||||
expect(v.map((c) => c.extra)).toEqual([false, false, false, true]);
|
||||
expect(columnsOf(cols, [iss("A", "Готово")]).map((c) => c.name)).toEqual(["К выполнению", "В работе", "Готово"]);
|
||||
});
|
||||
|
||||
it("columns = null — одна «Без колонки»", () => {
|
||||
const v = columnsOf(null, [iss("A", "Готово")]);
|
||||
expect(v).toHaveLength(1);
|
||||
expect(v[0]).toMatchObject({ name: "Без колонки", extra: true });
|
||||
expect(v[0].issues.map((i) => i.key)).toEqual(["A"]);
|
||||
});
|
||||
});
|
||||
21
web/src/modules/jira/agile/columns.ts
Normal file
21
web/src/modules/jira/agile/columns.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import type { AgileIssue, BoardColumn } from "../api/types.ts";
|
||||
|
||||
export type ColumnView = { name: string; issues: AgileIssue[]; extra: boolean };
|
||||
|
||||
const NO_COLUMN = "Без колонки";
|
||||
|
||||
// задачи по колонкам доски: порядок колонок — доски, порядок задач — как во входе (ранг);
|
||||
// статусы вне колонок — последняя «Без колонки», только если в ней есть задачи; нет колонок — одна «Без колонки»
|
||||
export function columnsOf(columns: BoardColumn[] | null, issues: AgileIssue[]): ColumnView[] {
|
||||
if (columns === null) return [{ name: NO_COLUMN, issues: [...issues], extra: true }];
|
||||
const byStatus = new Map<string, number>();
|
||||
columns.forEach((c, i) => c.statuses.forEach((s) => { if (!byStatus.has(s)) byStatus.set(s, i); }));
|
||||
const out: ColumnView[] = columns.map((c) => ({ name: c.name, issues: [], extra: false }));
|
||||
const rest: AgileIssue[] = [];
|
||||
for (const i of issues) {
|
||||
const idx = i.status === null ? undefined : byStatus.get(i.status);
|
||||
if (idx === undefined) rest.push(i); else out[idx].issues.push(i);
|
||||
}
|
||||
if (rest.length) out.push({ name: NO_COLUMN, issues: rest, extra: true });
|
||||
return out;
|
||||
}
|
||||
@ -1,8 +1,9 @@
|
||||
import { Navigate, useLocation, useParams } from "react-router";
|
||||
import { ProjectGate } from "../../projects/ProjectGate.tsx";
|
||||
import { IconBacklog, IconBoard, IconHistory, IconList } from "../../ui/index.ts";
|
||||
import { IconBacklog, IconBoard, IconHistory, IconList, IconSprint } from "../../ui/index.ts";
|
||||
import type { HubModule } from "../types.ts";
|
||||
import { BacklogPage } from "./agile/BacklogPage.tsx";
|
||||
import { SprintPage } from "./agile/SprintPage.tsx";
|
||||
import { BoardPage } from "./board/BoardPage.tsx";
|
||||
import { FeedPage } from "./feed/FeedPage.tsx";
|
||||
import { useIssueOverlay } from "./issues/overlay.tsx";
|
||||
@ -25,6 +26,7 @@ export const jiraModule: HubModule = {
|
||||
pages: [
|
||||
{ path: "/jira/links", element: <ProjectGate>{(p) => <BoardPage key={p.label} project={p.label} />}</ProjectGate> },
|
||||
{ path: "/jira/backlog", element: <ProjectGate>{(p) => <BacklogPage key={p.label} project={p} />}</ProjectGate> },
|
||||
{ path: "/jira/sprint", element: <ProjectGate>{(p) => <SprintPage key={p.label} project={p} />}</ProjectGate> },
|
||||
{ path: "/jira/issues", element: <ProjectGate>{(p) => <IssuesPage key={p.label} project={p.label} />}</ProjectGate> },
|
||||
{ path: "/jira/feed", element: <ProjectGate>{(p) => <FeedPage key={p.label} project={p.label} />}</ProjectGate> },
|
||||
// прежние адреса (закладки): перенаправление с сохранением строки запроса, без новой записи истории
|
||||
@ -37,6 +39,7 @@ export const jiraModule: HubModule = {
|
||||
nav: [
|
||||
{ to: "/jira/links", label: "Связи", icon: <IconBoard /> },
|
||||
{ to: "/jira/backlog", label: "Бэклог", icon: <IconBacklog />, padded: true, when: (p) => boardType(p) === "scrum" },
|
||||
{ to: "/jira/sprint", label: "Спринт", icon: <IconSprint />, when: (p) => boardType(p) === "scrum" },
|
||||
{ to: "/jira/issues", label: "Задачи", icon: <IconList />, padded: true },
|
||||
{ to: "/jira/feed", label: "Лента", icon: <IconHistory />, padded: true, keepSearch: true },
|
||||
],
|
||||
|
||||
@ -6,12 +6,12 @@ import { MODULES } from "./index.ts";
|
||||
|
||||
describe("сборка оболочки из модулей", () => {
|
||||
it("страницы: «Обзор», адреса jira, перенаправления, вики, NotFound последним", () => {
|
||||
expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/jira/links", "/jira/backlog", "/jira/issues", "/jira/feed", "/jira/board", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]);
|
||||
expect(PAGE_ROUTES.map((r) => r.path)).toEqual(["/", "/jira/links", "/jira/backlog", "/jira/sprint", "/jira/issues", "/jira/feed", "/jira/board", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]);
|
||||
});
|
||||
|
||||
it("пункты панели: подписи прежние, адреса jira с префиксом, плюс вики", () => {
|
||||
expect(MODULES.flatMap((m) => m.nav).map((n) => [n.label, n.to])).toEqual([
|
||||
["Связи", "/jira/links"], ["Бэклог", "/jira/backlog"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"],
|
||||
["Связи", "/jira/links"], ["Бэклог", "/jira/backlog"], ["Спринт", "/jira/sprint"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@ -44,4 +44,5 @@ export const IconImage = make(<><rect x="3.5" y="5" width="17" height="14" rx="2
|
||||
export const IconSheet = make(<><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M4 10h16M4 15h16M10 4v16"/></>);
|
||||
export const IconHome = make(<><path d="M4 11l8-7 8 7"/><path d="M6 10v9a1 1 0 0 0 1 1h3.5v-5h3v5H17a1 1 0 0 0 1-1v-9"/></>);
|
||||
export const IconBook = make(<><path d="M5 4.5A1.5 1.5 0 0 1 6.5 3H19v15H6.5A1.5 1.5 0 0 0 5 19.5z"/><path d="M5 19.5A1.5 1.5 0 0 0 6.5 21H19v-3"/><path d="M9 7.5h6M9 11h6"/></>);
|
||||
export const IconSprint = make(<><circle cx="12" cy="12" r="8"/><path d="M12 7.5V12l3 2"/></>);
|
||||
export const IconBacklog = make(<><rect x="4" y="4" width="16" height="4.5" rx="1.5"/><rect x="4" y="10" width="16" height="4.5" rx="1.5"/><rect x="4" y="16" width="10" height="4" rx="1.5"/></>);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user