From a9f4fdd6088d87abf460490d87664d2ff1c6d1a1 Mon Sep 17 00:00:00 2001 From: Oliver Dragland Date: Mon, 14 Sep 2026 09:35:46 +0200 Subject: [PATCH 1/2] feature(weather): add loading, error and empty states --- docs/development.md | 2 + docs/testing.md | 10 ++ e2e/weather-states.spec.ts | 119 +++++++++++++++++++++++ web/src/WeatherExample.test.tsx | 146 ++++++++++++++++++++++++++-- web/src/WeatherExample.tsx | 44 ++++++--- web/src/features/weather/weather.ts | 12 ++- web/src/styles.css | 10 ++ 7 files changed, 320 insertions(+), 23 deletions(-) create mode 100644 e2e/weather-states.spec.ts diff --git a/docs/development.md b/docs/development.md index 0d0d844..286293c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -12,6 +12,8 @@ The README covers everyday setup. This page contains optional infrastructure com Project 1 calls Open-Meteo directly. Wind is requested in m/s, timestamps use Europe/Oslo and precipitation is labelled with the provider's interval. Only the active response is held in memory; weather payloads are never persisted. Future storage features should store preferences and city IDs only. +`fetchWeather` returns `CityWeather | null`: HTTP 204, a JSON `null` response, or a validated response with a missing/null `current` observation means no weather is available. Partial observations, invalid units and malformed responses still fail Zod validation and produce a retryable error. Consumers must handle the empty result before reading weather fields. `WeatherExample` shows loading during initial requests and retries, explains requests paused while offline, and provides a retry button for errors and empty results. + Project 2 requires GraphQL and a database installed directly on the VM. Hosted Supabase and Docker backend/database deployment do not fit that requirement. Docker here is only an optional frontend development tool; Redis is not needed. ## Native PostgreSQL setup for Project 2 diff --git a/docs/testing.md b/docs/testing.md index b0c4ab0..721fa7d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -15,6 +15,16 @@ Playwright smoke tests intercept provider data and check the production example, - `docker compose config --quiet`: passed. The optional frontend image/container was not built or started during this verification. - Verification toolchain: isolated Node 24.21.0 and npm 11.11.1. The machine's default Node/npm were not changed; follow README setup before installing locally. +## Loading, error and empty states — 2026-09-14 + +Codex assisted with the loading/error/empty-state implementation, regression tests and documentation. The changes were checked with an isolated Node 24.21.0 / npm 11.11.1 toolchain after `npm ci`. + +- `npm run check`: passed formatting, zero-warning lint, generated GraphQL contract verification, strict TypeScript, migration metadata, all 18 Vitest tests (15 frontend, 3 API), the unchanged success snapshot and both builds. +- Frontend tests cover a held loading response, HTTP failure with manual retry and loading feedback, no-content/null/missing observations, incomplete measurements, invalid JSON, network failure, and offline pause/resume. Invalid data never reaches the rendered weather fields. +- `npm run test:e2e`: all 25 tests passed in Chromium, Firefox, WebKit and emulated iPhone portrait/landscape. New checks cover pending requests, error semantics, keyboard retry, empty-result recovery, disabled duplicate retries and missing-field responses without uncaught page errors. Axe reported no violations in loading, error, empty or successful states. + +Automated tests use mocked provider responses. Real-device and screen-reader testing, live provider behavior, deployment and teammate review remain separate checks. + ## Remaining manual checks - Final client: navigation, activity assessment, filters, sort, favorites and both storage lifetimes. diff --git a/e2e/weather-states.spec.ts b/e2e/weather-states.spec.ts new file mode 100644 index 0000000..d63e995 --- /dev/null +++ b/e2e/weather-states.spec.ts @@ -0,0 +1,119 @@ +import { expect, test } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import { weatherResponse } from '../web/src/test/fixtures'; + +test('announces loading while the API request is pending', async ({ page }) => { + let finishRequest = () => {}; + const responseReady = new Promise((resolve) => { + finishRequest = resolve; + }); + await page.route('https://api.open-meteo.com/**', async (route) => { + await responseReady; + await route.fulfill({ json: weatherResponse }); + }); + await page.goto('./'); + try { + await expect(page.getByRole('status')).toHaveText('Henter værdata…'); + await expect(page.getByRole('alert')).toHaveCount(0); + expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + } finally { + finishRequest(); + } + await expect(page.getByText('14 °C')).toBeVisible(); + await expect(page.getByRole('status')).toHaveCount(0); +}); + +test('announces API errors and supports keyboard retry with loading feedback', async ({ + page, +}) => { + let calls = 0; + let finishRetry = () => {}; + const responseReady = new Promise((resolve) => { + finishRetry = resolve; + }); + await page.route('https://api.open-meteo.com/**', async (route) => { + calls++; + if (calls === 1) { + await route.fulfill({ status: 503 }); + return; + } + await responseReady; + await route.fulfill({ json: weatherResponse }); + }); + await page.goto('./'); + try { + await expect(page.getByRole('alert')).toContainText( + 'Kunne ikke hente værdata.', + ); + expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + expect(calls).toBe(1); + await page.keyboard.press('Tab'); + await expect( + page.getByRole('button', { name: 'Prøv igjen' }), + ).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('status')).toHaveText('Henter værdata…'); + await expect(page.getByRole('alert')).toHaveCount(0); + } finally { + finishRetry(); + } + await expect(page.getByText('14 °C')).toBeVisible(); + await expect(page.getByRole('status')).toHaveCount(0); + expect(calls).toBe(2); +}); + +test('explains empty results and disables repeat requests while retrying', async ({ + page, +}) => { + let calls = 0; + let finishRetry = () => {}; + const responseReady = new Promise((resolve) => { + finishRetry = resolve; + }); + await page.route('https://api.open-meteo.com/**', async (route) => { + calls++; + if (calls === 1) { + await route.fulfill({ status: 204 }); + return; + } + await responseReady; + await route.fulfill({ json: weatherResponse }); + }); + await page.goto('./'); + try { + await expect(page.getByRole('status')).toContainText( + 'Ingen værdata er tilgjengelige for Trondheim akkurat nå.', + ); + await expect(page.getByRole('alert')).toHaveCount(0); + expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + await page.getByRole('button', { name: 'Prøv igjen' }).click(); + await expect(page.getByRole('status')).toHaveText('Henter værdata…'); + await expect( + page.getByRole('button', { name: 'Prøv igjen' }), + ).toBeDisabled(); + } finally { + finishRetry(); + } + await expect(page.getByText('14 °C')).toBeVisible(); + await expect(page.getByRole('status')).toHaveCount(0); + expect(calls).toBe(2); +}); + +test('keeps the app usable when weather fields are missing', async ({ + page, +}) => { + const pageErrors: string[] = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + await page.route('https://api.open-meteo.com/**', async (route) => { + await route.fulfill({ json: { ...weatherResponse, current: {} } }); + }); + await page.goto('./'); + await expect(page.getByRole('alert')).toContainText( + 'Kunne ikke hente værdata.', + ); + await expect( + page.getByRole('heading', { name: 'Turvær Norge' }), + ).toBeVisible(); + await expect(page.getByRole('button', { name: 'Prøv igjen' })).toBeEnabled(); + expect(pageErrors).toEqual([]); +}); diff --git a/web/src/WeatherExample.test.tsx b/web/src/WeatherExample.test.tsx index bc6021b..1dfab6e 100644 --- a/web/src/WeatherExample.test.tsx +++ b/web/src/WeatherExample.test.tsx @@ -1,7 +1,11 @@ import { expect, it } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + onlineManager, + QueryClient, + QueryClientProvider, +} from '@tanstack/react-query'; import { http, HttpResponse } from 'msw'; import { WeatherExample } from './WeatherExample'; import { WEATHER_URL, fetchWeather } from './features/weather/weather'; @@ -25,15 +29,141 @@ it('renders validated weather and a stable snapshot', async () => { expect(screen.getByText('3 m/s')).toBeVisible(); expect(asFragment()).toMatchSnapshot(); }); -it('shows errors and retries on user interaction', async () => { +it('shows a loading message until the response arrives', async () => { + let finishRequest = () => {}; + const responseReady = new Promise((resolve) => { + finishRequest = resolve; + }); server.use( - http.get(WEATHER_URL, () => new HttpResponse(null, { status: 503 })), + http.get(WEATHER_URL, async () => { + await responseReady; + return HttpResponse.json(weatherResponse); + }), + ); + renderExample(); + try { + expect(screen.getByRole('status')).toHaveTextContent('Henter værdata…'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + } finally { + finishRequest(); + } + await screen.findByText('14 °C'); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); +}); +it('shows an accessible error and loading feedback during a manual retry', async () => { + let calls = 0; + let finishRetry = () => {}; + const responseReady = new Promise((resolve) => { + finishRetry = resolve; + }); + server.use( + http.get(WEATHER_URL, async () => { + calls++; + if (calls === 1) return new HttpResponse(null, { status: 503 }); + await responseReady; + return HttpResponse.json(weatherResponse); + }), ); renderExample(); - await screen.findByRole('alert'); - server.resetHandlers(); - await userEvent.click(screen.getByRole('button', { name: 'Prøv igjen' })); + try { + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Kunne ikke hente værdata.', + ); + expect(calls).toBe(1); + await userEvent.click(screen.getByRole('button', { name: 'Prøv igjen' })); + expect(await screen.findByRole('status')).toHaveTextContent( + 'Henter værdata…', + ); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + } finally { + finishRetry(); + } await screen.findByText('14 °C'); + expect(calls).toBe(2); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Prøv igjen' }), + ).not.toBeInTheDocument(); +}); +it.each([ + ['HTTP 204', () => new HttpResponse(null, { status: 204 })], + ['null response', () => HttpResponse.json(null)], + [ + 'null observation', + () => HttpResponse.json({ ...weatherResponse, current: null }), + ], + [ + 'missing observation', + () => + HttpResponse.json({ + timezone: weatherResponse.timezone, + current_units: weatherResponse.current_units, + }), + ], +])( + 'shows an empty state for %s and allows a new request', + async (_name, response) => { + server.use(http.get(WEATHER_URL, response)); + renderExample(); + await screen.findByText( + 'Ingen værdata er tilgjengelige for Trondheim akkurat nå. Prøv igjen om litt.', + ); + expect(screen.getByRole('status')).toHaveTextContent('Ingen værdata'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.queryByRole('definition')).not.toBeInTheDocument(); + server.resetHandlers(); + await userEvent.click(screen.getByRole('button', { name: 'Prøv igjen' })); + await screen.findByText('14 °C'); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }, +); +it.each([ + ['missing fields', () => HttpResponse.json({})], + [ + 'partial observation', + () => + HttpResponse.json({ + ...weatherResponse, + current: { temperature_2m: 14 }, + }), + ], + [ + 'null measurement', + () => + HttpResponse.json({ + ...weatherResponse, + current: { ...weatherResponse.current, temperature_2m: null }, + }), + ], + ['invalid JSON', () => new HttpResponse('{')], + ['network failure', () => HttpResponse.error()], +])( + 'handles %s without crashing and allows recovery', + async (_name, response) => { + server.use(http.get(WEATHER_URL, response)); + renderExample(); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Kunne ikke hente værdata.', + ); + expect(screen.getByRole('heading', { name: 'Turvær Norge' })).toBeVisible(); + expect(screen.queryByRole('definition')).not.toBeInTheDocument(); + server.resetHandlers(); + await userEvent.click(screen.getByRole('button', { name: 'Prøv igjen' })); + await screen.findByText('14 °C'); + }, +); +it('explains a paused offline request and resumes when connected', async () => { + onlineManager.setOnline(false); + try { + renderExample(); + expect(screen.getByRole('status')).toHaveTextContent('Du er frakoblet.'); + act(() => { + onlineManager.setOnline(true); + }); + await screen.findByText('14 °C'); + } finally { + onlineManager.setOnline(true); + } }); it('rejects malformed provider data and unexpected units', async () => { server.use( @@ -60,7 +190,7 @@ it('includes coordinates, explicit units, and cancellation in requests', async ( return HttpResponse.json(weatherResponse); }), ); - expect((await fetchWeather(exampleCity)).city.id).toBe('trondheim'); + expect((await fetchWeather(exampleCity))?.city.id).toBe('trondheim'); const controller = new AbortController(); controller.abort(); await expect(fetchWeather(exampleCity, controller.signal)).rejects.toThrow(); diff --git a/web/src/WeatherExample.tsx b/web/src/WeatherExample.tsx index 3045ab4..873a17b 100644 --- a/web/src/WeatherExample.tsx +++ b/web/src/WeatherExample.tsx @@ -14,19 +14,37 @@ export function WeatherExample() {

