Модульная перестройка хаба: ядро + модуль jira (#87) #88

Merged
aep merged 23 commits from feat/87-modules into main 2026-10-23 22:42:15 +03:00
140 changed files with 389 additions and 256 deletions
Showing only changes of commit 2cb454af8b - Show all commits

View File

@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test";
import { CW } from "../src/board/constants.ts";
import { CW } from "../src/modules/jira/board/constants.ts";
import { agentServeRefresh, login } from "./hub.ts";
test.beforeEach(async ({ page }) => { await login(page); });

View File

@ -1,5 +1,5 @@
// разовый генератор golden-эталонов доски: исполняет <script> страницы links.html ревизии 3931a88 в node:vm с заглушкой
// DOM и записывает результаты её функций на фикстурах в src/board/__fixtures__/*.expected.json.
// DOM и записывает результаты её функций на фикстурах в src/modules/jira/board/__fixtures__/*.expected.json.
// Запуск: cd web && npm run golden (Node 24 исполняет .ts без сборки). Эталоны коммитятся; скрипт перезапускать только
// при смене фикстур.
import { execFileSync } from "node:child_process";
@ -9,7 +9,7 @@ import { canon } from "../src/test/canon.ts";
const REV = "3931a88";
const NOW = "2026-10-14T12:00:00.000Z";
const FIX = new URL("../src/board/__fixtures__/", import.meta.url);
const FIX = new URL("../src/modules/jira/board/__fixtures__/", import.meta.url);
const read = (name: string) => readFileSync(new URL(name, FIX), "utf8");
const write = (name: string, v: unknown) => writeFileSync(new URL(name, FIX), JSON.stringify(canon(v), null, 1) + "\n");

View File

@ -19,5 +19,5 @@ fi
rm -rf "$JIRA_HUB_HOME"
cd "$ROOT"
uv run jira-hub init --config tests/fixtures/config.example.json --force >/dev/null
uv run python web/scripts/seed-hub.py web/src/board/__fixtures__/hub-basic.board.json
uv run python web/scripts/seed-hub.py web/src/modules/jira/board/__fixtures__/hub-basic.board.json
exec uv run jira-hub serve --host 127.0.0.1 --port "$PORT"

View File

@ -3,7 +3,7 @@ import { useState } from "react";
import { createBrowserRouter, RouterProvider } from "react-router";
import { makeQueryClient } from "./api/queryClient.ts";
import { AuthGate } from "./auth/AuthGate.tsx";
import { RefreshProvider } from "./shell/RefreshProvider.tsx";
import { ModuleProviders } from "./modules/ModuleProviders.tsx";
import { routes } from "./routes.tsx";
import "./styles/tokens.css";
import "./styles/base.css";
@ -15,7 +15,7 @@ export function App({ router }: { router?: Router }) {
const [r] = useState(() => router ?? createBrowserRouter(routes, { basename: "/app" }));
return (
<QueryClientProvider client={client}>
<AuthGate><RefreshProvider><RouterProvider router={r} /></RefreshProvider></AuthGate>
<AuthGate><ModuleProviders><RouterProvider router={r} /></ModuleProviders></AuthGate>
</QueryClientProvider>
);
}

View File

@ -1,43 +1,16 @@
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "./client.ts";
import type { BoardSnapshot, ChangesResponse, CreatedProject, HubSettings, IssueDetail, IssueRow, Note, NoteFields, Project } from "./types.ts";
// фильтры списка в адресе; hide_closed в адрес не пишется — его выводит список из флажка «скрыть закрытые» (closed=1 — снят)
export const ISSUE_FILTER_NAMES = ["sprint", "assignee", "work_kind", "status", "label", "epic", "neighbor", "q"] as const;
export type IssueFilters = Partial<Record<(typeof ISSUE_FILTER_NAMES)[number] | "hide_closed" | "project", string>>;
export const ISSUE_PAGE = 50;
import type { CreatedProject, HubSettings, Project } from "./types.ts";
// запросы ядра: настройки и проекты; запросы модулей — в web/src/modules/<модуль>/api/queries.ts
export const qk = {
settings: ["settings"] as const,
projects: ["projects"] as const,
board: (project: string) => ["board", project] as const,
boardAll: ["board"] as const, // префикс: все снимки досок сразу (инвалидация)
issues: (f: IssueFilters) => ["issues", f] as const,
issue: (key: string) => ["issue", key] as const,
changes: (q: ChangesQuery) => ["changes", q] as const,
};
// лента изменений: в ключе — проект, период и виды; границы считаются в момент запроса (bounds), поэтому перечитывание
// (после «Обновить из Jira», при возврате на вкладку) берёт окно «до сейчас», а не до открытия страницы (C-1, C-2);
// limit — максимум хаба (design web-feed #3)
export interface ChangesQuery { project: string; period: string; kinds: string }
export const CHANGES_LIMIT = 2000;
export const useChanges = (q: ChangesQuery, bounds: () => { since: Date; until: Date }, enabled = true) =>
useQuery({
queryKey: qk.changes(q), enabled, refetchOnWindowFocus: true,
queryFn: () => {
const { since, until } = bounds();
return api.get<ChangesResponse>("/changes", { project: q.project, kinds: q.kinds, since: since.toISOString(), until: until.toISOString(), limit: CHANGES_LIMIT });
},
});
export const useSettings = () =>
useQuery({ queryKey: qk.settings, queryFn: () => api.get<HubSettings>("/settings"), staleTime: Infinity });
// project === null — проект ещё не выбран: запроса нет
export const useBoard = (project: string | null) =>
useQuery({ queryKey: qk.board(project ?? ""), enabled: project !== null, queryFn: () => api.get<BoardSnapshot>("/board", { project: project! }) });
export const useProjects = () =>
useQuery({ queryKey: qk.projects, queryFn: () => api.get<{ projects?: Project[] }>("/projects").then((r) => r.projects ?? []) });
@ -50,30 +23,3 @@ export function useCreateProject() {
onSuccess: () => qc.invalidateQueries({ queryKey: qk.projects }), // ждём список: onCreated уже видит новый проект
});
}
export const useIssue = (key: string) =>
useQuery({ queryKey: qk.issue(key), queryFn: () => api.get<IssueDetail>(`/issues/${encodeURIComponent(key)}`) });
// список порциями по ISSUE_PAGE: следующая порция есть, пока последняя полная
export const useIssues = (filters: IssueFilters) =>
useInfiniteQuery({
queryKey: qk.issues(filters),
initialPageParam: 0,
queryFn: ({ pageParam }) =>
api.get<{ issues: IssueRow[] }>("/issues", { ...filters, limit: ISSUE_PAGE, offset: pageParam }).then((r) => r.issues),
getNextPageParam: (last, all) => (last.length < ISSUE_PAGE ? undefined : all.length * ISSUE_PAGE),
});
// сохранение заметки: key фиксируется при создании мутации — ответ пишется в карточку той задачи, для которой отправлен
export function useSaveNote(key: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (fields: NoteFields) =>
api.put<Note>(`/issues/${encodeURIComponent(key)}/note`, { ...fields, updated_by: "web" }),
onSuccess: (note) => {
qc.setQueryData<IssueDetail>(qk.issue(key), (d) => (d ? { ...d, note } : d));
void qc.invalidateQueries({ queryKey: ["issues"] });
void qc.invalidateQueries({ queryKey: qk.boardAll });
},
});
}

