Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions assets/refactor-first-report.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -336,8 +336,8 @@
<a class="github-button" href="https://github.com/refactorfirst/refactorfirst/issues"
data-icon="octicon-issue-opened" data-size="large" data-show-count="false"
aria-label="Issue refactorfirst/refactorfirst on GitHub">Issue</a>
<a class="github-button" href="https://github.com/sponsors/jimbethancourt" data-icon="octicon-heart"
data-size="large" aria-label="Sponsor @jimbethancourt on GitHub">Sponsor</a>
<a class="github-button" href="https://github.com/sponsors/refactorfirst" data-icon="octicon-heart"
data-size="large" aria-label="Sponsor @refactorfirst on GitHub">Sponsor</a>
</div>

<nav aria-label="Report sections">
Expand Down
109 changes: 109 additions & 0 deletions lib/report-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,101 @@ export function bindPopupHandlers(root) {
});
}

// ---------------------------------------------------------------------------
// Section navigation ("Report sections" menu). The report is rendered
// asynchronously, so a fragment in the URL (#CLASSMAP, #GOD, ...) has no
// target when the browser tries to scroll at load time; and clicking a menu
// link whose fragment already matches the URL is a native no-op. Scrolling
// is therefore handled explicitly: a delegated listener scrolls the target
// into view and enhanceReport honours the initial fragment after rendering.
// ---------------------------------------------------------------------------

/**
* Finds a section by its literal fragment ID, then by its decoded ID.
*
* @param {string} hash - Fragment including '#'; empty and bare fragments are ignored.
* @returns {HTMLElement|null} Matching element, or null when no target exists.
*/
function findSectionTarget(hash) {
if (!hash || hash === '#') return null;
const fragment = hash.slice(1);
const literalTarget = document.getElementById(fragment);
if (literalTarget) return literalTarget;
let decodedFragment;
try {
decodedFragment = decodeURIComponent(fragment);
} catch {
return null;
}
return document.getElementById(decodedFragment);
}

/**
* Scrolls to an existing fragment target after the report has rendered.
*
* @param {string} hash - URL fragment to resolve; missing targets are ignored.
* @returns {void}
*/
export function scrollToSectionHash(hash) {
const target = findSectionTarget(hash);
if (target) target.scrollIntoView();
}

/**
* Moves keyboard focus to a section without scrolling again. Adds tabindex=-1
* when needed so section headings can receive programmatic focus.
*
* @param {HTMLElement} target - Section element to focus.
* @returns {void}
*/
function focusSectionTarget(target) {
if (!target.hasAttribute('tabindex') && !/^(A|AREA|BUTTON|INPUT|SELECT|TEXTAREA)$/.test(target.tagName)) {
target.setAttribute('tabindex', '-1');
}
target.focus({ preventScroll: true });
}

