From 0ee84766700d5c8735db33b8caf67ad67b778fc2 Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Sun, 20 Sep 2026 10:00:58 -0500 Subject: [PATCH 01/10] Initial pass of enhanced report tables implementation --- AGENTS.md | 41 +- README.md | 29 +- app/globals.css | 47 ++ assets/refactor-first-report.mustache | 314 +++++++++++- components/report-view.jsx | 120 ++++- components/toast-notification.jsx | 82 ++++ lib/renderer.js | 167 +++++++ lib/report-view.js | 11 +- lib/table-enhancer.js | 177 +++++++ lib/table-operations.js | 449 +++++++++++++++++ ...nt-paginated-tables-with-sticky-headers.md | 458 ++++++++++++++++++ public/assets/refactor-first-report.mustache | 314 +++++++++++- scripts/sync-repositories.mjs | 7 + tests/e2e/report-pagination.spec.js | 290 +++++++++++ tests/integration/report-view.test.jsx | 336 ++++++++++++- tests/integration/toast-notification.test.jsx | 72 +++ tests/unit/renderer.test.js | 241 ++++++++- tests/unit/report-template-wcag.test.js | 149 +++++- tests/unit/table-enhancer.test.js | 227 +++++++++ tests/unit/table-operations.test.js | 356 ++++++++++++++ 20 files changed, 3788 insertions(+), 99 deletions(-) create mode 100644 components/toast-notification.jsx create mode 100644 lib/table-enhancer.js create mode 100644 lib/table-operations.js create mode 100644 plans/implement-paginated-tables-with-sticky-headers.md create mode 100644 tests/e2e/report-pagination.spec.js create mode 100644 tests/integration/toast-notification.test.jsx create mode 100644 tests/unit/table-enhancer.test.js create mode 100644 tests/unit/table-operations.test.js diff --git a/AGENTS.md b/AGENTS.md index e6c39fe..5920f90 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,36 @@ 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` + (the template overrides mvp.css `overflow-x: auto` on tables, which would + otherwise break viewport stickiness), toolbar (search + match live region + + CSV export), sortable th buttons with `aria-sort`, pagination below 20+ + row tables, and click/Enter/Space cell copy with toast feedback. +- 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). +- 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). +~464 unit/integration + 160 E2E (156 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..0998156 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,31 @@ 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 above each table narrows rows case-insensitively across all columns (debounced), + with the match count announced via a live region and a Clear 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. +- **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..30b6239 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,151 @@ 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; + } + + .rf-table-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin: 0.5rem auto; + max-width: 95%; + } + + .rf-table-toolbar .rf-copy-hint { + font-size: 0.85rem; + margin: 0; + } + + .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.55rem; + cursor: pointer; + } + + .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; + } + + .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-search input { + width: 100%; + min-width: 0; + box-sizing: border-box; + } + }
@@ -222,16 +371,31 @@ Removing class relationships below will eliminate class cycles
-
+ {{#classRelationshipsToRemove.tableUi.enhanced}} +
+ {{#classRelationshipsToRemove.tableUi.searchable}} + + {{/classRelationshipsToRemove.tableUi.searchable}} + {{classRelationshipsToRemove.tableUi.matchStatus}} + {{#classRelationshipsToRemove.tableUi.copyable}} + Click a cell (or press Enter on it) to copy its text. + {{/classRelationshipsToRemove.tableUi.copyable}} + {{#classRelationshipsToRemove.tableUi.exportable}} + + {{/classRelationshipsToRemove.tableUi.exportable}} +
+ {{/classRelationshipsToRemove.tableUi.enhanced}} +
- - - - - - + + + + + + @@ -249,6 +413,13 @@ {{/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}} @@ -307,15 +478,30 @@ Removing package relationships below will eliminate package cycles
- + {{#packageRelationshipsToRemove.tableUi.enhanced}} +
+ {{#packageRelationshipsToRemove.tableUi.searchable}} + + {{/packageRelationshipsToRemove.tableUi.searchable}} + {{packageRelationshipsToRemove.tableUi.matchStatus}} + {{#packageRelationshipsToRemove.tableUi.copyable}} + Click a cell (or press Enter on it) to copy its text. + {{/packageRelationshipsToRemove.tableUi.copyable}} + {{#packageRelationshipsToRemove.tableUi.exportable}} + + {{/packageRelationshipsToRemove.tableUi.exportable}} +
+ {{/packageRelationshipsToRemove.tableUi.enhanced}} +
- - - - - + + + + + @@ -331,6 +517,13 @@ {{/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}} @@ -374,13 +567,33 @@

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

- + {{#ui.enhanced}} +
+ {{#ui.searchable}} + + {{/ui.searchable}} + {{ui.matchStatus}} + {{#ui.copyable}} + Click a cell (or press Enter on it) to copy its text. + {{/ui.copyable}} + {{#ui.exportable}} + + {{/ui.exportable}} +
+ {{/ui.enhanced}} +
- {{#table.headers}} - - {{/table.headers}} + {{#table.headerObjs}} + + {{/table.headerObjs}} + {{^table.headerObjs}} + {{#table.headers}} + + {{/table.headers}} + {{/table.headerObjs}} @@ -393,6 +606,13 @@ {{/table.rows}}
{{title}} findings, in priority order
{{.}}{{.}}
+ {{#ui.paginated}} + + {{/ui.paginated}}
{{/disharmonies}} @@ -406,14 +626,29 @@

Class Cycles

Class Cycles by the numbers:

- + {{#classCycles.summaryUi.enhanced}} +
+ {{#classCycles.summaryUi.searchable}} + + {{/classCycles.summaryUi.searchable}} + {{classCycles.summaryUi.matchStatus}} + {{#classCycles.summaryUi.copyable}} + Click a cell (or press Enter on it) to copy its text. + {{/classCycles.summaryUi.copyable}} + {{#classCycles.summaryUi.exportable}} + + {{/classCycles.summaryUi.exportable}} +
+ {{/classCycles.summaryUi.enhanced}} +
- - - - + + + + @@ -427,6 +662,13 @@ {{/classCycles.summary}}
Class cycles summary
Cycle NamePriorityClass CountRelationship Count
+ {{#classCycles.summaryUi.paginated}} + + {{/classCycles.summaryUi.paginated}}
{{#classCycles.largestCycle.hasCycleMap}} @@ -453,12 +695,27 @@
- + {{#classCycles.largestCycle.breakdownUi.enhanced}} +
+ {{#classCycles.largestCycle.breakdownUi.searchable}} + + {{/classCycles.largestCycle.breakdownUi.searchable}} + {{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.exportable}} + + {{/classCycles.largestCycle.breakdownUi.exportable}} +
+ {{/classCycles.largestCycle.breakdownUi.enhanced}} +
- - + + @@ -470,6 +727,13 @@ {{/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..e822d00 100644 --- a/components/report-view.jsx +++ b/components/report-view.jsx @@ -1,21 +1,26 @@ '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 { renderTemplate, prepareReportData } from '../lib/renderer'; import { enhanceReport } 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' }, @@ -40,19 +45,44 @@ export default function ReportView({ branch: branchProp, environment: environmentProp, platformBaseUrl: platformBaseUrlProp, - widgetSettleMs = 5000 + widgetSettleMs = 5000, + toastDurationMs = TABLE_CONFIG.copy.toastDuration }) { 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); + 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; async function run() { + setPayload(null); container.innerHTML = '

Loading report…

'; try { // The bundled template is authoritative — repository-provided @@ -77,18 +107,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 +118,71 @@ 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. + useEffect(() => { + if (!payload) return undefined; + let cancelled = false; + const container = containerRef.current; + + 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 => setTimeout(resolve, widgetSettleMs)) + ]); + widgetsSettledRef.current = true; + } + if (cancelled) return; + + container.innerHTML = renderTemplate( + payload.template, + prepareReportData(payload.data, tableStates) + ); + container.dataset.resolvedBranch = payload.resolvedBranch; + await enhanceReport(container, payload.data); + enhanceTables(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; }; + }, [payload, tableStates, widgetSettleMs, username, repository, branch, handleTableAction, handleCopy]); return ( <>
+ {CLASSIC_WIDGETS.map(widget => (