View File

@ -1,7 +1,7 @@
import { act, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
import { qk, useBoard } from "../api/queries.ts";
import { qk, useBoard } from "../modules/jira/api/queries.ts";
import { json, mockFetch } from "../test/fetchMock.ts";
import { renderWithProviders } from "../test/render.tsx";
import { AuthGate } from "./AuthGate.tsx";

View File

@ -0,0 +1,7 @@
import type { ReactNode } from "react";
import { MODULES } from "./index.ts";
// провайдеры модулей вкладываются в порядке списка: первый — внешний
export function ModuleProviders({ children }: { children: ReactNode }) {
return MODULES.reduceRight<ReactNode>((acc, m) => (m.Provider ? <m.Provider>{acc}</m.Provider> : acc), children);
}

19
web/src/modules/index.ts Normal file
View File

@ -0,0 +1,19 @@
import { jiraModule } from "./jira/index.tsx";
import type { HubModule, Overlay } from "./types.ts";
// список модулей — константа: хуки модулей вызываются в постоянном порядке на каждой отрисовке
export const MODULES: readonly HubModule[] = [jiraModule];
const OVERLAY_HOOKS = MODULES.flatMap((m) => m.useOverlay ?? []);
const CREATED_HOOKS = MODULES.flatMap((m) => m.useOnProjectCreated ?? []);
// окна всех модулей; их число и порядок не меняются между отрисовками
export function useOverlays(): Overlay[] {
return OVERLAY_HOOKS.map((use) => use());
}
// обработчик «проект создан»: вызывает обработчики всех модулей
export function useOnProjectCreated(): (batchId: number) => void {
const handlers = CREATED_HOOKS.map((use) => use());
return (batchId) => handlers.forEach((h) => h(batchId));
}

View File

@ -2,10 +2,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { createElement, type ReactNode } from "react";
import { beforeEach, describe, expect, it } from "vitest";
import { setToken } from "../auth/token.ts";
import { json, mockFetch } from "../test/fetchMock.ts";
import { setToken } from "../../../auth/token.ts";
import { json, mockFetch } from "../../../test/fetchMock.ts";
import { qk, useBoard, useIssues, useSaveNote } from "./queries.ts";
import type { IssueDetail } from "./types.ts";
import type { IssueDetail } from "../../../api/types.ts";
const wrap = (client: QueryClient) => ({ children }: { children: ReactNode }) =>
createElement(QueryClientProvider, { client }, children);

View File

@ -0,0 +1,64 @@
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../../../api/client.ts";
import { qk as coreQk } from "../../../api/queries.ts";
import type { BoardSnapshot, ChangesResponse, IssueDetail, IssueRow, Note, NoteFields } from "../../../api/types.ts";
// фильтры списка в адресе; hide_closed в адрес не пишется — его выводит список из флажка «скрыть закрытые» (closed=1 — снят)
export const ISSUE_FILTER_NAMES = ["sprint", "assignee", "work_kind", "status", "label", "epic", "neighbor", "q"] as const;
export type IssueFilters = Partial<Record<(typeof ISSUE_FILTER_NAMES)[number] | "hide_closed" | "project", string>>;
export const ISSUE_PAGE = 50;
// ключи ядра (настройки, проекты) входят целиком: модуль инвалидирует и их
export const qk = {
...coreQk,
board: (project: string) => ["board", project] as const,
boardAll: ["board"] as const, // префикс: все снимки досок сразу (инвалидация)
issues: (f: IssueFilters) => ["issues", f] as const,
issue: (key: string) => ["issue", key] as const,
changes: (q: ChangesQuery) => ["changes", q] as const,
};
// лента изменений: в ключе — проект, период и виды; границы считаются в момент запроса (bounds), поэтому перечитывание
// (после «Обновить из Jira», при возврате на вкладку) берёт окно «до сейчас», а не до открытия страницы (C-1, C-2);
// limit — максимум хаба (design web-feed #3)
export interface ChangesQuery { project: string; period: string; kinds: string }
export const CHANGES_LIMIT = 2000;
export const useChanges = (q: ChangesQuery, bounds: () => { since: Date; until: Date }, enabled = true) =>
useQuery({
queryKey: qk.changes(q), enabled, refetchOnWindowFocus: true,
queryFn: () => {
const { since, until } = bounds();
return api.get<ChangesResponse>("/changes", { project: q.project, kinds: q.kinds, since: since.toISOString(), until: until.toISOString(), limit: CHANGES_LIMIT });
},
});
// project === null — проект ещё не выбран: запроса нет
export const useBoard = (project: string | null) =>
useQuery({ queryKey: qk.board(project ?? ""), enabled: project !== null, queryFn: () => api.get<BoardSnapshot>("/board", { project: project! }) });
export const useIssue = (key: string) =>
useQuery({ queryKey: qk.issue(key), queryFn: () => api.get<IssueDetail>(`/issues/${encodeURIComponent(key)}`) });
// список порциями по ISSUE_PAGE: следующая порция есть, пока последняя полная
export const useIssues = (filters: IssueFilters) =>
useInfiniteQuery({
queryKey: qk.issues(filters),
initialPageParam: 0,
queryFn: ({ pageParam }) =>
api.get<{ issues: IssueRow[] }>("/issues", { ...filters, limit: ISSUE_PAGE, offset: pageParam }).then((r) => r.issues),
getNextPageParam: (last, all) => (last.length < ISSUE_PAGE ? undefined : all.length * ISSUE_PAGE),
});
// сохранение заметки: key фиксируется при создании мутации — ответ пишется в карточку той задачи, для которой отправлен
export function useSaveNote(key: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (fields: NoteFields) =>
api.put<Note>(`/issues/${encodeURIComponent(key)}/note`, { ...fields, updated_by: "web" }),
onSuccess: (note) => {
qc.setQueryData<IssueDetail>(qk.issue(key), (d) => (d ? { ...d, note } : d));
void qc.invalidateQueries({ queryKey: ["issues"] });
void qc.invalidateQueries({ queryKey: qk.boardAll });
},
});
}

