Модульная перестройка хаба: ядро + модуль jira (#87) #88
@ -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); });
|
||||
|
||||
@ -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");
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
7
web/src/modules/ModuleProviders.tsx
Normal file
7
web/src/modules/ModuleProviders.tsx
Normal 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
19
web/src/modules/index.ts
Normal 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));
|
||||
}
|
||||
@ -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);
|
||||
64
web/src/modules/jira/api/queries.ts
Normal file
64
web/src/modules/jira/api/queries.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
@ -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";
|
||||
@ -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";
|
||||
@ -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";
|
||||
@ -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;
|
||||
@ -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";
|
||||
@ -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);
|
||||
@ -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";
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
@ -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[] }) {
|
||||
@ -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 }: {
|
||||
@ -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";
|
||||
|
||||
@ -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) обращаются
|
||||
@ -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";
|
||||
@ -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";
|
||||
@ -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";
|
||||
@ -1,4 +1,4 @@
|
||||
// события строки состояния доски; ход пакета обновления из Jira — в оболочке (shell/refreshStatus.ts)
|
||||
// события строки состояния доски; ход пакета обновления из Jira — в модуле (refresh/refreshStatus.ts)
|
||||
export type BoardStatus =
|
||||
| { kind: "loading" }
|
||||
| { kind: "hub-down"; hasData: boolean; fromFile: boolean }
|
||||
@ -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";
|
||||
@ -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";
|
||||
@ -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";
|
||||
@ -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";
|
||||
|
||||
@ -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 };
|
||||
@ -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";
|
||||
@ -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);
|
||||
@ -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";
|
||||
37
web/src/modules/jira/index.tsx
Normal file
37
web/src/modules/jira/index.tsx
Normal 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,
|
||||
};
|
||||
@ -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" };
|
||||
@ -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";
|
||||
@ -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";
|
||||
|
||||
@ -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: "" }));
|
||||
|
||||
@ -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
|
||||
@ -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";
|
||||
@ -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"));
|
||||
@ -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";
|
||||
@ -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 =>
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user