From 9e3e2da4d2acc065a5e4d1be16ca51b78d506140 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:46:16 +0000 Subject: [PATCH] feature: port the app shell, router, header, footer and settings modal to React Co-Authored-By: Vibha Seshadri --- web/src/App.test.tsx | 69 +++++++++++---- web/src/App.tsx | 29 +++++-- web/src/core/Footer.test.tsx | 17 ++++ web/src/core/Footer.tsx | 16 ++++ web/src/core/Header.test.tsx | 80 ++++++++++++++++++ web/src/core/Header.tsx | 51 +++++++++++ web/src/core/Settings.test.tsx | 141 +++++++++++++++++++++++++++++++ web/src/core/Settings.tsx | 100 ++++++++++++++++++++++ web/src/core/footer.scss | 23 +++++ web/src/core/header.scss | 149 +++++++++++++++++++++++++++++++++ web/src/core/settings.scss | 75 +++++++++++++++++ web/src/main.tsx | 7 +- web/src/routes.test.tsx | 94 +++++++++++++++++++++ web/src/routes.tsx | 31 +++++++ 14 files changed, 861 insertions(+), 21 deletions(-) create mode 100644 web/src/core/Footer.test.tsx create mode 100644 web/src/core/Footer.tsx create mode 100644 web/src/core/Header.test.tsx create mode 100644 web/src/core/Header.tsx create mode 100644 web/src/core/Settings.test.tsx create mode 100644 web/src/core/Settings.tsx create mode 100644 web/src/core/footer.scss create mode 100644 web/src/core/header.scss create mode 100644 web/src/core/settings.scss create mode 100644 web/src/routes.test.tsx create mode 100644 web/src/routes.tsx diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index de1a37e59..9323ceac6 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,40 +1,81 @@ import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { App } from './App'; import { SettingsProvider } from './context/SettingsContext'; import { stubMatchMedia } from './testUtils/matchMedia'; +function renderApp(initialEntry = '/news/1') { + vi.stubGlobal('scrollTo', vi.fn()); + + return render( + + + + }> + news feed

} /> + show feed

} /> +
+
+
+
+ ); +} + afterEach(() => { vi.unstubAllGlobals(); }); describe('App', () => { - it('wraps its children in the themed shell', () => { + it('renders the themed shell with the header, the routed page and the footer', () => { stubMatchMedia(false); - const { container } = render( - - -

content

-
-
- ); + const { container } = renderApp(); expect(container.querySelector('.default .body-cover')).not.toBeNull(); - expect(screen.getByText('content').parentElement).toHaveClass('wrapper'); + expect(screen.getByText('news feed').parentElement).toHaveClass('wrapper'); + expect(container.querySelector('.wrapper #header')).not.toBeNull(); + expect(container.querySelector('.wrapper #footer')).not.toBeNull(); }); it('applies the theme coming from the settings', () => { localStorage.setItem('theme', 'amoledblack'); stubMatchMedia(false); - const { container } = render( - - - - ); + const { container } = renderApp(); expect(container.querySelector('.amoledblack')).not.toBeNull(); }); + + it('sends a Google Analytics pageview on the initial render and on every navigation', async () => { + stubMatchMedia(false); + const ga = vi.fn(); + vi.stubGlobal('ga', ga); + + renderApp(); + + expect(ga.mock.calls).toEqual([ + ['set', 'page', '/news/1'], + ['send', 'pageview'], + ]); + + ga.mockClear(); + await userEvent.click(screen.getByRole('link', { name: 'show' })); + + expect(screen.getByText('show feed')).toBeInTheDocument(); + expect(ga.mock.calls).toEqual([ + ['set', 'page', '/show/1'], + ['send', 'pageview'], + ]); + }); + + it('does not throw when Google Analytics is not loaded', () => { + stubMatchMedia(false); + vi.stubGlobal('ga', undefined); + + expect(() => renderApp()).not.toThrow(); + expect(screen.getByText('news feed')).toBeInTheDocument(); + }); }); diff --git a/web/src/App.tsx b/web/src/App.tsx index ed63d3622..c47d2f5cd 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,19 +1,38 @@ -import { ReactNode } from 'react'; +import { useEffect } from 'react'; +import { Outlet, useLocation } from 'react-router-dom'; import { useSettings } from './context/SettingsContext'; +import { Footer } from './core/Footer'; +import { Header } from './core/Header'; import './App.scss'; -export interface AppProps { - children?: ReactNode; +declare global { + interface Window { + ga?: (...args: unknown[]) => void; + } } -export function App({ children }: AppProps) { +export function App() { const { settings } = useSettings(); + const { pathname } = useLocation(); + + useEffect(() => { + if (typeof window.ga !== 'function') { + return; + } + + window.ga('set', 'page', pathname); + window.ga('send', 'pageview'); + }, [pathname]); return (
-
{children}
+
+
+ +
+
); } diff --git a/web/src/core/Footer.test.tsx b/web/src/core/Footer.test.tsx new file mode 100644 index 000000000..91e7c892e --- /dev/null +++ b/web/src/core/Footer.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { Footer } from './Footer'; + +describe('Footer', () => { + it('links to the project on GitHub', () => { + const { container } = render(