View File

@ -4,9 +4,9 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { StrictMode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { setToken } from "../auth/token.ts";
import { json, mockFetch } from "../test/fetchMock.ts";
import { renderWithProviders } from "../test/render.tsx";
import { setToken } from "../../../auth/token.ts";
import { json, mockFetch } from "../../../test/fetchMock.ts";
import { renderWithProviders } from "../../../test/render.tsx";
import settings from "./__fixtures__/settings.json";
import { BoardPage } from "./BoardPage.tsx";

View File

@ -1,6 +1,6 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useClosedStatuses } from "../api/closed.ts";
import { useSettings } from "../api/queries.ts";
import { useClosedStatuses } from "../../../api/closed.ts";
import { useSettings } from "../../../api/queries.ts";
import { computeVisible, emptyReason } from "./analysis/visible.ts";
import { BoardCanvas } from "./components/BoardCanvas.tsx";
import { ExportBox } from "./components/ExportBox.tsx";
@ -8,7 +8,7 @@ import { FilterBar } from "./components/FilterBar.tsx";
import { Legend } from "./components/Legend.tsx";
import { LinkTable } from "./components/LinkTable.tsx";
import { problemCount, ProblemsDrawer } from "./components/ProblemsDrawer.tsx";
import { Notice } from "../ui/index.ts";
import { Notice } from "../../../ui/index.ts";
import { BoardHeader, BoardNotes } from "./components/BoardHeader.tsx";
import { dictsFromSettings } from "./dicts.ts";
import { exportFileName, exportJson } from "./exportJson.ts";

