Skip to content
Open
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
69 changes: 55 additions & 14 deletions web/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<SettingsProvider>
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/" element={<App />}>
<Route path="news/:page" element={<p>news feed</p>} />
<Route path="show/:page" element={<p>show feed</p>} />
</Route>
</Routes>
</MemoryRouter>
</SettingsProvider>
);
}

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(
<SettingsProvider>
<App>
<p>content</p>
</App>
</SettingsProvider>
);
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(
<SettingsProvider>
<App />
</SettingsProvider>
);
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();
});
});
29 changes: 24 additions & 5 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={settings.theme}>
<div className="body-cover"></div>
<div className="wrapper">{children}</div>
<div className="wrapper">
<Header />
<Outlet />
<Footer />
</div>
</div>
);
}
Expand Down
17 changes: 17 additions & 0 deletions web/src/core/Footer.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Footer />);

expect(container.querySelector('#footer p')?.textContent).toBe('Show this project some ❤ on GitHub');

const link = screen.getByRole('link', { name: 'GitHub' });
expect(link).toHaveAttribute('href', 'https://github.com/hdjirdeh/angular2-hn');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener');
});
});
16 changes: 16 additions & 0 deletions web/src/core/Footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import './footer.scss';

export function Footer() {
return (
<div id="footer">
<p>
Show this project some ❤ on{' '}
<a href="https://github.com/hdjirdeh/angular2-hn" target="_blank" rel="noopener">
GitHub
</a>
</p>
</div>
);
}

export default Footer;
80 changes: 80 additions & 0 deletions web/src/core/Header.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { SettingsProvider } from '../context/SettingsContext';
import { stubMatchMedia } from '../testUtils/matchMedia';
import { Header } from './Header';

const scrollTo = vi.fn();

beforeEach(() => {
scrollTo.mockClear();
vi.stubGlobal('scrollTo', scrollTo);
});

function renderHeader(initialEntry = '/news/1') {
stubMatchMedia(false);

return render(
<SettingsProvider>
<MemoryRouter initialEntries={[initialEntry]}>
<Header />
</MemoryRouter>
</SettingsProvider>
);
}

afterEach(() => {
vi.unstubAllGlobals();
});

describe('Header', () => {
it('links the logo to the first news page and the nav to the other feeds', () => {
const { container } = renderHeader();

expect(screen.getByRole('img', { name: 'Logo' }).closest('a')).toHaveAttribute('href', '/news/1');
expect(screen.getByRole('link', { name: 'new' })).toHaveAttribute('href', '/newest/1');
expect(screen.getByRole('link', { name: 'show' })).toHaveAttribute('href', '/show/1');
expect(screen.getByRole('link', { name: 'ask' })).toHaveAttribute('href', '/ask/1');
expect(screen.getByRole('link', { name: 'jobs' })).toHaveAttribute('href', '/jobs/1');
expect(container.querySelector('.header-nav')?.textContent).toBe('new | show | ask | jobs');
});

it('marks the link of the current feed as active', () => {
renderHeader('/ask/1');

expect(screen.getByRole('link', { name: 'ask' })).toHaveClass('active');
expect(screen.getByRole('link', { name: 'show' })).not.toHaveClass('active');
expect(screen.getByRole('img', { name: 'Logo' }).closest('a')).not.toHaveClass('active');
});

it('scrolls back to the top when a link is clicked', async () => {
renderHeader();
await userEvent.click(screen.getByRole('link', { name: 'new' }));

expect(scrollTo).toHaveBeenCalledWith(0, 0);
});

it('opens and closes the settings modal from the cog', async () => {
renderHeader();

expect(document.getElementById('popup1')).toBeNull();

await userEvent.click(screen.getByRole('img', { name: 'Settings' }));
expect(document.getElementById('popup1')).not.toBeNull();

await userEvent.click(screen.getByRole('img', { name: 'Settings' }));
expect(document.getElementById('popup1')).toBeNull();
});

it('closes the settings modal from its close button', async () => {
const { container } = renderHeader();

await userEvent.click(screen.getByRole('img', { name: 'Settings' }));
await userEvent.click(container.querySelector('.close')!);

expect(document.getElementById('popup1')).toBeNull();
});
});
51 changes: 51 additions & 0 deletions web/src/core/Header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { NavLink } from 'react-router-dom';

import { useSettings } from '../context/SettingsContext';
import { Settings } from './Settings';
import './header.scss';

function scrollTop() {
window.scrollTo(0, 0);
}

export function Header() {
const { settings, toggleSettings } = useSettings();

return (
<header>
<div id="header">
<NavLink className="home-link" to="/news/1" onClick={scrollTop}>
<div className="logo-inner"></div>
<img className="logo" src="/assets/images/logo.svg" alt="Logo" />
</NavLink>
<div className="header-text">
<div className="left">
<span className="header-nav">
<NavLink to="/newest/1" onClick={scrollTop}>
new
</NavLink>
{' | '}
<NavLink to="/show/1" onClick={scrollTop}>
show
</NavLink>
{' | '}
<NavLink to="/ask/1" onClick={scrollTop}>
ask
</NavLink>
{' | '}
<NavLink to="/jobs/1" onClick={scrollTop}>
jobs
</NavLink>
</span>
</div>
</div>
<div className="info">
<img className="settings" src="/assets/images/cog.svg" alt="Settings" onClick={toggleSettings} />
</div>
</div>
{settings.showSettings && <Settings />}
</header>
);
}

export default Header;
Loading
Loading