/**
* Binds one delegated fragment-link listener per root, surviving report rerenders.
* Unmodified primary clicks on resolved targets update history, scroll and focus
* the section; other clicks retain the browser's default behavior.
*
* @param {Element|Document} [root] - Persistent report container; defaults to document.
* @returns {void}
*/
export function bindSectionNavLinks(root) {
const scope = root || document;
// Delegate on the persistent container: enhanced-table interactions
// re-render the report with innerHTML, which would drop per-link
// listeners; one delegated listener survives those swaps. The guard
// keeps effect re-runs from stacking duplicate listeners.
const guard = scope === document ? scope.documentElement : scope;
if (guard.hasAttribute('data-rf-nav-bound')) return;
guard.setAttribute('data-rf-nav-bound', '');
scope.addEventListener('click', event => {
// Only intercept unmodified primary-button clicks that target the
// current tab; Ctrl/Cmd/Shift clicks must keep their browser defaults
// (open in new tab/window etc.).
if (event.defaultPrevented || event.button !== 0 ||
event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const link = typeof event.target?.closest === 'function'
? event.target.closest('a[href^="#"]')
: null;
if (!link || (scope !== document && !scope.contains(link))) return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const destination = link.getAttribute('target');
if (destination !== null && destination !== '_self') return;
const href = link.getAttribute('href');
const target = findSectionTarget(href);
if (!target) return; // leave unresolved fragments to default navigation
event.preventDefault();
if (window.location.hash !== href) {
history.pushState(null, '', href);
}
target.scrollIntoView();
focusSectionTarget(target);
});
}

// ---------------------------------------------------------------------------
// Chart.js bubble charts per disharmony.
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -470,6 +565,7 @@ export function graftStatefulDom(root, stash) {

/**
* Initializes popup handlers, charts, and WASM graphs for a rendered report.
* Binds section links and scrolls to the initial URL fragment once per root.
*
* @param {Element} root - Rendered report container.
* @param {object} data - Raw report data used by visual enhancements.
Expand All @@ -493,6 +589,19 @@ export async function enhanceReport(root, data) {
}
bindPopupHandlers(root);

// Menu links: explicit scrolling because the report renders too late for
// the browser's own fragment navigation (deep links) and re-clicking the
// current fragment is otherwise a no-op.
bindSectionNavLinks(root);
// Honor a deep-link fragment only on the first enhancement of this
// report root: later enhanceReport runs (retry, refetch, branch switch)
// must not yank the user's scroll position back to the fragment.
const navGuard = root && typeof root.hasAttribute === 'function' ? root : document.documentElement;
if (!navGuard.hasAttribute('data-rf-nav-scrolled')) {
navGuard.setAttribute('data-rf-nav-scrolled', '');
scrollToSectionHash(window.location.hash);
}

// Reload the GitHub buttons script so dynamically injected buttons render.
if (!document.querySelector('script[src*="buttons.github.io"]')) {
const script = document.createElement('script');
Expand Down
4 changes: 2 additions & 2 deletions public/assets/refactor-first-report.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -336,8 +336,8 @@
<a class="github-button" href="https://github.com/refactorfirst/refactorfirst/issues"
data-icon="octicon-issue-opened" data-size="large" data-show-count="false"
aria-label="Issue refactorfirst/refactorfirst on GitHub">Issue</a>
<a class="github-button" href="https://github.com/sponsors/jimbethancourt" data-icon="octicon-heart"
data-size="large" aria-label="Sponsor @jimbethancourt on GitHub">Sponsor</a>
<a class="github-button" href="https://github.com/sponsors/refactorfirst" data-icon="octicon-heart"
data-size="large" aria-label="Sponsor @refactorfirst on GitHub">Sponsor</a>
</div>

<nav aria-label="Report sections">
Expand Down
23 changes: 23 additions & 0 deletions tests/e2e/user-journeys.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,29 @@ test('report renders all sections from repository JSON with fallback template',
await expect(page.locator('#publishDate')).toContainText('Last Published:');
});

test('report menu links scroll the referenced section into view', async ({ page }) => {
await page.goto('/refactorfirst/refactorfirst');
const section = page.getByRole('heading', { name: 'Code Disharmonies', level: 2 });
await expect(section).toBeAttached();
await expect(section).not.toBeInViewport();

await page.locator('.rf-report nav a[href="#DISHARMONIES"]').click();
await expect(page).toHaveURL(/#DISHARMONIES$/);
await expect(section).toBeInViewport();

// Re-clicking the same link (URL fragment already current) still scrolls.
await page.evaluate(() => window.scrollTo(0, 0));
await expect(section).not.toBeInViewport();
await page.locator('.rf-report nav a[href="#DISHARMONIES"]').click();
await expect(section).toBeInViewport();
});

test('deep link with a section fragment scrolls the section into view on load', async ({ page }) => {
await page.goto('/refactorfirst/refactorfirst/#GOD');
const section = page.getByRole('heading', { name: 'God Classes', level: 3 });
await expect(section).toBeInViewport();
});

test('branch fallback shows report from master', async ({ page }) => {
let requested = [];
await page.unroute('**/raw.githubusercontent.com/**');
Expand Down
17 changes: 5 additions & 12 deletions tests/integration/app-routes.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,13 @@ import { describe, test, expect } from 'bun:test';
import { mock } from 'bun:test';
import { loadListedRepositories, listUsernames } from '../../lib/repositories';

import { sharedNextNavigationMock, notFoundCalls } from './next-navigation-stub';

const listing = loadListedRepositories();

// next/navigation notFound: throws a NEXT_HTTP_ERROR_FALLBACK; catch it in a
// controllable way for assertions.
const notFoundCalls = [];
mock.module('next/navigation', () => ({
notFound: () => {
notFoundCalls.push(true);
throw new Error('NEXT_NOT_FOUND');
},
usePathname: () => '/',
useRouter: () => ({ push: () => {} }),
useSearchParams: () => new URLSearchParams('')
}));
// next/navigation notFound: the shared stub records the call and throws a
// NEXT_NOT_FOUND sentinel so it can be asserted here.
mock.module('next/navigation', () => sharedNextNavigationMock());

const userPage = await import('../../app/[username]/page');
const repoPage = await import('../../app/[username]/[repository]/page');
Expand Down
4 changes: 3 additions & 1 deletion tests/integration/app-shell.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import { describe, test, expect } from 'bun:test';
import { mock } from 'bun:test';

mock.module('next/navigation', () => ({
import { sharedNextNavigationMock } from './next-navigation-stub';

mock.module('next/navigation', () => sharedNextNavigationMock({
useRouter: () => ({ push: () => {} })
}));

Expand Down
8 changes: 4 additions & 4 deletions tests/integration/error-boundary.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test';
import { mock } from 'bun:test';

mock.module('next/navigation', () => ({
usePathname: () => '/boom',
useRouter: () => ({ push: () => {} }),
useSearchParams: () => new URLSearchParams('')
import { sharedNextNavigationMock } from './next-navigation-stub';

mock.module('next/navigation', () => sharedNextNavigationMock({
usePathname: () => '/boom'
}));

import { render, fireEvent, cleanup, installRtlDom } from './rtl';
Expand Down
4 changes: 3 additions & 1 deletion tests/integration/landing-page.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
import { describe, test, expect, beforeEach } from 'bun:test';
import { mock } from 'bun:test';

import { sharedNextNavigationMock } from './next-navigation-stub';

const pushed = [];
mock.module('next/navigation', () => ({
mock.module('next/navigation', () => sharedNextNavigationMock({
useRouter: () => ({ push: url => pushed.push(url) })
}));

Expand Down
38 changes: 38 additions & 0 deletions tests/integration/next-navigation-stub.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Shared 'next/navigation' mock for integration tests.
//
// Bun hoists mock.module() and keeps one process-wide registry per module
// specifier, while test file evaluation can overlap (top-level awaits in one
// file yield while another file registers its own mock). Any module graph
// that imports { notFound } from 'next/navigation' (the app route pages) may
// therefore link against ANOTHER file's mock; if that mock lacks the
// notFound export the import fails at link time with a SyntaxError and the
// whole test file aborts. Routing every mock through this shared helper
// keeps all of them export-shape compatible no matter the load order.
export const notFoundCalls = [];

/**
* Records a notFound call before aborting route rendering with a test sentinel.
*
* @throws {Error} Always throws NEXT_NOT_FOUND.
* @returns {never}
*/
export function notFoundStub() {
notFoundCalls.push(true);
throw new Error('NEXT_NOT_FOUND');
}

/**
* Builds the shared next/navigation mock with optional test-specific exports.
*
* @param {object} [overrides={}] - Exports to replace or add to the defaults.
* @returns {object} Navigation exports suitable for Bun's mock.module factory.
*/
export function sharedNextNavigationMock(overrides = {}) {
return {
notFound: notFoundStub,
usePathname: () => '/',
useRouter: () => ({ push: () => {} }),
useSearchParams: () => new URLSearchParams(''),
...overrides
};
}
7 changes: 4 additions & 3 deletions tests/integration/not-found.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
import { describe, test, expect } from 'bun:test';
import { mock } from 'bun:test';

import { sharedNextNavigationMock } from './next-navigation-stub';

let currentPath = '/';
mock.module('next/navigation', () => ({
usePathname: () => currentPath,
useRouter: () => ({ push: () => {} })
mock.module('next/navigation', () => sharedNextNavigationMock({
usePathname: () => currentPath
}));

import { render, installRtlDom } from './rtl';
Expand Down
8 changes: 4 additions & 4 deletions tests/integration/report-view-abort.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
// fetch on remount (Technical Appendix §9).
import { describe, test, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test';

mock.module('next/navigation', () => ({
usePathname: () => '/alice/one',
useRouter: () => ({ push: () => {} }),
useSearchParams: () => new URLSearchParams('')
import { sharedNextNavigationMock } from './next-navigation-stub';

mock.module('next/navigation', () => sharedNextNavigationMock({
usePathname: () => '/alice/one'
}));
mock.module('next/script', () => ({ default: () => null }));

Expand Down
5 changes: 3 additions & 2 deletions tests/integration/report-view.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import { mock } from 'bun:test';
import fs from 'node:fs';
import path from 'node:path';

import { sharedNextNavigationMock } from './next-navigation-stub';

let currentSearch = '';
mock.module('next/navigation', () => ({
mock.module('next/navigation', () => sharedNextNavigationMock({
usePathname: () => '/junit-team/junit4',
useRouter: () => ({ push: () => {} }),
useSearchParams: () => new URLSearchParams(currentSearch)
}));
mock.module('next/script', () => ({
Expand Down
4 changes: 3 additions & 1 deletion tests/integration/search-components.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
// tests/integration/search-flow.test.js type-ahead cases.
import { describe, test, expect, mock, beforeEach } from 'bun:test';

import { sharedNextNavigationMock } from './next-navigation-stub';

const pushed = [];
mock.module('next/navigation', () => ({
mock.module('next/navigation', () => sharedNextNavigationMock({
useRouter: () => ({
push: url => pushed.push(url)
})
Expand Down
8 changes: 3 additions & 5 deletions tests/integration/sentry-provider.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,9 @@ mock.module('next/script', () => ({
return null;
}
}));
mock.module('next/navigation', () => ({
usePathname: () => '/',
useRouter: () => ({ push: () => {} }),
useSearchParams: () => new URLSearchParams('')
}));
import { sharedNextNavigationMock } from './next-navigation-stub';

mock.module('next/navigation', () => sharedNextNavigationMock());

import { createElement as h } from 'react';
import SentryProvider from '../../components/sentry-provider';
Expand Down
8 changes: 4 additions & 4 deletions tests/integration/submission-form.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test';
import { mock } from 'bun:test';

mock.module('next/navigation', () => ({
usePathname: () => '/add-repo',
useRouter: () => ({ push: () => {} }),
useSearchParams: () => new URLSearchParams('')
import { sharedNextNavigationMock } from './next-navigation-stub';

mock.module('next/navigation', () => sharedNextNavigationMock({
usePathname: () => '/add-repo'
}));

import { render, waitFor, fireEvent, cleanup, installRtlDom } from './rtl';
Expand Down
5 changes: 3 additions & 2 deletions tests/integration/user-listing.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test';
import { mock } from 'bun:test';

import { sharedNextNavigationMock } from './next-navigation-stub';

let currentSearch = '';
mock.module('next/navigation', () => ({
mock.module('next/navigation', () => sharedNextNavigationMock({
usePathname: () => '/alice',
useRouter: () => ({ push: () => {} }),
useSearchParams: () => new URLSearchParams(currentSearch)
}));

Expand Down
Loading
Loading