Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
11 changes: 6 additions & 5 deletions app/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { createBrowserRouter, RouterProvider } from 'react-router'
import { ROUTER_ROUTES } from './routes'

const router = createBrowserRouter(ROUTER_ROUTES)

export function App() {
return (
<main className="grid min-h-dvh place-items-center bg-white font-sans">
<p className="text-lg text-neutral-500">ReactMap 2.0</p>
</main>
)
return <RouterProvider router={router} />
}
47 changes: 47 additions & 0 deletions app/layout/BottomNav.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { afterAll, afterEach, beforeAll, expect, test } from 'bun:test'
import { cleanup, render, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { setupDom, teardownDom } from '../test-setup'
import { BottomNav } from './BottomNav'

// `@testing-library/dom`'s `screen` singleton snapshots `document` the
// moment the module is first imported (dist/screen.js), so it only works
// when a global document exists before any test file's imports run — which
// means registering it process-wide via bunfig's preload, for every
// workspace in this monorepo. That broke unrelated suites elsewhere (see
// test-setup.ts). Using the queries `render` returns needs the DOM only once
// the test body actually runs, so registering it here in beforeAll, scoped to
// this file, is enough.
beforeAll(setupDom)
afterAll(teardownDom)

// Every render is appended to the same document and stays there, so without
// this each test sees the leftovers of the ones before it.
afterEach(cleanup)

// Queries are scoped to the container this render owns rather than the whole
// body. The hub renders a link labelled Filters too, so a document-wide query
// finds more than one and getByRole throws for being ambiguous. Whether that
// happened depended on which files had already run, which is why this passed
// locally and failed in CI.
test('shows the four primary destinations in order', () => {
const { container } = render(
<MemoryRouter initialEntries={['/map']}>
<BottomNav />
</MemoryRouter>,
)
const labels = within(container)
.getAllByRole('link')
.map((link) => link.textContent)
expect(labels).toEqual(['Map', 'Filters', 'Alerts', 'Me'])
})

test('marks the active destination for assistive tech', () => {
const { container } = render(
<MemoryRouter initialEntries={['/filters']}>
<BottomNav />
</MemoryRouter>,
)
const active = within(container).getByRole('link', { name: 'Filters' })
expect(active.getAttribute('aria-current')).toBe('page')
})
26 changes: 26 additions & 0 deletions app/layout/BottomNav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { NavLink } from 'react-router'

const DESTINATIONS = [
{ to: '/map', label: 'Map' },
{ to: '/filters', label: 'Filters' },
{ to: '/alerts', label: 'Alerts' },
{ to: '/profile', label: 'Me' },
] as const

export function BottomNav() {
return (
<nav className="fixed inset-x-0 bottom-0 grid grid-cols-4 border-t border-neutral-200 bg-white pb-[env(safe-area-inset-bottom)]">
{DESTINATIONS.map(({ to, label }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`py-3 text-center text-sm ${isActive ? 'text-violet-600' : 'text-neutral-500'}`
}
>
{label}
</NavLink>
))}
</nav>
)
}
13 changes: 13 additions & 0 deletions app/layout/Shell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Outlet } from 'react-router'
import { BottomNav } from './BottomNav'

export function Shell() {
return (
<div className="min-h-dvh bg-white font-sans">
<main className="pb-16">
<Outlet />
</main>
<BottomNav />
</div>
)
}
8 changes: 8 additions & 0 deletions app/pages/AlertsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function AlertsPage() {
return (
<section className="p-6">
<h1 className="text-2xl font-semibold">Alerts</h1>
<p className="mt-2 text-neutral-500">Alerts arrive in a later plan.</p>
</section>
)
}
8 changes: 8 additions & 0 deletions app/pages/FiltersPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function FiltersPage() {
return (
<section className="p-6">
<h1 className="text-2xl font-semibold">Filters</h1>
<p className="mt-2 text-neutral-500">Filters arrive in a later plan.</p>
</section>
)
}
26 changes: 26 additions & 0 deletions app/pages/Hub.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { afterAll, afterEach, beforeAll, expect, test } from 'bun:test'
import { cleanup, render, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { setupDom, teardownDom } from '../test-setup'
import { Hub } from './Hub'

beforeAll(setupDom)
afterAll(teardownDom)

// Every render is appended to the same document and stays there, so without
// this each test sees the leftovers of the ones before it. The bottom nav
// renders a link labelled Filters as well, so a stale render from either file
// can make the other's query ambiguous.
afterEach(cleanup)

test('links to the four primary surfaces without a session', () => {
const { container } = render(
<MemoryRouter>
<Hub />
</MemoryRouter>,
)
const hrefs = within(container)
.getAllByRole('link')
.map((link) => link.getAttribute('href'))
expect(hrefs).toEqual(['/map', '/filters', '/alerts', '/profile'])
})
27 changes: 27 additions & 0 deletions app/pages/Hub.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Link } from 'react-router'

const DESTINATIONS = [
{ to: '/map', label: 'Map' },
{ to: '/filters', label: 'Filters' },
{ to: '/alerts', label: 'Alerts' },
{ to: '/profile', label: 'Profile' },
] as const