View File

@ -1,13 +1,13 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { BoardSnapshot } from "../../api/types.ts";
import { canon } from "../../test/canon.ts";
import type { BoardSnapshot } from "../../../../api/types.ts";
import { canon } from "../../../../test/canon.ts";
import { dictsFromSettings } from "../dicts.ts";
import settings from "../__fixtures__/settings.json";
import scenarios from "../__fixtures__/scenarios.json";
import { fromHub } from "../model/fromHub.ts";
import { filtersOf } from "../../test/boardScenarios.ts";
import { filtersOf } from "../../../../test/boardScenarios.ts";
import { noFilters } from "../types.ts";
import { initialSprints, initialWho, sprintItems, whoItems } from "./facets.ts";
import { emptyReason, computeVisible } from "./visible.ts";

View File

@ -3,8 +3,8 @@ import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { BoardSnapshot } from "../../api/types.ts";
import { renderWithProviders } from "../../test/render.tsx";
import type { BoardSnapshot } from "../../../../api/types.ts";
import { renderWithProviders } from "../../../../test/render.tsx";
import { computeVisible } from "../analysis/visible.ts";
import { dictsFromSettings } from "../dicts.ts";
import settings from "../__fixtures__/settings.json";

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type ReactNode } from "react";
import { isClosed } from "../../api/closed.ts";
import { isClosed } from "../../../../api/closed.ts";
import { CW, GRID, MINH, MINW, TOP } from "../constants.ts";
import { alignEdges } from "../layout/align.ts";
import { clampToCol, computeLayout, measuredHeight, place, type Layout, type Placed, type RelBox } from "../layout/layout.ts";

View File

