Веб: вложения в карточке задачи и скачивание (#83)
Раздел «Вложения (N)», плашки под комментарием и ссылки !имя!, [^имя] в тексте; хук useAttachmentDownload: fetch, опрос раз в 2 с, сохранение через Blob (api.blob). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d3de2d6670
commit
e5db848b6b
@ -28,7 +28,7 @@
|
||||
`web/` — веб-приложение хаба (React + TypeScript + Vite): вход по клиентскому токену, страницы «Доска» (`/app/board`),
|
||||
«Задачи» (`/app/issues`) и «Лента» (`/app/feed`: журнал изменений `GET /changes` за период; `web/src/feed/` — чистые
|
||||
функции `events.ts` (период, виды, текст события, группировка) и `EventList.tsx`, общий с блоком «История» карточки); текущий проект — `?project=<метка>` в адресе (`web/src/projects/`: контекст проекта, выпадающий список «Проект», окно «Новый проект», заглушка «проект не найден»; страницы пересоздаются по `key={project}`); карточка `/app/issues/{key}` — окно поверх страницы, с которой её открыли: `shell/Layout.tsx` рисует
|
||||
страницу по адресу из `location.state.background` (`useRoutes` с явным адресом) и `issues/IssueModal.tsx` поверх неё. Собирается `make web` в `src/jira_hub/web/app/`; `api` отдаёт сборку
|
||||
страницу по адресу из `location.state.background` (`useRoutes` с явным адресом) и `issues/IssueModal.tsx` поверх неё. Вложения в карточке — `web/src/issues/attachments/`: `format.ts` (размер, адрес в Jira, значок по типу, предел 10 МБ), `useAttachmentDownload.ts` (хук карточки: `POST /attachments/{id}/fetch`, опрос `GET /attachments/{id}` раз в 2 с до `ready`, затем `api.blob` на `/content` и сохранение через временную ссылку; одно состояние на вложение для всех кнопок; опрос стоит при закрытии окна и смене задачи), `AttachmentList.tsx` (раздел «Вложения»), `AttachmentChips.tsx` (плашки под комментарием); ссылки `!имя!`, `[^имя]` в тексте разбирает `issues/wiki/inline.ts` по списку вложений задачи (узел `attachment`). Собирается `make web` в `src/jira_hub/web/app/`; `api` отдаёт сборку
|
||||
без токена (`/app/assets/*` — файлы, прочие `/app…` — `index.html`, 503 без сборки), `/` и `/links` перенаправляет на
|
||||
`/app/board`. Данные — только через HTTP API с клиентским токеном; `mirror.board` отдаёт зеркало без преобразований,
|
||||
перевод в модель доски — в `web/src/board/model`. Логика доски — чистые модули `web/src/board/{model,analysis,layout}`
|
||||
|
||||
@ -67,4 +67,16 @@ describe("api", () => {
|
||||
expect((fetch.mock.calls[0][1]!.headers as Record<string, string>).Authorization).toBe("Bearer candidate");
|
||||
expect(fetch.mock.calls[0][0]).toBe("/settings");
|
||||
});
|
||||
|
||||
it("blob: тот же токен, тело как есть без разбора JSON; ошибки как у call", async () => {
|
||||
const fetch = mockFetch((_m, url) => url.pathname === "/ok" ? new Response("не JSON", { status: 200 }) : json(404, { error: "not_fetched", detail: "нет файла" }));
|
||||
const blob = await api.blob("/ok");
|
||||
expect(await blob.text()).toBe("не JSON");
|
||||
expect((fetch.mock.calls[0][1]!.headers as Record<string, string>).Authorization).toBe("Bearer tok");
|
||||
const e = await api.blob("/no").catch((err: unknown) => err) as ApiError;
|
||||
expect(e).toBeInstanceOf(ApiError);
|
||||
expect(e.detail).toBe("нет файла");
|
||||
mockFetch(() => json(401, {}));
|
||||
await expect(api.blob("/x")).rejects.toBeInstanceOf(AuthError);
|
||||
});
|
||||
});
|
||||
|
||||
@ -40,11 +40,12 @@ function withParams(path: string, params?: Params): string {
|
||||
return s ? `${path}?${s}` : path;
|
||||
}
|
||||
|
||||
async function call<T>(method: string, path: string, body?: unknown, token = getToken()): Promise<T> {
|
||||
// общая часть запросов: токен, сеть, 401/5xx/4xx; успешный Response возвращается нетронутым
|
||||
async function send(method: string, path: string, accept: string, body?: unknown, token = getToken()): Promise<Response> {
|
||||
if (!token) throw new AuthError("нет токена", token);
|
||||
// токен с недопустимыми символами (например, оставленный прежней доской) — сразу на экран входа, а не «хаб не отвечает»
|
||||
if (!TOKEN_RE.test(token)) throw new AuthError("недопустимые символы в токене", token);
|
||||
const headers: Record<string, string> = { Authorization: "Bearer " + token, Accept: "application/json" };
|
||||
const headers: Record<string, string> = { Authorization: "Bearer " + token, Accept: accept };
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json; charset=utf-8";
|
||||
let resp: Response;
|
||||
try {
|
||||
@ -53,13 +54,18 @@ async function call<T>(method: string, path: string, body?: unknown, token = get
|
||||
throw new HubDownError("хаб не отвечает");
|
||||
}
|
||||
if (resp.status === 401) throw new AuthError("токен не подошёл", token);
|
||||
if (resp.ok) return resp;
|
||||
let data: unknown = null;
|
||||
try { data = await resp.json(); } catch { /* тело не JSON */ }
|
||||
if (resp.status >= 500) throw new HubDownError(`хаб ответил ${resp.status}`);
|
||||
if (!resp.ok) {
|
||||
const d = (data ?? {}) as { error?: string; detail?: unknown };
|
||||
throw new ApiError(resp.status, d.error ?? "http", d.detail === undefined || d.detail === null ? null : String(d.detail));
|
||||
}
|
||||
const d = (data ?? {}) as { error?: string; detail?: unknown };
|
||||
throw new ApiError(resp.status, d.error ?? "http", d.detail === undefined || d.detail === null ? null : String(d.detail));
|
||||
}
|
||||
|
||||
async function call<T>(method: string, path: string, body?: unknown, token = getToken()): Promise<T> {
|
||||
const resp = await send(method, path, "application/json", body, token);
|
||||
let data: unknown = null;
|
||||
try { data = await resp.json(); } catch { /* тело не JSON */ }
|
||||
return data as T;
|
||||
}
|
||||
|
||||
@ -67,6 +73,8 @@ 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),
|
||||
put: <T>(path: string, body: unknown) => call<T>("PUT", path, body),
|
||||
// тело ответа как есть (содержимое вложения): те же токен и ошибки, JSON не разбирается
|
||||
blob: async (path: string) => (await send("GET", path, "*/*")).blob(),
|
||||
};
|
||||
|
||||
// проверка токена до сохранения: экран входа
|
||||
|
||||
@ -40,7 +40,18 @@ export interface IssueCommand {
|
||||
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 IssueComment {
|
||||
comment_id: string; author: string | null; created: string | null; updated: string | null; body: string;
|
||||
attachment_ids?: string[]; // id вложений, на которые ссылается текст (хаб с вложениями, схема v6)
|
||||
}
|
||||
// вложение задачи (хаб со схемой v6): state — ready (файл у хаба) / fetching / failed / none; error — причина при failed
|
||||
export type AttachmentStateName = "ready" | "fetching" | "failed" | "none";
|
||||
export interface IssueAttachment {
|
||||
attachment_id: string; filename: string; size: number | null; mime_type: string | null; author: string | null;
|
||||
created: string | null; state: AttachmentStateName; error: string | null;
|
||||
}
|
||||
// ответ GET /attachments/{id} и POST /attachments/{id}/fetch
|
||||
export interface AttachmentState extends IssueAttachment { key: string; command_id: number | null; batch_id: number | null }
|
||||
// событие журнала изменений (GET /changes, GET /issues/{key}.changes; в истории задачи нет key/summary/status)
|
||||
export type ChangeKind = "status" | "description" | "dates" | "comment" | "other";
|
||||
export interface ChangeEvent {
|
||||
@ -56,6 +67,8 @@ export interface IssueDetail extends IssueRow {
|
||||
note: Note | null;
|
||||
commands: IssueCommand[];
|
||||
comments: IssueComment[];
|
||||
attachments?: IssueAttachment[]; // хаб до вложений поля не шлёт
|
||||
description_attachment_ids?: string[];
|
||||
changes?: ChangeEvent[]; // хаб до журнала изменений (#70) поля не шлёт
|
||||
}
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import { createMemoryRouter, RouterProvider, useNavigate, useParams } from "reac
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { qk } from "../api/queries.ts";
|
||||
import { makeQueryClient } from "../api/queryClient.ts";
|
||||
import type { IssueAttachment, IssueDetail } from "../api/types.ts";
|
||||
import { setToken } from "../auth/token.ts";
|
||||
import baseSettings from "../board/__fixtures__/settings.json";
|
||||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||||
@ -482,3 +483,94 @@ describe("история в карточке", () => {
|
||||
expect(screen.queryByTestId("history")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("вложения в карточке", () => {
|
||||
beforeEach(() => setToken("tok"));
|
||||
const att = (id: string, filename: string, over: Partial<IssueAttachment> = {}): IssueAttachment => ({
|
||||
attachment_id: id, filename, size: 240000, mime_type: "image/png", author: "Иванов Иван", created: "2026-10-15T12:00:00.000+0300",
|
||||
state: "none", error: null, ...over,
|
||||
});
|
||||
const comment = { comment_id: "101", author: "Петров", created: null, updated: null, body: "см. !скрин 1.png|thumbnail!", attachment_ids: ["31337"] };
|
||||
const base = { description: "текст", comments: [comment], attachments: [att("31337", "скрин 1.png"), att("31338", "ОСВ 60.xlsx", { mime_type: null, size: 1024, created: "2026-10-16T12:00:00.000+0300" })] };
|
||||
function hub(over: Partial<IssueDetail>, extra?: (m: string, url: URL) => Response | undefined) {
|
||||
return mockFetch((m, url) => extra?.(m, url) ?? (url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-1650", { ...base, ...over }))));
|
||||
}
|
||||
|
||||
it("раздел «Вложения (2)» после «Описание», новые сверху; размер, автор · дата", async () => {
|
||||
hub({});
|
||||
open("/issues/AURORA-1650");
|
||||
const sec = within(await screen.findByTestId("attachments"));
|
||||
expect(sec.getByRole("heading", { level: 3, name: "Вложения (2)" })).toBeInTheDocument();
|
||||
const heads = screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent);
|
||||
expect(heads.indexOf("Вложения (2)")).toBe(heads.indexOf("Описание") + 1);
|
||||
const rows = sec.getAllByRole("listitem");
|
||||
expect(rows[0]).toHaveTextContent("ОСВ 60.xlsx");
|
||||
expect(rows[0]).toHaveTextContent("1,0 КБ");
|
||||
expect(rows[1]).toHaveTextContent("скрин 1.png");
|
||||
expect(rows[1]).toHaveTextContent("234,4 КБ");
|
||||
expect(rows[1]).toHaveTextContent("Иванов Иван · 15.10");
|
||||
expect(within(rows[1]).getByRole("button", { name: "Скачать" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("без поля attachments (старый хаб) раздела нет", async () => {
|
||||
hub({ attachments: undefined, comments: [] });
|
||||
open("/issues/AURORA-1650");
|
||||
await screen.findByText("Описание");
|
||||
expect(screen.queryByTestId("attachments")).toBeNull();
|
||||
});
|
||||
|
||||
it("состояние ready — отметка «у хаба»", async () => {
|
||||
hub({ attachments: [att("31337", "скрин 1.png", { state: "ready" })], comments: [] });
|
||||
open("/issues/AURORA-1650");
|
||||
expect(within(await screen.findByTestId("attachments")).getByText("у хаба")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("25 МБ — ссылка «Открыть в Jira» с подсказкой вместо кнопки", async () => {
|
||||
hub({ attachments: [att("77", "запись.mp4", { size: 25 * 1024 ** 2, mime_type: "video/mp4" })], comments: [] });
|
||||
open("/issues/AURORA-1650");
|
||||
const sec = within(await screen.findByTestId("attachments"));
|
||||
const a = sec.getByRole("link", { name: "Открыть в Jira" });
|
||||
expect(a).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/secure/attachment/77/%D0%B7%D0%B0%D0%BF%D0%B8%D1%81%D1%8C.mp4");
|
||||
expect(a).toHaveAttribute("target", "_blank");
|
||||
expect(a).toHaveAttribute("title", "больше 10 МБ — только в Jira");
|
||||
expect(sec.queryByRole("button", { name: "Скачать" })).toBeNull();
|
||||
});
|
||||
|
||||
it("размер неизвестен — подпись и обычная кнопка", async () => {
|
||||
hub({ attachments: [att("5", "x.bin", { size: null })], comments: [] });
|
||||
open("/issues/AURORA-1650");
|
||||
const sec = within(await screen.findByTestId("attachments"));
|
||||
expect(sec.getByText(/размер неизвестен/)).toBeInTheDocument();
|
||||
expect(sec.getByRole("button", { name: "Скачать" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("плашка под комментарием и кнопка в тексте", async () => {
|
||||
hub({});
|
||||
open("/issues/AURORA-1650");
|
||||
const c = within(await screen.findByTestId("comments"));
|
||||
expect(c.getByRole("button", { name: "скрин 1.png · 234,4 КБ" })).toBeInTheDocument();
|
||||
expect(c.getByRole("button", { name: "скрин 1.png" })).toBeInTheDocument();
|
||||
expect(c.queryByText(/thumbnail/)).toBeNull();
|
||||
});
|
||||
|
||||
it("«Загружается…» на всех кнопках одного вложения; чужие не затронуты", async () => {
|
||||
hub({}, (_m, url) => url.pathname === "/attachments/31337/fetch" || url.pathname === "/attachments/31337"
|
||||
? json(200, { attachment_id: "31337", state: "fetching", error: null, command_id: 1, batch_id: 1 }) : undefined);
|
||||
open("/issues/AURORA-1650");
|
||||
await userEvent.click(within(await screen.findByTestId("comments")).getByRole("button", { name: "скрин 1.png · 234,4 КБ" }));
|
||||
expect(await screen.findAllByText("Загружается…")).toHaveLength(3);
|
||||
for (const b of screen.getAllByText("Загружается…")) expect(b.closest("button")).toBeDisabled();
|
||||
expect(within(screen.getByTestId("attachments")).getByRole("button", { name: "Скачать" })).toBeEnabled(); // ОСВ 60.xlsx
|
||||
});
|
||||
|
||||
it("ошибка хаба — текст и «Повторить» под вложением", async () => {
|
||||
const f = hub({}, (_m, url) => url.pathname === "/attachments/31337/fetch"
|
||||
? json(409, { error: "agent_too_old", detail: "агент v13 не умеет скачивать вложения — нужен v14" }) : undefined);
|
||||
open("/issues/AURORA-1650");
|
||||
const sec = within(await screen.findByTestId("attachments"));
|
||||
await userEvent.click(sec.getAllByRole("button", { name: "Скачать" })[1]);
|
||||
expect(await sec.findByText("агент v13 не умеет скачивать вложения — нужен v14")).toBeInTheDocument();
|
||||
await userEvent.click(sec.getByRole("button", { name: "Повторить" }));
|
||||
expect(f.mock.calls.filter(([u]) => String(u).endsWith("/fetch"))).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,7 +2,7 @@ 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 { ChangeEvent, IssueComment, IssueDetail } from "../api/types.ts";
|
||||
import type { ChangeEvent, IssueAttachment, IssueComment, IssueDetail } from "../api/types.ts";
|
||||
import { JIRA_URL } from "../board/constants.ts";
|
||||
import { fmtDate, fmtStamp } from "../board/model/util.ts";
|
||||
import { EventList } from "../feed/EventList.tsx";
|
||||
@ -12,6 +12,10 @@ import { IssueLink } from "./IssueLink.tsx";
|
||||
import { Badge, Button, IconButton, IconClose, IconExternal, Notice, Panel } from "../ui/index.ts";
|
||||
import { Notes } from "./Notes.tsx";
|
||||
import { dueOf } from "./options.ts";
|
||||
import { AttachmentChips } from "./attachments/AttachmentChips.tsx";
|
||||
import { AttachmentList, type Download } from "./attachments/AttachmentList.tsx";
|
||||
import { isTooBig, jiraAttachmentUrl } from "./attachments/format.ts";
|
||||
import { useAttachmentDownload } from "./attachments/useAttachmentDownload.ts";
|
||||
import { WikiText } from "./wiki/WikiText.tsx";
|
||||
import "./issues.css";
|
||||
|
||||
@ -21,6 +25,7 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
|
||||
const issue = useIssue(key);
|
||||
const settings = useSettings();
|
||||
const closed = useClosedStatuses();
|
||||
const dl = useAttachmentDownload(key); // одно состояние на вложение для всех кнопок карточки
|
||||
const close = <IconButton label="Закрыть" icon={<IconClose />} onClick={onClose} />;
|
||||
if (issue.isPending) {
|
||||
return <div className="issue" data-testid="issue-card"><div className="issue-head"><div className="row1"><span className="key">{key}</span><span className="spacer" />{close}</div><p className="hint">загрузка {key}…</p></div></div>;
|
||||
@ -49,6 +54,8 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
|
||||
i.sprint && <span key="sprint">{i.sprint}</span>,
|
||||
i.epic_key && <span key="epic">эпик: {i.epic_summary ?? i.epic_key}</span>,
|
||||
].filter(Boolean);
|
||||
const atts = i.attachments;
|
||||
const wiki = wikiAttachments(atts, dl);
|
||||
return (
|
||||
<div className="issue" data-testid="issue-card">
|
||||
<header className="issue-head" data-testid="issue-head">
|
||||
@ -68,8 +75,9 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
|
||||
<div className="cols">
|
||||
<div className="main">
|
||||
<Links issue={i} linkTypes={(settings.data?.link_types ?? {}) as Record<string, string>} />
|
||||
{i.description && <Description text={i.description} projectKey={settings.data?.project_key} />}
|
||||
<Comments items={i.comments ?? []} projectKey={settings.data?.project_key} />
|
||||
{i.description && <Description text={i.description} projectKey={settings.data?.project_key} wiki={wiki} />}
|
||||
{atts && atts.length > 0 && <AttachmentList items={atts} dl={dl} />}
|
||||
<Comments items={i.comments ?? []} projectKey={settings.data?.project_key} wiki={wiki} atts={atts ?? []} dl={dl} />
|
||||
{i.changes && <History items={i.changes} />}
|
||||
</div>
|
||||
<Panel className="side">
|
||||
@ -82,6 +90,22 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
|
||||
);
|
||||
}
|
||||
|
||||
// ссылки на вложения в тексте: список для разбора, щелчок и вид кнопки (занята / ведёт в Jira)
|
||||
interface WikiAtt { attachments?: { id: string; filename: string; created: string | null }[]; onAttachment?: (id: string) => void; attachmentView?: (id: string) => { busy: boolean; href?: string } }
|
||||
function wikiAttachments(atts: IssueAttachment[] | undefined, dl: Download): WikiAtt {
|
||||
if (!atts?.length) return {};
|
||||
const byId = new Map(atts.map((a) => [a.attachment_id, a]));
|
||||
return {
|
||||
attachments: atts.map((a) => ({ id: a.attachment_id, filename: a.filename, created: a.created })),
|
||||
onAttachment: (id) => { const a = byId.get(id); if (a) dl.start(a); },
|
||||
attachmentView: (id) => {
|
||||
const a = byId.get(id);
|
||||
if (a && isTooBig(a.size)) return { busy: false, href: jiraAttachmentUrl(a.attachment_id, a.filename) };
|
||||
return { busy: dl.status(id).busy };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record<string, string> }) {
|
||||
const groups = groupLinks(issue, linkTypes);
|
||||
return (
|
||||
@ -113,7 +137,7 @@ function SourceToggle({ source, onToggle }: { source: boolean; onToggle: () => v
|
||||
return <Button variant="ghost" aria-pressed={source} onClick={onToggle}>{source ? "текст" : "исходник"}</Button>;
|
||||
}
|
||||
|
||||
function Description({ text, projectKey }: { text: string; projectKey?: string }) {
|
||||
function Description({ text, projectKey, wiki }: { text: string; projectKey?: string; wiki: WikiAtt }) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [source, setSource] = useState(false);
|
||||
@ -141,13 +165,13 @@ function Description({ text, projectKey }: { text: string; projectKey?: string }
|
||||
</div>
|
||||
{source
|
||||
? <pre ref={ref as RefObject<HTMLPreElement>} className={cls}>{text}</pre>
|
||||
: <div ref={ref as RefObject<HTMLDivElement>} className={cls}><WikiText text={text} projectKey={projectKey} /></div>}
|
||||
: <div ref={ref as RefObject<HTMLDivElement>} className={cls}><WikiText text={text} projectKey={projectKey} {...wiki} /></div>}
|
||||
{(long || open) && <Button variant="ghost" className="more" onClick={() => setOpen(!open)}>{open ? "свернуть" : "развернуть"}</Button>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Comment({ c, projectKey }: { c: IssueComment; projectKey?: string }) {
|
||||
function Comment({ c, projectKey, wiki, atts, dl }: { c: IssueComment; projectKey?: string; wiki: WikiAtt; atts: IssueAttachment[]; dl: Download }) {
|
||||
const [source, setSource] = useState(false);
|
||||
return (
|
||||
<li>
|
||||
@ -157,12 +181,13 @@ function Comment({ c, projectKey }: { c: IssueComment; projectKey?: string }) {
|
||||
</div>
|
||||
{source
|
||||
? <pre className="body">{c.body}</pre>
|
||||
: <div className="body"><WikiText text={c.body} projectKey={projectKey} /></div>}
|
||||
: <div className="body"><WikiText text={c.body} projectKey={projectKey} {...wiki} /></div>}
|
||||
<AttachmentChips items={(c.attachment_ids ?? []).flatMap((id) => atts.filter((a) => a.attachment_id === id))} dl={dl} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function Comments({ items, projectKey }: { items: IssueComment[]; projectKey?: string }) {
|
||||
function Comments({ items, projectKey, wiki, atts, dl }: { items: IssueComment[]; projectKey?: string; wiki: WikiAtt; atts: IssueAttachment[]; dl: Download }) {
|
||||
const [hidden, setHidden] = useCommentsHidden();
|
||||
return (
|
||||
<section className="comments" data-testid="comments">
|
||||
@ -173,7 +198,7 @@ function Comments({ items, projectKey }: { items: IssueComment[]; projectKey?: s
|
||||
{!items.length && <p className="hint">комментариев нет</p>}
|
||||
{items.length > 0 && !hidden && (
|
||||
<ul>
|
||||
{items.map((c) => <Comment key={c.comment_id} c={c} projectKey={projectKey} />)}
|
||||
{items.map((c) => <Comment key={c.comment_id} c={c} projectKey={projectKey} wiki={wiki} atts={atts} dl={dl} />)}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
33
web/src/issues/attachments/AttachmentChips.tsx
Normal file
33
web/src/issues/attachments/AttachmentChips.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import type { IssueAttachment } from "../../api/types.ts";
|
||||
import { IconExternal, IconPaperclip } from "../../ui/index.ts";
|
||||
import type { Download } from "./AttachmentList.tsx";
|
||||
import { fmtSize, isTooBig, jiraAttachmentUrl, TOO_BIG_HINT } from "./format.ts";
|
||||
|
||||
// плашки под комментарием: «имя · размер», щелчок — скачивание (большой файл — ссылка в Jira)
|
||||
export function AttachmentChips({ items, dl }: { items: IssueAttachment[]; dl: Download }) {
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<ul className="att-chips">
|
||||
{items.map((a) => {
|
||||
const text = `${a.filename} · ${fmtSize(a.size)}`;
|
||||
if (isTooBig(a.size)) {
|
||||
return (
|
||||
<li key={a.attachment_id}>
|
||||
<a className="att-chip" href={jiraAttachmentUrl(a.attachment_id, a.filename)} target="_blank" rel="noopener noreferrer" title={TOO_BIG_HINT}>
|
||||
<IconExternal size={16} />{text}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const { busy } = dl.status(a.attachment_id);
|
||||
return (
|
||||
<li key={a.attachment_id}>
|
||||
<button type="button" className="att-chip" disabled={busy} onClick={() => dl.start(a)}>
|
||||
<IconPaperclip size={16} />{text}{busy && <span className="att-busy"> Загружается…</span>}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
56
web/src/issues/attachments/AttachmentList.tsx
Normal file
56
web/src/issues/attachments/AttachmentList.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import type { IssueAttachment } from "../../api/types.ts";
|
||||
import { fmtStamp } from "../../board/model/util.ts";
|
||||
import { Badge, Button, IconDownload, IconExternal, IconFile, IconImage, IconSheet } from "../../ui/index.ts";
|
||||
import { fileKind, fmtSize, isTooBig, jiraAttachmentUrl, TOO_BIG_HINT } from "./format.ts";
|
||||
import type { useAttachmentDownload } from "./useAttachmentDownload.ts";
|
||||
import "./attachments.css";
|
||||
|
||||
export type Download = ReturnType<typeof useAttachmentDownload>;
|
||||
|
||||
const KIND_ICON = { image: IconImage, sheet: IconSheet, pdf: IconFile, other: IconFile };
|
||||
const KIND_LABEL = { image: "картинка", sheet: "таблица", pdf: "PDF", other: "файл" };
|
||||
|
||||
// новые сверху: по времени Jira, затем по числовому id
|
||||
export function newestFirst(items: IssueAttachment[]): IssueAttachment[] {
|
||||
const t = (a: IssueAttachment) => (a.created ? Date.parse(a.created) || 0 : 0);
|
||||
return [...items].sort((a, b) => t(b) - t(a) || (Number(b.attachment_id) || 0) - (Number(a.attachment_id) || 0));
|
||||
}
|
||||
|
||||
// раздел «Вложения (N)»: значок по типу, имя, размер, «автор · дата», отметка «у хаба», кнопка скачивания
|
||||
export function AttachmentList({ items, dl }: { items: IssueAttachment[]; dl: Download }) {
|
||||
return (
|
||||
<section className="attachments" data-testid="attachments">
|
||||
<div className="sec-head"><h3>Вложения ({items.length})</h3></div>
|
||||
<ul className="att-list">
|
||||
{newestFirst(items).map((a) => <Row key={a.attachment_id} a={a} dl={dl} />)}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ a, dl }: { a: IssueAttachment; dl: Download }) {
|
||||
const kind = fileKind(a.mime_type, a.filename);
|
||||
const Icon = KIND_ICON[kind];
|
||||
const { busy, error: local } = dl.status(a.attachment_id);
|
||||
// ошибка: своя (последняя попытка в этой карточке) или команда, закончившаяся failed до открытия карточки
|
||||
const error = busy ? null : local ?? (a.state === "failed" ? a.error ?? "не удалось скачать вложение" : null);
|
||||
const who = [a.author, a.created ? fmtStamp(a.created) : null].filter(Boolean).join(" · ");
|
||||
return (
|
||||
<li className="att-row">
|
||||
<span className="att-icon" role="img" aria-label={KIND_LABEL[kind]}><Icon /></span>
|
||||
<div className="att-main">
|
||||
<span className="att-name">{a.filename}</span>
|
||||
<span className="att-meta">
|
||||
{fmtSize(a.size)}{who && ` · ${who}`}
|
||||
{a.state === "ready" && <>{" "}<Badge tone="success">у хаба</Badge></>}
|
||||
</span>
|
||||
{error && <p className="att-error" role="alert">{error}</p>}
|
||||
</div>
|
||||
{isTooBig(a.size)
|
||||
? <a className="ui-btn ui-btn--secondary ui-btn--md" href={jiraAttachmentUrl(a.attachment_id, a.filename)} target="_blank" rel="noopener noreferrer" title={TOO_BIG_HINT}><IconExternal size={16} />Открыть в Jira</a>
|
||||
: <Button icon={busy ? undefined : <IconDownload size={16} />} loading={busy} onClick={() => dl.start(a)}>
|
||||
{busy ? "Загружается…" : error ? "Повторить" : "Скачать"}
|
||||
</Button>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
24
web/src/issues/attachments/attachments.css
Normal file
24
web/src/issues/attachments/attachments.css
Normal file
@ -0,0 +1,24 @@
|
||||
/* вложения в карточке (web-app «Скачивание вложений в карточке»): цвета только через токены */
|
||||
.issue .att-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column}
|
||||
.issue .att-row{display:flex;align-items:flex-start;gap:var(--space-3);padding:var(--space-3) 0;border-top:1px solid var(--color-border-default)}
|
||||
.issue .att-row:first-child{border-top:0;padding-top:0}
|
||||
.issue .att-icon{flex:none;display:flex;align-items:center;justify-content:center;width:var(--control-h);height:var(--control-h);color:var(--color-text-secondary)}
|
||||
.issue .att-main{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:var(--space-1)}
|
||||
.issue .att-name{font-weight:600;overflow-wrap:anywhere}
|
||||
.issue .att-meta{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)}
|
||||
.issue .att-error{margin:0;color:var(--color-status-error)}
|
||||
.issue .att-row>.ui-btn{flex:none}
|
||||
/* плашки под комментарием и ссылка-кнопка в тексте */
|
||||
.issue .att-chips{list-style:none;margin:var(--space-2) 0 0;padding:0;display:flex;flex-wrap:wrap;gap:var(--space-2)}
|
||||
.issue .att-chip,.issue .att-ref{display:inline-flex;align-items:center;gap:var(--space-1);font:inherit;color:var(--color-action-primary);cursor:pointer;text-decoration:none;overflow-wrap:anywhere}
|
||||
.issue .att-chip{min-height:var(--control-h);padding:0 var(--space-3);background:var(--color-bg-subtle);border:1px solid var(--color-border-default);border-radius:var(--radius-round)}
|
||||
.issue .att-chip:hover:not(:disabled){background:var(--color-bg-hover)}
|
||||
.issue .att-ref{padding:0;background:none;border:0;text-align:left}
|
||||
.issue .att-ref:hover:not(:disabled){text-decoration:underline}
|
||||
.issue .att-chip:disabled,.issue .att-ref:disabled{cursor:default;color:var(--color-text-secondary)}
|
||||
.issue .att-chip:focus-visible,.issue .att-ref:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px}
|
||||
.issue .att-busy{color:var(--color-text-secondary)}
|
||||
@media (max-width:700px){
|
||||
.issue .att-row{flex-wrap:wrap}
|
||||
.issue .att-row>.ui-btn{margin-left:calc(var(--control-h) + var(--space-3))}
|
||||
}
|
||||
43
web/src/issues/attachments/format.test.ts
Normal file
43
web/src/issues/attachments/format.test.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { JIRA_URL } from "../../board/constants.ts";
|
||||
import { fileKind, fmtSize, isTooBig, jiraAttachmentUrl } from "./format.ts";
|
||||
|
||||
describe("fmtSize", () => {
|
||||
it("как в хабе: Б, КБ, МБ с запятой", () => {
|
||||
expect(fmtSize(500)).toBe("500 Б");
|
||||
expect(fmtSize(1023)).toBe("1023 Б");
|
||||
expect(fmtSize(1024)).toBe("1,0 КБ");
|
||||
expect(fmtSize(240000)).toBe("234,4 КБ");
|
||||
expect(fmtSize(1024 ** 2)).toBe("1,0 МБ");
|
||||
expect(fmtSize(25 * 1024 ** 2)).toBe("25,0 МБ");
|
||||
expect(fmtSize(null)).toBe("размер неизвестен");
|
||||
});
|
||||
});
|
||||
|
||||
describe("jiraAttachmentUrl", () => {
|
||||
it("имя в URL-кодировке", () => {
|
||||
expect(jiraAttachmentUrl("31337", "скрин 1.png")).toBe(`${JIRA_URL}/secure/attachment/31337/%D1%81%D0%BA%D1%80%D0%B8%D0%BD%201.png`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTooBig", () => {
|
||||
it("больше 10 МБ — только в Jira; ровно 10 МБ и неизвестный размер — нет", () => {
|
||||
expect(isTooBig(10 * 1024 * 1024)).toBe(false);
|
||||
expect(isTooBig(10 * 1024 * 1024 + 1)).toBe(true);
|
||||
expect(isTooBig(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fileKind", () => {
|
||||
it("тип по mime, иначе по расширению", () => {
|
||||
expect(fileKind("image/png", "a.bin")).toBe("image");
|
||||
expect(fileKind(null, "Скрин.PNG")).toBe("image");
|
||||
expect(fileKind("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "x")).toBe("sheet");
|
||||
expect(fileKind(null, "ОСВ 60.xlsx")).toBe("sheet");
|
||||
expect(fileKind("text/csv", "d.txt")).toBe("sheet");
|
||||
expect(fileKind("application/pdf", "x")).toBe("pdf");
|
||||
expect(fileKind(null, "отчёт.pdf")).toBe("pdf");
|
||||
expect(fileKind("video/mp4", "запись.mp4")).toBe("other");
|
||||
expect(fileKind(null, "без расширения")).toBe("other");
|
||||
});
|
||||
});
|
||||
31
web/src/issues/attachments/format.ts
Normal file
31
web/src/issues/attachments/format.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { JIRA_URL } from "../../board/constants.ts";
|
||||
|
||||
// предел скачивания через хаб — files.MAX_SIZE (10 485 760 байт); больше — только в Jira
|
||||
export const MAX_SIZE = 10 * 1024 * 1024;
|
||||
export const TOO_BIG_HINT = "больше 10 МБ — только в Jira";
|
||||
|
||||
// формат размера как в хабе (attachments.fmt_size): «234,4 КБ», «23,8 МБ»; null — «размер неизвестен»
|
||||
export function fmtSize(size: number | null): string {
|
||||
if (size === null) return "размер неизвестен";
|
||||
if (size < 1024) return `${size} Б`;
|
||||
if (size < 1024 ** 2) return `${(size / 1024).toFixed(1).replace(".", ",")} КБ`;
|
||||
return `${(size / 1024 ** 2).toFixed(1).replace(".", ",")} МБ`;
|
||||
}
|
||||
|
||||
export const isTooBig = (size: number | null): boolean => size !== null && size > MAX_SIZE;
|
||||
|
||||
export function jiraAttachmentUrl(id: string, filename: string): string {
|
||||
return `${JIRA_URL}/secure/attachment/${encodeURIComponent(id)}/${encodeURIComponent(filename)}`;
|
||||
}
|
||||
|
||||
export type FileKind = "image" | "sheet" | "pdf" | "other";
|
||||
|
||||
// значок по типу файла: сначала mime, затем расширение
|
||||
export function fileKind(mime: string | null, filename: string): FileKind {
|
||||
const m = (mime ?? "").toLowerCase();
|
||||
const ext = filename.includes(".") ? filename.slice(filename.lastIndexOf(".") + 1).toLowerCase() : "";
|
||||
if (m.startsWith("image/") || ["png", "jpg", "jpeg", "gif", "bmp", "webp", "svg"].includes(ext)) return "image";
|
||||
if (m.includes("spreadsheet") || m.includes("ms-excel") || m === "text/csv" || ["xls", "xlsx", "xlsm", "ods", "csv"].includes(ext)) return "sheet";
|
||||
if (m === "application/pdf" || ext === "pdf") return "pdf";
|
||||
return "other";
|
||||
}
|
||||
109
web/src/issues/attachments/useAttachmentDownload.test.tsx
Normal file
109
web/src/issues/attachments/useAttachmentDownload.test.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { makeQueryClient } from "../../api/queryClient.ts";
|
||||
import type { IssueAttachment } from "../../api/types.ts";
|
||||
import { setToken } from "../../auth/token.ts";
|
||||
import { json, mockFetch } from "../../test/fetchMock.ts";
|
||||
import { useAttachmentDownload } from "./useAttachmentDownload.ts";
|
||||
|
||||
const att: IssueAttachment = {
|
||||
attachment_id: "31337", filename: "скрин 1.png", size: 240000, mime_type: "image/png", author: "Иванов", created: null, state: "none", error: null,
|
||||
};
|
||||
const st = (state: string, extra: object = {}) => ({ attachment_id: "31337", state, error: null, command_id: 7, batch_id: 3, ...extra });
|
||||
const wrapper = ({ children }: { children: ReactNode }) => <QueryClientProvider client={makeQueryClient()}>{children}</QueryClientProvider>;
|
||||
const flush = () => act(async () => { await vi.advanceTimersByTimeAsync(0); });
|
||||
|
||||
describe("useAttachmentDownload", () => {
|
||||
let clicked: { download: string; href: string }[];
|
||||
beforeEach(() => {
|
||||
setToken("tok");
|
||||
vi.useFakeTimers();
|
||||
clicked = [];
|
||||
URL.createObjectURL = vi.fn(() => "blob:fake");
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { clicked.push({ download: this.download, href: this.href }); });
|
||||
});
|
||||
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
||||
|
||||
it("fetching → опрос раз в 2 с → ready → файл скачан один раз под своим именем", async () => {
|
||||
let polls = 0;
|
||||
const f = mockFetch((m, url) => {
|
||||
if (m === "POST") return json(200, st("fetching"));
|
||||
if (url.pathname.endsWith("/content")) return new Response("PNG", { status: 200 });
|
||||
polls++;
|
||||
return json(200, st(polls < 2 ? "fetching" : "ready"));
|
||||
});
|
||||
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
|
||||
act(() => result.current.start(att));
|
||||
await flush();
|
||||
expect(result.current.status("31337")).toEqual({ busy: true, error: null });
|
||||
expect(JSON.parse(String(f.mock.calls[0][1]?.body))).toEqual({ author: "web" });
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
|
||||
expect(polls).toBe(1);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
|
||||
expect(polls).toBe(2);
|
||||
expect(clicked).toEqual([{ download: "скрин 1.png", href: "blob:fake" }]);
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:fake");
|
||||
expect(result.current.status("31337")).toEqual({ busy: false, error: null });
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
|
||||
expect(polls).toBe(2);
|
||||
expect(f.mock.calls.filter(([u]) => String(u).endsWith("/content"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ready сразу в ответе на POST — без опроса", async () => {
|
||||
const f = mockFetch((m, url) => m === "POST" ? json(200, st("ready")) : url.pathname.endsWith("/content") ? new Response("x") : json(500, {}));
|
||||
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
|
||||
act(() => result.current.start(att));
|
||||
await flush();
|
||||
expect(clicked).toHaveLength(1);
|
||||
expect(f.mock.calls.map(([u]) => String(u))).toEqual(["/attachments/31337/fetch", "/attachments/31337/content"]);
|
||||
});
|
||||
|
||||
it("размонтирование прекращает опрос", async () => {
|
||||
const f = mockFetch((m) => m === "POST" ? json(200, st("fetching")) : json(200, st("fetching")));
|
||||
const { result, unmount } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
|
||||
act(() => result.current.start(att));
|
||||
await flush();
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
|
||||
const n = f.mock.calls.length;
|
||||
unmount();
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
|
||||
expect(f.mock.calls.length).toBe(n);
|
||||
});
|
||||
|
||||
it("смена задачи прекращает опрос", async () => {
|
||||
const f = mockFetch(() => json(200, st("fetching")));
|
||||
const { result, rerender } = renderHook(({ k }) => useAttachmentDownload(k), { wrapper, initialProps: { k: "AURORA-1650" } });
|
||||
act(() => result.current.start(att));
|
||||
await flush();
|
||||
rerender({ k: "AURORA-1651" });
|
||||
const n = f.mock.calls.length;
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
|
||||
expect(f.mock.calls.length).toBe(n);
|
||||
expect(result.current.status("31337")).toEqual({ busy: false, error: null });
|
||||
});
|
||||
|
||||
it("409 — ошибка с текстом хаба", async () => {
|
||||
mockFetch(() => json(409, { error: "agent_too_old", detail: "агент v13 не умеет скачивать вложения — нужен v14" }));
|
||||
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
|
||||
act(() => result.current.start(att));
|
||||
await flush();
|
||||
expect(result.current.status("31337")).toEqual({ busy: false, error: "агент v13 не умеет скачивать вложения — нужен v14" });
|
||||
expect(clicked).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("failed при опросе — ошибка команды; повторное start сбрасывает ошибку", async () => {
|
||||
let n = 0;
|
||||
mockFetch((m) => m === "POST" ? json(200, st("fetching")) : json(200, ++n === 1 ? st("failed", { error: "Jira: 404" }) : st("fetching")));
|
||||
const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper });
|
||||
act(() => result.current.start(att));
|
||||
await flush();
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
|
||||
expect(result.current.status("31337")).toEqual({ busy: false, error: "Jira: 404" });
|
||||
act(() => result.current.start(att));
|
||||
await flush();
|
||||
expect(result.current.status("31337")).toEqual({ busy: true, error: null });
|
||||
});
|
||||
});
|
||||
85
web/src/issues/attachments/useAttachmentDownload.ts
Normal file
85
web/src/issues/attachments/useAttachmentDownload.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ApiError } from "../../api/client.ts";
|
||||
import { api } from "../../api/client.ts";
|
||||
import { qk } from "../../api/queries.ts";
|
||||
import type { AttachmentState, IssueAttachment } from "../../api/types.ts";
|
||||
|
||||
export const POLL_MS = 2000;
|
||||
export interface DownloadStatus { busy: boolean; error: string | null }
|
||||
const IDLE: DownloadStatus = { busy: false, error: null };
|
||||
|
||||
// браузер сохраняет файл под своим именем: Blob → временная ссылка download (токен в адресе не появляется)
|
||||
function save(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const errorText = (e: unknown): string => e instanceof ApiError ? (e.detail ?? e.message) : e instanceof Error ? e.message : String(e);
|
||||
|
||||
// скачивание вложений карточки: одно состояние на вложение, общее для всех её кнопок (раздел, плашки, ссылки в тексте);
|
||||
// опрос прекращается при размонтировании и смене задачи, команда при этом остаётся в очереди хаба
|
||||
export function useAttachmentDownload(issueKey: string) {
|
||||
const qc = useQueryClient();
|
||||
const [map, setMap] = useState<Record<string, DownloadStatus>>({});
|
||||
const mapRef = useRef(map);
|
||||
const gen = useRef(0);
|
||||
const timers = useRef(new Set<ReturnType<typeof setTimeout>>());
|
||||
|
||||
const put = useCallback((id: string, s: DownloadStatus) => {
|
||||
mapRef.current = { ...mapRef.current, [id]: s };
|
||||
setMap(mapRef.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const pending = timers.current;
|
||||
const counter = gen;
|
||||
return () => {
|
||||
counter.current++;
|
||||
for (const t of pending) clearTimeout(t);
|
||||
pending.clear();
|
||||
mapRef.current = {};
|
||||
setMap({});
|
||||
};
|
||||
}, [issueKey]);
|
||||
|
||||
const start = useCallback((a: IssueAttachment) => {
|
||||
const id = a.attachment_id;
|
||||
if (mapRef.current[id]?.busy) return;
|
||||
const my = gen.current;
|
||||
const alive = () => gen.current === my;
|
||||
const fail = (error: string) => { if (alive()) put(id, { busy: false, error }); };
|
||||
|
||||
// разбор состояния: ready — забрать файл, fetching — подождать и спросить снова, иначе — ошибка
|
||||
const handle = async (st: AttachmentState): Promise<void> => {
|
||||
if (!alive()) return;
|
||||
if (st.state === "ready") {
|
||||
const blob = await api.blob(`/attachments/${encodeURIComponent(id)}/content`);
|
||||
if (!alive()) return;
|
||||
save(blob, a.filename);
|
||||
put(id, IDLE);
|
||||
void qc.invalidateQueries({ queryKey: qk.issue(issueKey) }); // отметка «у хаба»
|
||||
} else if (st.state === "fetching") {
|
||||
const t = setTimeout(() => {
|
||||
timers.current.delete(t);
|
||||
if (!alive()) return;
|
||||
api.get<AttachmentState>(`/attachments/${encodeURIComponent(id)}`).then(handle).catch((e) => fail(errorText(e)));
|
||||
}, POLL_MS);
|
||||
timers.current.add(t);
|
||||
} else {
|
||||
fail(st.error ?? "не удалось скачать вложение");
|
||||
}
|
||||
};
|
||||
|
||||
put(id, { busy: true, error: null });
|
||||
api.post<AttachmentState>(`/attachments/${encodeURIComponent(id)}/fetch`, { author: "web" })
|
||||
.then(handle).catch((e) => fail(errorText(e)));
|
||||
}, [issueKey, put, qc]);
|
||||
|
||||
const status = useCallback((id: string): DownloadStatus => map[id] ?? IDLE, [map]);
|
||||
return { start, status };
|
||||
}
|
||||
@ -1,14 +1,23 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { IconPaperclip } from "../../ui/index.ts";
|
||||
import { IssueLink } from "../IssueLink.tsx";
|
||||
import type { Inline } from "./inline.ts";
|
||||
import { TOO_BIG_HINT } from "../attachments/format.ts";
|
||||
import type { Inline, WikiAttachment } from "./inline.ts";
|
||||
import { type Block, type ListBlock, parseWiki } from "./parse.ts";
|
||||
import "./wiki.css";
|
||||
|
||||
interface Props { text: string; projectKey?: string }
|
||||
// вид ссылки на вложение в тексте: busy — идёт скачивание; href — вложение больше предела, ссылка ведёт в Jira
|
||||
export interface AttachmentView { busy: boolean; href?: string }
|
||||
interface Props {
|
||||
text: string; projectKey?: string;
|
||||
attachments?: WikiAttachment[]; // вложения задачи: по ним распознаются `!имя!`, `[^имя]`
|
||||
onAttachment?: (id: string) => void;
|
||||
attachmentView?: (id: string) => AttachmentView;
|
||||
}
|
||||
|
||||
// вики-разметка Jira только React-элементами, HTML строкой не вставляется (design wiki-render #2, #5); при ошибке — исходник (#6)
|
||||
export function WikiText({ text, projectKey }: Props) {
|
||||
return <Fallback text={text}><Rendered text={text} projectKey={projectKey} /></Fallback>;
|
||||
export function WikiText(props: Props) {
|
||||
return <Fallback text={props.text}><Rendered {...props} /></Fallback>;
|
||||
}
|
||||
|
||||
class Fallback extends Component<{ text: string; children: ReactNode }, { failed: boolean }> {
|
||||
@ -20,54 +29,67 @@ class Fallback extends Component<{ text: string; children: ReactNode }, { failed
|
||||
}
|
||||
}
|
||||
|
||||
function Rendered({ text, projectKey }: Props) {
|
||||
return <div className="wiki">{renderBlocks(parseWiki(text, { projectKey }))}</div>;
|
||||
// то, что нужно рендеру строки, кроме текста: ссылки на вложения
|
||||
interface Ctx { onAttachment?: (id: string) => void; attachmentView?: (id: string) => AttachmentView }
|
||||
|
||||
function Rendered({ text, projectKey, attachments, onAttachment, attachmentView }: Props) {
|
||||
const ctx: Ctx = { onAttachment, attachmentView };
|
||||
return <div className="wiki">{renderBlocks(parseWiki(text, { projectKey, attachments }), ctx)}</div>;
|
||||
}
|
||||
|
||||
function renderBlocks(blocks: Block[]): ReactNode[] {
|
||||
function renderBlocks(blocks: Block[], ctx: Ctx): ReactNode[] {
|
||||
return blocks.map((b, n) => {
|
||||
switch (b.t) {
|
||||
case "heading": { const H = `h${b.level}` as const; return <H key={n}>{renderInlines(b.children)}</H>; }
|
||||
case "p": return <p key={n}>{renderInlines(b.children)}</p>;
|
||||
case "list": return renderList(b, n);
|
||||
case "heading": { const H = `h${b.level}` as const; return <H key={n}>{renderInlines(b.children, ctx)}</H>; }
|
||||
case "p": return <p key={n}>{renderInlines(b.children, ctx)}</p>;
|
||||
case "list": return renderList(b, n, ctx);
|
||||
case "table":
|
||||
return (
|
||||
<table key={n}><tbody>
|
||||
{b.rows.map((r, i) => (
|
||||
<tr key={i}>{r.map((c, j) => c.header ? <th key={j}>{renderInlines(c.children)}</th> : <td key={j}>{renderInlines(c.children)}</td>)}</tr>
|
||||
<tr key={i}>{r.map((c, j) => c.header ? <th key={j}>{renderInlines(c.children, ctx)}</th> : <td key={j}>{renderInlines(c.children, ctx)}</td>)}</tr>
|
||||
))}
|
||||
</tbody></table>
|
||||
);
|
||||
case "pre": return <pre key={n}>{b.text}</pre>;
|
||||
case "quote": return <blockquote key={n}>{renderBlocks(b.children)}</blockquote>;
|
||||
case "quote": return <blockquote key={n}>{renderBlocks(b.children, ctx)}</blockquote>;
|
||||
case "hr": return <hr key={n} />;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderList(list: ListBlock, key: number): ReactNode {
|
||||
function renderList(list: ListBlock, key: number, ctx: Ctx): ReactNode {
|
||||
const L = list.ordered ? "ol" : "ul";
|
||||
return (
|
||||
<L key={key}>
|
||||
{list.items.map((it, i) => <li key={i}>{renderInlines(it.children)}{it.lists.map(renderList)}</li>)}
|
||||
{list.items.map((it, i) => <li key={i}>{renderInlines(it.children, ctx)}{it.lists.map((l, k) => renderList(l, k, ctx))}</li>)}
|
||||
</L>
|
||||
);
|
||||
}
|
||||
|
||||
function renderInlines(nodes: Inline[]): ReactNode[] {
|
||||
function renderInlines(nodes: Inline[], ctx: Ctx): ReactNode[] {
|
||||
return nodes.map((x, n) => {
|
||||
switch (x.t) {
|
||||
case "text": return x.text;
|
||||
case "br": return <br key={n} />;
|
||||
case "strong": return <strong key={n}>{renderInlines(x.children)}</strong>;
|
||||
case "em": return <em key={n}>{renderInlines(x.children)}</em>;
|
||||
case "del": return <del key={n}>{renderInlines(x.children)}</del>;
|
||||
case "ins": return <ins key={n}>{renderInlines(x.children)}</ins>;
|
||||
case "sup": return <sup key={n}>{renderInlines(x.children)}</sup>;
|
||||
case "sub": return <sub key={n}>{renderInlines(x.children)}</sub>;
|
||||
case "strong": return <strong key={n}>{renderInlines(x.children, ctx)}</strong>;
|
||||
case "em": return <em key={n}>{renderInlines(x.children, ctx)}</em>;
|
||||
case "del": return <del key={n}>{renderInlines(x.children, ctx)}</del>;
|
||||
case "ins": return <ins key={n}>{renderInlines(x.children, ctx)}</ins>;
|
||||
case "sup": return <sup key={n}>{renderInlines(x.children, ctx)}</sup>;
|
||||
case "sub": return <sub key={n}>{renderInlines(x.children, ctx)}</sub>;
|
||||
case "code": return <code key={n}>{x.text}</code>;
|
||||
case "link": return <a key={n} href={x.href} target="_blank" rel="noopener noreferrer">{renderInlines(x.children)}</a>;
|
||||
case "issue": return <IssueLink key={n} issueKey={x.key}>{x.children.length ? renderInlines(x.children) : undefined}</IssueLink>;
|
||||
case "link": return <a key={n} href={x.href} target="_blank" rel="noopener noreferrer">{renderInlines(x.children, ctx)}</a>;
|
||||
case "issue": return <IssueLink key={n} issueKey={x.key}>{x.children.length ? renderInlines(x.children, ctx) : undefined}</IssueLink>;
|
||||
case "attachment": {
|
||||
const view = ctx.attachmentView?.(x.id) ?? { busy: false };
|
||||
const label = <><IconPaperclip size={16} />{renderInlines(x.children, ctx)}</>;
|
||||
return view.href
|
||||
? <a key={n} className="att-ref" href={view.href} target="_blank" rel="noopener noreferrer" title={TOO_BIG_HINT}>{label}</a>
|
||||
: <button key={n} type="button" className="att-ref" disabled={view.busy} onClick={() => ctx.onAttachment?.(x.id)}>
|
||||
{label}{view.busy && <span className="att-busy"> Загружается…</span>}
|
||||
</button>;
|
||||
}
|
||||
case "mention": return <span key={n} className="mention">@{x.login}</span>;
|
||||
}
|
||||
});
|
||||
|
||||
@ -74,3 +74,41 @@ describe("parseInline: ключи задач", () => {
|
||||
});
|
||||
it("без projectKey — текст", () => expect(parseInline("AURORA-1445")).toEqual([t("AURORA-1445")]));
|
||||
});
|
||||
|
||||
describe("parseInline: ссылки на вложения", () => {
|
||||
const atts = [
|
||||
{ id: "31337", filename: "скрин 1.png", created: "2026-10-15T12:00:00.000+0300" },
|
||||
{ id: "31338", filename: "a.pdf", created: null },
|
||||
{ id: "10", filename: "дубль.png", created: "2026-10-14T12:00:00.000+0300" },
|
||||
{ id: "20", filename: "дубль.png", created: "2026-10-20T12:00:00.000+0300" },
|
||||
];
|
||||
const A = { attachments: atts };
|
||||
const att = (id: string, ...children: Inline[]): Inline => ({ t: "attachment", id, children });
|
||||
|
||||
it("!имя|параметры! и !имя!", () => {
|
||||
expect(parseInline("см. !скрин 1.png|thumbnail!", A)).toEqual([t("см. "), att("31337", t("скрин 1.png"))]);
|
||||
expect(parseInline("!скрин 1.png!", A)).toEqual([att("31337", t("скрин 1.png"))]);
|
||||
});
|
||||
it("[^имя] и [текст|^имя]", () => {
|
||||
expect(parseInline("[^a.pdf]", A)).toEqual([att("31338", t("a.pdf"))]);
|
||||
expect(parseInline("[отчёт|^a.pdf]", A)).toEqual([att("31338", t("отчёт"))]);
|
||||
});
|
||||
it("имя точно (с регистром); несколько с одним именем — самое новое", () => {
|
||||
expect(parseInline("!Скрин 1.png!", A)).toEqual([t("!Скрин 1.png!")]);
|
||||
expect(parseInline("!дубль.png!", A)).toEqual([att("20", t("дубль.png"))]);
|
||||
});
|
||||
it("имени нет среди вложений — исходник как есть", () => {
|
||||
expect(parseInline("!нет.png!", A)).toEqual([t("!нет.png!")]);
|
||||
expect(parseInline("[^нет.pdf] и [x|^нет.pdf]", A)).toEqual([t("[^нет.pdf] и [x|^нет.pdf]")]);
|
||||
});
|
||||
it("внешняя картинка не вложение", () => {
|
||||
expect(JSON.stringify(parseInline("!http://x/скрин 1.png!", { attachments: [{ id: "1", filename: "http://x/скрин 1.png", created: null }] }))).not.toContain("attachment");
|
||||
});
|
||||
it("без опции attachments — как раньше", () => {
|
||||
expect(parseInline("!скрин 1.png!")).toEqual([t("!скрин 1.png!")]);
|
||||
expect(parseInline("[^a.pdf]")).toEqual([t("[^a.pdf]")]);
|
||||
});
|
||||
it("внутри подписи ссылки вложения нет (кнопка в ссылке недопустима)", () => {
|
||||
expect(parseInline("[!a.pdf!|https://x.y]", A)).toEqual([link("https://x.y", t("!a.pdf!"))]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
// Строчная вики-разметка Jira (design wiki-render #4, #5): выделение, {{моно}}, ссылки, упоминания, ключи задач.
|
||||
// Вход — одна строка без переводов строк; разбор не бросает исключений, нераспознанное остаётся текстом как есть.
|
||||
|
||||
export interface WikiOptions { projectKey?: string }
|
||||
// вложения задачи для разбора ссылок `!имя!`, `[^имя]` (как у хаба: имя точно, при повторе имени — самое новое)
|
||||
export interface WikiAttachment { id: string; filename: string; created: string | null }
|
||||
export interface WikiOptions { projectKey?: string; attachments?: WikiAttachment[] }
|
||||
|
||||
type Mark = "strong" | "em" | "del" | "ins" | "sup" | "sub";
|
||||
|
||||
@ -11,6 +13,7 @@ export type Inline =
|
||||
| { t: "code"; text: string }
|
||||
| { t: "link"; href: string; children: Inline[] }
|
||||
| { t: "issue"; key: string; children: Inline[] }
|
||||
| { t: "attachment"; id: string; children: Inline[] }
|
||||
| { t: "mention"; login: string }
|
||||
| { t: "br" };
|
||||
|
||||
@ -20,6 +23,8 @@ const WORD = /[\p{L}\p{N}]/u;
|
||||
const SPACE = /\s/;
|
||||
const URL = /https?:\/\/[^\s\]|<>"]+/y;
|
||||
const URL_TAIL = /[.,;:!?)]+$/;
|
||||
const IMAGE = /!([^!|\n]+)(?:\|[^!\n]*)?!/y;
|
||||
const ATT_LINK = /^(?:([^|]*)\|)?\^(.+)$/;
|
||||
|
||||
// схема ссылки — только http, https, mailto (спека «Разметка Jira в карточке»); иначе ссылки нет
|
||||
function safeHref(raw: string): string | null {
|
||||
@ -27,6 +32,18 @@ function safeHref(raw: string): string | null {
|
||||
return /^(https?:\/\/|mailto:)\S/i.test(href) ? href : null;
|
||||
}
|
||||
|
||||
// вложение по имени: несколько с одним именем — самое новое по created, затем по числовому id
|
||||
function findAttachment(opts: WikiOptions, name: string): string | null {
|
||||
const same = (opts.attachments ?? []).filter((a) => a.filename === name);
|
||||
if (!same.length) return null;
|
||||
const rank = (a: WikiAttachment): [number, number] => [a.created ? Date.parse(a.created) || 0 : 0, Number(a.id) || 0];
|
||||
return same.reduce((best, a) => {
|
||||
const [bt, bi] = rank(best);
|
||||
const [at, ai] = rank(a);
|
||||
return at > bt || (at === bt && ai > bi) ? a : best;
|
||||
}).id;
|
||||
}
|
||||
|
||||
function keyPattern(projectKey: string | undefined): RegExp | null {
|
||||
if (!projectKey) return null;
|
||||
const escaped = projectKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
@ -71,6 +88,13 @@ export function parseInline(line: string, opts: WikiOptions = {}, label = false)
|
||||
text(c); i += 1; continue;
|
||||
}
|
||||
|
||||
if (c === "!" && !label && opts.attachments?.length) {
|
||||
IMAGE.lastIndex = i;
|
||||
const m = IMAGE.exec(line);
|
||||
const id = m && !m[1].includes("://") ? findAttachment(opts, m[1]) : null;
|
||||
if (m && id) { out.push({ t: "attachment", id, children: [{ t: "text", text: m[1] }] }); i += m[0].length; continue; }
|
||||
}
|
||||
|
||||
if (c === "h" && !label && prevOk(i)) {
|
||||
URL.lastIndex = i;
|
||||
const m = URL.exec(line);
|
||||
@ -126,6 +150,11 @@ function bracket(body: string, opts: WikiOptions, isKey: (s: string) => boolean)
|
||||
const login = body.slice(1).trim();
|
||||
return login && !SPACE.test(login) ? { t: "mention", login } : null;
|
||||
}
|
||||
const ref = ATT_LINK.exec(body);
|
||||
if (ref) {
|
||||
const id = findAttachment(opts, ref[2]);
|
||||
return id ? { t: "attachment", id, children: ref[1] ? parseInline(ref[1], opts, true) : [{ t: "text", text: ref[2] }] } : null;
|
||||
}
|
||||
const bar = body.indexOf("|");
|
||||
const label = bar >= 0 ? body.slice(0, bar) : "";
|
||||
const target = (bar >= 0 ? body.slice(bar + 1) : body).trim();
|
||||
|
||||
@ -37,3 +37,8 @@ export const IconList = make(<><path d="M9 6h11M9 12h11M9 18h11"/><path d="M4 6h
|
||||
export const IconMenu = make(<><path d="M4 6h16M4 12h16M4 18h16"/></>);
|
||||
export const IconHistory = make(<><path d="M4 12a8 8 0 1 0 2.3-5.7"/><path d="M4 4v4h4"/><path d="M12 8v4l3 2"/></>);
|
||||
export const IconSidebar = make(<><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></>);
|
||||
export const IconPaperclip = make(<><path d="M20 11.5l-7.8 7.8a5 5 0 0 1-7.1-7.1l8.5-8.5a3.3 3.3 0 0 1 4.7 4.7l-8.5 8.5a1.7 1.7 0 0 1-2.4-2.4l7.8-7.8"/></>);
|
||||
export const IconDownload = make(<><path d="M12 4v12"/><path d="M7 11l5 5 5-5"/><path d="M4 20h16"/></>);
|
||||
export const IconFile = make(<><path d="M14 3H7a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7z"/><path d="M14 3v4h4"/></>);
|
||||
export const IconImage = make(<><rect x="3.5" y="5" width="17" height="14" rx="2"/><circle cx="9" cy="10" r="1.5"/><path d="M4 17l5-4.5 3.5 3 3-2.5 4.5 4"/></>);
|
||||
export const IconSheet = make(<><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M4 10h16M4 15h16M10 4v16"/></>);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user