Været i {exampleCity.name}

- {weather.isPending &&

Henter værdata…

} - {weather.isError && ( -
-

Kunne ikke hente værdata. Prøv igjen om litt.

- -
+ {weather.isFetching &&

Henter værdata…

} + {weather.isPaused && ( +

+ Du er frakoblet. Venter på nettforbindelse for å hente værdata. +

+ )} + {weather.isError && !weather.isFetching && !weather.isPaused && ( +

+ Kunne ikke hente værdata. Sjekk nettforbindelsen din eller prøv + igjen om litt. +

+ )} + {weather.isSuccess && + !weather.data && + !weather.isFetching && + !weather.isPaused && ( +

+ Ingen værdata er tilgjengelige for {exampleCity.name} akkurat nå. + Prøv igjen om litt. +

+ )} + {(weather.isError || (weather.isSuccess && !weather.data)) && ( + )} {weather.data && ( <> diff --git a/web/src/features/weather/weather.ts b/web/src/features/weather/weather.ts index ef440b6..b755fc6 100644 --- a/web/src/features/weather/weather.ts +++ b/web/src/features/weather/weather.ts @@ -21,6 +21,12 @@ export const weatherResponseSchema = z.object({ }); export type WeatherResponse = z.infer; +// A successful response may contain no current observation. Partial observations +// still fail validation instead of reaching the UI with missing fields. +const weatherResultSchema = weatherResponseSchema + .extend({ current: weatherResponseSchema.shape.current.nullish() }) + .nullable(); + export async function fetchWeather(city: City, signal?: AbortSignal) { const url = new URL(WEATHER_URL); url.search = new URLSearchParams({ @@ -38,11 +44,13 @@ export async function fetchWeather(city: City, signal?: AbortSignal) { }); if (!response.ok) throw new Error(`Weather API returned HTTP ${response.status}`); + if (response.status === 204) return null; const body: unknown = await response.json(); - const parsed = weatherResponseSchema.parse(body); + const parsed = weatherResultSchema.parse(body); + if (!parsed?.current) return null; return { city, current: parsed.current }; } -export type CityWeather = Awaited>; +export type CityWeather = NonNullable>>; export function weatherQuery(city: City) { return queryOptions({ diff --git a/web/src/styles.css b/web/src/styles.css index 8943805..9656e8e 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -50,6 +50,16 @@ button { border-radius: 0.25rem; cursor: pointer; } +button:disabled { + opacity: 0.65; + cursor: wait; +} +[role='alert'] { + padding: 0.75rem 1rem; + border-left: 4px solid #9a491c; + background: #fff3ec; + color: #673514; +} a { color: inherit; text-underline-offset: 0.2em; From 2c022f6dd678d644ddfc5336223063bf7b571290 Mon Sep 17 00:00:00 2001 From: Oliver Dragland Date: Mon, 14 Sep 2026 09:49:12 +0200 Subject: [PATCH 2/2] feature(ui): share query feedback components --- README.md | 2 +- docs/development.md | 3 +- docs/testing.md | 7 ++ docs/ui-feedback.md | 65 ++++++++++++++ web/src/WeatherExample.tsx | 44 +++------ .../feedback/FeedbackMessage.test.tsx | 79 ++++++++++++++++ .../components/feedback/FeedbackMessage.tsx | 69 ++++++++++++++ .../feedback/QueryFeedback.test.tsx | 89 +++++++++++++++++++ web/src/components/feedback/QueryFeedback.tsx | 57 ++++++++++++ web/src/components/feedback/feedback.css | 37 ++++++++ web/src/components/feedback/index.ts | 9 ++ web/src/styles.css | 20 ----- 12 files changed, 427 insertions(+), 54 deletions(-) create mode 100644 docs/ui-feedback.md create mode 100644 web/src/components/feedback/FeedbackMessage.test.tsx create mode 100644 web/src/components/feedback/FeedbackMessage.tsx create mode 100644 web/src/components/feedback/QueryFeedback.test.tsx create mode 100644 web/src/components/feedback/QueryFeedback.tsx create mode 100644 web/src/components/feedback/feedback.css create mode 100644 web/src/components/feedback/index.ts diff --git a/README.md b/README.md index ebeaa0a..0efd558 100644 --- a/README.md +++ b/README.md @@ -122,4 +122,4 @@ Velg et issue før du starter, og lenk det i PR-en, for eksempel `Closes #8`. Å ## Dokumentasjon -[Roadmap](roadmap.md) · [Utviklerguide og planlagt dataflyt](docs/development.md) · [Krav og gjenstående arbeid](docs/requirements.md) · [Tester](docs/testing.md) +[Roadmap](roadmap.md) · [Utviklerguide og planlagt dataflyt](docs/development.md) · [Delte statuskomponenter](docs/ui-feedback.md) · [Krav og gjenstående arbeid](docs/requirements.md) · [Tester](docs/testing.md) diff --git a/docs/development.md b/docs/development.md index 286293c..6781b81 100644 --- a/docs/development.md +++ b/docs/development.md @@ -5,6 +5,7 @@ The README covers everyday setup. This page contains optional infrastructure com ## Structure and type safety - `web/src/WeatherExample.tsx`: the single weather example. +- `web/src/components/feedback/`: shared loading, error, empty and offline messages plus a typed TanStack Query adapter; see [usage and extension](ui-feedback.md). - `web/src/features/weather/`: typed city definitions, Zod response validation and TanStack Query options. - `web/src/shared/storage.ts`: storage helper for future preferences and favorite city IDs. - `project2/api/`: independent Node/Hono/GraphQL API with native PostgreSQL and Drizzle migrations. @@ -79,7 +80,7 @@ Project 2's eventual client belongs at `/project2/`. Run its built API on Node, ## Planned Project 1 structure -The following diagrams are retained from the team's project plan; these components are not implemented by the skeleton. +The following diagrams are retained from the team's project plan. Shared loading/error feedback is implemented; the full app, list and details composition remains planned. ## Dataflyt diff --git a/docs/testing.md b/docs/testing.md index 721fa7d..eab9dad 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -25,6 +25,13 @@ Codex assisted with the loading/error/empty-state implementation, regression tes Automated tests use mocked provider responses. Real-device and screen-reader testing, live provider behavior, deployment and teammate review remain separate checks. +## Shared feedback components — 2026-09-14 + +Codex assisted with extracting standalone loading, error, empty and offline messages, a typed TanStack Query adapter, scoped CSS, component tests and the [usage guide](ui-feedback.md). The weather example now consumes the shared components. Component tests cover standalone use, keyboard actions, disabled actions, independent instances, custom empty-list rules, valid falsy data and disabled queries. + +- `npm run check`: passed formatting, zero-warning lint, generated contracts, strict TypeScript, migration metadata, all 27 Vitest tests (24 frontend, 3 API), the unchanged success snapshot and both builds, using Node 24.21.0 and npm 11.11.1. +- `npm run test:e2e`: all 25 tests passed with the shared components in Chromium, Firefox, WebKit and emulated iPhone portrait/landscape. This includes keyboard retry, state transitions, empty-result recovery, malformed data, overflow and axe accessibility checks. Axe found no violations in the tested loading, error, empty and success states. + ## Remaining manual checks - Final client: navigation, activity assessment, filters, sort, favorites and both storage lifetimes. diff --git a/docs/ui-feedback.md b/docs/ui-feedback.md new file mode 100644 index 0000000..3af5652 --- /dev/null +++ b/docs/ui-feedback.md @@ -0,0 +1,65 @@ +# Shared UI feedback + +Import feedback components from `web/src/components/feedback`. They are self-authored React components with plain CSS and no additional runtime dependencies. `WeatherExample` is the working integration. + +## Standalone messages + +| Component | Default message | Semantics | +| ---------------- | ------------------------------------------- | ------------- | +| `LoadingMessage` | Laster… | `role=status` | +| `ErrorMessage` | Noe gikk galt. Prøv igjen om litt. | `role=alert` | +| `EmptyMessage` | Ingen resultater. | `role=status` | +| `OfflineMessage` | Du er frakoblet. Venter på nettforbindelse. | `role=status` | + +Each component accepts a custom `message` string and an optional `action` with `label`, `onClick` and `disabled`. They work without TanStack Query or a provider, so features can use them for local filters, favorites and other UI state. + +For example, inside a feature with a `resetFilters` callback: + +```tsx +import { EmptyMessage } from './components/feedback'; + +; +``` + +Actions use native buttons with an explicit `type="button"`, visible keyboard focus and a minimum 44px height. Disabled actions cannot be activated. The button sits outside the live message region, so the alert contains only the explanation. Roles are fixed by the component to keep semantics consistent across features. + +## TanStack Query integration + +`QueryFeedback` selects a message from an existing typed query result. It does not fetch data, create a query client, render results or change query options. Features continue to own query keys, live REST requests, Zod validation and data presentation. + +For a feature with an existing `citiesQuery` whose successful data is an array: + +```tsx +import { QueryFeedback } from './components/feedback'; + + cities.length === 0} + messages={{ + loading: 'Henter byer…', + error: 'Kunne ikke hente byene. Prøv igjen om litt.', + empty: 'Ingen byer er tilgjengelige akkurat nå.', + }} + retryLabel="Hent byer på nytt" +/>; +``` + +- `query`: the existing query result; data and error types are inferred. +- `messages`: optional overrides for `loading`, `error`, `empty` and `offline`. Omitted values use the standalone defaults. Use understandable, feature-specific text rather than raw provider errors. +- `isEmpty`: optional domain rule. Null/undefined data is always empty and is never passed to the predicate; valid values such as `0` and `false` are not empty. Empty arrays require an explicit length check. +- `retryLabel`: accessible button text, defaulting to `Prøv igjen`. Errors and empty responses offer a manual `refetch`; retry buttons are disabled while a request is fetching or paused. + +Paused and fetching states take precedence over previous errors or empty results. A disabled query that is pending but idle shows no loading message. Successful nonempty data shows no feedback; the feature renders its own data alongside `QueryFeedback`. + +Use one adapter for a list loaded by one query. Use per-card feedback when cards fetch independently, and include the city name in messages and retry labels so users can distinguish actions. For client-side filtering, use `EmptyMessage` with an action that clears filters rather than refetching unchanged server data. + +## Styling and extension + +The components import `feedback.css` themselves. All selectors are scoped under `feedback-message`, so unrelated alerts and buttons keep their own styles. Wrapping text, logical spacing and native button sizing allow the same components to fit cards and full-page sections. + +Add feature wording and behavior through props. Change common spacing, colors or focus styles in the shared stylesheet. Keep weather types, API URLs and feature-specific state out of the standalone components. The adapter reads TanStack Query state directly; it does not duplicate that state in effects or component state. + +Component tests cover standalone use, keyboard actions, disabled actions, independent instances, custom empty-list rules, valid falsy data and disabled queries. The weather integration tests and Playwright suite cover the full request/retry lifecycle and accessibility checks. diff --git a/web/src/WeatherExample.tsx b/web/src/WeatherExample.tsx index 873a17b..2e3c417 100644 --- a/web/src/WeatherExample.tsx +++ b/web/src/WeatherExample.tsx @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query'; +import { QueryFeedback } from './components/feedback'; import { exampleCity } from './features/weather/cities'; import { weatherQuery } from './features/weather/weather'; @@ -14,38 +15,17 @@ export function WeatherExample() {

Været i {exampleCity.name}

- {weather.isFetching &&

Henter værdata…

} - {weather.isPaused && ( -

- Du er frakoblet. Venter på nettforbindelse for å hente værdata. -

- )} - {weather.isError && !weather.isFetching && !weather.isPaused && ( -

- Kunne ikke hente værdata. Sjekk nettforbindelsen din eller prøv - igjen om litt. -

- )} - {weather.isSuccess && - !weather.data && - !weather.isFetching && - !weather.isPaused && ( -

- Ingen værdata er tilgjengelige for {exampleCity.name} akkurat nå. - Prøv igjen om litt. -

- )} - {(weather.isError || (weather.isSuccess && !weather.data)) && ( - - )} + {weather.data && ( <>
diff --git a/web/src/components/feedback/FeedbackMessage.test.tsx b/web/src/components/feedback/FeedbackMessage.test.tsx new file mode 100644 index 0000000..45037ab --- /dev/null +++ b/web/src/components/feedback/FeedbackMessage.test.tsx @@ -0,0 +1,79 @@ +import { expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + EmptyMessage, + ErrorMessage, + LoadingMessage, + OfflineMessage, +} from './FeedbackMessage'; + +it('renders standalone status messages without a query provider', () => { + render( + <> + + + , + ); + const statuses = screen.getAllByRole('status'); + expect(statuses[0]).toHaveTextContent('Henter favoritter…'); + expect(statuses[1]).toHaveTextContent('Du er frakoblet.'); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); +}); + +it('provides an accessible error with a keyboard action outside the live region', async () => { + const onRetry = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + const alert = screen.getByRole('alert'); + const button = screen.getByRole('button', { name: 'Prøv Bergen igjen' }); + expect(alert).toHaveTextContent('Noe gikk galt. Prøv igjen om litt.'); + expect(alert).not.toContainElement(button); + expect(button).toHaveAttribute('type', 'button'); + await user.tab(); + expect(button).toHaveFocus(); + await user.keyboard('{Enter}'); + expect(onRetry).toHaveBeenCalledOnce(); +}); + +it('supports a custom empty-result action and prevents disabled actions', async () => { + const onReset = vi.fn(); + const action = { label: 'Nullstill filtre', onClick: onReset }; + const { rerender } = render( + , + ); + expect(screen.getByRole('status')).toHaveTextContent( + 'Ingen byer passer filtrene dine.', + ); + await userEvent.click(screen.getByRole('button', { name: action.label })); + expect(onReset).toHaveBeenCalledOnce(); + rerender(); + expect(screen.getByRole('button', { name: action.label })).toBeDisabled(); + await userEvent.click(screen.getByRole('button', { name: action.label })); + expect(onReset).toHaveBeenCalledOnce(); +}); + +it('keeps messages and actions independent when several cards need feedback', async () => { + const retryBergen = vi.fn(); + const retryOslo = vi.fn(); + render( + <> + + + , + ); + expect(screen.getAllByRole('alert')).toHaveLength(2); + await userEvent.click( + screen.getByRole('button', { name: 'Prøv Bergen igjen' }), + ); + expect(retryBergen).toHaveBeenCalledOnce(); + expect(retryOslo).not.toHaveBeenCalled(); +}); diff --git a/web/src/components/feedback/FeedbackMessage.tsx b/web/src/components/feedback/FeedbackMessage.tsx new file mode 100644 index 0000000..10101e9 --- /dev/null +++ b/web/src/components/feedback/FeedbackMessage.tsx @@ -0,0 +1,69 @@ +import './feedback.css'; + +export type FeedbackAction = { + label: string; + onClick: () => void; + disabled?: boolean; +}; + +export type FeedbackMessageProps = { + message?: string | undefined; + action?: FeedbackAction | undefined; +}; + +type FeedbackKind = 'loading' | 'error' | 'empty' | 'offline'; + +function FeedbackMessage({ + kind, + message, + action, +}: FeedbackMessageProps & { kind: FeedbackKind; message: string }) { + return ( +
+

+ {message} +

+ {action && ( + + )} +
+ ); +} + +export function LoadingMessage({ + message = 'Laster…', + action, +}: FeedbackMessageProps) { + return ; +} + +export function ErrorMessage({ + message = 'Noe gikk galt. Prøv igjen om litt.', + action, +}: FeedbackMessageProps) { + return ; +} + +export function EmptyMessage({ + message = 'Ingen resultater.', + action, +}: FeedbackMessageProps) { + return ; +} + +export function OfflineMessage({ + message = 'Du er frakoblet. Venter på nettforbindelse.', + action, +}: FeedbackMessageProps) { + return ; +} diff --git a/web/src/components/feedback/QueryFeedback.test.tsx b/web/src/components/feedback/QueryFeedback.test.tsx new file mode 100644 index 0000000..9c865c3 --- /dev/null +++ b/web/src/components/feedback/QueryFeedback.test.tsx @@ -0,0 +1,89 @@ +import { expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + QueryClient, + QueryClientProvider, + useQuery, +} from '@tanstack/react-query'; +import { QueryFeedback } from './QueryFeedback'; + +function QueryExample({ + load, + isEmpty, + enabled = true, +}: { + load: () => Promise; + isEmpty?: (data: NonNullable) => boolean; + enabled?: boolean; +}) { + const query = useQuery({ + queryKey: ['feedback-example'], + queryFn: load, + enabled, + retry: false, + gcTime: 0, + }); + return ( + <> + + {query.isSuccess && ( + {JSON.stringify(query.data)} + )} + + ); +} + +it('supports list-specific emptiness and a manual retry through TanStack Query', async () => { + const load = vi + .fn<() => Promise>() + .mockResolvedValueOnce([]) + .mockResolvedValue(['Bergen']); + render( + + cities.length === 0} /> + , + ); + await screen.findByText('Ingen byer funnet.'); + await userEvent.click( + screen.getByRole('button', { name: 'Hent byer på nytt' }), + ); + await screen.findByText('["Bergen"]'); + expect(screen.queryByText('Ingen byer funnet.')).not.toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(load).toHaveBeenCalledTimes(2); +}); + +it.each([0, false, ''])( + 'does not mistake the valid value %j for an empty result', + async (value) => { + render( + + Promise.resolve(value)} /> + , + ); + expect(await screen.findByLabelText('Resultat')).toHaveTextContent( + JSON.stringify(value), + ); + expect(screen.queryByText('Ingen byer funnet.')).not.toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }, +); + +it('does not show a loading message or start a disabled query', () => { + const load = vi.fn<() => Promise>().mockResolvedValue([]); + render( + + + , + ); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(load).not.toHaveBeenCalled(); +}); diff --git a/web/src/components/feedback/QueryFeedback.tsx b/web/src/components/feedback/QueryFeedback.tsx new file mode 100644 index 0000000..adcba52 --- /dev/null +++ b/web/src/components/feedback/QueryFeedback.tsx @@ -0,0 +1,57 @@ +import type { UseQueryResult } from '@tanstack/react-query'; +import { + EmptyMessage, + ErrorMessage, + LoadingMessage, + OfflineMessage, + type FeedbackAction, +} from './FeedbackMessage'; + +export type QueryFeedbackProps = { + query: Pick< + UseQueryResult, + 'data' | 'status' | 'fetchStatus' | 'refetch' + >; + messages?: Partial>; + /** Null/undefined are always empty; supply domain rules such as list.length === 0. */ + isEmpty?: ((data: NonNullable) => boolean) | undefined; + retryLabel?: string; +}; + +/** Renders query feedback only; the feature owns its data view and query options. */ +export function QueryFeedback({ + query, + messages = {}, + isEmpty, + retryLabel = 'Prøv igjen', +}: QueryFeedbackProps) { + const empty = + query.status === 'success' && + (query.data == null || isEmpty?.(query.data) === true); + const action: FeedbackAction | undefined = + query.status === 'error' || empty + ? { + label: retryLabel, + disabled: query.fetchStatus !== 'idle', + onClick: () => { + void query.refetch(); + }, + } + : undefined; + + // Paused/fetching states take precedence over the result of an earlier request. + if (query.fetchStatus === 'paused') { + return ; + } + if (query.fetchStatus === 'fetching') { + return ; + } + if (query.status === 'error') { + return ; + } + if (empty) { + return ; + } + // Includes disabled queries (pending but idle), which are not loading. + return null; +} diff --git a/web/src/components/feedback/feedback.css b/web/src/components/feedback/feedback.css new file mode 100644 index 0000000..e997c76 --- /dev/null +++ b/web/src/components/feedback/feedback.css @@ -0,0 +1,37 @@ +.feedback-message { + margin-block: 1rem; + overflow-wrap: anywhere; +} + +.feedback-message__text { + margin: 0; +} + +.feedback-message--error .feedback-message__text { + padding: 0.75rem 1rem; + border-inline-start: 4px solid #9a491c; + background: #fff3ec; + color: #673514; +} + +.feedback-message__action { + margin-block-start: 0.75rem; + font: inherit; + min-height: 44px; + padding: 0.5rem 1rem; + background: #173d35; + color: white; + border: 0; + border-radius: 0.25rem; + cursor: pointer; +} + +.feedback-message__action:disabled { + opacity: 0.65; + cursor: wait; +} + +.feedback-message__action:focus-visible { + outline: 3px solid #9a491c; + outline-offset: 4px; +} diff --git a/web/src/components/feedback/index.ts b/web/src/components/feedback/index.ts new file mode 100644 index 0000000..66f48ab --- /dev/null +++ b/web/src/components/feedback/index.ts @@ -0,0 +1,9 @@ +export { + EmptyMessage, + ErrorMessage, + LoadingMessage, + OfflineMessage, + type FeedbackAction, + type FeedbackMessageProps, +} from './FeedbackMessage'; +export { QueryFeedback, type QueryFeedbackProps } from './QueryFeedback'; diff --git a/web/src/styles.css b/web/src/styles.css index 9656e8e..ed731af 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -40,26 +40,6 @@ dd { font-size: 1.5rem; font-weight: 600; } -button { - font: inherit; - min-height: 44px; - padding: 0.5rem 1rem; - background: #173d35; - color: white; - border: 0; - border-radius: 0.25rem; - cursor: pointer; -} -button:disabled { - opacity: 0.65; - cursor: wait; -} -[role='alert'] { - padding: 0.75rem 1rem; - border-left: 4px solid #9a491c; - background: #fff3ec; - color: #673514; -} a { color: inherit; text-underline-offset: 0.2em;