From 37f28c38121462a671dcf80482004d172de88824 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 11:26:10 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=D0=B0=20=C2=AB=D0=9B=D0=B5=D0=BD=D1=82=D0=B0=C2=BB=20=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=20=D0=B2=20=D0=BF=D0=B0?= =?UTF-8?q?=D0=BD=D0=B5=D0=BB=D0=B8=20(#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/e2e/feed.spec.ts | 44 ++++++++++ web/src/feed/EventList.tsx | 20 +++++ web/src/feed/FeedPage.test.tsx | 152 +++++++++++++++++++++++++++++++++ web/src/feed/FeedPage.tsx | 79 +++++++++++++++++ web/src/feed/feed.css | 16 ++++ web/src/shell/Layout.tsx | 9 +- web/src/shell/Sidebar.test.tsx | 10 +++ web/src/shell/Sidebar.tsx | 5 +- web/src/shell/pages.tsx | 2 + web/src/ui/icons.tsx | 1 + 10 files changed, 333 insertions(+), 5 deletions(-) create mode 100644 web/e2e/feed.spec.ts create mode 100644 web/src/feed/EventList.tsx create mode 100644 web/src/feed/FeedPage.test.tsx create mode 100644 web/src/feed/FeedPage.tsx create mode 100644 web/src/feed/feed.css diff --git a/web/e2e/feed.spec.ts b/web/e2e/feed.spec.ts new file mode 100644 index 0000000..6958e7d --- /dev/null +++ b/web/e2e/feed.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from "@playwright/test"; +import { HUB_URL } from "../playwright.config.ts"; +import { login, tokens } from "./hub.ts"; + +// журнал изменений засевается агентским маршрутом, как его шлёт агент v13 (#70); id записей уникальны на прогон +async function seedHistory() { + const run = Date.now(); + const created = new Date(Date.now() - 10 * 60_000).toISOString(); + const changes = [ + { history_id: `e2e-${run}-1`, seq: 0, key: "AURORA-9002", created, author: "Петров Пётр", field_id: "status", + field_name: "status", from: "1", from_string: "В работе", to: "2", to_string: "Готово" }, + { history_id: `e2e-${run}-2`, seq: 0, key: "AURORA-9002", created, author: "Петров Пётр", field_id: "assignee", + field_name: "assignee", from: null, from_string: null, to: "x".repeat(200), to_string: "Очень-длинное-имя-без-пробелов-".repeat(8) }, + ]; + const r = await fetch(HUB_URL + "/agent/history", { method: "POST", headers: { Authorization: "Bearer " + tokens().agent, "Content-Type": "application/json" }, body: JSON.stringify({ changes }) }); + expect(r.ok).toBe(true); +} + +test("лента: событие за сегодня, карточка окном поверх ленты", async ({ page }) => { + await seedHistory(); + await login(page, "/app/feed?project=AWPCA"); + const group = page.getByTestId("feed-group").filter({ has: page.getByRole("link", { name: "AURORA-9002", exact: true }) }); + await expect(group.getByText("статус «В работе» → «Готово»").first()).toBeVisible(); + const other = page.getByRole("checkbox", { name: "Прочие поля" }); + await other.click(); // флажок управляемый: состояние приходит из адреса после навигации + await expect(other).toBeChecked(); + await expect(page).toHaveURL(/kinds=status%2Cdescription%2Cdates%2Ccomment%2Cother|kinds=status,description,dates,comment,other/); + await expect(group.getByText(/исполнитель:/).first()).toBeVisible(); + await group.getByRole("link", { name: "AURORA-9002", exact: true }).click(); + const d = page.getByRole("dialog"); + await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.getByTestId("page-feed")).toBeVisible(); +}); + +test("лента на 390 px: нет горизонтальной прокрутки при длинных значениях", async ({ page }) => { + await seedHistory(); + await page.setViewportSize({ width: 390, height: 844 }); + await login(page, "/app/feed?project=AWPCA&kinds=status,other"); + await expect(page.getByTestId("feed-group").first()).toBeVisible(); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); + expect(overflow).toBeLessThanOrEqual(0); +}); diff --git a/web/src/feed/EventList.tsx b/web/src/feed/EventList.tsx new file mode 100644 index 0000000..708f3a5 --- /dev/null +++ b/web/src/feed/EventList.tsx @@ -0,0 +1,20 @@ +import type { ChangeEvent } from "../api/types.ts"; +import { eventText, eventTime } from "./events.ts"; +import "./feed.css"; + +// строки событий журнала: время, автор, что изменилось (лента и история в карточке; spec web-app «Лента изменений») +export function EventList({ events, now, alwaysDate = false }: { events: ChangeEvent[]; now: Date; alwaysDate?: boolean }) { + return ( + + ); +} diff --git a/web/src/feed/FeedPage.test.tsx b/web/src/feed/FeedPage.test.tsx new file mode 100644 index 0000000..42e5421 --- /dev/null +++ b/web/src/feed/FeedPage.test.tsx @@ -0,0 +1,152 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createMemoryRouter } from "react-router"; +import { beforeEach, describe, expect, it } from "vitest"; +import { App } from "../App.tsx"; +import type { ChangeEvent } from "../api/types.ts"; +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"; + +const SNAP = JSON.parse(readFileSync(join(import.meta.dirname, "..", "board", "__fixtures__", "hub-basic.board.json"), "utf8")); +const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9 }; +const B = { label: "STOCKS", name: "Запасы", board_id: null, board_type: null, created_at: "", issues_count: 1 }; +const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000).toISOString(); +const ev = (over: Partial): ChangeEvent => ({ + at: minutesAgo(30), key: "AURORA-9002", summary: "API свода", status: "Готово", kind: "status", field: "status", + author: "Петров Пётр", from: "В работе", to: "Готово", from_value: null, to_value: null, ...over, +}); + +type Changes = { since?: string; until?: string; truncated?: boolean; changes: ChangeEvent[] } | Response; +function hub(changes: () => Changes = () => ({ changes: [] })) { + const calls: URL[] = []; + mockFetch((method, url) => { + const k = `${method} ${url.pathname}`; + if (k === "GET /changes") { + calls.push(url); + const r = changes(); + return r instanceof Response ? r : json(200, { since: "", until: "", truncated: false, ...r }); + } + if (k === "GET /projects") return json(200, { projects: [A, B] }); + if (k === "GET /settings") return json(200, settings); + if (k === "GET /board") return json(200, SNAP); + if (k === "GET /issues") return json(200, { issues: [] }); + if (k === "GET /batches") return json(200, { batches: [] }); + if (k === "GET /agent") return json(200, { online: true, last_seen_at: null }); + if (k.startsWith("GET /issues/")) return json(200, issue(decodeURIComponent(url.pathname.split("/").pop()!))); + return json(404, { error: "not_found" }); + }); + return calls; +} +const open = (path: string) => { + const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" }); + render(); + return router; +}; +const page = () => screen.findByTestId("page-feed"); + +describe("лента изменений", () => { + beforeEach(() => { setToken("tok"); localStorage.clear(); }); + + it("по умолчанию — «Сегодня», виды без «Прочих полей», события сгруппированы по задаче", async () => { + const calls = hub(() => ({ changes: [ + ev({}), ev({ key: "AURORA-9001", summary: "Схема", status: "В работе", kind: "comment", field: "comment", from: null, to: "Готово к проверке" }), + ev({ at: minutesAgo(40), kind: "description", field: "description" }), + ] })); + open("/app/feed?project=AWPCA"); + await page(); + const groups = await screen.findAllByTestId("feed-group"); + expect(groups.map((g) => within(g).getByRole("link").textContent)).toEqual(["AURORA-9002", "AURORA-9001"]); + expect(within(groups[0]).getByText("API свода")).toBeInTheDocument(); + expect(within(groups[0]).getByText(/статус «В работе» → «Готово»/)).toBeInTheDocument(); + expect(within(groups[0]).getByText("описание изменено")).toBeInTheDocument(); + expect(within(groups[1]).getByText("комментарий: Готово к проверке")).toBeInTheDocument(); + const q = calls[0].searchParams; + expect(q.get("project")).toBe("AWPCA"); + expect(q.get("kinds")).toBe("status,description,dates,comment"); + const midnight = new Date(); midnight.setHours(0, 0, 0, 0); + expect(new Date(q.get("since")!).getTime()).toBe(midnight.getTime()); + expect(q.get("limit")).toBe("2000"); + expect(screen.getByRole("radio", { name: "Сегодня" })).toHaveAttribute("aria-checked", "true"); + expect(screen.getByText(/3 события/)).toBeInTheDocument(); + }); + + it("«Что закрылось за два часа»: период и виды в адресе и в запросе", async () => { + const calls = hub(); + const router = open("/app/feed?project=AWPCA"); + await page(); + await userEvent.click(screen.getByRole("radio", { name: "2 часа" })); + for (const name of ["Описание", "Сроки", "Комментарии"]) await userEvent.click(screen.getByRole("checkbox", { name })); + await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA&period=2h&kinds=status")); + expect(router.state.location.pathname).toBe("/app/feed"); + await waitFor(() => expect(calls.at(-1)!.searchParams.get("kinds")).toBe("status")); + const since = new Date(calls.at(-1)!.searchParams.get("since")!).getTime(); + expect(Math.abs(Date.now() - 2 * 3600_000 - since)).toBeLessThan(60_000); + }); + + it("карточка из ленты — окно поверх, закрытие возвращает ленту с тем же периодом", async () => { + hub(() => ({ changes: [ev({})] })); + const router = open("/app/feed?project=AWPCA&period=7d"); + await page(); + await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" })); + const d = await screen.findByRole("dialog"); + expect(await within(d).findByRole("heading", { name: "Тема AURORA-9002" })).toBeInTheDocument(); + expect(screen.getByTestId("page-feed")).toBeInTheDocument(); + await userEvent.click(within(d).getAllByRole("button", { name: "Закрыть" })[0]); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(router.state.location.pathname + router.state.location.search).toBe("/app/feed?project=AWPCA&period=7d"); + }); + + it("пустой период, truncated, ошибка с «Повторить»", async () => { + let mode: "empty" | "truncated" | "error" = "empty"; + hub(() => mode === "error" ? json(500, { error: "boom", detail: "сбой" }) + : mode === "truncated" ? { truncated: true, changes: [ev({})] } : { changes: [] }); + open("/app/feed?project=AWPCA"); + expect(await screen.findByText("за период изменений нет")).toBeInTheDocument(); + mode = "truncated"; + await userEvent.click(screen.getByRole("radio", { name: "Вчера" })); + expect(await screen.findByText("показаны последние 1 событий — выберите период короче")).toBeInTheDocument(); + mode = "error"; + await userEvent.click(screen.getByRole("radio", { name: "7 дней" })); + const retry = await screen.findByRole("button", { name: "Повторить" }); + mode = "empty"; + await userEvent.click(retry); + expect(await screen.findByText("за период изменений нет")).toBeInTheDocument(); + }); + + it("все флажки сняты — подсказка, запроса нет", async () => { + const calls = hub(); + open("/app/feed?project=AWPCA&kinds=status"); + await page(); + await waitFor(() => expect(calls.length).toBe(1)); + await userEvent.click(screen.getByRole("checkbox", { name: "Статусы" })); + expect(await screen.findByText("выберите хотя бы один вид")).toBeInTheDocument(); + expect(calls.length).toBe(1); + }); + + it("мусор в адресе: период по умолчанию, неизвестные виды отброшены", async () => { + const calls = hub(); + open("/app/feed?project=AWPCA&period=foo&kinds=status,foo"); + await page(); + await waitFor(() => expect(calls.length).toBeGreaterThan(0)); + expect(screen.getByRole("radio", { name: "Сегодня" })).toHaveAttribute("aria-checked", "true"); + expect(calls[0].searchParams.get("kinds")).toBe("status"); + expect(screen.getByRole("checkbox", { name: "Статусы" })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: "Сроки" })).not.toBeChecked(); + }); + + it("смена проекта: запрос по новому проекту, период и виды сохраняются", async () => { + const calls = hub(); + const router = open("/app/feed?project=AWPCA&period=2h&kinds=status"); + await page(); + await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS"); + await waitFor(() => expect(calls.at(-1)!.searchParams.get("project")).toBe("STOCKS")); + expect(calls.at(-1)!.searchParams.get("kinds")).toBe("status"); + expect(router.state.location.pathname).toBe("/app/feed"); + expect(router.state.location.search).toBe("?project=STOCKS&period=2h&kinds=status"); + }); +}); diff --git a/web/src/feed/FeedPage.tsx b/web/src/feed/FeedPage.tsx new file mode 100644 index 0000000..c10b390 --- /dev/null +++ b/web/src/feed/FeedPage.tsx @@ -0,0 +1,79 @@ +import { useMemo, useState } from "react"; +import { useSearchParams } from "react-router"; +import { useChanges } from "../api/queries.ts"; +import type { ChangeKind } from "../api/types.ts"; +import { IssueLink } from "../issues/IssueLink.tsx"; +import { Badge, Button, Checkbox, Notice, PageHeader, Panel, Segmented } from "../ui/index.ts"; +import { EventList } from "./EventList.tsx"; +import { groupByIssue, KINDS, kindsParam, parseKinds, parsePeriod, PERIODS, periodRange, type Period } from "./events.ts"; +import "./feed.css"; + +const pad = (n: number) => String(n).padStart(2, "0"); +const stamp = (d: Date) => `${pad(d.getDate())}.${pad(d.getMonth() + 1)} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + +function plural(n: number): string { + const m10 = n % 10, m100 = n % 100; + if (m10 === 1 && m100 !== 11) return "событие"; + if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return "события"; + return "событий"; +} + +// лента изменений проекта за период (spec web-app «Лента изменений», design web-feed 1–7) +export function FeedPage({ project }: { project: string }) { + const [params, setParams] = useSearchParams(); + const period = parsePeriod(params.get("period")); + const kinds = useMemo(() => parseKinds(params.get("kinds")), [params]); + // «сейчас» фиксируется на смену периода: границы запроса стабильны, ключ кэша не меняется на каждой отрисовке + const [now, setNow] = useState(() => new Date()); + const range = useMemo(() => periodRange(period, now), [period, now]); + const changes = useChanges({ project, since: range.since.toISOString(), until: range.until.toISOString(), kinds: kinds.join(",") }, + kinds.length > 0); + + const setParam = (name: string, value: string | null) => setParams((p) => { + const n = new URLSearchParams(p); + if (value) n.set(name, value); else n.delete(name); + return n; + }); + const choosePeriod = (p: Period) => { setNow(new Date()); setParam("period", p === "today" ? null : p); }; + const toggleKind = (k: ChangeKind, on: boolean) => { + const next = on ? [...kinds, k] : kinds.filter((x) => x !== k); + setParam("kinds", next.length ? kindsParam(next) : "none"); // пустой набор — явное «none», иначе адрес вернул бы умолчание + }; + + const data = changes.data; + const groups = useMemo(() => groupByIssue(data?.changes ?? []), [data]); + const count = data?.changes.length ?? 0; + const subtitle = kinds.length && data ? `${count} ${plural(count)} · ${stamp(range.since)} — ${stamp(range.until)}` : undefined; + + return ( +
+ +
+ +
+ {KINDS.map((k) => ( + toggleKind(k.value, e.currentTarget.checked)} /> + ))} +
+
+ {!kinds.length && выберите хотя бы один вид} + {kinds.length > 0 && changes.isError && ( + void changes.refetch()}>Повторить}>{(changes.error as Error).message} + )} + {kinds.length > 0 && changes.isPending &&

Загрузка…

} + {data && !count && за период изменений нет} + {groups.map((g) => ( + +
+ + {g.summary ?? "нет в зеркале"} + {g.status && {g.status}} +
+ +
+ ))} + {data?.truncated && {`показаны последние ${count} событий — выберите период короче`}} +
+ ); +} diff --git a/web/src/feed/feed.css b/web/src/feed/feed.css new file mode 100644 index 0000000..9f948e4 --- /dev/null +++ b/web/src/feed/feed.css @@ -0,0 +1,16 @@ +/* лента изменений (spec web-app «Лента изменений»): только токены дизайн-системы */ +.feed{display:flex;flex-direction:column;gap:var(--space-4);padding-top:var(--space-6)} +.feed .ui-page-header{margin-bottom:0} +.feed__controls{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-3) var(--space-5)} +.feed__kinds{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2) var(--space-4)} +.feed__hint{margin:0;color:var(--color-text-secondary)} +.feed-group{display:flex;flex-direction:column;gap:var(--space-3);min-width:0} +.feed-group__head{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2)} +.feed-group__key{font-family:var(--font-mono);font-size:13px;color:var(--color-action-primary);text-decoration:none} +.feed-group__key:hover{text-decoration:underline} +.feed-group__sum{font-weight:600;min-width:0;overflow-wrap:anywhere} +.feed-events{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--space-2)} +.feed-event{display:flex;gap:var(--space-3);font-size:var(--fs-body);line-height:var(--lh-body);min-width:0} +.feed-event__time{flex:0 0 auto;font-family:var(--font-mono);font-size:13px;color:var(--color-text-secondary);white-space:nowrap} +.feed-event__body{min-width:0;overflow-wrap:anywhere;color:var(--color-text-primary)} +.feed-event__author{font-weight:600} diff --git a/web/src/shell/Layout.tsx b/web/src/shell/Layout.tsx index 0468854..d262f35 100644 --- a/web/src/shell/Layout.tsx +++ b/web/src/shell/Layout.tsx @@ -70,8 +70,11 @@ export function Layout() { }, [okLabel, fromUrl, issueKey, onPage, at, pageLocation.search, navigate]); const [newOpen, setNewOpen] = useState(false); - const section = at === "/issues" || at === "/board" ? at : null; - const select = useCallback((label: string) => { void navigate({ pathname: section ?? "/board", search: withProject("", label) }); }, [navigate, section]); + const section = at === "/issues" || at === "/board" || at === "/feed" ? at : null; + // смена проекта: фильтры доски и списка — проектные и сбрасываются; период и виды ленты от проекта не зависят (R-1) + const select = useCallback((label: string) => { + void navigate({ pathname: section ?? "/board", search: withProject(section === "/feed" ? pageLocation.search : "", label) }); + }, [navigate, section, pageLocation.search]); const ctx = useMemo(() => ({ state, projects: projects.data ?? [], select, openNew: () => setNewOpen(true) }), [state, projects.data, select]); @@ -80,7 +83,7 @@ export function Layout() {
-
+
{page}
diff --git a/web/src/shell/Sidebar.test.tsx b/web/src/shell/Sidebar.test.tsx index 8c5abed..b7cac14 100644 --- a/web/src/shell/Sidebar.test.tsx +++ b/web/src/shell/Sidebar.test.tsx @@ -52,6 +52,16 @@ describe("боковая панель", () => { expect(screen.getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current"); }); + it("раздел «Лента» ведёт на /feed с проектом и отмечается как текущий", async () => { + hub({ "GET /changes": () => json(200, { since: "", until: "", truncated: false, changes: [] }) }); + open("/app/feed?project=AWPCA"); + expect(await screen.findByTestId("page-feed")).toBeInTheDocument(); + const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); + await waitFor(() => expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("href", "/app/feed?project=AWPCA")); + expect(within(nav).getByRole("link", { name: "Лента" })).toHaveAttribute("aria-current", "page"); + expect(within(nav).getByRole("link", { name: "Задачи" })).not.toHaveAttribute("aria-current"); + }); + it("на странице «не найдено» ни один раздел не активен; ссылки в сообщении ведут на разделы", async () => { hub(); open("/app/nowhere?project=AWPCA"); diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx index b72139a..9f4f600 100644 --- a/web/src/shell/Sidebar.tsx +++ b/web/src/shell/Sidebar.tsx @@ -4,17 +4,18 @@ import { Link } from "react-router"; import { clearToken } from "../auth/token.ts"; import { withProject } from "../projects/current.ts"; import { ProjectPicker } from "../projects/ProjectPicker.tsx"; -import { IconBoard, IconButton, IconClose, IconList, IconMenu, IconSidebar, Menu, MenuItem, ThemeSwitch } from "../ui/index.ts"; +import { IconBoard, IconButton, IconClose, IconHistory, IconList, IconMenu, IconSidebar, Menu, MenuItem, ThemeSwitch } from "../ui/index.ts"; import { DataStatus } from "./DataStatus.tsx"; import { useJiraRefresh } from "./refresh.ts"; import { refreshText } from "./refreshStatus.ts"; import { readSidebar, saveSidebar } from "./sidebarPref.ts"; -export type Section = "/board" | "/issues"; +export type Section = "/board" | "/issues" | "/feed"; const SECTIONS: { to: Section; label: string; icon: React.ReactNode }[] = [ { to: "/board", label: "Доска связей", icon: }, { to: "/issues", label: "Задачи", icon: }, + { to: "/feed", label: "Лента", icon: }, ]; // узкий экран (<640 px): панель не в потоке, а выезжает по кнопке «Меню» из закреплённой строки (design 4) diff --git a/web/src/shell/pages.tsx b/web/src/shell/pages.tsx index fda044e..3ce1720 100644 --- a/web/src/shell/pages.tsx +++ b/web/src/shell/pages.tsx @@ -1,5 +1,6 @@ import { Navigate, type RouteObject } from "react-router"; import { BoardPage } from "../board/BoardPage.tsx"; +import { FeedPage } from "../feed/FeedPage.tsx"; import { IssuesPage } from "../issues/IssuesPage.tsx"; import { ProjectGate } from "../projects/ProjectGate.tsx"; import { NotFound } from "./NotFound.tsx"; @@ -9,5 +10,6 @@ export const PAGE_ROUTES: RouteObject[] = [ { path: "/", element: }, { path: "/board", element: {(p) => } }, { path: "/issues", element: {(p) => } }, + { path: "/feed", element: {(p) => } }, { path: "*", element: }, ]; diff --git a/web/src/ui/icons.tsx b/web/src/ui/icons.tsx index 9471e52..252d6b9 100644 --- a/web/src/ui/icons.tsx +++ b/web/src/ui/icons.tsx @@ -35,4 +35,5 @@ export const IconOpen = make(<>) export const IconBoard = make(<>); export const IconList = make(<>); export const IconMenu = make(<>); +export const IconHistory = make(<>); export const IconSidebar = make(<>);