diff --git a/AGENTS.md b/AGENTS.md index e6c39fe..58cc049 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,8 @@ in `out/` is served by any static host (GitHub Pages, GitLab Pages, Bitbucket). **Key Features:** - Search over curated repository listing (`repositories.txt`) - Reports rendered with Mustache.js (bundled template is authoritative) +- Enhanced report tables: sticky headers, pagination (>20 rows), sortable + columns, in-table search, CSV export, click/keyboard cell copy with toasts - Repository submission via pre-filled platform issues — no login, apps or tokens - Fully static deploy; deep links handled via generateStaticParams + `404.html` @@ -60,12 +62,17 @@ app/ # Next.js App Router (static export) components/ # client components: report-view, repo-list, # repo-submission-form, search-combobox, # hero-search, menu-search, menu-toggle, - # workflow-sample, platform-config, sentry-provider + # workflow-sample, platform-config, sentry-provider, + # toast-notification (copy feedback live region) lib/ # shared logic (client + RSC): routes, fetcher, # renderer, search, utils, host, rate-limiter, # cache-manager, error-handler, repo-submission, - # report-view, static-params, widget-loader; the - # Node-side listing loader is lib/repositories.js + # report-view, static-params, widget-loader, + # table-operations (filter/sort/paginate/CSV/copy + + # TABLE_CONFIG + REPORT_TABLES descriptors), + # table-enhancer (binds toolbar/sort/pagination/ + # copy onto the rendered report DOM); the Node-side + # listing loader is lib/repositories.js public/ # static files copied verbatim into out/: repositories.txt # synced from the repo root (sync-repositories.mjs) assets/ # mustache template, logo @@ -117,12 +124,54 @@ coverage there when introducing new markup patterns. `sentry-dsn`, `platform-base-url`) plus `NEXT_PUBLIC_HOSTING_ENVIRONMENT` / `NEXT_PUBLIC_BASE_PATH` at build time. +## Report Tables (Enhanced) + +- Every data table in the report (class/package relationships, disharmony + findings, cycle summary, cycle breakdown) is enhanced: sticky `thead th`, + toolbar (match live region + copy hint left; search + CSV export right — + the `.rf-table-block` wrapper shrink-wraps the table and the toolbar uses + `contain: inline-size` so controls align with the table's right edge), + sortable th buttons with `aria-sort`, pagination below 20+ row tables, and + click/Enter/Space cell copy with toast feedback. The filter's clear control + is an × button (accessible name "Clear the … table filter"). +- Horizontal scrollbar: tables wider than the viewport get `overflow-x: auto` + via the `rf-scroll-x-enabled` class, toggled by `lib/table-enhancer.js` + after measuring `wrapper.scrollWidth > clientWidth` (re-measured on each + re-render and on window resize). It MUST stay conditional — any overflow + ancestor becomes the sticky constraint container and breaks the + viewport-sticky `thead th`. Scrolling tables keep their header pinned + anyway: `refreshStickyHeaders` in lib/table-enhancer.js compensates by + translating every `thead th` down by the viewport scroll offset (clamped to + the table's bottom edge) on window scroll/resize; narrow tables keep pure + CSS stickiness and stale transforms are cleared when overflow goes away. +- Pipeline: `prepareReportData(data, tableStates, TABLE_CONFIG)` in + lib/renderer.js applies **filter → sort → paginate** per table and injects + `tableUi` blocks the mustache template renders; `enhanceTables` in + lib/table-enhancer.js binds the controls and reports state changes back to + components/report-view.jsx, which re-renders (widgets only gate the first + render; the search input's focus/caret is restored after each re-render). + The expensive `enhanceReport` pipeline (Chart.js charts, WASM DOT layout) + runs only when the payload changes — table-state re-renders stash the live + chart canvases / graph containers before the innerHTML swap and graft them + back into the fresh DOM (`statefulElementIds`/`stashStatefulDom`/ + `graftStatefulDom` in lib/report-view.js), re-binding only the cheap popup + handlers. +- Search ``s are injected by table-enhancer — `` is FORBID in + the renderer's sanitization allow-list, so it must never appear in the + mustache template. +- CSV export honors the current filter + sort but ignores pagination; + filenames are `refactorfirst--.csv`. + ## Current Test Count -~313 unit/integration + 112 E2E (three browsers + basePath leg). +~485 unit/integration + 175 E2E (171 across three browsers + 4 basePath leg). WCAG 2.2 AA / HTML5 guards live in tests/unit/html5-attributes.test.js, tests/unit/report-template-wcag.test.js, tests/unit/css-a11y.test.js and tests/unit/page-titles.test.js — the report mustache keeps a single h1, scoped table headers, captions, labelled canvases and a named nav; obsolete -presentational attributes are FORBID_ATTR-stripped in lib/renderer.js. +presentational attributes are FORBID_ATTR-stripped in lib/renderer.js. The +report-template-wcag guard also asserts sticky-header CSS, per-table +toolbars/aria-labelled export buttons and pagination navs, valid `aria-sort` +on every enhanced th, sortable keyboard-operable header buttons and live +match-count regions. diff --git a/README.md b/README.md index a6db81c..b7839e7 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ is a **Next.js static export** (`bun run build` produces `out/`, which any stati branch fallback — the same report the [RefactorFirst report viewer](https://github.com/RefactorFirst/RefactorFirst) produces: class/package maps (vizdom WASM SVGs with pan/zoom, plus Sigma 2D and 3D force-graph popups), relationship-removal - priority tables, Chart.js disharmony bubble charts and class cycle summaries + priority tables, Chart.js disharmony bubble charts and class cycle summaries — with + [enhanced tables](#enhanced-report-tables) (sticky headers, pagination, sorting, search, CSV export, copy) - **Repository submission** via a pre-filled issue on the hosting platform (no login, apps or tokens on this site): your platform account is captured as the issue author and validated server-side by the platform's CI - Reports and submissions work for repositories hosted on the same platform as the deployment (GitHub, GitLab or @@ -29,6 +30,7 @@ is a **Next.js static export** (`bun run build` produces `out/`, which any stati - [Deploying to Bitbucket](#deploying-to-bitbucket) - [Deploying to GitLab](#deploying-to-gitlab) - [How repository submission works](#how-repository-submission-works) +- [Enhanced report tables](#enhanced-report-tables) - [Making Changes (Developer Guide)](#making-changes-developer-guide) - [Testing](#testing) @@ -289,6 +291,37 @@ GitLab Pages deploys from the `pages` job and serves --- +## Enhanced report tables + +Large report tables (class/package relationships, disharmony findings, cycle summaries and cycle breakdowns) are +interactive — all WCAG 2.2 AA and keyboard-operable, with no extra dependencies: + +- **Sticky headers** — column headers stay pinned to the top of the viewport while you scroll a table. +- **Pagination** — tables with more than 20 matching rows paginate at 20 rows per page ("Page X of Y" + Previous/Next + buttons; disabled on the first/last page). Small tables render in full without controls. +- **Sorting** — click a column header (or focus it and press Enter/Space) to sort the whole table ascending; click + again for descending. `aria-sort` reflects the current direction, sorting happens before pagination, and the sort is + kept while navigating pages. +- **Search/filter** — the filter box sits at the right edge of the table (next to Export) and narrows rows + case-insensitively across all columns (debounced), with the match count announced via a live region and an **×** + button to reset. +- **CSV export** — "Export CSV" downloads the entire table (current filter and sort applied, pagination ignored) with + proper escaping; the filename includes the table name and a timestamp. +- **Horizontal scrolling** — when a table is wider than the screen its wrapper gains a horizontal scrollbar + (`rf-scroll-x-enabled`, toggled by measurement in `lib/table-enhancer.js`); the scrollbar is deliberately conditional + because an unconditional `overflow-x` would break the viewport-sticky table header. Scrolling tables keep their + header pinned anyway: `lib/table-enhancer.js` translates the header cells down by the viewport scroll offset + (clamped to the table's bottom edge) on every window scroll/resize. +- **Copy cells** — click any cell (or focus it and press Enter/Space) to copy its text; an auto-dismissing toast + confirms the copy. Falls back gracefully when the Clipboard API is unavailable. + +Implementation: pure table operations live in `lib/table-operations.js` (filter → sort → paginate pipeline, CSV +generation, clipboard helper, `TABLE_CONFIG` defaults: threshold/page size 20, 300 ms search debounce, 3 s +toast duration), the rendered DOM is wired by `lib/table-enhancer.js`, and table state re-renders through +`prepareReportData` in `lib/renderer.js`. + +--- + ## How repository submission works No OAuth app, client ID, token or secret is involved on the client side — forks need **zero auth setup**. The flow on diff --git a/app/globals.css b/app/globals.css index 8f3b9ab..c99f19a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -461,3 +461,50 @@ pre { grid-template-columns: 1fr; } } + +/* Toast notifications (copy feedback on report tables). Rendered by + components/toast-notification.jsx as a persistent aria-live region. */ +.rf-toast-region { + position: fixed; + left: 50%; + bottom: 1.25rem; + transform: translateX(-50%); + z-index: 2000; + display: flex; + flex-direction: column; + gap: 0.5rem; + align-items: center; + pointer-events: none; + max-width: min(90vw, 40rem); +} + +.rf-toast { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.9rem; + border-radius: var(--radius); + background: #1f2933; + color: #fff; + box-shadow: var(--shadow); + pointer-events: auto; +} + +.rf-toast-message { + overflow-wrap: anywhere; +} + +.rf-toast-dismiss { + background: none; + border: none; + color: #fff; + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + padding: 0.15rem 0.45rem; +} + +.rf-toast-dismiss:focus-visible { + outline: 2px solid var(--brand-color); + outline-offset: 2px; +} diff --git a/assets/refactor-first-report.mustache b/assets/refactor-first-report.mustache index 4043571..2d075d5 100644 --- a/assets/refactor-first-report.mustache +++ b/assets/refactor-first-report.mustache @@ -89,6 +89,10 @@ border: 5px solid; border-collapse: collapse; margin: 0 auto; + /* mvp.css gives every table overflow-x: auto, which would make the + table itself the scroll container and prevent the sticky header + from pinning to the viewport while the page scrolls. */ + overflow: visible; } .rf-data-table th, @@ -96,6 +100,216 @@ border: 1px solid; padding: 2px 6px; } + + /* Enhanced tables: sticky headers pinned to the viewport while their + table scrolls, plus sortable-header, toolbar, pagination and copy + affordances (plan: implement-paginated-tables-with-sticky-headers). */ + .rf-data-table thead th { + position: sticky; + top: 0; + z-index: 5; + /* A solid, contrasting background keeps scrolled rows from showing + through and keeps the header text readable (the base stylesheet + colours table header text near-white); the inset shadow restores + the bottom border that border-collapse drops from sticky cells. */ + background: #2a6f97; + color: #fff; + box-shadow: inset 0 -1px 0 #1f5273; + } + + .rf-sort-btn { + background: none; + border: none; + padding: 0; + font: inherit; + color: inherit; + cursor: pointer; + } + + .rf-sort-btn:hover, + .rf-sort-btn:focus-visible { + text-decoration: underline; + } + + .rf-sort-btn:focus-visible { + outline: 2px solid #fff; + outline-offset: 2px; + } + + .rf-sort-indicator { + display: inline-block; + min-width: 1em; + margin-left: 0.25rem; + } + + /* The block shrink-wraps the table (or clamps to the available width), + so the toolbar and pagination align with the table edges. */ + .rf-table-block { + width: fit-content; + max-width: 100%; + margin-left: auto; + margin-right: auto; + } + + .rf-table-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin: 0.5rem 0; + /* Excluded from the shrink-wrap sizing of .rf-table-block so the + toolbar always spans the table's width, never wider. */ + contain: inline-size; + } + + /* Search + export hug the right edge of the table; status text and the + copy hint stay left. */ + .rf-table-actions { + margin-left: auto; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; + } + + /* The outer 5px frame moves from the table to the scroll wrapper: + when the table overflows and scrolls horizontally, a border on the + table itself would scroll away with the content instead of boxing + the visible area. Unwrapped tables (problem/solution) keep their + own border. + Horizontal scrollbar appears only when the table overflows: adding + overflow unconditionally would make the wrapper a scroll container + and break the viewport-sticky table header, so + lib/table-enhancer.js toggles .rf-scroll-x-enabled after measuring — + and pins the header of scrolling tables anyway by translating the + header cells with the viewport scroll offset. */ + .rf-table-scroll { + max-width: 100%; + border: 5px solid; + box-sizing: border-box; + } + + .rf-table-scroll .rf-data-table { + border: none; + } + + .rf-table-scroll.rf-scroll-x-enabled { + overflow-x: auto; + } + + .rf-table-toolbar .rf-copy-hint { + font-size: 0.85rem; + margin: 0; + } + + /* Keep the label, text box and clear button on one centred line — as + plain inline siblings their boxes would sit on a shared baseline and + visibly misalign vertically. */ + .rf-table-search { + display: flex; + align-items: center; + gap: 0.35rem; + } + + .rf-table-search label { + display: inline-flex; + align-items: center; + gap: 0.35rem; + } + + .rf-table-search input { + padding: 0.3rem 0.5rem; + min-width: 14rem; + } + + .rf-table-search .rf-search-clear { + padding: 0.2rem 0.45rem; + cursor: pointer; + font-size: 1rem; + line-height: 1; + font-weight: bold; + } + + .rf-table-match { + font-size: 0.9rem; + } + + .rf-export-btn { + padding: 0.3rem 0.75rem; + border: 1px solid #2a6f97; + border-radius: 6px; + background: #2a6f97; + color: #fff; + cursor: pointer; + } + + .rf-export-btn:focus-visible, + .rf-page-btn:focus-visible { + outline: 2px solid #1f5273; + outline-offset: 2px; + } + + .rf-table-pagination { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; + justify-content: center; + margin: 0.75rem 0; + contain: inline-size; + } + + .rf-page-btn { + padding: 0.35rem 0.75rem; + /* Explicit colours on both states: the base stylesheet gives plain + buttons near-white text, which would be invisible on a white + background. */ + border: 1px solid #2a6f97; + border-radius: 6px; + background: #fff; + color: #1f2937; + cursor: pointer; + } + + .rf-page-btn:hover:not([disabled]) { + background: #e8f0f6; + } + + .rf-page-btn[disabled] { + background: #f3f4f6; + border-color: #c6d2dc; + color: #6b7280; + cursor: default; + } + + .rf-data-table td[data-rf-copy] { + cursor: copy; + } + + .rf-data-table td[data-rf-copy]:focus-visible { + outline: 2px solid #2a6f97; + outline-offset: -2px; + } + + @media (max-width: 640px) { + .rf-table-toolbar { + flex-direction: column; + align-items: stretch; + } + + .rf-table-actions { + align-self: stretch; + flex-direction: column; + align-items: stretch; + } + + .rf-table-search input { + width: 100%; + min-width: 0; + box-sizing: border-box; + } + }
@@ -221,17 +435,35 @@ Classes with * should be broken apart
Removing class relationships below will eliminate class cycles
-
-
+
+ {{#classRelationshipsToRemove.tableUi.enhanced}} +
+ {{classRelationshipsToRemove.tableUi.matchStatus}} + {{#classRelationshipsToRemove.tableUi.copyable}} + Click a cell (or press Enter on it) to copy its text. + {{/classRelationshipsToRemove.tableUi.copyable}} +
+ {{#classRelationshipsToRemove.tableUi.searchable}} + + {{/classRelationshipsToRemove.tableUi.searchable}} + {{#classRelationshipsToRemove.tableUi.exportable}} + + {{/classRelationshipsToRemove.tableUi.exportable}} +
+
+ {{/classRelationshipsToRemove.tableUi.enhanced}} +
+
- - - - - - + + + + + + @@ -249,6 +481,14 @@ {{/classRelationshipsToRemove.relationships}}
Class relationships to remove, in priority order
Class RelationshipPriorityIn Class
Cycles
Relationship
Strength
Also Removes Pkg
Cycle Relationship
In Package
Cycles
+ + {{#classRelationshipsToRemove.tableUi.paginated}} + + {{/classRelationshipsToRemove.tableUi.paginated}} {{/classRelationshipsToRemove.hasRelationships}} @@ -306,16 +546,34 @@ Packages and classes with * should be broken apart
Removing package relationships below will eliminate package cycles -
- +
+ {{#packageRelationshipsToRemove.tableUi.enhanced}} +
+ {{packageRelationshipsToRemove.tableUi.matchStatus}} + {{#packageRelationshipsToRemove.tableUi.copyable}} + Click a cell (or press Enter on it) to copy its text. + {{/packageRelationshipsToRemove.tableUi.copyable}} +
+ {{#packageRelationshipsToRemove.tableUi.searchable}} + + {{/packageRelationshipsToRemove.tableUi.searchable}} + {{#packageRelationshipsToRemove.tableUi.exportable}} + + {{/packageRelationshipsToRemove.tableUi.exportable}} +
+
+ {{/packageRelationshipsToRemove.tableUi.enhanced}} +
+
- - - - - + + + + + @@ -331,6 +589,14 @@ {{/packageRelationshipsToRemove.relationships}}
Package relationships to remove, in priority order
Package RelationshipPriorityIn Pkg
Cycles
Relationship
Strength
Class Relationships to Remove
To Break Package Relationship
+
+ {{#packageRelationshipsToRemove.tableUi.paginated}} + + {{/packageRelationshipsToRemove.tableUi.paginated}} {{/packageRelationshipsToRemove.hasRelationships}} @@ -373,14 +639,37 @@

{{title}} by the numbers: (Refactor Starting with Priority 1)

-
- +
+ {{#ui.enhanced}} +
+ {{ui.matchStatus}} + {{#ui.copyable}} + Click a cell (or press Enter on it) to copy its text. + {{/ui.copyable}} +
+ {{#ui.searchable}} + + {{/ui.searchable}} + {{#ui.exportable}} + + {{/ui.exportable}} +
+
+ {{/ui.enhanced}} +
+
- {{#table.headers}} - - {{/table.headers}} + {{#table.headerObjs}} + + {{/table.headerObjs}} + {{^table.headerObjs}} + {{#table.headers}} + + {{/table.headers}} + {{/table.headerObjs}} @@ -393,6 +682,14 @@ {{/table.rows}}
{{title}} findings, in priority order
{{.}}{{.}}
+
+ {{#ui.paginated}} + + {{/ui.paginated}} {{/disharmonies}} @@ -405,15 +702,33 @@ {{#classCycles.hasCycles}}

Class Cycles

Class Cycles by the numbers:

-
- +
+ {{#classCycles.summaryUi.enhanced}} +
+ {{classCycles.summaryUi.matchStatus}} + {{#classCycles.summaryUi.copyable}} + Click a cell (or press Enter on it) to copy its text. + {{/classCycles.summaryUi.copyable}} +
+ {{#classCycles.summaryUi.searchable}} + + {{/classCycles.summaryUi.searchable}} + {{#classCycles.summaryUi.exportable}} + + {{/classCycles.summaryUi.exportable}} +
+
+ {{/classCycles.summaryUi.enhanced}} +
+
- - - - + + + + @@ -427,6 +742,14 @@ {{/classCycles.summary}}
Class cycles summary
Cycle NamePriorityClass CountRelationship Count
+
+ {{#classCycles.summaryUi.paginated}} + + {{/classCycles.summaryUi.paginated}} {{#classCycles.largestCycle.hasCycleMap}} @@ -452,13 +775,31 @@ style="width: 95%; height: 70vh; margin: auto; border: thin solid black"> -
- +
+ {{#classCycles.largestCycle.breakdownUi.enhanced}} +
+ {{classCycles.largestCycle.breakdownUi.matchStatus}} + {{#classCycles.largestCycle.breakdownUi.copyable}} + Click a cell (or press Enter on it) to copy its text. + {{/classCycles.largestCycle.breakdownUi.copyable}} +
+ {{#classCycles.largestCycle.breakdownUi.searchable}} + + {{/classCycles.largestCycle.breakdownUi.searchable}} + {{#classCycles.largestCycle.breakdownUi.exportable}} + + {{/classCycles.largestCycle.breakdownUi.exportable}} +
+
+ {{/classCycles.largestCycle.breakdownUi.enhanced}} +
+
- - + + @@ -470,6 +811,14 @@ {{/classCycles.largestCycle.breakdown}}
Classes and relationships in the largest cycle
ClassesRelationships
+
+ {{#classCycles.largestCycle.breakdownUi.paginated}} + + {{/classCycles.largestCycle.breakdownUi.paginated}} {{/classCycles.largestCycle.hasCycleMap}} {{/classCycles.hasCycles}} diff --git a/components/report-view.jsx b/components/report-view.jsx index 6139ddd..fffb5fb 100644 --- a/components/report-view.jsx +++ b/components/report-view.jsx @@ -1,21 +1,31 @@ 'use client'; // Report page client component. Fetches the bundled Mustache template and -// the repository's refactor-first.json, renders into a ref'd container and -// enhances the result with the CDN widgets (Chart.js bubbles, vizdom WASM -// inline graphs, sigma/3D popups). Widget scripts are loaded via next/script -// and report readiness through lib/widget-loader.js. +// the repository's refactor-first.json once, then re-renders through +// prepareReportData whenever per-table UI state (page/sort/search) changes. +// lib/table-enhancer.js binds controls in the rendered DOM and reports table +// actions back here; copy feedback surfaces through the toast region. +// Widget scripts are loaded via next/script and report readiness through +// lib/widget-loader.js. -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'next/navigation'; import Script from 'next/script'; import { withBasePath } from '../lib/base-path'; import { detectHostingEnvironment, getPlatformBaseUrl, readMetaTag } from '../lib/host'; import { fetchReport } from '../lib/fetcher'; -import { renderTemplate } from '../lib/renderer'; -import { enhanceReport } from '../lib/report-view'; +import { renderTemplate, prepareReportData } from '../lib/renderer'; +import { + enhanceReport, + bindPopupHandlers, + stashStatefulDom, + graftStatefulDom +} from '../lib/report-view'; +import { enhanceTables } from '../lib/table-enhancer'; import { renderErrorPage, logError } from '../lib/error-handler'; import { markWidgetReady, waitForWidget } from '../lib/widget-loader'; +import ToastRegion, { useToastNotifications } from './toast-notification'; +import { TABLE_CONFIG } from '../lib/table-operations'; const CLASSIC_WIDGETS = [ { name: 'chart', src: 'https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js' }, @@ -34,25 +44,65 @@ const BRIDGE_WIDGETS = ['/widgets/vizdom-bridge.js', '/widgets/three-spritetext- // gracefully at popup-open time. const ENHANCE_WIDGETS = ['chart', 'vizdom']; +/** + * Fetches and renders a repository report with interactive table controls. + * + * @param {object} props - Report location and optional runtime overrides. + * @returns {import('react').ReactElement} The report container and widget scripts. + */ export default function ReportView({ username, repository, branch: branchProp, environment: environmentProp, platformBaseUrl: platformBaseUrlProp, - widgetSettleMs = 5000 + widgetSettleMs = 5000, + toastDurationMs = TABLE_CONFIG.copy.toastDuration, + // Test seams (bun's mock.module leaks across test files, so lifecycle + // tests inject Deferred/spying implementations instead of module mocks). + enhanceReportImpl = enhanceReport, + enhanceTablesImpl = enhanceTables }) { const containerRef = useRef(null); const searchParams = useSearchParams(); const [attempt, setAttempt] = useState(0); + const [payload, setPayload] = useState(null); + const [tableStates, setTableStates] = useState({}); + const widgetsSettledRef = useRef(false); + const pendingFocusRef = useRef(null); + // The last payload that went through the full (expensive) enhanceReport + // pipeline; table-state-only re-renders skip it and graft the live chart + // canvases / graph SVGs into the fresh DOM instead. + const lastEnhancedPayloadRef = useRef(null); + const { toasts, show: showToast, dismiss: dismissToast } = useToastNotifications({ + duration: toastDurationMs + }); const branch = branchProp || searchParams.get('branch') || undefined; + const handleTableAction = useCallback((tableId, patch, meta) => { + if (meta?.restoreFocus) { + pendingFocusRef.current = { tableId }; + } + setTableStates(current => ({ + ...current, + [tableId]: { ...current[tableId], ...patch } + })); + }, []); + + const handleCopy = useCallback((ok, text) => { + showToast(ok ? `Copied ${text}` : 'Could not copy this cell to the clipboard'); + }, [showToast]); + + // Fetch the template + report JSON once per report (no refetch on table + // interactions). useEffect(() => { const controller = new AbortController(); const container = containerRef.current; + /** Fetches the trusted template and the selected repository report. */ async function run() { + setPayload(null); container.innerHTML = '

Loading report…

'; try { // The bundled template is authoritative — repository-provided @@ -77,18 +127,8 @@ export default function ReportView({ }); if (controller.signal.aborted) return; - - // Give the widgets that enhanceReport uses synchronously a moment to - // arrive; everything else degrades gracefully when missing. - await Promise.race([ - Promise.all(ENHANCE_WIDGETS.map(name => waitForWidget(name, { timeoutMs: widgetSettleMs }))), - new Promise(resolve => setTimeout(resolve, widgetSettleMs)) - ]); - if (controller.signal.aborted) return; - - container.innerHTML = renderTemplate(template, data); - container.dataset.resolvedBranch = resolvedBranch; - await enhanceReport(container, data); + setTableStates({}); + setPayload({ template, data, resolvedBranch }); } catch (error) { if (controller.signal.aborted || error.name === 'AbortError') return; logError(error, { route: 'report', username, repository, branch }); @@ -98,11 +138,104 @@ export default function ReportView({ run(); return () => controller.abort(); - }, [username, repository, branch, environmentProp, platformBaseUrlProp, widgetSettleMs, attempt]); + }, [username, repository, branch, environmentProp, platformBaseUrlProp, attempt]); + + // Render effect: re-renders the report whenever the payload arrives or the + // per-table UI state changes. Widgets only gate the first render. The + // enhanceReport pipeline (Chart.js charts, WASM DOT layout) runs once per + // payload; table-state re-renders carry the live stateful nodes over to + // the fresh DOM and only re-bind the cheap popup/table handlers. + useEffect(() => { + if (!payload) return undefined; + let cancelled = false; + const container = containerRef.current; + let widgetSettleTimer; + + /** Renders the current table state and restores stateful report widgets. */ + async function run() { + try { + if (!widgetsSettledRef.current) { + // Give the widgets that enhanceReport uses synchronously a moment + // to arrive; everything else degrades gracefully when missing. + await Promise.race([ + Promise.all(ENHANCE_WIDGETS.map(name => waitForWidget(name, { timeoutMs: widgetSettleMs }))), + new Promise(resolve => { + widgetSettleTimer = setTimeout(resolve, widgetSettleMs); + }) + ]); + // The race is settled — the fallback no longer needs to fire, so + // release its handle (and the closure it retains) early. + clearTimeout(widgetSettleTimer); + widgetSettleTimer = undefined; + widgetsSettledRef.current = true; + } + if (cancelled) return; + + const payloadChanged = lastEnhancedPayloadRef.current !== payload; + // Stash live chart canvases and rendered graphs before the innerHTML + // swap throws the old DOM away (no-op on first render — there is + // nothing stateful in the loading placeholder). + const stash = payloadChanged ? null : stashStatefulDom(container, payload.data); + + container.innerHTML = renderTemplate( + payload.template, + prepareReportData(payload.data, tableStates) + ); + container.dataset.resolvedBranch = payload.resolvedBranch; + + if (payloadChanged) { + await enhanceReportImpl(container, payload.data); + if (cancelled) return; + lastEnhancedPayloadRef.current = payload; + } else { + graftStatefulDom(container, stash); + // Popup buttons are fresh nodes after every re-render; binding + // them is cheap (unlike charts/graph layout, which the graft saved). + bindPopupHandlers(container); + } + enhanceTablesImpl(container, { + data: payload.data, + tableStates, + onTableAction: handleTableAction, + onCopy: handleCopy + }); + + // Type-to-filter re-renders the input; restore focus + caret so the + // user can keep typing. + const pendingFocus = pendingFocusRef.current; + if (pendingFocus) { + pendingFocusRef.current = null; + const input = container.querySelector( + `input[data-rf-search="${pendingFocus.tableId}"]`); + if (input) { + input.focus(); + const end = input.value.length; + if (typeof input.setSelectionRange === 'function') { + input.setSelectionRange(end, end); + } + } + } + } catch (error) { + if (cancelled) return; + logError(error, { route: 'report', username, repository, branch }); + renderErrorPage(container, error, { onRetry: () => setAttempt(a => a + 1) }); + } + } + + run(); + return () => { + cancelled = true; + // Cancel a still-pending settle fallback so its timer cannot fire after + // the effect is torn down. (No-op once the race has settled.) + clearTimeout(widgetSettleTimer); + }; + }, [payload, tableStates, widgetSettleMs, username, repository, branch, + handleTableAction, handleCopy, enhanceReportImpl, enhanceTablesImpl]); return ( <>
+ {CLASSIC_WIDGETS.map(widget => (