export function Hub() {
return (
<section className="p-6">
<h1 className="text-2xl font-semibold">Hub</h1>
<nav className="mt-4 grid grid-cols-2 gap-3">
{DESTINATIONS.map(({ to, label }) => (
<Link
key={to}
to={to}
className="rounded-lg border border-neutral-200 p-4 text-center text-sm font-medium"
>
{label}
</Link>
))}
</nav>
</section>
)
}
8 changes: 8 additions & 0 deletions app/pages/Locales.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function Locales() {
return (
<section className="p-6">
<h1 className="text-2xl font-semibold">Locales</h1>
<p className="mt-2 text-neutral-500">Locales arrive in a later plan.</p>
</section>
)
}
8 changes: 8 additions & 0 deletions app/pages/MapPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function MapPage() {
return (
<section className="p-6">
<h1 className="text-2xl font-semibold">Map</h1>
<p className="mt-2 text-neutral-500">The map arrives in a later plan.</p>
</section>
)
}
15 changes: 15 additions & 0 deletions app/pages/NotFound.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Link } from 'react-router'

export function NotFound() {
return (
<section className="p-6">
<h1 className="text-2xl font-semibold">Page not found</h1>
<p className="mt-2 text-neutral-500">
This page does not exist.{' '}
<Link className="underline" to="/">
Go back home
</Link>
</p>
</section>
)
}
10 changes: 10 additions & 0 deletions app/pages/Playground.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export function Playground() {
return (
<section className="p-6">
<h1 className="text-2xl font-semibold">Playground</h1>
<p className="mt-2 text-neutral-500">
The playground arrives in a later plan.
</p>
</section>
)
}
64 changes: 64 additions & 0 deletions app/pages/Profile.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { afterAll, afterEach, beforeAll, expect, mock, test } from 'bun:test'
import { cleanup, render } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { setupDom, teardownDom } from '../test-setup'
import { Profile } from './Profile'

beforeAll(setupDom)
afterAll(teardownDom)

const originalFetch = globalThis.fetch

afterEach(() => {
globalThis.fetch = originalFetch
// `render()` queries are bound to `document.body`, not to the returned
// container, so a prior test's markup is still visible to the next
// test's queries unless the render tree is unmounted here.
cleanup()
})

test('renders a loading affordance while the session resolves', () => {
globalThis.fetch = mock(
() => new Promise(() => {}),
) as unknown as typeof fetch
const { getByText } = render(
<MemoryRouter>
<Profile />
</MemoryRouter>,
)
expect(getByText('Loading...')).toBeTruthy()
})

test('prompts to sign in when logged out, without account details', async () => {
globalThis.fetch = mock(
async () =>
new Response(JSON.stringify({ user: { loggedIn: false, perms: {} } }), {
status: 200,
}),
) as unknown as typeof fetch
const { findByText, queryByText } = render(
<MemoryRouter>
<Profile />
</MemoryRouter>,
)
expect(await findByText('Sign in to see your profile.')).toBeTruthy()
expect(queryByText('Loading...')).toBeNull()
})

test('renders the username when logged in', async () => {
globalThis.fetch = mock(
async () =>
new Response(
JSON.stringify({
user: { loggedIn: true, username: 'ash', perms: { map: true } },
}),
{ status: 200 },
),
) as unknown as typeof fetch
const { findByText } = render(
<MemoryRouter>
<Profile />
</MemoryRouter>,
)
expect(await findByText('ash')).toBeTruthy()
})
40 changes: 40 additions & 0 deletions app/pages/Profile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useSession } from '../session/useSession'

export function Profile() {
const { status, data } = useSession()

if (status === 'loading') {
return (
<section className="p-6">
<h1 className="text-2xl font-semibold">Profile</h1>
<p className="mt-2 text-neutral-500">Loading...</p>
</section>
)
}

if (status === 'error' || !data?.user.loggedIn) {
return (
<section className="p-6">
<h1 className="text-2xl font-semibold">Profile</h1>
<p className="mt-2 text-neutral-500">Sign in to see your profile.</p>
</section>
)
}

const { username, perms } = data.user

return (
<section className="p-6">
<h1 className="text-2xl font-semibold">Profile</h1>
<p className="mt-2 text-lg">{username}</p>
<ul className="mt-4 space-y-1 text-sm text-neutral-500">
{Object.keys(perms).map((perm) => (
<li key={perm}>{perm}</li>
))}
</ul>
<p className="mt-6 text-sm text-neutral-400">
Account reset and linked accounts arrive in a later plan.
</p>
</section>
)
}
24 changes: 24 additions & 0 deletions app/routes.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { expect, test } from 'bun:test'
import { ROUTES } from './routes'

test('every spec route is present exactly once', () => {
const paths = ROUTES.map((route) => route.path).sort()
expect(paths).toEqual(
[
'*',
'/',
'/alerts',
'/filters',
'/locales',
'/map',
'/playground',
'/profile',
].sort(),
)
})

test('every route element is lazy so it becomes its own chunk', () => {
for (const route of ROUTES) {
expect(typeof route.lazy).toBe('function')
}
})
Loading
Loading