@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { statusText, type BoardStatus } from "../status.ts";
import type { BoardModel, Visible } from "../types.ts";
import { NOT_IN_HUB } from "../constants.ts";
import { Badge, Button, IconButton, IconRefresh, IconUpload, IconWarning, Menu, MenuItem, MenuSeparator, Notice, PageHeader } from "../../ui/index.ts";
import { Badge, Button, IconButton, IconRefresh, IconUpload, IconWarning, Menu, MenuItem, MenuSeparator, Notice, PageHeader } from "../../../../ui/index.ts";
interface BoardHeaderProps {
label: string; title: string; source: "hub" | "file"; fileName: string; status: BoardStatus | null; byDate: boolean; snap: boolean;

View File

@ -1,6 +1,6 @@
import { memo, type PointerEvent } from "react";
import { Link } from "react-router";
import { IconOpen } from "../../ui/icons.tsx";
import { IconOpen } from "../../../../ui/icons.tsx";
import { BOARD_CARD_STATE } from "../../issues/modal.ts";
import { CW, JIRA_URL, KIND, PRIO } from "../constants.ts";
import { lineClamp, type Rect, type RelBox } from "../layout/layout.ts";

View File

@ -1,5 +1,5 @@
import { useState } from "react";
import { Button, Panel } from "../../ui/index.ts";
import { Button, Panel } from "../../../../ui/index.ts";
export function ExportBox({ text, onClose }: { text: string; onClose(): void }) {
const [copied, setCopied] = useState(false);

View File

@ -2,7 +2,7 @@ import type { ReactNode } from "react";
import { sprintBox, sprintItems, whoBox, whoItems } from "../analysis/facets.ts";
import type { BoardPrefsApi } from "../useBoardPrefs.ts";
import type { BoardModel } from "../types.ts";
import { Button, IconCalendar, IconCritical, IconHandoff, IconLink, IconNeighbors, Panel } from "../../ui/index.ts";
import { Button, IconCalendar, IconCritical, IconHandoff, IconLink, IconNeighbors, Panel } from "../../../../ui/index.ts";
import { MultiSelect } from "./MultiSelect.tsx";
type Toggle = "neighbors" | "onlyHand" | "onlyLinked" | "crit" | "byDate";

View File

@ -1,5 +1,5 @@
import { useRef, useState, type PointerEvent } from "react";
import { IconChevronDown, IconChevronUp } from "../../ui/icons.tsx";
import { IconChevronDown, IconChevronUp } from "../../../../ui/icons.tsx";
import type { LaneGeom } from "../layout/layout.ts";
import { plural } from "../model/util.ts";

View File

@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import type { BoardPrefsApi } from "../useBoardPrefs.ts";
import { Badge, Button, IconLegend } from "../../ui/index.ts";
import { Badge, Button, IconLegend } from "../../../../ui/index.ts";
const KINDS = [["decision", "решение"], ["analytics", "аналитика / актуализация"], ["dev", "разработка"], ["ui", "UI"],
["test", "тестирование"], ["org", "организационная"]] as const;

View File

@ -1,4 +1,4 @@
import { Panel } from "../../ui/index.ts";
import { Panel } from "../../../../ui/index.ts";
import type { VisLink } from "../types.ts";
export function LinkTable({ links }: { links: VisLink[] }) {

View File

@ -1,6 +1,6 @@
import { useState } from "react";
import type { FacetItem } from "../analysis/facets.ts";
import { Button, Dropdown, IconChevronDown, SearchInput } from "../../ui/index.ts";
import { Button, Dropdown, IconChevronDown, SearchInput } from "../../../../ui/index.ts";
// мультиселект с поиском: пустой выбор = без фильтра (строки 913–938); кнопка — вид Select
export function MultiSelect({ items, selected, allLabel, onChange }: {

View File

@ -1,4 +1,4 @@
import { Button, Panel } from "../../ui/index.ts";
import { Button, Panel } from "../../../../ui/index.ts";
import { JIRA_URL } from "../constants.ts";
import type { ProblemItem, Problems } from "../types.ts";

View File

@ -1,4 +1,4 @@
import type { HubSettings } from "../api/types.ts";
import type { HubSettings } from "../../../api/types.ts";
import type { Dicts } from "./types.ts";
// словари доски из настроек хаба: kind_labels (вид → метка) и link_types (логический тип → имя Jira) обращаются

View File

@ -1,10 +1,10 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { BoardSnapshot } from "../../api/types.ts";
import { canon } from "../../test/canon.ts";
import type { BoardSnapshot } from "../../../../api/types.ts";
import { canon } from "../../../../test/canon.ts";
import { computeVisible } from "../analysis/visible.ts";
import { filtersOf } from "../../test/boardScenarios.ts";
import { filtersOf } from "../../../../test/boardScenarios.ts";
import { dictsFromSettings } from "../dicts.ts";
import settings from "../__fixtures__/settings.json";
import scenarios from "../__fixtures__/scenarios.json";

View File

@ -1,4 +1,4 @@
import type { BoardIssue, BoardSnapshot } from "../../api/types.ts";
import type { BoardIssue, BoardSnapshot } from "../../../../api/types.ts";
import { EPICS, KEY_RE, NO_EPIC, NOT_IN_HUB } from "../constants.ts";
import type { BoardModel, BoardNode, Dicts, Link } from "../types.ts";
import { addLink, addStubs } from "./links.ts";

View File

@ -1,8 +1,8 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { BoardSnapshot } from "../../api/types.ts";
import { canon } from "../../test/canon.ts";
import type { BoardSnapshot } from "../../../../api/types.ts";
import { canon } from "../../../../test/canon.ts";
import { dictsFromSettings } from "../dicts.ts";
import settings from "../__fixtures__/settings.json";
import { parseCsv } from "./csv.ts";

View File

@ -1,4 +1,4 @@
// события строки состояния доски; ход пакета обновления из Jira — в оболочке (shell/refreshStatus.ts)
// события строки состояния доски; ход пакета обновления из Jira — в модуле (refresh/refreshStatus.ts)
export type BoardStatus =
| { kind: "loading" }
| { kind: "hub-down"; hasData: boolean; fromFile: boolean }

View File

@ -1,8 +1,8 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BoardSnapshot } from "../api/types.ts";
import { canon } from "../test/canon.ts";
import type { BoardSnapshot } from "../../../api/types.ts";
import { canon } from "../../../test/canon.ts";
import { computeVisible } from "./analysis/visible.ts";
import { dictsFromSettings } from "./dicts.ts";
import { exportFileName, exportJson } from "./exportJson.ts";

View File

@ -3,12 +3,12 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it } from "vitest";
import { qk } from "../api/queries.ts";
import { makeQueryClient } from "../api/queryClient.ts";
import { setToken } from "../auth/token.ts";
import { json } from "../test/fetchMock.ts";
import { batch, deferred, fixture as fx, hub, SNAP } from "../test/batchHub.ts";
import { useJiraRefresh } from "../shell/refresh.ts";
import { RefreshProvider } from "../shell/RefreshProvider.tsx";
import { makeQueryClient } from "../../../api/queryClient.ts";
import { setToken } from "../../../auth/token.ts";
import { json } from "../../../test/fetchMock.ts";
import { batch, deferred, fixture as fx, hub, SNAP } from "../../../test/batchHub.ts";
import { useJiraRefresh } from "../refresh/refresh.ts";
import { RefreshProvider } from "../refresh/RefreshProvider.tsx";
import { dictsFromSettings } from "./dicts.ts";
import settings from "./__fixtures__/settings.json";
import { useBoardSource } from "./useBoardSource.ts";

View File

@ -1,8 +1,8 @@
// источник данных доски: снимок хаба (GET /board) или файл (CSV/JSON). Обновление из Jira живёт в оболочке
// (shell/RefreshProvider.tsx); здесь — явный источник (M-1) и одно событие состояния доски (M-5)
// (refresh/RefreshProvider.tsx); здесь — явный источник (M-1) и одно событие состояния доски (M-5)
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AuthError, dropTokenOn, HubDownError } from "../api/client.ts";
import { AuthError, dropTokenOn, HubDownError } from "../../../api/client.ts";
import { qk, useBoard } from "../api/queries.ts";
import { fromCsv } from "./model/fromCsv.ts";
import { fromHub } from "./model/fromHub.ts";

View File

@ -1,4 +1,4 @@
import type { ChangeEvent } from "../api/types.ts";
import type { ChangeEvent } from "../../../api/types.ts";
import { eventText, eventTime } from "./events.ts";
import "./feed.css";

View File

@ -6,13 +6,13 @@ import { createMemoryRouter } from "react-router";
import { focusManager } from "@tanstack/react-query";
import { act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "../App.tsx";
import type { ChangeEvent } from "../api/types.ts";
import { setToken } from "../auth/token.ts";
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";
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 };

View File

@ -1,9 +1,9 @@
import { useMemo } from "react";
import { useSearchParams } from "react-router";
import { useChanges } from "../api/queries.ts";
import type { ChangeKind } from "../api/types.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 { 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";

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { ChangeEvent } from "../api/types.ts";
import type { ChangeEvent } from "../../../api/types.ts";
import { DEFAULT_KINDS, eventText, eventTime, groupByIssue, kindsParam, parseKinds, parsePeriod, periodRange } from "./events.ts";
const NOW = new Date(2026, 9, 22, 14, 5);

View File

@ -1,4 +1,4 @@
import type { ChangeEvent, ChangeKind } from "../api/types.ts";
import type { ChangeEvent, ChangeKind } from "../../../api/types.ts";
// период ленты: границы по местному времени браузера; «Сегодня» — по умолчанию и в адрес не пишется (spec web-app «Лента изменений»)
export type Period = "2h" | "today" | "yesterday" | "7d";

View File

@ -0,0 +1,37 @@
import { Navigate } from "react-router";
import { ProjectGate } from "../../projects/ProjectGate.tsx";
import { IconBoard, IconHistory, IconList } from "../../ui/index.ts";
import type { HubModule } from "../types.ts";
import { BoardPage } from "./board/BoardPage.tsx";
import { FeedPage } from "./feed/FeedPage.tsx";
import { useIssueOverlay } from "./issues/overlay.tsx";
import { IssuesPage } from "./issues/IssuesPage.tsx";
import { useJiraRefresh } from "./refresh/refresh.ts";
import { RefreshProvider } from "./refresh/RefreshProvider.tsx";
import { SidebarStatus, TopbarNotice } from "./refresh/SidebarStatus.tsx";
// после создания проекта пакет обновления, поставленный хабом, показывается в плашке данных
function useOnProjectCreated() {
const refresh = useJiraRefresh();
return (batchId: number) => refresh.track(batchId);
}
export const jiraModule: HubModule = {
name: "jira",
pages: [
{ path: "/", element: <Navigate to="/board" replace /> },
{ path: "/board", element: <ProjectGate>{(p) => <BoardPage key={p.label} project={p.label} />}</ProjectGate> },
{ path: "/issues", element: <ProjectGate>{(p) => <IssuesPage key={p.label} project={p.label} />}</ProjectGate> },
{ path: "/feed", element: <ProjectGate>{(p) => <FeedPage key={p.label} project={p.label} />}</ProjectGate> },
],
nav: [
{ to: "/board", label: "Доска связей", icon: <IconBoard /> },
{ to: "/issues", label: "Задачи", icon: <IconList /> },
{ to: "/feed", label: "Лента", icon: <IconHistory /> },
],
Provider: RefreshProvider,
SidebarStatus,
TopbarNotice,
useOverlay: useIssueOverlay,
useOnProjectCreated,
};

View File

@ -4,12 +4,12 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { createMemoryRouter, RouterProvider, useNavigate, useParams } from "react-router";
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 { 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";
import { issue, note } from "../test/issueFixtures.ts";
import { json, mockFetch } from "../../../test/fetchMock.ts";
import { issue, note } from "../../../test/issueFixtures.ts";
import { IssueCard } from "./IssueCard.tsx";
const settings = { ...baseSettings, project_key: "AURORA" };

View File

@ -1,15 +1,16 @@
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, IssueAttachment, IssueComment, IssueDetail } from "../api/types.ts";
import { ApiError } from "../../../api/client.ts";
import { useClosedStatuses } from "../../../api/closed.ts";
import { useSettings } from "../../../api/queries.ts";
import { useIssue } from "../api/queries.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";
import { groupLinks, isOverdue, todayISO } from "./card.ts";
import { useCommentsHidden } from "./commentsPref.ts";
import { IssueLink } from "./IssueLink.tsx";
import { Badge, Button, IconButton, IconClose, IconExternal, Notice, Panel } from "../ui/index.ts";
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";

View File

@ -1,5 +1,5 @@
import { useState } from "react";
import { Badge, Button, Chip, Field, IconFilter, Panel, SearchInput, Segmented, Select } from "../ui/index.ts";
import { Badge, Button, Chip, Field, IconFilter, Panel, SearchInput, Segmented, Select } from "../../../ui/index.ts";
import type { IssueFilters as Filters } from "../api/queries.ts";
import { withCurrent, type Opt } from "./options.ts";

View File

@ -2,13 +2,13 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-librar
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 { App } from "../../../App.tsx";
import { setToken } from "../../../auth/token.ts";
import board from "../board/__fixtures__/hub-basic.board.json";
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 { routes } from "../../../routes.tsx";
import { json, mockFetch } from "../../../test/fetchMock.ts";
import { issue } from "../../../test/issueFixtures.ts";
const rows = board.issues.map((i) => ({ ...i, id: i.key, description: null, reporter: null, created: null, jira_updated: null, fetched_at: "" }));

View File

@ -1,6 +1,6 @@
import { useEffect, useRef } from "react";
import { Dialog } from "../ui/index.ts";
import { ErrorBoundary } from "../shell/ErrorBoundary.tsx";
import { Dialog } from "../../../ui/index.ts";
import { ErrorBoundary } from "../../../shell/ErrorBoundary.tsx";
import { IssueCard } from "./IssueCard.tsx";
// имя окна — ключ задачи; заголовок карточки (h2) подписывает окно через aria-labelledby

View File

@ -1,6 +1,6 @@
import type { MouseEvent } from "react";
import { Badge, Panel } from "../ui/index.ts";
import type { IssueRow } from "../api/types.ts";
import { Badge, Panel } from "../../../ui/index.ts";
import type { IssueRow } from "../../../api/types.ts";
import { isOverdue, todayISO } from "./card.ts";
import { IssueLink } from "./IssueLink.tsx";
import { dueOf } from "./options.ts";

View File

@ -4,10 +4,10 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { qk } from "../api/queries.ts";
import { setToken } from "../auth/token.ts";
import { setToken } from "../../../auth/token.ts";
import settings from "../board/__fixtures__/settings.json";
import { json, mockFetch } from "../test/fetchMock.ts";
import { renderWithProviders } from "../test/render.tsx";
import { json, mockFetch } from "../../../test/fetchMock.ts";
import { renderWithProviders } from "../../../test/render.tsx";
import { IssuesPage } from "./IssuesPage.tsx";
const SNAP = JSON.parse(readFileSync(join(import.meta.dirname, "..", "board", "__fixtures__", "hub-basic.board.json"), "utf8"));

View File

@ -1,8 +1,9 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router";
import { isClosed, useClosedStatuses } from "../api/closed.ts";
import { ISSUE_FILTER_NAMES, useBoard, useIssues, useSettings, type IssueFilters as Filters } from "../api/queries.ts";
import { Button, Notice, PageHeader } from "../ui/index.ts";
import { isClosed, useClosedStatuses } from "../../../api/closed.ts";
import { useSettings } from "../../../api/queries.ts";
import { ISSUE_FILTER_NAMES, useBoard, useIssues, type IssueFilters as Filters } from "../api/queries.ts";
import { Button, Notice, PageHeader } from "../../../ui/index.ts";
import { IssueFilters, SELECTS } from "./IssueFilters.tsx";
import { useOpenIssue } from "./IssueLink.tsx";
import { IssueTable } from "./IssueTable.tsx";

View File

@ -1,10 +1,11 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useBlocker } from "react-router";
import { useCloseAbort } from "./closeAbort.ts";
import { ApiError } from "../api/client.ts";
import { useSaveNote, useSettings } from "../api/queries.ts";
import { Button, Field, Notice, Select, Textarea } from "../ui/index.ts";
import { NOTE_FIELDS, type IssueDetail, type NoteField, type NoteFields } from "../api/types.ts";
import { ApiError } from "../../../api/client.ts";
import { useSettings } from "../../../api/queries.ts";
import { useSaveNote } from "../api/queries.ts";
import { Button, Field, Notice, Select, Textarea } from "../../../ui/index.ts";
import { NOTE_FIELDS, type IssueDetail, type NoteField, type NoteFields } from "../../../api/types.ts";
type Values = Record<NoteField, string>;
const valuesOf = (n: IssueDetail["note"]): Values =>

View File

@ -1,6 +1,6 @@
import { useState } from "react";
import type { IssueDetail, NoteField } from "../api/types.ts";
import { Button } from "../ui/index.ts";
import type { IssueDetail, NoteField } from "../../../api/types.ts";
import { Button } from "../../../ui/index.ts";
import { fmtStamp } from "../board/model/util.ts";
import { NoteForm } from "./NoteForm.tsx";

View File

@ -1,5 +1,5 @@
import type { IssueAttachment } from "../../api/types.ts";
import { IconExternal, IconPaperclip } from "../../ui/index.ts";
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";

View File

@ -1,6 +1,6 @@
import type { IssueAttachment } from "../../api/types.ts";
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 { 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";

Some files were not shown because too many files have changed in this diff Show More