diff --git a/.gitattributes b/.gitattributes index 9932b43b..603183f1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,3 +11,9 @@ # Ignore massive diffs each time you add/update yarn plugins /web/.yarn/releases/** binary /web/.yarn/plugins/** binary + +# Generated files — hide from GitHub diffs/language stats and mark as not +# hand-edited. The TanStack Router Vite plugin owns routeTree.gen.ts; +# `@hey-api/openapi-ts` owns api-generated/. +/web/src/app/routeTree.gen.ts linguist-generated=true +/web/src/api-generated/** linguist-generated=true diff --git a/.mise/tasks/lint.toml b/.mise/tasks/lint.toml index 81e76e41..07ba4159 100644 --- a/.mise/tasks/lint.toml +++ b/.mise/tasks/lint.toml @@ -15,10 +15,17 @@ depends = ["install-dependencies:web"] dir = "{{config_root}}/web" run = "yarn compile" +["lint:web:query"] +description = "Lint TanStack Query usage in the Web application" +depends = ["install-dependencies:web"] +dir = "{{config_root}}/web" +run = "yarn lint:query" + ["lint:web"] description = "Lint the Web application" run = [ "mise run lint:web:typecheck", + "mise run lint:web:query", ] [lint] diff --git a/biome.json b/biome.json index 9c6973b8..19ea0f53 100644 --- a/biome.json +++ b/biome.json @@ -1,7 +1,14 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.9/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.14/schema.json", "files": { - "includes": ["web/**", "!web/build", "!web/src/api/generated"] + "includes": [ + "web/**", + "!web/build", + "!web/.yarn", + "!web/.pnp.*", + "!web/src/api-generated", + "!web/src/app/routeTree.gen.ts" + ] }, "javascript": { "formatter": { @@ -19,6 +26,11 @@ "indentWidth": 2 } }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, "linter": { "rules": { "a11y": { diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 035dd3cc..93f169ae 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -48,6 +48,16 @@ services: - .env environment: - NODE_ENV=development + # Vite reads VITE_-prefixed env vars at dev-server start. The Dockerfile + # bakes a default of 0 from the build arg; override it here so the SPA + # actually fetches /oauth2/userinfo instead of falling back to the + # local-developer anonymous user. + - VITE_AUTH=$AUTH_ENABLED + # Telemetry: SPA ships events to `/api/monitoring/v2/track` (see + # appInsightsProxyPath default) and the API re-exports them to Azure + # Monitor using its own APPINSIGHTS_CONSTRING. The SPA needs no + # connection string of its own — see web/src/config/env.ts. + - VITE_TELEMETRY=${VITE_TELEMETRY:-appinsights} db: volumes: diff --git a/web/.yarn/sdks/eslint/bin/eslint.js b/web/.yarn/sdks/eslint/bin/eslint.js new file mode 100755 index 00000000..e6604ff5 --- /dev/null +++ b/web/.yarn/sdks/eslint/bin/eslint.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +const {existsSync} = require(`fs`); +const {createRequire, register} = require(`module`); +const {resolve} = require(`path`); +const {pathToFileURL} = require(`url`); + +const relPnpApiPath = "../../../../.pnp.cjs"; + +const absPnpApiPath = resolve(__dirname, relPnpApiPath); +const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); +const absRequire = createRequire(absPnpApiPath); + +const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); +const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); + +if (existsSync(absPnpApiPath)) { + if (!process.versions.pnp) { + // Setup the environment to be able to require eslint/bin/eslint.js + require(absPnpApiPath).setup(); + if (isPnpLoaderEnabled && register) { + register(pathToFileURL(absPnpLoaderPath)); + } + } +} + +const wrapWithUserWrapper = existsSync(absUserWrapperPath) + ? exports => absRequire(absUserWrapperPath)(exports) + : exports => exports; + +// Defer to the real eslint/bin/eslint.js your application uses +module.exports = wrapWithUserWrapper(absRequire(`eslint/bin/eslint.js`)); diff --git a/web/.yarn/sdks/eslint/package.json b/web/.yarn/sdks/eslint/package.json new file mode 100644 index 00000000..f5d1cabf --- /dev/null +++ b/web/.yarn/sdks/eslint/package.json @@ -0,0 +1,31 @@ +{ + "name": "eslint", + "version": "10.3.0-sdk", + "main": "./lib/api.js", + "type": "commonjs", + "bin": { + "eslint": "./bin/eslint.js" + }, + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/api.js" + }, + "./config": { + "types": "./lib/types/config-api.d.ts", + "default": "./lib/config-api.js" + }, + "./package.json": "./package.json", + "./use-at-your-own-risk": { + "types": "./lib/types/use-at-your-own-risk.d.ts", + "default": "./lib/unsupported-api.js" + }, + "./rules": { + "types": "./lib/types/rules.d.ts" + }, + "./universal": { + "types": "./lib/types/universal.d.ts", + "default": "./lib/universal.js" + } + } +} diff --git a/web/.yarn/sdks/integrations.yml b/web/.yarn/sdks/integrations.yml new file mode 100644 index 00000000..aa9d0d0a --- /dev/null +++ b/web/.yarn/sdks/integrations.yml @@ -0,0 +1,5 @@ +# This file is automatically generated by @yarnpkg/sdks. +# Manual changes might be lost! + +integrations: + - vscode diff --git a/web/.yarn/sdks/typescript/bin/tsc b/web/.yarn/sdks/typescript/bin/tsc new file mode 100755 index 00000000..867a7bdf --- /dev/null +++ b/web/.yarn/sdks/typescript/bin/tsc @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +const {existsSync} = require(`fs`); +const {createRequire, register} = require(`module`); +const {resolve} = require(`path`); +const {pathToFileURL} = require(`url`); + +const relPnpApiPath = "../../../../.pnp.cjs"; + +const absPnpApiPath = resolve(__dirname, relPnpApiPath); +const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); +const absRequire = createRequire(absPnpApiPath); + +const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); +const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); + +if (existsSync(absPnpApiPath)) { + if (!process.versions.pnp) { + // Setup the environment to be able to require typescript/bin/tsc + require(absPnpApiPath).setup(); + if (isPnpLoaderEnabled && register) { + register(pathToFileURL(absPnpLoaderPath)); + } + } +} + +const wrapWithUserWrapper = existsSync(absUserWrapperPath) + ? exports => absRequire(absUserWrapperPath)(exports) + : exports => exports; + +// Defer to the real typescript/bin/tsc your application uses +module.exports = wrapWithUserWrapper(absRequire(`typescript/bin/tsc`)); diff --git a/web/.yarn/sdks/typescript/bin/tsserver b/web/.yarn/sdks/typescript/bin/tsserver new file mode 100755 index 00000000..3fc5aa31 --- /dev/null +++ b/web/.yarn/sdks/typescript/bin/tsserver @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +const {existsSync} = require(`fs`); +const {createRequire, register} = require(`module`); +const {resolve} = require(`path`); +const {pathToFileURL} = require(`url`); + +const relPnpApiPath = "../../../../.pnp.cjs"; + +const absPnpApiPath = resolve(__dirname, relPnpApiPath); +const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); +const absRequire = createRequire(absPnpApiPath); + +const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); +const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); + +if (existsSync(absPnpApiPath)) { + if (!process.versions.pnp) { + // Setup the environment to be able to require typescript/bin/tsserver + require(absPnpApiPath).setup(); + if (isPnpLoaderEnabled && register) { + register(pathToFileURL(absPnpLoaderPath)); + } + } +} + +const wrapWithUserWrapper = existsSync(absUserWrapperPath) + ? exports => absRequire(absUserWrapperPath)(exports) + : exports => exports; + +// Defer to the real typescript/bin/tsserver your application uses +module.exports = wrapWithUserWrapper(absRequire(`typescript/bin/tsserver`)); diff --git a/web/.yarn/sdks/typescript/package.json b/web/.yarn/sdks/typescript/package.json new file mode 100644 index 00000000..2f369936 --- /dev/null +++ b/web/.yarn/sdks/typescript/package.json @@ -0,0 +1,10 @@ +{ + "name": "typescript", + "version": "6.0.3-sdk", + "main": "./lib/typescript.js", + "type": "commonjs", + "bin": { + "tsc": "./bin/tsc", + "tsserver": "./bin/tsserver" + } +} diff --git a/web/.yarnrc.yml b/web/.yarnrc.yml index 5c750000..956fdce4 100644 --- a/web/.yarnrc.yml +++ b/web/.yarnrc.yml @@ -1,2 +1,3 @@ -nodeLinker: pnp enableGlobalCache: true + +nodeLinker: pnp diff --git a/web/eslint.config.mts b/web/eslint.config.mts new file mode 100644 index 00000000..bb5baef0 --- /dev/null +++ b/web/eslint.config.mts @@ -0,0 +1,119 @@ +// Minimal ESLint flat config — runs ONLY @tanstack/eslint-plugin-query rules +// alongside Biome. Biome remains the formatter and primary linter; this file +// hosts the TanStack-specific rules Biome cannot express +// (queryKey-aware exhaustive-deps, prefer-query-options, mutation/infinite +// property order, etc.). +// +// Run with: `yarn lint:query` (or via mise: `mise run lint:web:query`). + +import pluginQuery from '@tanstack/eslint-plugin-query' +import tseslint from 'typescript-eslint' + +const recommended = pluginQuery.configs['flat/recommended'] +const recommendedRules = {} +for (const c of Array.isArray(recommended) ? recommended : [recommended]) { + Object.assign(recommendedRules, c.rules ?? {}) +} + +export default [ + { + ignores: ['src/api-generated/**', 'build/**', 'node_modules/**', '.yarn/**'], + }, + { + files: ['src/**/*.{ts,tsx}'], + // We only enable the @tanstack/query rules — silence reports about + // pre-existing `eslint-disable` directives for rules we don't load. + linterOptions: { + reportUnusedDisableDirectives: 'off', + }, + languageOptions: { + parser: tseslint.parser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { jsx: true }, + }, + }, + plugins: { + '@tanstack/query': pluginQuery, + }, + rules: { + ...recommendedRules, + // Feature-slice boundary: outside code may only import a feature + // through its public barrel (`@/features/`), never deep paths + // like `@/features//api/...`. Inside-feature code uses + // relative paths and is exempted by the override below. + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@/features/*/*'], + message: + 'Import from the feature barrel (`@/features/`), not its internals. ' + + 'If you need something not exported, add it to the barrel.', + }, + { + group: ['@/shared/platform/*/*'], + message: + 'Import from the platform module barrel (`@/shared/platform/`), not its ' + + 'internals. If you need something not exported, add it to the barrel.', + }, + ], + }, + ], + }, + }, + { + // Inside a feature, deep imports are fine — but prefer relative paths + // (the rule still nudges that direction by allowing only relatives here). + files: ['src/features/*/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': 'off', + }, + }, + { + // Layering: `shared/` is a downstream layer. It must not depend on + // `features/` or `app/` — that would create a cycle (features import + // shared; app composes both). Pure utilities only. + files: ['src/shared/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@/features/*', '@/features/*/*', '@/app/*', '@/app/*/*'], + message: + '`shared/` must not import from `features/` or `app/`. ' + + 'Move the symbol down to `shared/` or invert the dependency.', + }, + ], + }, + ], + }, + }, + { + // Layering: `config/` may import `features/*` *barrels* (e.g. + // `accessControl.ts` types its `Permissions` map against `Todo` from + // `@/features/todos`). It must not depend on `app/`. + files: ['src/config/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@/app/*', '@/app/*/*'], + message: '`config/` must not import from `app/`.', + }, + { + group: ['@/features/*/*'], + message: 'Import from the feature barrel (`@/features/`), not its internals.', + }, + ], + }, + ], + }, + }, +] diff --git a/web/index.html b/web/index.html index 91a4835b..3b3d32d4 100644 --- a/web/index.html +++ b/web/index.html @@ -14,6 +14,16 @@
+ diff --git a/web/openapi-ts.config.ts b/web/openapi-ts.config.ts index ba309431..ee89898b 100644 --- a/web/openapi-ts.config.ts +++ b/web/openapi-ts.config.ts @@ -2,12 +2,14 @@ import type { UserConfig } from '@hey-api/openapi-ts' export default { input: '../api/.openapi.json', - output: './src/api/generated', + output: './src/api-generated', plugins: [ { name: '@hey-api/client-fetch', }, '@hey-api/typescript', '@hey-api/sdk', + '@tanstack/react-query', + 'zod', ], } satisfies UserConfig diff --git a/web/package.json b/web/package.json index c10faf5e..374a14a1 100644 --- a/web/package.json +++ b/web/package.json @@ -3,45 +3,69 @@ "version": "1.5.0", "private": true, "scripts": { + "preinstall": "npx -y only-allow yarn", "start": "vite", "build": "tsc && vite build", "serve": "vite preview", "compile": "tsc --noEmit", "generate": "openapi-ts", "test": "vitest", - "lint": "biome check --write --no-errors-on-unmatched" + "lint": "biome check --write --no-errors-on-unmatched", + "lint:query": "eslint 'src/**/*.{ts,tsx}'" }, "dependencies": { "@equinor/eds-core-react": "^2.4.1", "@equinor/eds-icons": "^1.3.0", + "@equinor/eds-tokens": "^2.2.0", + "@hookform/resolvers": "^5.2.2", + "@microsoft/applicationinsights-react-js": "^19.4.0", + "@microsoft/applicationinsights-web": "^3.4.1", + "@tailwindcss/vite": "^4.2.4", + "@tanstack/react-query": "^5.100.9", + "@tanstack/react-query-devtools": "^5.100.9", + "@tanstack/react-router": "^1.95.0", + "clsx": "^2.1.1", "react": "^19.2.5", "react-dom": "^19.2.5", - "react-oauth2-code-pkce": "^1.24.0", - "react-router-dom": "^7.14.2", - "styled-components": "^6.4.1" + "react-hook-form": "^7.75.0", + "styled-components": "^6.4.1", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.4", + "zod": "^4.4.3" }, "devDependencies": { "@biomejs/biome": "^2.4.14", + "@faker-js/faker": "^9.0.0", "@hey-api/openapi-ts": "0.97.1", + "@tanstack/eslint-plugin-query": "^5.100.9", + "@tanstack/router-devtools": "^1.95.0", + "@tanstack/router-plugin": "^1.95.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^25.5.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "jiti": "^2.6.1", "jsdom": "^29.1.1", + "msw": "^2.14.2", "typescript": "~6.0.3", + "typescript-eslint": "^8.59.2", "vite": "^8.0.10", "vite-plugin-checker": "^0.13.0", "vite-plugin-csp-guard": "^4.0.1", "vite-plugin-svgr": "^5.2.0", - "vite-tsconfig-paths": "^6.1.1", "vitest": "^4.1.5" }, - "packageManager": "yarn@4.12.0", + "packageManager": "yarn@4.14.0", "dependenciesMeta": { - "@hey-api/openapi-ts@0.96.0": { + "@equinor/eds-core-react@2.5.0": { + "unplugged": true + }, + "@hey-api/openapi-ts@0.97.1": { "unplugged": true } } diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx deleted file mode 100644 index 032146f6..00000000 --- a/web/src/App.test.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { render, screen } from '@testing-library/react' -import App from './App' - -test('renders without crashing', () => { - render() -}) -test('has an input field', () => { - render() - expect(screen.getByPlaceholderText('Add Task')).toBeDefined() -}) -test('has an Add button', () => { - render() - expect(screen.getByText('Add')).toBeDefined() -}) diff --git a/web/src/App.tsx b/web/src/App.tsx deleted file mode 100644 index ea229b28..00000000 --- a/web/src/App.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { Button, Progress, Typography } from '@equinor/eds-core-react' -import { useContext, useEffect } from 'react' -import { AuthContext } from 'react-oauth2-code-pkce' -import { RouterProvider } from 'react-router-dom' -import styled from 'styled-components' -import { client } from './api/generated/client.gen' -import Header from './common/components/Header' -import { router } from './router' - -const hasAuthConfig = import.meta.env.VITE_AUTH === '1' - -const CenterContainer = styled.div` - display: flex; - gap: 12px; - flex-direction: column; - justify-content: center; - align-items: center; - width: 100vw; - height: 100vh; -` - -function App() { - const { token, error, logIn, loginInProgress } = useContext(AuthContext) - - useEffect(() => { - client.setConfig({ - auth: hasAuthConfig && token ? token : undefined, - }) - }, [token]) - - if (hasAuthConfig && error) { - return {error} - } - - if (hasAuthConfig && loginInProgress) { - return ( - - Login in progress. - - - ) - } - - if (hasAuthConfig && !token) { - return ( - - - - ) - } - - return ( - <> -
- - - ) -} - -export default App diff --git a/web/src/api-generated/@tanstack/react-query.gen.ts b/web/src/api-generated/@tanstack/react-query.gen.ts new file mode 100644 index 00000000..1b6358e1 --- /dev/null +++ b/web/src/api-generated/@tanstack/react-query.gen.ts @@ -0,0 +1,180 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { queryOptions, type UseMutationOptions } from '@tanstack/react-query'; + +import { client } from '../client.gen'; +import { createTodo, deleteTodoById, getAllTodos, getHealthCheckGet, getTodoById, type Options, track, updateTodoById, whoami } from '../sdk.gen'; +import type { CreateTodoData, CreateTodoError, CreateTodoResponse, DeleteTodoByIdData, DeleteTodoByIdError, DeleteTodoByIdResponse2, GetAllTodosData, GetAllTodosError, GetAllTodosResponse, GetHealthCheckGetData, GetHealthCheckGetError, GetHealthCheckGetResponse, GetTodoByIdData, GetTodoByIdError, GetTodoByIdResponse2, TrackData, TrackError, TrackResponse, UpdateTodoByIdData, UpdateTodoByIdError, UpdateTodoByIdResponse, WhoamiData, WhoamiError, WhoamiResponse } from '../types.gen'; + +/** + * Track + */ +export const trackMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await track({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +export type QueryKey = [ + Pick & { + _id: string; + _infinite?: boolean; + tags?: ReadonlyArray; + } +]; + +const createQueryKey = (id: string, options?: TOptions, infinite?: boolean, tags?: ReadonlyArray): [ + QueryKey[0] +] => { + const params: QueryKey[0] = { _id: id, baseUrl: options?.baseUrl || (options?.client ?? client).getConfig().baseUrl } as QueryKey[0]; + if (infinite) { + params._infinite = infinite; + } + if (tags) { + params.tags = tags; + } + if (options?.body) { + params.body = options.body; + } + if (options?.headers) { + params.headers = options.headers; + } + if (options?.path) { + params.path = options.path; + } + if (options?.query) { + params.query = options.query; + } + return [params]; +}; + +export const getAllTodosQueryKey = (options?: Options) => createQueryKey('getAllTodos', options); + +/** + * Get Todo All + */ +export const getAllTodosOptions = (options?: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getAllTodos({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getAllTodosQueryKey(options) +}); + +/** + * Add Todo + */ +export const createTodoMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await createTodo({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +/** + * Delete Todo By Id + */ +export const deleteTodoByIdMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await deleteTodoById({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +export const getTodoByIdQueryKey = (options: Options) => createQueryKey('getTodoById', options); + +/** + * Get Todo By Id + */ +export const getTodoByIdOptions = (options: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getTodoById({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getTodoByIdQueryKey(options) +}); + +/** + * Update Todo + */ +export const updateTodoByIdMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await updateTodoById({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +export const whoamiQueryKey = (options?: Options) => createQueryKey('whoami', options); + +/** + * Get Information On Authenticated User + */ +export const whoamiOptions = (options?: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await whoami({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: whoamiQueryKey(options) +}); + +export const getHealthCheckGetQueryKey = (options?: Options) => createQueryKey('getHealthCheckGet', options); + +/** + * Get + */ +export const getHealthCheckGetOptions = (options?: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getHealthCheckGet({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getHealthCheckGetQueryKey(options) +}); diff --git a/web/src/api/generated/client.gen.ts b/web/src/api-generated/client.gen.ts similarity index 100% rename from web/src/api/generated/client.gen.ts rename to web/src/api-generated/client.gen.ts diff --git a/web/src/api/generated/client/client.gen.ts b/web/src/api-generated/client/client.gen.ts similarity index 100% rename from web/src/api/generated/client/client.gen.ts rename to web/src/api-generated/client/client.gen.ts diff --git a/web/src/api/generated/client/index.ts b/web/src/api-generated/client/index.ts similarity index 100% rename from web/src/api/generated/client/index.ts rename to web/src/api-generated/client/index.ts diff --git a/web/src/api/generated/client/types.gen.ts b/web/src/api-generated/client/types.gen.ts similarity index 100% rename from web/src/api/generated/client/types.gen.ts rename to web/src/api-generated/client/types.gen.ts diff --git a/web/src/api/generated/client/utils.gen.ts b/web/src/api-generated/client/utils.gen.ts similarity index 100% rename from web/src/api/generated/client/utils.gen.ts rename to web/src/api-generated/client/utils.gen.ts diff --git a/web/src/api/generated/core/auth.gen.ts b/web/src/api-generated/core/auth.gen.ts similarity index 100% rename from web/src/api/generated/core/auth.gen.ts rename to web/src/api-generated/core/auth.gen.ts diff --git a/web/src/api/generated/core/bodySerializer.gen.ts b/web/src/api-generated/core/bodySerializer.gen.ts similarity index 100% rename from web/src/api/generated/core/bodySerializer.gen.ts rename to web/src/api-generated/core/bodySerializer.gen.ts diff --git a/web/src/api/generated/core/params.gen.ts b/web/src/api-generated/core/params.gen.ts similarity index 100% rename from web/src/api/generated/core/params.gen.ts rename to web/src/api-generated/core/params.gen.ts diff --git a/web/src/api/generated/core/pathSerializer.gen.ts b/web/src/api-generated/core/pathSerializer.gen.ts similarity index 100% rename from web/src/api/generated/core/pathSerializer.gen.ts rename to web/src/api-generated/core/pathSerializer.gen.ts diff --git a/web/src/api/generated/core/queryKeySerializer.gen.ts b/web/src/api-generated/core/queryKeySerializer.gen.ts similarity index 100% rename from web/src/api/generated/core/queryKeySerializer.gen.ts rename to web/src/api-generated/core/queryKeySerializer.gen.ts diff --git a/web/src/api/generated/core/serverSentEvents.gen.ts b/web/src/api-generated/core/serverSentEvents.gen.ts similarity index 100% rename from web/src/api/generated/core/serverSentEvents.gen.ts rename to web/src/api-generated/core/serverSentEvents.gen.ts diff --git a/web/src/api/generated/core/types.gen.ts b/web/src/api-generated/core/types.gen.ts similarity index 100% rename from web/src/api/generated/core/types.gen.ts rename to web/src/api-generated/core/types.gen.ts diff --git a/web/src/api/generated/core/utils.gen.ts b/web/src/api-generated/core/utils.gen.ts similarity index 100% rename from web/src/api/generated/core/utils.gen.ts rename to web/src/api-generated/core/utils.gen.ts diff --git a/web/src/api-generated/index.ts b/web/src/api-generated/index.ts new file mode 100644 index 00000000..f5827d67 --- /dev/null +++ b/web/src/api-generated/index.ts @@ -0,0 +1,4 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export { createTodo, deleteTodoById, getAllTodos, getHealthCheckGet, getTodoById, type Options, track, updateTodoById, whoami } from './sdk.gen'; +export type { AccessLevel, AddTodoRequest, AddTodoResponse, ClientOptions, CreateTodoData, CreateTodoError, CreateTodoErrors, CreateTodoResponse, CreateTodoResponses, DeleteTodoByIdData, DeleteTodoByIdError, DeleteTodoByIdErrors, DeleteTodoByIdResponse, DeleteTodoByIdResponse2, DeleteTodoByIdResponses, ErrorResponse, Event, EventBase, EventData, ExceptionData, GetAllTodosData, GetAllTodosError, GetAllTodosErrors, GetAllTodosResponse, GetAllTodosResponses, GetHealthCheckGetData, GetHealthCheckGetError, GetHealthCheckGetErrors, GetHealthCheckGetResponse, GetHealthCheckGetResponses, GetTodoAllResponse, GetTodoByIdData, GetTodoByIdError, GetTodoByIdErrors, GetTodoByIdResponse, GetTodoByIdResponse2, GetTodoByIdResponses, MetricsData, TelemetryErrorDetails, TelemetryResult, TrackData, TrackError, TrackErrors, TrackResponse, TrackResponses, UpdateTodoByIdData, UpdateTodoByIdError, UpdateTodoByIdErrors, UpdateTodoByIdResponse, UpdateTodoByIdResponses, UpdateTodoRequest, UpdateTodoResponse, User, WhoamiData, WhoamiError, WhoamiErrors, WhoamiResponse, WhoamiResponses } from './types.gen'; diff --git a/web/src/api/generated/sdk.gen.ts b/web/src/api-generated/sdk.gen.ts similarity index 85% rename from web/src/api/generated/sdk.gen.ts rename to web/src/api-generated/sdk.gen.ts index ba420e60..530c9243 100644 --- a/web/src/api/generated/sdk.gen.ts +++ b/web/src/api-generated/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { CreateTodoData, CreateTodoErrors, CreateTodoResponses, DeleteTodoByIdData, DeleteTodoByIdErrors, DeleteTodoByIdResponses, GetAllTodosData, GetAllTodosErrors, GetAllTodosResponses, GetHealthCheckGetData, GetHealthCheckGetErrors, GetHealthCheckGetResponses, GetTodoByIdData, GetTodoByIdErrors, GetTodoByIdResponses, UpdateTodoByIdData, UpdateTodoByIdErrors, UpdateTodoByIdResponses, WhoamiData, WhoamiErrors, WhoamiResponses } from './types.gen'; +import type { CreateTodoData, CreateTodoErrors, CreateTodoResponses, DeleteTodoByIdData, DeleteTodoByIdErrors, DeleteTodoByIdResponses, GetAllTodosData, GetAllTodosErrors, GetAllTodosResponses, GetHealthCheckGetData, GetHealthCheckGetErrors, GetHealthCheckGetResponses, GetTodoByIdData, GetTodoByIdErrors, GetTodoByIdResponses, TrackData, TrackErrors, TrackResponses, UpdateTodoByIdData, UpdateTodoByIdErrors, UpdateTodoByIdResponses, WhoamiData, WhoamiErrors, WhoamiResponses } from './types.gen'; export type Options = Options2 & { /** @@ -18,6 +18,19 @@ export type Options; }; +/** + * Track + */ +export const track = (options: Options) => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/monitoring/v2/track', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + /** * Get Todo All */ diff --git a/web/src/api/generated/types.gen.ts b/web/src/api-generated/types.gen.ts similarity index 73% rename from web/src/api/generated/types.gen.ts rename to web/src/api-generated/types.gen.ts index adbdf10f..497b5fd6 100644 --- a/web/src/api/generated/types.gen.ts +++ b/web/src/api-generated/types.gen.ts @@ -27,6 +27,10 @@ export type AddTodoResponse = { * Id */ id: string; + /** + * User Id + */ + user_id: string; /** * Title */ @@ -75,6 +79,93 @@ export type ErrorResponse = { } | null; }; +/** + * Event + */ +export type Event = { + /** + * Name + */ + name: string; + /** + * Tags + */ + tags: { + [key: string]: string; + }; + /** + * Time + */ + time: string; + data: EventBase; +}; + +/** + * EventBase + */ +export type EventBase = { + /** + * Basetype + */ + baseType: string; + /** + * Basedata + */ + baseData: EventData | MetricsData | ExceptionData; +}; + +/** + * EventData + */ +export type EventData = { + /** + * Ver + */ + ver: number; + /** + * Properties + */ + properties: { + [key: string]: unknown; + }; + /** + * Name + */ + name: string; + /** + * Url + */ + url?: string; + /** + * Measurements + */ + measurements?: { + [key: string]: number; + }; +}; + +/** + * ExceptionData + */ +export type ExceptionData = { + /** + * Ver + */ + ver: number; + /** + * Exceptions + */ + exceptions: Array<{ + [key: string]: unknown; + }>; + /** + * Properties + */ + properties: { + [key: string]: unknown; + }; +}; + /** * GetTodoAllResponse */ @@ -83,6 +174,10 @@ export type GetTodoAllResponse = { * Id */ id: string; + /** + * User Id + */ + user_id: string; /** * Title */ @@ -101,6 +196,10 @@ export type GetTodoByIdResponse = { * Id */ id: string; + /** + * User Id + */ + user_id: string; /** * Title */ @@ -111,6 +210,64 @@ export type GetTodoByIdResponse = { is_completed?: boolean; }; +/** + * MetricsData + */ +export type MetricsData = { + /** + * Ver + */ + ver: number; + /** + * Metrics + */ + metrics: Array<{ + [key: string]: unknown; + }>; + /** + * Properties + */ + properties: { + [key: string]: unknown; + }; +}; + +/** + * TelemetryErrorDetails + */ +export type TelemetryErrorDetails = { + /** + * Index + */ + index: number; + /** + * Statuscode + */ + statusCode: number; + /** + * Message + */ + message: string; +}; + +/** + * TelemetryResult + */ +export type TelemetryResult = { + /** + * Items Received + */ + items_received: number; + /** + * Items Accepted + */ + items_accepted: number; + /** + * Errors + */ + errors: Array; +}; + /** * UpdateTodoRequest */ @@ -158,6 +315,56 @@ export type User = { scope?: AccessLevel; }; +export type TrackData = { + /** + * Events + */ + body: Array; + path?: never; + query?: never; + url: '/monitoring/v2/track'; +}; + +export type TrackErrors = { + /** + * Bad Request + */ + 400: ErrorResponse; + /** + * Unauthorized + */ + 401: ErrorResponse; + /** + * Forbidden + */ + 403: ErrorResponse; + /** + * Not Found + */ + 404: ErrorResponse; + /** + * Unprocessable Content + */ + 422: ErrorResponse; + /** + * Internal Server Error + */ + 500: ErrorResponse; +}; + +export type TrackError = TrackErrors[keyof TrackErrors]; + +export type TrackResponses = { + /** + * Response Track + * + * Successful Response + */ + 200: TelemetryResult | null; +}; + +export type TrackResponse = TrackResponses[keyof TrackResponses]; + export type GetAllTodosData = { body?: never; path?: never; diff --git a/web/src/api-generated/zod.gen.ts b/web/src/api-generated/zod.gen.ts new file mode 100644 index 00000000..944edd17 --- /dev/null +++ b/web/src/api-generated/zod.gen.ts @@ -0,0 +1,229 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod'; + +/** + * AccessLevel + */ +export const zAccessLevel = z.union([ + z.literal(2), + z.literal(1), + z.literal(0) +]); + +/** + * AddTodoRequest + */ +export const zAddTodoRequest = z.object({ + title: z.string().min(1).max(300) +}); + +/** + * AddTodoResponse + */ +export const zAddTodoResponse = z.object({ + id: z.string(), + user_id: z.string(), + title: z.string(), + is_completed: z.boolean().optional().default(false) +}); + +/** + * DeleteTodoByIdResponse + */ +export const zDeleteTodoByIdResponse = z.object({ + success: z.boolean() +}); + +/** + * ErrorResponse + */ +export const zErrorResponse = z.object({ + status: z.int().optional().default(500), + type: z.string().optional().default('ApplicationException'), + message: z.string().optional().default('The requested operation failed'), + debug: z.string().optional().default('An unknown and unhandled exception occurred in the API'), + extra: z.record(z.string(), z.unknown()).nullish() +}); + +/** + * EventData + */ +export const zEventData = z.object({ + ver: z.int(), + properties: z.record(z.string(), z.unknown()), + name: z.string(), + url: z.string().optional(), + measurements: z.record(z.string(), z.number()).optional() +}); + +/** + * ExceptionData + */ +export const zExceptionData = z.object({ + ver: z.int(), + exceptions: z.array(z.record(z.string(), z.unknown())), + properties: z.record(z.string(), z.unknown()) +}); + +/** + * GetTodoAllResponse + */ +export const zGetTodoAllResponse = z.object({ + id: z.string(), + user_id: z.string(), + title: z.string(), + is_completed: z.boolean() +}); + +/** + * GetTodoByIdResponse + */ +export const zGetTodoByIdResponse = z.object({ + id: z.string(), + user_id: z.string(), + title: z.string(), + is_completed: z.boolean().optional().default(false) +}); + +/** + * MetricsData + */ +export const zMetricsData = z.object({ + ver: z.int(), + metrics: z.array(z.record(z.string(), z.unknown())), + properties: z.record(z.string(), z.unknown()) +}); + +/** + * EventBase + */ +export const zEventBase = z.object({ + baseType: z.string(), + baseData: z.union([ + zEventData, + zMetricsData, + zExceptionData + ]) +}); + +/** + * Event + */ +export const zEvent = z.object({ + name: z.string(), + tags: z.record(z.string(), z.string()), + time: z.iso.datetime(), + data: zEventBase +}); + +/** + * TelemetryErrorDetails + */ +export const zTelemetryErrorDetails = z.object({ + index: z.int(), + statusCode: z.int(), + message: z.string() +}); + +/** + * TelemetryResult + */ +export const zTelemetryResult = z.object({ + items_received: z.int(), + items_accepted: z.int(), + errors: z.array(zTelemetryErrorDetails) +}); + +/** + * UpdateTodoRequest + */ +export const zUpdateTodoRequest = z.object({ + title: z.string().min(1).max(300).optional().default(''), + is_completed: z.boolean() +}); + +/** + * UpdateTodoResponse + */ +export const zUpdateTodoResponse = z.object({ + success: z.boolean() +}); + +/** + * User + */ +export const zUser = z.object({ + user_id: z.string(), + email: z.string().nullish(), + full_name: z.string().nullish(), + roles: z.array(z.string()).optional().default([]), + scope: zAccessLevel.optional().default(2) +}); + +/** + * Events + */ +export const zTrackBody = z.array(zEvent); + +/** + * Response Track + * + * Successful Response + */ +export const zTrackResponse = zTelemetryResult.nullable(); + +/** + * Response Getalltodos + * + * Successful Response + */ +export const zGetAllTodosResponse = z.array(zGetTodoAllResponse); + +export const zCreateTodoBody = zAddTodoRequest; + +/** + * Successful Response + */ +export const zCreateTodoResponse = zAddTodoResponse; + +export const zDeleteTodoByIdPath = z.object({ + id: z.string() +}); + +/** + * Successful Response + */ +export const zDeleteTodoByIdResponse2 = zDeleteTodoByIdResponse; + +export const zGetTodoByIdPath = z.object({ + id: z.string() +}); + +/** + * Successful Response + */ +export const zGetTodoByIdResponse2 = zGetTodoByIdResponse; + +export const zUpdateTodoByIdBody = zUpdateTodoRequest; + +export const zUpdateTodoByIdPath = z.object({ + id: z.string() +}); + +/** + * Successful Response + */ +export const zUpdateTodoByIdResponse = zUpdateTodoResponse; + +/** + * Successful Response + */ +export const zWhoamiResponse = zUser; + +/** + * Response 200 Get Health Check Get + * + * Successful Response + */ +export const zGetHealthCheckGetResponse = z.string(); diff --git a/web/src/api/generated/index.ts b/web/src/api/generated/index.ts deleted file mode 100644 index c7738d6a..00000000 --- a/web/src/api/generated/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export { createTodo, deleteTodoById, getAllTodos, getHealthCheckGet, getTodoById, type Options, updateTodoById, whoami } from './sdk.gen'; -export type { AccessLevel, AddTodoRequest, AddTodoResponse, ClientOptions, CreateTodoData, CreateTodoError, CreateTodoErrors, CreateTodoResponse, CreateTodoResponses, DeleteTodoByIdData, DeleteTodoByIdError, DeleteTodoByIdErrors, DeleteTodoByIdResponse, DeleteTodoByIdResponse2, DeleteTodoByIdResponses, ErrorResponse, GetAllTodosData, GetAllTodosError, GetAllTodosErrors, GetAllTodosResponse, GetAllTodosResponses, GetHealthCheckGetData, GetHealthCheckGetError, GetHealthCheckGetErrors, GetHealthCheckGetResponse, GetHealthCheckGetResponses, GetTodoAllResponse, GetTodoByIdData, GetTodoByIdError, GetTodoByIdErrors, GetTodoByIdResponse, GetTodoByIdResponse2, GetTodoByIdResponses, UpdateTodoByIdData, UpdateTodoByIdError, UpdateTodoByIdErrors, UpdateTodoByIdResponse, UpdateTodoByIdResponses, UpdateTodoRequest, UpdateTodoResponse, User, WhoamiData, WhoamiError, WhoamiErrors, WhoamiResponse, WhoamiResponses } from './types.gen'; diff --git a/web/src/app/auth/SessionExpiredDialog/SessionExpiredDialog.test.tsx b/web/src/app/auth/SessionExpiredDialog/SessionExpiredDialog.test.tsx new file mode 100644 index 00000000..b19ebb36 --- /dev/null +++ b/web/src/app/auth/SessionExpiredDialog/SessionExpiredDialog.test.tsx @@ -0,0 +1,107 @@ +/** + * SessionExpiredDialog reads `sessionExpiredStore` (latched by + * `httpClient` on 401). Tests cover both directions: + * 1. store is notified → dialog opens + * 2. popup posts AUTH_SUCCESS_MESSAGE → store is cleared + */ + +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createQueryClient } from '@/app/bootstrap/createQueryClient' +import { Providers } from '@/app/bootstrap/Providers' +import { AUTH_SUCCESS_MESSAGE, sessionExpiredStore } from '@/shared/platform/auth' +import { createTelemetry, TelemetryBackend } from '@/shared/platform/telemetry' + +const telemetry = createTelemetry(TelemetryBackend.None) +const renderProviders = () => + render( + + {null} + + ) + +afterEach(() => { + sessionExpiredStore._reset() + vi.restoreAllMocks() +}) + +describe('SessionExpiredDialog', () => { + test('opens when the session-expired store is notified', async () => { + renderProviders() + + expect(screen.queryByText('Session expired')).toBeNull() + + sessionExpiredStore.notify() + + await waitFor(() => { + expect(screen.getByText('Session expired')).toBeDefined() + }) + }) + + test('reads a pre-existing latched flag on first render', () => { + // Loader-fired 401: store was already latched before React mounted. + sessionExpiredStore.notify() + + renderProviders() + + expect(screen.getByText('Session expired')).toBeDefined() + }) + + test('clears the latch when the popup posts AUTH_SUCCESS_MESSAGE', async () => { + sessionExpiredStore.notify() + renderProviders() + expect(screen.getByText('Session expired')).toBeDefined() + + // Simulate the popup landing on `/auth-success` and posting back. + window.dispatchEvent( + new MessageEvent('message', { + data: AUTH_SUCCESS_MESSAGE, + origin: window.location.origin, + }) + ) + + await waitFor(() => { + expect(sessionExpiredStore.getSnapshot()).toBe(false) + }) + expect(screen.queryByText('Session expired')).toBeNull() + }) + + test('ignores AUTH_SUCCESS_MESSAGE from a foreign origin', async () => { + sessionExpiredStore.notify() + renderProviders() + + window.dispatchEvent( + new MessageEvent('message', { + data: AUTH_SUCCESS_MESSAGE, + origin: 'https://evil.example.com', + }) + ) + + // Give the listener a tick; the dialog must remain open. + await new Promise((r) => setTimeout(r, 10)) + expect(sessionExpiredStore.getSnapshot()).toBe(true) + expect(screen.getByText('Session expired')).toBeDefined() + }) + + test('falls back to same-tab redirect when the popup is blocked', async () => { + const user = userEvent.setup() + // Simulate popup blocker: window.open returns null. + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null) + // Stub navigation — jsdom can't navigate. + const assignSpy = vi.fn() + Object.defineProperty(window, 'location', { + value: { ...window.location, assign: assignSpy }, + writable: true, + }) + + sessionExpiredStore.notify() + renderProviders() + + await user.click(screen.getByRole('button', { name: 'Sign in' })) + + expect(openSpy).toHaveBeenCalledOnce() + expect(assignSpy).toHaveBeenCalledOnce() + expect(assignSpy.mock.calls[0]?.[0]).toMatch(/\/oauth2\/sign_in/) + }) +}) diff --git a/web/src/app/auth/SessionExpiredDialog/SessionExpiredDialog.tsx b/web/src/app/auth/SessionExpiredDialog/SessionExpiredDialog.tsx new file mode 100644 index 00000000..57102a91 --- /dev/null +++ b/web/src/app/auth/SessionExpiredDialog/SessionExpiredDialog.tsx @@ -0,0 +1,78 @@ +/** + * Modal dialog shown when `httpClient` latches `sessionExpiredStore` + * on a 401. Pure presentation: state and the popup handshake live in + * `useReauthFlow` (under `platform/auth`). + * + * Built on the native `` element via `showModal()`, which gives + * us focus trap, top-layer rendering, and focus restoration for free. + * Esc is intercepted (`cancel` event) to keep the dialog modal-required + * — dismissing only delays the next 401, which immediately re-opens + * the dialog. The only escape is "Sign in". + */ + +import { Button, Card, Typography } from '@equinor/eds-core-react' +import { useQueryClient } from '@tanstack/react-query' +import { type SyntheticEvent, useEffect, useRef } from 'react' +import { useReauthFlow } from '@/shared/platform/auth' + +const TITLE_ID = 'session-expired-title' +const DESC_ID = 'session-expired-description' + +export const SessionExpiredDialog = () => { + const queryClient = useQueryClient() + const { open, signingIn, requestSignIn } = useReauthFlow(queryClient) + return open ? : null +} + +interface DialogProps { + signingIn: boolean + onSignIn: () => void +} + +const Dialog = ({ signingIn, onSignIn }: DialogProps) => { + const ref = useRef(null) + + useEffect(() => { + const dialog = ref.current + if (!dialog) return + dialog.showModal() + return () => { + if (dialog.open) dialog.close() + } + }, []) + + // Block Esc-to-dismiss; see file header. + const blockCancel = (e: SyntheticEvent) => e.preventDefault() + + return ( + + + + Session expired + + + {signingIn + ? 'Complete sign-in in the popup window. This dialog will close automatically when you’re done.' + : 'Your session has expired. Sign in again in a popup — this page and your work will stay as they are.'} + +
+ +
+
+
+ ) +} diff --git a/web/src/app/bootstrap/ApplicationError.tsx b/web/src/app/bootstrap/ApplicationError.tsx new file mode 100644 index 00000000..5ea2efc8 --- /dev/null +++ b/web/src/app/bootstrap/ApplicationError.tsx @@ -0,0 +1,21 @@ +import { Button, Typography } from '@equinor/eds-core-react' +import { ErrorPanel } from '@/shared/components/ErrorPanel/ErrorPanel' + +// Outermost fallback rendered by TelemetryProvider's error boundary — +// if this is showing, the provider stack itself failed (no router, no +// theme, possibly no auth). The Tailwind stylesheet is loaded by +// `index.tsx` *before* AppProviders mounts, so utility classes are safe +// here even when React itself bails. Accepts `unknown` so it can also +// be used directly from the bootstrap try/catch in `index.tsx`. +export const ApplicationError = ({ error }: { error: unknown }) => { + const normalised = error instanceof Error ? error : new Error(String(error)) + return ( +
+ The application failed to load + +
+ +
+
+ ) +} diff --git a/web/src/app/bootstrap/Providers.tsx b/web/src/app/bootstrap/Providers.tsx new file mode 100644 index 00000000..5ef2a954 --- /dev/null +++ b/web/src/app/bootstrap/Providers.tsx @@ -0,0 +1,24 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' +import { SessionExpiredDialog } from '@/app/auth/SessionExpiredDialog/SessionExpiredDialog' +import { ApplicationError } from '@/app/bootstrap/ApplicationError' +import { TelemetryErrorBoundary, TelemetryProvider } from '@/shared/platform/telemetry' +import { ToastContainer } from '@/shared/platform/toast' +import type { ProvidersProps } from './Providers.types' + +// No ``: page-views use `enableAutoRouteTracking` +// and nothing currently calls `useAppInsightsContext`. +export const Providers = ({ telemetry, queryClient, children }: ProvidersProps) => ( + + + + + {children} + + {/* `import.meta.env.DEV` is statically replaced by Vite, so the + devtools import is tree-shaken from production builds. */} + {import.meta.env.DEV && } + + + +) diff --git a/web/src/app/bootstrap/Providers.types.ts b/web/src/app/bootstrap/Providers.types.ts new file mode 100644 index 00000000..672a8ffb --- /dev/null +++ b/web/src/app/bootstrap/Providers.types.ts @@ -0,0 +1,9 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import type { Telemetry } from '@/shared/platform/telemetry' + +export interface ProvidersProps { + telemetry: Telemetry + queryClient: QueryClient + children: ReactNode +} diff --git a/web/src/app/bootstrap/createQueryClient.ts b/web/src/app/bootstrap/createQueryClient.ts new file mode 100644 index 00000000..d35af2ce --- /dev/null +++ b/web/src/app/bootstrap/createQueryClient.ts @@ -0,0 +1,50 @@ +// App-wide `QueryClient` factory. The only place that couples the data +// layer to UI concerns (toast) and observability (telemetry). Templates +// that want a different notification surface — a banner, Sentry +// breadcrumbs, nothing at all — should fork *this* file and leave +// `shared/platform/api/createBaseQueryClient.ts` untouched. + +import { createBaseQueryClient, isApiError, type QueryNotifier } from '@/shared/platform/api' +import type { Telemetry } from '@/shared/platform/telemetry' +import { toast } from '@/shared/platform/toast' + +// 401s are handled out-of-band by `SessionExpiredDialog` (which listens +// for the `sessionExpiredStore` flag latched by `httpClient`). A toast +// on top of the dialog would be noise. +const isSessionExpired = (error: unknown): boolean => + isApiError(error) && error.kind === 'http' && error.status === 401 + +export const createQueryClient = ({ telemetry }: { telemetry: Telemetry }) => { + const toastNotifier: QueryNotifier = { + success: toast.success, + error: (message, { error, source }) => { + // Toasts are for users; trace ids and error details are for + // engineers. Log a correlation event to telemetry where engineers + // look, and keep the toast text human-friendly. Anyone debugging + // a user-reported failure can match the trace id from the inline + // `` against this event in App Insights. + if (isApiError(error)) { + telemetry.trackEvent('toast.error', { + kind: source, + template: message, + traceId: error.traceId, + errorKind: error.kind, + ...(error.kind === 'http' ? { status: String(error.status) } : {}), + }) + } else { + telemetry.trackEvent('toast.error', { + kind: source, + template: message, + errorKind: 'unknown', + message: error instanceof Error ? error.message : String(error), + }) + } + toast.error(message) + }, + } + + return createBaseQueryClient({ + notifier: toastNotifier, + suppressNotification: isSessionExpired, + }) +} diff --git a/web/src/app/error-pages/ForbiddenPage/ForbiddenPage.tsx b/web/src/app/error-pages/ForbiddenPage/ForbiddenPage.tsx new file mode 100644 index 00000000..4c18a5a7 --- /dev/null +++ b/web/src/app/error-pages/ForbiddenPage/ForbiddenPage.tsx @@ -0,0 +1,6 @@ +import { GoHomeButton } from '../GoHomeButton/GoHomeButton' +import { StatusPage } from '../StatusPage/StatusPage' + +export const ForbiddenPage = () => ( + } /> +) diff --git a/web/src/app/error-pages/GoHomeButton/GoHomeButton.tsx b/web/src/app/error-pages/GoHomeButton/GoHomeButton.tsx new file mode 100644 index 00000000..ac0023d3 --- /dev/null +++ b/web/src/app/error-pages/GoHomeButton/GoHomeButton.tsx @@ -0,0 +1,9 @@ +import { Button } from '@equinor/eds-core-react' +import { Link } from '@tanstack/react-router' + +/** Convenience for the most common action: "go home". */ +export const GoHomeButton = () => ( + + + +) diff --git a/web/src/app/error-pages/NotFoundPage/NotFoundPage.tsx b/web/src/app/error-pages/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 00000000..7f8d2566 --- /dev/null +++ b/web/src/app/error-pages/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1,6 @@ +import { GoHomeButton } from '../GoHomeButton/GoHomeButton' +import { StatusPage } from '../StatusPage/StatusPage' + +export const NotFoundPage = () => ( + } /> +) diff --git a/web/src/app/error-pages/RouteErrorBoundary/RouteErrorBoundary.tsx b/web/src/app/error-pages/RouteErrorBoundary/RouteErrorBoundary.tsx new file mode 100644 index 00000000..34ed6454 --- /dev/null +++ b/web/src/app/error-pages/RouteErrorBoundary/RouteErrorBoundary.tsx @@ -0,0 +1,61 @@ +/** + * Catches loader and render errors thrown anywhere inside the route tree. + * Mounted via `errorComponent` on the root route in `routes/__root.tsx`. + * Logs once via telemetry, then renders the specialised app-shell page + * that matches the status code (401 → quiet fallback under the + * session-expired dialog, 403/404 → dedicated page, otherwise + * UnexpectedErrorPage). + * + * Client-side 4xx (401/403/404) are normal navigation outcomes — a user + * landing on a stale link or hitting a permission gate isn't a bug. + * Reporting them as exceptions would drown out real failures in App + * Insights, so they're skipped. 5xx and unexpected throws still report. + * + * TanStack Router calls this with the thrown value as the `error` prop + * (replacement for react-router's `useRouteError()`). The component + * also serves as `notFoundComponent` — a TSR `NotFoundError` carries + * no HTTP status, so we treat it as 404 by name check. + */ + +import { useEffect, useRef } from 'react' +import { useTelemetry } from '@/shared/platform/telemetry' +import { ForbiddenPage } from '../ForbiddenPage/ForbiddenPage' +import { NotFoundPage } from '../NotFoundPage/NotFoundPage' +import { UnexpectedErrorPage } from '../UnexpectedErrorPage/UnexpectedErrorPage' +import { isExpectedClientError, statusOf } from './RouteErrorBoundary.utils' + +interface RouteErrorBoundaryProps { + // TSR passes the thrown value here. Optional so the same component + // can also serve as `notFoundComponent` (called without props). + error?: unknown +} + +export const RouteErrorBoundary = ({ error }: RouteErrorBoundaryProps = {}) => { + const telemetry = useTelemetry() + // React Strict Mode (and any benign re-render) would otherwise fire + // telemetry twice for the same error. Identity-compare against the + // last-reported error so a real second failure still reports. + const reportedRef = useRef(null) + + useEffect(() => { + if (reportedRef.current === error) return + reportedRef.current = error + if (error === undefined) return + if (isExpectedClientError(error)) return + telemetry.trackException(error) + }, [error, telemetry]) + + // Called as `notFoundComponent` (no error) → render 404. + if (error === undefined) return + + const status = statusOf(error) + // 401: `httpClient` already latched `sessionExpiredStore`, so + // `` is open over us with its own dimmed + // backdrop and call-to-action. Render nothing here — a spinner or + // chrome behind the dialog would either lie ("signing in…" before + // the user clicks) or flash unrelated UI for a frame. + if (status === 401) return null + if (status === 404) return + if (status === 403) return + return +} diff --git a/web/src/app/error-pages/RouteErrorBoundary/RouteErrorBoundary.utils.ts b/web/src/app/error-pages/RouteErrorBoundary/RouteErrorBoundary.utils.ts new file mode 100644 index 00000000..f85e52ab --- /dev/null +++ b/web/src/app/error-pages/RouteErrorBoundary/RouteErrorBoundary.utils.ts @@ -0,0 +1,18 @@ +import { isApiError } from '@/shared/platform/api' + +const isResponse = (e: unknown): e is Response => typeof Response !== 'undefined' && e instanceof Response + +export const statusOf = (error: unknown): number | null => { + if (isResponse(error)) return error.status + if (isApiError(error) && error.kind === 'http') return error.status + // TSR's NotFoundError doesn't carry an HTTP status; flag it as 404. + if (error && typeof error === 'object' && (error as { name?: string }).name === 'NotFoundError') { + return 404 + } + return null +} + +export const isExpectedClientError = (error: unknown): boolean => { + const s = statusOf(error) + return s !== null && s >= 400 && s < 500 +} diff --git a/web/src/app/error-pages/StatusPage/StatusPage.tsx b/web/src/app/error-pages/StatusPage/StatusPage.tsx new file mode 100644 index 00000000..8672c6cb --- /dev/null +++ b/web/src/app/error-pages/StatusPage/StatusPage.tsx @@ -0,0 +1,29 @@ +import { Typography } from '@equinor/eds-core-react' +import type { StatusPageProps } from './StatusPage.types' + +/** + * Shared layout for app-shell status screens (404, 403, unexpected error). + * Renders a centred card with title + body + optional action so all three + * pages look like part of the same family. The card has its own surface + * so it reads as a single bounded element regardless of what the route + * body renders inside it. + */ +export const StatusPage = ({ title, body, action }: StatusPageProps) => ( +
+ + {title} + + {body && ( +
+ {typeof body === 'string' ? ( + + {body} + + ) : ( + body + )} +
+ )} + {action &&
{action}
} +
+) diff --git a/web/src/app/error-pages/StatusPage/StatusPage.types.ts b/web/src/app/error-pages/StatusPage/StatusPage.types.ts new file mode 100644 index 00000000..55d29236 --- /dev/null +++ b/web/src/app/error-pages/StatusPage/StatusPage.types.ts @@ -0,0 +1,7 @@ +import type { ReactNode } from 'react' + +export interface StatusPageProps { + title: string + body?: ReactNode + action?: ReactNode +} diff --git a/web/src/app/error-pages/UnexpectedErrorPage/UnexpectedErrorPage.tsx b/web/src/app/error-pages/UnexpectedErrorPage/UnexpectedErrorPage.tsx new file mode 100644 index 00000000..f4cbc1a8 --- /dev/null +++ b/web/src/app/error-pages/UnexpectedErrorPage/UnexpectedErrorPage.tsx @@ -0,0 +1,11 @@ +import { ErrorPanel } from '@/shared/components/ErrorPanel/ErrorPanel' +import { GoHomeButton } from '../GoHomeButton/GoHomeButton' +import { StatusPage } from '../StatusPage/StatusPage' + +/** + * Default 5xx / unhandled error surface for the route tree. Receives the + * raw `useRouteError()` value so it can show traceIds when present. + */ +export const UnexpectedErrorPage = ({ error }: { error: unknown }) => ( + } action={} /> +) diff --git a/web/src/app/layout/Header/Header.tsx b/web/src/app/layout/Header/Header.tsx new file mode 100644 index 00000000..4938b667 --- /dev/null +++ b/web/src/app/layout/Header/Header.tsx @@ -0,0 +1,45 @@ +import { Icon, TopBar, Typography } from '@equinor/eds-core-react' +import { info_circle, log_out, receipt } from '@equinor/eds-icons' +import { useRef, useState } from 'react' +import { IconButton } from '@/shared/components/IconButton/IconButton' +import { Popover } from '@/shared/components/Popover/Popover' +import { useCurrentUser, useSignOut } from '@/shared/platform/auth' +import { useColorScheme } from '@/shared/platform/theme' +import { VersionText } from '../VersionText/VersionText' +import { ICON, NEXT, TITLE } from './Header.utils' + +export const Header = () => { + const { data: user } = useCurrentUser() + const username = user.name + const [scheme, setScheme] = useColorScheme() + const [isPopoverOpen, setPopoverOpen] = useState(false) + const aboutRef = useRef(null) + const signOut = useSignOut() + + const togglePopover = () => setPopoverOpen((open) => !open) + + return ( + <> + + + + Todo App + + + setScheme(NEXT[scheme])} /> + + + + + + {username && ( + + {`Logged in as ${username}`} + + )} + +

Person of contact: Eirik Ola Aksnes (eaks@equinor.com)

+
+ + ) +} diff --git a/web/src/app/layout/Header/Header.utils.ts b/web/src/app/layout/Header/Header.utils.ts new file mode 100644 index 00000000..8b40e205 --- /dev/null +++ b/web/src/app/layout/Header/Header.utils.ts @@ -0,0 +1,12 @@ +import { light, lightbulb, sun } from '@equinor/eds-icons' +import type { ColorScheme } from '@/shared/platform/theme' + +// Cycle: auto → light → dark → auto. Tooltip shows the *next* state so +// the click outcome is predictable. +export const NEXT: Record = { auto: 'light', light: 'dark', dark: 'auto' } +export const ICON: Record = { auto: light, light: sun, dark: lightbulb } +export const TITLE: Record = { + auto: 'Theme: follow system — switch to light', + light: 'Theme: light — switch to dark', + dark: 'Theme: dark — follow system', +} diff --git a/web/src/app/layout/RootLayout/RootLayout.tsx b/web/src/app/layout/RootLayout/RootLayout.tsx new file mode 100644 index 00000000..6bf2c52f --- /dev/null +++ b/web/src/app/layout/RootLayout/RootLayout.tsx @@ -0,0 +1,21 @@ +import { Outlet } from '@tanstack/react-router' +import { Suspense } from 'react' +import { Header } from '@/app/layout/Header/Header' +import { FEATURE_FLAGS } from '@/config/featureFlags' +import { LoadingState } from '@/shared/components/LoadingState/LoadingState' +import { useCurrentUser } from '@/shared/platform/auth' +import { FeatureFlagsProvider } from '@/shared/platform/feature-flags' + +export const RootLayout = () => { + const { data: user } = useCurrentUser() + return ( + +
+
+ }> + + +
+ + ) +} diff --git a/web/src/app/layout/VersionText/VersionText.test.tsx b/web/src/app/layout/VersionText/VersionText.test.tsx new file mode 100644 index 00000000..f2deae38 --- /dev/null +++ b/web/src/app/layout/VersionText/VersionText.test.tsx @@ -0,0 +1,64 @@ +/** + * VersionText fetches `version.txt` and parses `key: value` lines. + * We stub `fetch` per-test to drive each branch (success, parse-skip, + * non-OK, network reject). Falls back to "unknown" when the file is + * missing or empty. + */ + +import { render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { VersionText } from './VersionText' + +const mockFetchOk = (body: string) => + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(body, { status: 200 })) + +const mockFetchStatus = (status: number) => + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status, statusText: 'Not Found' })) + +const mockFetchReject = () => vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline')) + +beforeEach(() => { + vi.restoreAllMocks() +}) +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('VersionText', () => { + test('renders refs label and date when version.txt parses', async () => { + mockFetchOk('hash: abc123\ndate: 2026-05-04\nrefs: v1.2.3') + render() + await waitFor(() => expect(screen.getByText('v1.2.3')).toBeInTheDocument()) + expect(screen.getByText(/2026-05-04/)).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'v1.2.3' })).toHaveAttribute( + 'href', + 'https://github.com/equinor/template-fastapi-react/commit/abc123' + ) + }) + + test('falls back to hash when refs is missing', async () => { + mockFetchOk('hash: deadbeef\ndate: 2026-05-04') + render() + await waitFor(() => expect(screen.getByText('deadbeef')).toBeInTheDocument()) + }) + + test('shows "unknown" when the response is not OK', async () => { + mockFetchStatus(404) + render() + // The component renders "unknown" synchronously from the initial state; + // wait a tick to ensure the catch handler ran without changing it. + await waitFor(() => expect(screen.getByText('unknown')).toBeInTheDocument()) + }) + + test('shows "unknown" when fetch rejects', async () => { + mockFetchReject() + render() + await waitFor(() => expect(screen.getByText('unknown')).toBeInTheDocument()) + }) + + test('ignores malformed lines and parses only valid `key: value` pairs', async () => { + mockFetchOk('garbage line\nhash: cafef00d\nrefs: feature/x') + render() + await waitFor(() => expect(screen.getByText('feature/x')).toBeInTheDocument()) + }) +}) diff --git a/web/src/app/layout/VersionText/VersionText.tsx b/web/src/app/layout/VersionText/VersionText.tsx new file mode 100644 index 00000000..074a5cd3 --- /dev/null +++ b/web/src/app/layout/VersionText/VersionText.tsx @@ -0,0 +1,36 @@ +import { Typography } from '@equinor/eds-core-react' +import { useEffect, useState } from 'react' +import { ENV } from '@/config/env' +import type { CommitInfo } from './VersionText.types' +import { EMPTY_COMMIT_INFO, parseVersionFile } from './VersionText.utils' + +const useCommitInfo = () => { + const [commitInfo, setCommitInfo] = useState(EMPTY_COMMIT_INFO) + + useEffect(() => { + fetch('version.txt') + .then((res) => { + if (!res.ok) throw new Error(`Could not read version file, ${res.statusText}`) + return res.text() + }) + .then((text) => setCommitInfo(parseVersionFile(text))) + .catch(() => setCommitInfo(EMPTY_COMMIT_INFO)) + }, []) + + return commitInfo +} + +export const VersionText = () => { + const commitInfo = useCommitInfo() + const label = commitInfo.refs || commitInfo.hash || 'unknown' + + return ( +

+ Version:{' '} + + {label} + {' '} + {commitInfo.date} +

+ ) +} diff --git a/web/src/app/layout/VersionText/VersionText.types.ts b/web/src/app/layout/VersionText/VersionText.types.ts new file mode 100644 index 00000000..d1bca331 --- /dev/null +++ b/web/src/app/layout/VersionText/VersionText.types.ts @@ -0,0 +1,5 @@ +export type CommitInfo = { + hash: string + date: string + refs: string +} diff --git a/web/src/app/layout/VersionText/VersionText.utils.ts b/web/src/app/layout/VersionText/VersionText.utils.ts new file mode 100644 index 00000000..6f3a5a9c --- /dev/null +++ b/web/src/app/layout/VersionText/VersionText.utils.ts @@ -0,0 +1,12 @@ +import type { CommitInfo } from './VersionText.types' + +export const EMPTY_COMMIT_INFO: CommitInfo = { hash: '', date: '', refs: '' } + +/** Parse `key: value` lines from `version.txt`. Lines that don't match are ignored. */ +export const parseVersionFile = (text: string): CommitInfo => { + const entries = text + .split('\n') + .map((line) => line.split(': ')) + .filter((parts): parts is [string, string] => parts.length === 2 && parts[0].length > 0) + return { ...EMPTY_COMMIT_INFO, ...Object.fromEntries(entries) } +} diff --git a/web/src/app/routeTree.gen.ts b/web/src/app/routeTree.gen.ts new file mode 100644 index 00000000..9a324186 --- /dev/null +++ b/web/src/app/routeTree.gen.ts @@ -0,0 +1,95 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as TodosRouteImport } from './routes/todos' +import { Route as AuthSuccessRouteImport } from './routes/auth-success' +import { Route as IndexRouteImport } from './routes/index' + +const TodosRoute = TodosRouteImport.update({ + id: '/todos', + path: '/todos', + getParentRoute: () => rootRouteImport, +} as any) +const AuthSuccessRoute = AuthSuccessRouteImport.update({ + id: '/auth-success', + path: '/auth-success', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/auth-success': typeof AuthSuccessRoute + '/todos': typeof TodosRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/auth-success': typeof AuthSuccessRoute + '/todos': typeof TodosRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/auth-success': typeof AuthSuccessRoute + '/todos': typeof TodosRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/auth-success' | '/todos' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/auth-success' | '/todos' + id: '__root__' | '/' | '/auth-success' | '/todos' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + AuthSuccessRoute: typeof AuthSuccessRoute + TodosRoute: typeof TodosRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/todos': { + id: '/todos' + path: '/todos' + fullPath: '/todos' + preLoaderRoute: typeof TodosRouteImport + parentRoute: typeof rootRouteImport + } + '/auth-success': { + id: '/auth-success' + path: '/auth-success' + fullPath: '/auth-success' + preLoaderRoute: typeof AuthSuccessRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + AuthSuccessRoute: AuthSuccessRoute, + TodosRoute: TodosRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/web/src/app/routes/__root.tsx b/web/src/app/routes/__root.tsx new file mode 100644 index 00000000..a08c0dae --- /dev/null +++ b/web/src/app/routes/__root.tsx @@ -0,0 +1,24 @@ +import type { QueryClient } from '@tanstack/react-query' +import { createRootRouteWithContext } from '@tanstack/react-router' +import { NotFoundPage } from '@/app/error-pages/NotFoundPage/NotFoundPage' +import { RouteErrorBoundary } from '@/app/error-pages/RouteErrorBoundary/RouteErrorBoundary' +import { RootLayout } from '@/app/layout/RootLayout/RootLayout' +import { userQuery } from '@/shared/platform/auth' +import type { Telemetry } from '@/shared/platform/telemetry' + +export interface RouterContext { + queryClient: QueryClient + telemetry: Telemetry +} + +export const Route = createRootRouteWithContext()({ + // Resolve the user once before any component mounts so suspense reads + // are synchronous, and bridge identity to telemetry in the same step. + beforeLoad: async ({ context: { queryClient, telemetry } }) => { + const user = await queryClient.ensureQueryData(userQuery()) + telemetry.setUser(user.id) + }, + component: RootLayout, + errorComponent: RouteErrorBoundary, + notFoundComponent: NotFoundPage, +}) diff --git a/web/src/app/routes/auth-success.tsx b/web/src/app/routes/auth-success.tsx new file mode 100644 index 00000000..91156cdc --- /dev/null +++ b/web/src/app/routes/auth-success.tsx @@ -0,0 +1,20 @@ +/** + * `/auth-success` — landing target inside the re-auth popup tab. + * No loader — this route MUST mount even when the parent window's + * session is technically still expired at the moment the popup opens. + * The handshake hook posts a message back to the opener and closes + * this tab. + */ + +import { createFileRoute } from '@tanstack/react-router' +import { LoadingState } from '@/shared/components/LoadingState/LoadingState' +import { useAuthSuccessHandshake } from '@/shared/platform/auth' + +const AuthSuccessPage = () => { + useAuthSuccessHandshake() + return +} + +export const Route = createFileRoute('/auth-success')({ + component: AuthSuccessPage, +}) diff --git a/web/src/app/routes/index.tsx b/web/src/app/routes/index.tsx new file mode 100644 index 00000000..07963e48 --- /dev/null +++ b/web/src/app/routes/index.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { HomePage } from '@/features/home' + +export const Route = createFileRoute('/')({ + component: HomePage, +}) diff --git a/web/src/app/styles/eds-overrides.css b/web/src/app/styles/eds-overrides.css new file mode 100644 index 00000000..1c4eb769 --- /dev/null +++ b/web/src/app/styles/eds-overrides.css @@ -0,0 +1,49 @@ +/* + * EDS-component re-skinning via private CSS variables. + * + * ⚠️ ESCAPE HATCH — read before adding rules here. + * + * EDS components expose internal CSS vars (--eds_*, --eds-color-*) that + * we can override to make them inherit our semantic palette without + * touching className. This is *powerful* (one rule re-themes hundreds + * of usages) and *fragile* (these vars are EDS implementation detail + * and can rename between minor versions). + * + * Rules of engagement: + * 1. Prefer wrapping an EDS component over overriding its internals. + * Build from scratch with Tailwind if you need a + * different look. Reach for this file only when wrapping isn't + * feasible (e.g. EDS Tabs uses internal vars deeply). + * + * 2. Every override below MUST cite the EDS version it was verified + * against. When you upgrade EDS, search this file and re-verify. + * + * 3. If you find yourself adding more than ~10 overrides, the design + * system has diverged from EDS — that's a real conversation to + * have with design, not a CSS problem to solve here. + * + * Tested against: @equinor/eds-core-react 2.4.x (update on upgrade) + */ + +:root { + /* Dark-mode parity for EDS components. + * + * EDS 2.4.x ships static light-mode token values, so , + * - - ) -}) - -export default IconButton diff --git a/web/src/common/components/InvalidUrl.tsx b/web/src/common/components/InvalidUrl.tsx deleted file mode 100644 index 2ec76bdd..00000000 --- a/web/src/common/components/InvalidUrl.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Link } from 'react-router-dom' - -const InvalidUrl = () => { - return ( -
-

Invalid url. Please go back to the:

- home page -
- ) -} - -export default InvalidUrl diff --git a/web/src/common/components/VersionText.tsx b/web/src/common/components/VersionText.tsx deleted file mode 100644 index 41147240..00000000 --- a/web/src/common/components/VersionText.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Typography } from '@equinor/eds-core-react' -import { useEffect, useState } from 'react' - -type CommitInfo = { - hash: string - date: string - refs: string -} - -const useCommitInfo = () => { - const [commitInfo, setCommitInfo] = useState({ - hash: '', - date: '', - refs: '', - }) - - useEffect(() => { - const fetchVersionFile = () => - fetch('version.txt') - .then((res) => { - if (!res.ok) throw new Error(`Could not read version file, ${res.statusText}`) - return res.text() - }) - .then((text) => Object.fromEntries(text.split('\n').map((line) => line.split(': ')))) - fetchVersionFile().then((commitInfo) => setCommitInfo(commitInfo)) - }, []) - - return commitInfo -} - -export const VersionText = () => { - const commitInfo = useCommitInfo() - - return ( -

- Version:{' '} - <> - - {commitInfo.refs === '' ? commitInfo.hash : commitInfo.refs} - {' '} - {commitInfo.date} - -

- ) -} diff --git a/web/src/config/accessControl.test.ts b/web/src/config/accessControl.test.ts new file mode 100644 index 00000000..a40ea97b --- /dev/null +++ b/web/src/config/accessControl.test.ts @@ -0,0 +1,68 @@ +/** + * App-wired access control tests. Covers the editor ownership predicate + * which the generic factory tests don't exercise (those use a synthetic + * Doc type). Pin the false branches so a regression silently granting + * cross-user edits would fail here. + */ + +import { describe, expect, test } from 'vitest' +import type { Todo } from '@/features/todos' +import type { CurrentUser } from '@/shared/platform/auth' +import { isAllowed } from './accessControl' + +const editor = (id: string): CurrentUser => ({ + id, + name: 'E', + roles: ['editor'], +}) +const admin = (id: string): CurrentUser => ({ + id, + name: 'A', + roles: ['admin'], +}) +const viewer = (id: string): CurrentUser => ({ + id, + name: 'V', + roles: ['viewer'], +}) + +const todoOwnedBy = (ownerId: string): Todo => ({ + id: 't1', + title: 'T', + is_completed: false, + user_id: ownerId, +}) + +describe('isAllowed — editor ownership', () => { + test('editor can update their own todo', () => { + expect(isAllowed(editor('u1'), 'todos', 'update', todoOwnedBy('u1'))).toBe(true) + }) + + test("editor cannot update someone else's todo", () => { + expect(isAllowed(editor('u1'), 'todos', 'update', todoOwnedBy('u2'))).toBe(false) + }) + + test('editor cannot delete a null todo', () => { + expect(isAllowed(editor('u1'), 'todos', 'delete', null)).toBe(false) + }) + + test('editor can always create + read', () => { + expect(isAllowed(editor('u1'), 'todos', 'create')).toBe(true) + expect(isAllowed(editor('u1'), 'todos', 'read')).toBe(true) + }) +}) + +describe('isAllowed — admin / viewer', () => { + test('admin can update any todo regardless of owner', () => { + expect(isAllowed(admin('u1'), 'todos', 'update', todoOwnedBy('u2'))).toBe(true) + expect(isAllowed(admin('u1'), 'todos', 'delete', null)).toBe(true) + }) + + test('viewer can only read', () => { + const v = viewer('u1') + expect(isAllowed(v, 'todos', 'read')).toBe(true) + expect(isAllowed(v, 'todos', 'create')).toBe(false) + expect(isAllowed(v, 'todos', 'update', todoOwnedBy('u1'))).toBe(false) + expect(isAllowed(v, 'todos', 'delete', todoOwnedBy('u1'))).toBe(false) + }) +}) diff --git a/web/src/config/accessControl.ts b/web/src/config/accessControl.ts new file mode 100644 index 00000000..a66bf9e9 --- /dev/null +++ b/web/src/config/accessControl.ts @@ -0,0 +1,84 @@ +/** + * App-level access control configuration. + * + * Exports: + * - `Role` — the SPA's role union. + * - `Permissions` — typed map of resources → allowed actions and the + * data shape that ownership predicates receive. + * - `ROLES` — per-role rules (boolean or predicate) for each action. + * - `hasPermissionCheck(user, resource, action, data?)` — the bound + * checker. Returns `boolean | 'loading'`. Used from route + * `beforeLoad` (throws a 403 `Response`). + * - `isAllowed(user, resource, action, data?)` — strict-boolean + * variant for components and tests; `'loading'` collapses to + * `false`. + * + * Leaf-shaped on purpose (no React, no auth imports beyond the + * type-only `CurrentUser`) so it can be safely imported from anywhere + * including `userQuery`. + * + * To add a role: extend the `Role` union, give it an entry in `ROLES`, + * and make sure the backend (`/whoami`) emits the matching string in + * its `roles` array. Unknown role strings are silently ignored. + */ + +import type { Todo } from '@/features/todos' +import { createHasPermission, createIsAllowed, type RolesWithPermissions } from '@/shared/platform/access-control' +import type { CurrentUser } from '@/shared/platform/auth' + +// --------------------------------------------------------------------------- +// Roles +// --------------------------------------------------------------------------- + +export type Role = 'admin' | 'editor' | 'viewer' + +// --------------------------------------------------------------------------- +// Permissions +// --------------------------------------------------------------------------- + +export type Permissions = { + todos: { + dataType: Todo | null + action: 'read' | 'create' | 'update' | 'delete' + } +} + +/** True iff `todo` exists and its `user_id` matches the current user. */ +const isOwnTodo = (user: CurrentUser, todo: Todo | null): boolean => todo != null && todo.user_id === user.id + +// --------------------------------------------------------------------------- +// Roles → permissions table +// --------------------------------------------------------------------------- + +export const ROLES: RolesWithPermissions = { + admin: { + todos: { read: true, create: true, update: true, delete: true }, + }, + editor: { + todos: { + read: true, + create: true, + // Editors may only modify todos they own. + update: isOwnTodo, + delete: isOwnTodo, + }, + }, + viewer: { + todos: { read: true, create: false, update: false, delete: false }, + }, +} + +/** + * Singleton check bound to {@link ROLES}. Loader call sites (`*.route.ts`) + * call this directly and throw a 403 `Response` — caught by + * `RouteErrorBoundary` → ``. + */ +export const hasPermissionCheck = createHasPermission(ROLES) + +/** + * Strict-boolean variant of {@link hasPermissionCheck}: `'loading'` + * collapses to `false`. Prefer this in components and tests; loaders + * should keep using `hasPermissionCheck` so they can distinguish + * `'loading'` from a real deny if needed. + */ +export const isAllowed = createIsAllowed(hasPermissionCheck) diff --git a/web/src/config/env.ts b/web/src/config/env.ts new file mode 100644 index 00000000..af90aa74 --- /dev/null +++ b/web/src/config/env.ts @@ -0,0 +1,54 @@ +/** + * Centralised, typed view of `import.meta.env` values the app cares about. + * + * Read env vars only here — everything else imports from `@/config/env`. + * Vite inlines `import.meta.env.*` at build time, so this is a static + * snapshot, not a live read. + */ + +import { isTelemetryBackend, TelemetryBackend } from '@/shared/platform/telemetry' + +const str = (v: unknown): string => (typeof v === 'string' ? v : '') + +const trim = (v: string) => v.trim() + +const env = import.meta.env + +export const ENV = { + isDev: Boolean(env.DEV), + isProd: Boolean(env.PROD), + + // --- auth ------------------------------------------------------------- + // The BFF (oauth2-proxy + nginx) terminates user authentication at the + // edge and exposes `/whoami` (via the FastAPI backend) for identity. + // The SPA only needs a kill-switch: when `VITE_AUTH != '1'` it skips + // the whoami fetch and + // runs as an anonymous local developer (useful for standalone `vite` + // sessions without the proxy in front). + authEnabled: env.VITE_AUTH === '1', + + // --- telemetry -------------------------------------------------------- + /** + * `'appinsights'` → ship to Azure Application Insights via the + * same-origin BFF proxy at `/api/monitoring/v2/track`. The backend + * re-exports events to Azure Monitor, so no connection string or + * instrumentation key is needed in the SPA. + * `'console'` → log to devtools console. + * `'none'` → no-op. + * Default: `'console'` in dev, `'none'` in prod. + */ + telemetryBackend: ((): TelemetryBackend => { + const raw = trim(str(env.VITE_TELEMETRY)).toLowerCase() + if (isTelemetryBackend(raw)) return raw + return env.DEV ? TelemetryBackend.Console : TelemetryBackend.None + })(), + + // --- repo / version --------------------------------------------------- + /** + * Base URL of the source repository, used by `` to link a + * commit hash. Override with `VITE_REPO_URL` when forking this template. + */ + repoUrl: trim(str(env.VITE_REPO_URL)) || 'https://github.com/equinor/template-fastapi-react', +} as const + +export type AppEnv = typeof ENV diff --git a/web/src/config/featureFlags.ts b/web/src/config/featureFlags.ts new file mode 100644 index 00000000..bfd28e20 --- /dev/null +++ b/web/src/config/featureFlags.ts @@ -0,0 +1,28 @@ +/** + * App-level feature flags. The platform machinery lives in + * `shared/platform/feature-flags/`, so this file is just declaration. + * + * Add a flag: + * 1. Append to `FeatureFlagName`. + * 2. Add a row to `FEATURE_FLAGS` (boolean OR `[{ allowedRoles: [...] }]`). + * 3. Reference via `` + * or `useFeatureFlag(FeatureFlagName.X)`. + */ + +import { ENV } from '@/config/env' +import type { FeatureFlagsTable } from '@/shared/platform/feature-flags' + +export enum FeatureFlagName { + /** Show the create-todo form. Demo flag — on in dev, off in prod (boolean form). */ + NEW_TODO_FORM = 'NEW_TODO_FORM', + /** + * Bulk operations on todos (e.g. "Clear completed"). Demo flag — admin only + * (role-gated form). Demonstrates `FeatureFlagRule[]` with `allowedRoles`. + */ + BULK_TOOLS = 'BULK_TOOLS', +} + +export const FEATURE_FLAGS: FeatureFlagsTable = { + [FeatureFlagName.NEW_TODO_FORM]: ENV.isDev, + [FeatureFlagName.BULK_TOOLS]: [{ allowedRoles: ['admin'] }], +} as const diff --git a/web/src/contexts/TodoContext.tsx b/web/src/contexts/TodoContext.tsx deleted file mode 100644 index 98930407..00000000 --- a/web/src/contexts/TodoContext.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import type React from 'react' -import { createContext, useContext, useReducer } from 'react' -import type { AddTodoResponse } from '../api/generated' - -// Type alias to make it make more sense in the code -type TodoItem = AddTodoResponse - -/** - * Definitions of the types of actions an user can do, - * that will trigger an update of state. - */ -type Action = - | { type: 'ADD_TODO'; payload: TodoItem } - | { type: 'INITIALIZE'; payload: TodoItem[] } - | { type: 'REMOVE_TODO'; payload: TodoItem } - | { type: 'TOGGLE_TODO'; payload: TodoItem } -type Dispatch = (action: Action) => void -type State = { todoItems: TodoItem[] } -type TodoProviderProps = { children: React.ReactNode } - -const TodoContext = createContext<{ state: State; dispatch: Dispatch } | undefined>(undefined) - -function TodoProvider({ children }: TodoProviderProps) { - const [state, dispatch] = useReducer(todoReducer, { todoItems: [] }) - const value = { state, dispatch } - - return {children} -} - -function todoReducer(state: State, action: Action): State { - switch (action.type) { - case 'ADD_TODO': { - return { - ...state, - todoItems: [...state.todoItems, action.payload], - } - } - case 'INITIALIZE': { - return { - ...state, - todoItems: action.payload, - } - } - case 'REMOVE_TODO': { - return { - ...state, - todoItems: state.todoItems.filter((todo) => todo.id !== action.payload.id), - } - } - case 'TOGGLE_TODO': { - return { - ...state, - todoItems: state.todoItems.map((todo) => - todo !== action.payload ? todo : { ...todo, is_completed: !todo.is_completed } - ), - } - } - default: { - throw new Error(`Unhandled action type: ${action}`) - } - } -} - -// Custom hook to get the provided context value from TodoProvider -function useTodos() { - const context = useContext(TodoContext) - if (context === undefined) { - throw new Error('useTodos must be used within a TodoProvider') - } - return context -} - -export { TodoProvider, useTodos } diff --git a/web/src/features/todos/api/index.ts b/web/src/features/todos/api/index.ts new file mode 100644 index 00000000..42914da2 --- /dev/null +++ b/web/src/features/todos/api/index.ts @@ -0,0 +1,12 @@ +/** + * Public hook re-exports for the todos feature. Components import from + * here, never from the individual mutation/query files. + */ + +export { useClearCompletedTodos } from './mutations/useClearCompletedTodos' +export { useCreateTodo } from './mutations/useCreateTodo' +export { useDeleteTodo } from './mutations/useDeleteTodo' +export { useToggleTodo } from './mutations/useToggleTodo' +export { todoListQuery } from './queries/todoListQuery' +export { useTodos } from './queries/useTodos' +export type { Todo } from './schema' diff --git a/web/src/features/todos/api/invalidations.ts b/web/src/features/todos/api/invalidations.ts new file mode 100644 index 00000000..62d526ab --- /dev/null +++ b/web/src/features/todos/api/invalidations.ts @@ -0,0 +1,19 @@ +/** + * Declared invalidation contract for todo mutations. + * + * Each entry names the cache keys a given mutation must invalidate on + * success. Mutation factories reference these via `meta.invalidates`, + * and a single global `MutationCache.onSuccess` handler in + * `shared/platform/api/createBaseQueryClient` does the fan-out. Keeping the contract in + * one file makes the invalidation graph reviewable in a single PR diff + * and prevents the 'we forgot to invalidate the dashboard' bug class. + */ + +import type { QueryKey } from '@tanstack/react-query' +import { todoKeys } from './keys' + +export const todoInvalidations = { + onCreate: (): readonly QueryKey[] => [todoKeys.lists()], + onToggle: (): readonly QueryKey[] => [todoKeys.lists()], + onDelete: (): readonly QueryKey[] => [todoKeys.lists()], +} as const diff --git a/web/src/features/todos/api/keys.ts b/web/src/features/todos/api/keys.ts new file mode 100644 index 00000000..a2f2876d --- /dev/null +++ b/web/src/features/todos/api/keys.ts @@ -0,0 +1,14 @@ +import { getAllTodosQueryKey } from '@/api-generated/@tanstack/react-query.gen' + +/** + * Single source of truth for todo cache keys. + * + * `lists()` aliases the key produced by `@hey-api/openapi-ts`'s TanStack + * Query plugin so the loader (`ensureQueryData`), the query hooks, and + * the invalidation contract all reference the *exact* same key. + * Hand-typing keys here would let them drift the moment the generator's + * key shape changes. + */ +export const todoKeys = { + lists: () => getAllTodosQueryKey(), +} as const diff --git a/web/src/features/todos/api/mutations/useClearCompletedTodos.ts b/web/src/features/todos/api/mutations/useClearCompletedTodos.ts new file mode 100644 index 00000000..7779d638 --- /dev/null +++ b/web/src/features/todos/api/mutations/useClearCompletedTodos.ts @@ -0,0 +1,56 @@ +/** + * Bulk-delete all completed todos. Reads the current cached list itself + * (no `ids` parameter) so callers don't need to know cache keys — they + * just `mutate()`. + * + * The API has no batch endpoint, so we fan out individual DELETEs with + * `Promise.allSettled`: a partial failure surfaces a precise summary + * ("Deleted 4 of 5") instead of the all-or-nothing "Could not clear". + * + * If the API later grows a `DELETE /todos?status=done` endpoint, swap + * the fan-out for a single call here — call sites don't change. + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { deleteTodoById } from '@/api-generated' +import { toast } from '@/shared/platform/toast' +import { todoInvalidations } from '../invalidations' +import { todoKeys } from '../keys' +import type { Todo } from '../schema' + +type ClearResult = { deleted: number; failed: number } + +export const useClearCompletedTodos = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (): Promise => { + const todos = qc.getQueryData(todoKeys.lists()) ?? [] + const ids = todos.filter((t) => t.is_completed).map((t) => t.id) + if (ids.length === 0) return { deleted: 0, failed: 0 } + + const results = await Promise.allSettled(ids.map((id) => deleteTodoById({ path: { id }, throwOnError: true }))) + const failed = results.filter((r) => r.status === 'rejected').length + return { deleted: ids.length - failed, failed } + }, + onSuccess: ({ deleted, failed }) => { + // The MutationCache emits a generic `successMessage`; we want a + // contextual one here, so drive the toast manually and skip the + // global message via `meta.skipNotification`. + if (deleted === 0 && failed === 0) return + if (failed === 0) { + toast.success(`Cleared ${deleted} completed ${deleted === 1 ? 'todo' : 'todos'}`) + } else if (deleted === 0) { + toast.error(`Could not clear ${failed} completed ${failed === 1 ? 'todo' : 'todos'}`) + } else { + toast.info(`Cleared ${deleted}, failed ${failed}`) + } + }, + meta: { + invalidates: todoInvalidations.onDelete(), + skipNotification: true, + // Surfaced on a *thrown* error (network down, etc.); per-item + // failures don't reach here thanks to allSettled. + errorMessage: 'Could not clear completed todos', + }, + }) +} diff --git a/web/src/features/todos/api/mutations/useCreateTodo.ts b/web/src/features/todos/api/mutations/useCreateTodo.ts new file mode 100644 index 00000000..e0f7caa8 --- /dev/null +++ b/web/src/features/todos/api/mutations/useCreateTodo.ts @@ -0,0 +1,20 @@ +import { useMutation } from '@tanstack/react-query' +import { createTodoMutation } from '@/api-generated/@tanstack/react-query.gen' +import { todoInvalidations } from '../invalidations' + +/** + * Errors are surfaced inline by `` via `` — the + * action site is the right place to show what failed. Skipping the global + * toast avoids double-surfacing the same event. + * + * Variables: `{ body: { title } }` (generated SDK shape). Adapt at the + * call site rather than masking the SDK contract here. + */ +export const useCreateTodo = () => + useMutation({ + ...createTodoMutation(), + meta: { + invalidates: todoInvalidations.onCreate(), + successMessage: 'Todo created', + }, + }) diff --git a/web/src/features/todos/api/mutations/useDeleteTodo.ts b/web/src/features/todos/api/mutations/useDeleteTodo.ts new file mode 100644 index 00000000..e9a717fd --- /dev/null +++ b/web/src/features/todos/api/mutations/useDeleteTodo.ts @@ -0,0 +1,13 @@ +import { useMutation } from '@tanstack/react-query' +import { deleteTodoByIdMutation } from '@/api-generated/@tanstack/react-query.gen' +import { todoInvalidations } from '../invalidations' + +export const useDeleteTodo = () => + useMutation({ + ...deleteTodoByIdMutation(), + meta: { + invalidates: todoInvalidations.onDelete(), + successMessage: 'Todo deleted', + errorMessage: 'Could not delete todo', + }, + }) diff --git a/web/src/features/todos/api/mutations/useToggleTodo.ts b/web/src/features/todos/api/mutations/useToggleTodo.ts new file mode 100644 index 00000000..53375b2d --- /dev/null +++ b/web/src/features/todos/api/mutations/useToggleTodo.ts @@ -0,0 +1,51 @@ +/** + * Optimistic toggle — instantly flips the checkbox before the server + * confirms. Snapshot+rollback on error; meta-driven invalidate on success. + * + * Cache invalidation and toast notifications are *declared* via `meta` + * and executed by the global `MutationCache` handlers in + * `shared/platform/api/createBaseQueryClient`. Don't add `onSuccess: () => qc.invalidateQueries(...)` here. */ + +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { updateTodoById } from '@/api-generated' +import { todoInvalidations } from '../invalidations' +import { todoKeys } from '../keys' +import type { Todo } from '../schema' + +type ToggleContext = { snapshots: ReadonlyArray } + +export const useToggleTodo = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (todo) => + updateTodoById({ + path: { id: todo.id }, + body: { title: todo.title, is_completed: !todo.is_completed }, + throwOnError: true, + }), + onMutate: async (todo) => { + await qc.cancelQueries({ queryKey: todoKeys.lists() }) + const snapshots = qc.getQueriesData({ + queryKey: todoKeys.lists(), + }) + for (const [key, list] of snapshots) { + if (!list) continue + qc.setQueryData( + key, + list.map((t) => (t.id === todo.id ? { ...t, is_completed: !t.is_completed } : t)) + ) + } + return { snapshots } + }, + onError: (_e, _v, ctx) => { + if (!ctx) return + for (const [key, value] of ctx.snapshots) qc.setQueryData(key, value) + }, + meta: { + invalidates: todoInvalidations.onToggle(), + // The optimistic UI flip is the success feedback — no toast needed. + // We still surface a toast on failure so the rollback isn't silent. + errorMessage: 'Could not update todo', + }, + }) +} diff --git a/web/src/features/todos/api/queries/todoListQuery.ts b/web/src/features/todos/api/queries/todoListQuery.ts new file mode 100644 index 00000000..9f2ab017 --- /dev/null +++ b/web/src/features/todos/api/queries/todoListQuery.ts @@ -0,0 +1,24 @@ +/** + * `queryOptions()` factories — shared by route loaders and component hooks. + * + * Loaders call `queryClient.ensureQueryData(todoListQuery())` before render + * so the page paints with data already in cache; the component then calls + * `useSuspenseQuery(todoListQuery())` and gets an instant cache hit. Same + * factory, same key, no drift. + * + * The base `queryOptions` (queryKey + queryFn) come from the generated + * TanStack Query plugin, so a server-side route or schema change surfaces + * as a compile error here instead of a runtime mismatch. We layer on + * cross-cutting `meta` (used by `shared/platform/api/createBaseQueryClient` for toasts). + */ + +import { getAllTodosOptions } from '@/api-generated/@tanstack/react-query.gen' + +export const todoListQuery = () => ({ + ...getAllTodosOptions(), + meta: { + // Background refetch failures only — initial-load errors are + // surfaced through the component's `error` state. + errorMessage: 'Could not refresh todos', + }, +}) diff --git a/web/src/features/todos/api/queries/useTodos.ts b/web/src/features/todos/api/queries/useTodos.ts new file mode 100644 index 00000000..6f35686b --- /dev/null +++ b/web/src/features/todos/api/queries/useTodos.ts @@ -0,0 +1,11 @@ +/** + * Suspense-driven hook for the todo list. The route loader has already + * primed the cache via `ensureQueryData(todoListQuery())`, so consumers + * never see a loading state. Errors propagate to the nearest + * `errorElement` (RouteErrorBoundary). + */ + +import { useSuspenseQuery } from '@tanstack/react-query' +import { todoListQuery } from './todoListQuery' + +export const useTodos = () => useSuspenseQuery(todoListQuery()) diff --git a/web/src/features/todos/api/schema.ts b/web/src/features/todos/api/schema.ts new file mode 100644 index 00000000..16d3c79a --- /dev/null +++ b/web/src/features/todos/api/schema.ts @@ -0,0 +1,18 @@ +/** + * Domain types and runtime validators for the todos feature, sourced + * from the generated SDK so a server-side schema change surfaces here + * at compile time (TS) and at runtime (Zod) instead of crashing + * components reading `todo.is_completed` of undefined. + * + * Re-exporting from `@/api-generated/zod.gen` keeps a single feature- + * level boundary file: components and hooks import `Todo` from here, + * never directly from `api-generated`. + */ + +import type { z } from 'zod' +import { zGetTodoAllResponse } from '@/api-generated/zod.gen' + +export const todoSchema = zGetTodoAllResponse +export const todoListSchema = zGetTodoAllResponse.array() + +export type Todo = z.infer diff --git a/web/src/features/todos/index.ts b/web/src/features/todos/index.ts new file mode 100644 index 00000000..189ff64d --- /dev/null +++ b/web/src/features/todos/index.ts @@ -0,0 +1,14 @@ +// Public surface — for cross-feature imports only (e.g. `src/routes/`). +// Files inside this feature must use relative paths instead of importing from +// here; otherwise we get circular imports and the lazy-chunked page bundle +// pulls in everything reachable from this barrel. +// +// Enforced by the `no-restricted-imports` rule in `eslint.config.mts` — +// outside files cannot deep-import `@/features//...`. +// +// Code-splitting is now handled by the TanStack Router Vite plugin's +// `autoCodeSplitting` — the chunk boundary is the route file (`routes/index.tsx`), +// not the import path. Re-exporting `TodosPage` here is therefore safe. +export { todoListQuery } from './api' +export type { Todo } from './api/schema' +export { TodosPage } from './pages/TodosPage/TodosPage' diff --git a/web/src/features/todos/pages/TodosPage/TodosPage.error.test.tsx b/web/src/features/todos/pages/TodosPage/TodosPage.error.test.tsx new file mode 100644 index 00000000..e869e29f --- /dev/null +++ b/web/src/features/todos/pages/TodosPage/TodosPage.error.test.tsx @@ -0,0 +1,80 @@ +/** + * Error-path test: when the todos API returns 500, the route should + * surface the error through `RouteErrorBoundary` → `UnexpectedErrorPage` + * → `ErrorPanel` (with the traceId), proving the + * httpClient → ApiError → Suspense → errorComponent pipeline is intact. + */ + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from '@tanstack/react-router' +import { render, screen } from '@testing-library/react' +import { HttpResponse, http } from 'msw' +import { Suspense } from 'react' +import { describe, expect, test } from 'vitest' +import { z } from 'zod' +import { RouteErrorBoundary } from '@/app/error-pages/RouteErrorBoundary/RouteErrorBoundary' +import { createTelemetry, TelemetryBackend, TelemetryProvider } from '@/shared/platform/telemetry' +import { server } from '../../../../mocks/server' +import { TodosPage } from './TodosPage' + +const testTelemetry = createTelemetry(TelemetryBackend.None) + +const buildRouter = () => { + const rootRoute = createRootRoute({ + component: () => ( + Loading…}> + + + ), + errorComponent: RouteErrorBoundary, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: z.object({ + status: z.enum(['all', 'active', 'done']).catch('all').default('all'), + }), + component: TodosPage, + }) + return createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) +} + +const renderRoute = () => { + // Isolated QueryClient with retries disabled so the 500 surfaces fast + // and previous tests' cached `[]` doesn't satisfy the query. + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return render( + + + + + + ) +} + +describe('TodosPage — error path', () => { + test('renders an error panel when the API returns 500', async () => { + server.use( + http.get('http://localhost/todos', () => + HttpResponse.json({ message: 'boom' }, { status: 500, headers: { 'x-trace-id': 'trace-xyz' } }) + ) + ) + + renderRoute() + + const alert = await screen.findByRole('alert', {}, { timeout: 5000 }) + expect(alert.textContent).toMatch(/server error|internal server error/i) + }) +}) diff --git a/web/src/features/todos/pages/TodosPage/TodosPage.hooks.ts b/web/src/features/todos/pages/TodosPage/TodosPage.hooks.ts new file mode 100644 index 00000000..0e2125c4 --- /dev/null +++ b/web/src/features/todos/pages/TodosPage/TodosPage.hooks.ts @@ -0,0 +1,48 @@ +import { getRouteApi } from '@tanstack/react-router' +import { useCallback, useMemo } from 'react' +import { useTodos } from '../../api' +import type { TodoStatusFilter } from './components/TodosFilter/TodosFilter.types' +import { filterTodosByStatus } from './TodosPage.utils' + +// Bound at the top of the file — `getRouteApi` returns a typed accessor +// for the route's `validateSearch` schema and loader data. +const todosRoute = getRouteApi('/todos') + +/** + * Owns the `?status=` URL state and exposes the filtered todo list to + * the route component. Keeps the page itself a pure composition. + * + * URL writes go through `useNavigate({ from: '/todos' })`; passing the + * same default value back (`'all'`) is fine — TanStack Router does not + * write defaulted keys to the URL, so we never end up with `?status=all`. + */ +export const useTodosFilter = () => { + const { data: todos } = useTodos() + const { status } = todosRoute.useSearch() + const navigate = todosRoute.useNavigate() + + const visibleTodos = useMemo(() => filterTodosByStatus(todos, status), [todos, status]) + const completedCount = useMemo(() => todos.filter((t) => t.is_completed).length, [todos]) + + // Stable identity so memoised consumers (TodosFilter) don't re-render + // when their parent does for unrelated reasons. + const setStatusFilter = useCallback( + (next: TodoStatusFilter) => { + navigate({ + // `replace: true` keeps filter changes out of the back-button + // history — switching tabs is not a navigation event. + replace: true, + search: (prev) => ({ ...prev, status: next }), + }) + }, + [navigate] + ) + + return { + status: status as TodoStatusFilter, + setStatusFilter, + visibleTodos, + totalCount: todos.length, + completedCount, + } +} diff --git a/web/src/features/todos/pages/TodosPage/TodosPage.test.tsx b/web/src/features/todos/pages/TodosPage/TodosPage.test.tsx new file mode 100644 index 00000000..ac350fe1 --- /dev/null +++ b/web/src/features/todos/pages/TodosPage/TodosPage.test.tsx @@ -0,0 +1,215 @@ +/** + * Feature-level integration test: renders TodosPage inside `Providers` + * (real QueryClient + httpClient + MSW) and exercises the create, filter, + * mutation, and toast flows. + * + * URL state goes through a real TanStack Router memory router whose + * `/todos` route mirrors `src/app/routes/todos.tsx` (validateSearch + + * component) but skips the loader so each test starts from a known cache. + * + * The error-path test lives separately in `TodosPage.error.test.tsx` + * because it uses an isolated `QueryClient` to surface 5xx fast. + */ + +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from '@tanstack/react-router' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Suspense } from 'react' +import { beforeEach, describe, expect, test } from 'vitest' +import { z } from 'zod' +import { createQueryClient } from '@/app/bootstrap/createQueryClient' +import { Providers } from '@/app/bootstrap/Providers' +import { FEATURE_FLAGS } from '@/config/featureFlags' +import { userQuery } from '@/shared/platform/auth' +import { FeatureFlagsProvider } from '@/shared/platform/feature-flags' +import { createTelemetry, TelemetryBackend } from '@/shared/platform/telemetry' +import { toastStore } from '@/shared/platform/toast' +import { db } from '../../../../mocks/db' +import { TodosPage } from './TodosPage' + +const telemetry = createTelemetry(TelemetryBackend.None) +let queryClient = createQueryClient({ telemetry }) + +// Test-local route tree mirroring `src/app/routes/todos.tsx` minus the +// loader. `getRouteApi('/todos')` inside `useTodosFilter` resolves +// against whatever router context is mounted, so the route id MUST be +// `'/todos'`. +const buildRouter = (initialEntry: string) => { + const rootRoute = createRootRoute({ + component: () => ( + Loading…}> + + + ), + }) + const todosRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/todos', + validateSearch: z.object({ + status: z.enum(['all', 'active', 'done']).catch('all').default('all'), + }), + component: TodosPage, + }) + return createRouter({ + routeTree: rootRoute.addChildren([todosRoute]), + history: createMemoryHistory({ initialEntries: [initialEntry] }), + }) +} + +const renderPage = (search = '') => + render( + + + + + + ) + +// Bypass the route loader: hydrate the user directly so +// permission-gated controls render. +const seedAdminUser = () => { + queryClient.setQueryData(userQuery().queryKey, { + id: 'anonymous', + name: 'Local developer', + roles: ['admin'], + }) +} + +describe('TodosPage', () => { + beforeEach(() => { + // Fresh client per test so cached `[]` from the previous test doesn't + // leak across boundaries. + queryClient = createQueryClient({ telemetry }) + }) + + test('renders existing todos from the API', async () => { + db.todos = [ + { id: '1', user_id: 'u1', title: 'Buy milk', is_completed: false }, + { id: '2', user_id: 'u1', title: 'Walk dog', is_completed: true }, + ] + + renderPage() + + await waitFor(() => { + expect(screen.getByText('Buy milk')).toBeDefined() + expect(screen.getByText('Walk dog')).toBeDefined() + }) + }) + + test('adds a new todo via the form', async () => { + const user = userEvent.setup() + renderPage() + + const input = await screen.findByPlaceholderText('Add Task') + await user.type(input, 'Write docs') + await user.click(screen.getByRole('button', { name: 'Add' })) + + await waitFor(() => { + expect(screen.getByText('Write docs')).toBeDefined() + }) + }) + + test('filters todos by status from the URL', async () => { + db.todos = [ + { id: '1', user_id: 'u1', title: 'Buy milk', is_completed: false }, + { id: '2', user_id: 'u1', title: 'Walk dog', is_completed: true }, + ] + + renderPage('?status=done') + + await waitFor(() => { + expect(screen.getByText('Walk dog')).toBeDefined() + }) + expect(screen.queryByText('Buy milk')).toBeNull() + }) + + test('changing the filter hides non-matching todos', async () => { + const user = userEvent.setup() + db.todos = [ + { id: '1', user_id: 'u1', title: 'Buy milk', is_completed: false }, + { id: '2', user_id: 'u1', title: 'Walk dog', is_completed: true }, + ] + + renderPage() + + await waitFor(() => { + expect(screen.getByText('Buy milk')).toBeDefined() + }) + + await user.click(screen.getByLabelText('Active')) + + await waitFor(() => { + expect(screen.queryByText('Walk dog')).toBeNull() + }) + expect(screen.getByText('Buy milk')).toBeDefined() + }) + + describe('mutations', () => { + // Toggle and delete go through optimistic mutations + MSW; assert the + // UI updates immediately and the row is gone after delete. + beforeEach(seedAdminUser) + + test('toggles a todo', async () => { + const user = userEvent.setup() + db.todos = [{ id: '1', user_id: 'u1', title: 'Buy milk', is_completed: false }] + renderPage() + + await screen.findByText('Buy milk') + await user.click(screen.getByRole('button', { name: /Mark as done/i })) + + await waitFor(() => { + expect(screen.getByText('Done')).toBeDefined() + }) + }) + + test('deletes a todo', async () => { + const user = userEvent.setup() + db.todos = [{ id: '1', user_id: 'u1', title: 'Buy milk', is_completed: false }] + renderPage() + + await screen.findByText('Buy milk') + await user.click(screen.getByRole('button', { name: /Remove/i })) + + await waitFor(() => { + expect(screen.queryByText('Buy milk')).toBeNull() + }) + }) + }) + + describe('toast wiring', () => { + /* + * Locks the meta-driven toast pipeline end-to-end: + * useDeleteTodo (meta.successMessage) + * → MutationCache.onSuccess in shared/platform/api/createBaseQueryClient + * → toast.success → toastStore + * → ToastContainer (mounted by Providers) + * + * If anyone breaks any link in that chain, this test fails. The unit + * tests in `shared/platform/toast/toastStore.test.ts` cover the store + * in isolation; this one proves the wiring. + */ + beforeEach(() => { + seedAdminUser() + toastStore._reset() + }) + + test('shows a success toast after deleting a todo', async () => { + const user = userEvent.setup() + db.todos = [{ id: '1', user_id: 'u1', title: 'Buy milk', is_completed: false }] + renderPage() + + await screen.findByText('Buy milk') + await user.click(screen.getByRole('button', { name: /Remove/i })) + + const toast = await waitFor(() => screen.getByTestId('toast-success')) + expect(toast.textContent).toContain('Todo deleted') + }) + }) +}) diff --git a/web/src/features/todos/pages/TodosPage/TodosPage.tsx b/web/src/features/todos/pages/TodosPage/TodosPage.tsx new file mode 100644 index 00000000..b9a8e3b1 --- /dev/null +++ b/web/src/features/todos/pages/TodosPage/TodosPage.tsx @@ -0,0 +1,65 @@ +import { Button } from '@equinor/eds-core-react' +import { FeatureFlagName } from '@/config/featureFlags' +import { useClearCompletedTodos } from '@/features/todos/api' +import { PageHeader } from '@/shared/components/PageHeader/PageHeader' +import { FeatureToggle } from '@/shared/platform/feature-flags' +import { NewTodoForm } from './components/NewTodoForm/NewTodoForm' +import { TodoList } from './components/TodoList/TodoList' +import { TodosFilter } from './components/TodosFilter/TodosFilter' +import { useTodosFilter } from './TodosPage.hooks' + +/** + * Route component. Composition only: the data fetch + URL-state + + * filtering live in `useTodosFilter`; this file just wires hooks to + * presentational children. + * + * The create form is hidden behind a feature flag — demonstrates the + * `` wiring; flip `NEW_TODO_FORM` in `featureFlags.ts`. + */ +export const TodosPage = () => { + const { status, setStatusFilter, visibleTodos, totalCount, completedCount } = useTodosFilter() + const clearCompleted = useClearCompletedTodos() + + // Empty state has two flavours: a fresh list ("add your first") vs a + // filter that hides everything ("no todos"). The user needs + // different next steps in each case. + const emptyMessage = + totalCount === 0 + ? 'No todos yet — add your first one above.' + : status === 'done' + ? 'No completed todos yet.' + : status === 'active' + ? 'Nothing to do — well done!' + : 'No todos match this filter.' + + return ( +
+ + + +
+ +
+
+ +
+ + {/* Role-gated example: `BULK_TOOLS` is admin-only (see featureFlags.ts). + * The flag is the gate; permission is still enforced server-side. */} + + {completedCount > 0 && ( + + )} + +
+ + {/* aria-busy lets screen readers announce the bulk operation; + * visual feedback is handled per-item by TodoItem. */} +
+ +
+
+ ) +} diff --git a/web/src/features/todos/pages/TodosPage/TodosPage.utils.test.ts b/web/src/features/todos/pages/TodosPage/TodosPage.utils.test.ts new file mode 100644 index 00000000..5e800472 --- /dev/null +++ b/web/src/features/todos/pages/TodosPage/TodosPage.utils.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'vitest' +import type { Todo } from '../../api' +import { filterTodosByStatus } from './TodosPage.utils' + +const todos: Todo[] = [ + { id: '1', user_id: 'u1', title: 'Buy milk', is_completed: false }, + { id: '2', user_id: 'u1', title: 'Walk dog', is_completed: true }, + { id: '3', user_id: 'u1', title: 'Write docs', is_completed: false }, +] + +describe('filterTodosByStatus', () => { + test("returns all todos when status is 'all'", () => { + expect(filterTodosByStatus(todos, 'all')).toHaveLength(3) + }) + + test("returns only incomplete todos when status is 'active'", () => { + expect(filterTodosByStatus(todos, 'active').map((t) => t.id)).toEqual(['1', '3']) + }) + + test("returns only completed todos when status is 'done'", () => { + expect(filterTodosByStatus(todos, 'done').map((t) => t.id)).toEqual(['2']) + }) +}) diff --git a/web/src/features/todos/pages/TodosPage/TodosPage.utils.ts b/web/src/features/todos/pages/TodosPage/TodosPage.utils.ts new file mode 100644 index 00000000..8ac99731 --- /dev/null +++ b/web/src/features/todos/pages/TodosPage/TodosPage.utils.ts @@ -0,0 +1,12 @@ +import type { Todo } from '../../api' +import type { TodoStatusFilter } from './components/TodosFilter/TodosFilter.types' + +/** + * Pure filter for the todo list. Lives outside the page so it can be + * unit-tested without React, and reused if another view ever needs the + * same predicate. + */ +export const filterTodosByStatus = (todos: readonly Todo[], status: TodoStatusFilter): Todo[] => { + if (status === 'all') return [...todos] + return todos.filter((todo) => (status === 'done' ? todo.is_completed : !todo.is_completed)) +} diff --git a/web/src/features/todos/pages/TodosPage/components/NewTodoForm/NewTodoForm.tsx b/web/src/features/todos/pages/TodosPage/components/NewTodoForm/NewTodoForm.tsx new file mode 100644 index 00000000..5cee2265 --- /dev/null +++ b/web/src/features/todos/pages/TodosPage/components/NewTodoForm/NewTodoForm.tsx @@ -0,0 +1,60 @@ +/** + * Form for creating a new todo. Uses react-hook-form + Zod resolver per + * the v7 §4 forms convention. Submission goes through `useCreateTodo`, + * which is optimistic — the new row appears in the list before the + * server confirms. + * + * Validation reuses the OpenAPI-generated `zAddTodoRequest` schema as + * the single source of truth for field constraints (length, required- + * ness), so the client cannot drift from the server contract. A leading/ + * trailing-whitespace trim is layered on via a `pipe` so a string of + * spaces fails `min(1)` rather than reaching the API. + */ + +import { Button, Input, Typography } from '@equinor/eds-core-react' +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +import { useCreateTodo } from '@/features/todos/api' +import { ErrorPanel } from '@/shared/components/ErrorPanel/ErrorPanel' +import { type FormValues, schema } from './NewTodoForm.utils' + +export const NewTodoForm = () => { + const create = useCreateTodo() + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { title: '' }, + }) + + const onSubmit = handleSubmit(async ({ title }) => { + await create.mutateAsync({ body: { title } }) + reset() + }) + + return ( +
+
+ + +
+ {errors.title && ( + + {errors.title.message} + + )} + {create.error && } + + ) +} diff --git a/web/src/features/todos/pages/TodosPage/components/NewTodoForm/NewTodoForm.utils.ts b/web/src/features/todos/pages/TodosPage/components/NewTodoForm/NewTodoForm.utils.ts new file mode 100644 index 00000000..f67904a1 --- /dev/null +++ b/web/src/features/todos/pages/TodosPage/components/NewTodoForm/NewTodoForm.utils.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' +import { zAddTodoRequest } from '@/api-generated/zod.gen' + +export const schema = z.object({ + title: z.string().trim().pipe(zAddTodoRequest.shape.title), +}) + +export type FormValues = z.infer diff --git a/web/src/features/todos/pages/TodosPage/components/TodoItem/TodoItem.tsx b/web/src/features/todos/pages/TodosPage/components/TodoItem/TodoItem.tsx new file mode 100644 index 00000000..8c49eadc --- /dev/null +++ b/web/src/features/todos/pages/TodosPage/components/TodoItem/TodoItem.tsx @@ -0,0 +1,70 @@ +import { Typography } from '@equinor/eds-core-react' +import { done, remove_outlined, undo } from '@equinor/eds-icons' +import { useRef } from 'react' +import { isAllowed } from '@/config/accessControl' +import { type Todo, useDeleteTodo, useToggleTodo } from '@/features/todos/api' +import { IconButton } from '@/shared/components/IconButton/IconButton' +import { useCurrentUser } from '@/shared/platform/auth' +import { cn } from '@/shared/utils/cn' + +export const TodoItem = ({ todo }: { todo: Todo }) => { + const toggleMutation = useToggleTodo() + const deleteMutation = useDeleteTodo() + const rootRef = useRef(null) + + // UI hint only — server enforces. The root route's `beforeLoad` + // ensures the user is in cache, so this resolves synchronously. + const { data: user } = useCurrentUser() + const canUpdate = isAllowed(user, 'todos', 'update', todo) + const canDelete = isAllowed(user, 'todos', 'delete', todo) + + // Per-item pending state. The mutation hooks are global, so we identify + // *which* item is in flight by comparing `variables` to this todo. + const isToggling = toggleMutation.isPending && toggleMutation.variables?.id === todo.id + const isDeleting = deleteMutation.isPending && deleteMutation.variables?.path?.id === todo.id + const isBusy = isToggling || isDeleting + + // After delete, focus would otherwise fall to . Move it to the + // sibling
  • (next, then previous) so keyboard users keep their place. + // Computed *before* the mutation fires so the DOM is still intact. + const handleDelete = () => { + const li = rootRef.current?.closest('li') + const target = (li?.nextElementSibling ?? li?.previousElementSibling) as HTMLElement | null + deleteMutation.mutate( + { path: { id: todo.id } }, + { + onSuccess: () => target?.querySelector('button')?.focus(), + } + ) + } + + return ( +
    +
    + + {todo.title} + + + {todo.is_completed ? 'Done' : 'Todo'} + +
    + {canUpdate && ( + toggleMutation.mutate(todo)} + disabled={isBusy} + /> + )} + {canDelete && } +
    + ) +} diff --git a/web/src/features/todos/pages/TodosPage/components/TodoList/TodoList.tsx b/web/src/features/todos/pages/TodosPage/components/TodoList/TodoList.tsx new file mode 100644 index 00000000..05596459 --- /dev/null +++ b/web/src/features/todos/pages/TodosPage/components/TodoList/TodoList.tsx @@ -0,0 +1,26 @@ +import { EmptyState } from '@/shared/components/EmptyState/EmptyState' +import { TodoItem } from '../TodoItem/TodoItem' +import type { TodoListProps } from './TodoList.types' + +/** + * Presentational. The page owns the data fetch and passes the list + * in — keeps this component trivially testable and reusable. + */ +export const TodoList = ({ todos, emptyMessage = 'No todos to show.' }: TodoListProps) => { + if (todos.length === 0) { + return ( +
    + +
    + ) + } + return ( +
      + {todos.map((todo) => ( +
    • + +
    • + ))} +
    + ) +} diff --git a/web/src/features/todos/pages/TodosPage/components/TodoList/TodoList.types.ts b/web/src/features/todos/pages/TodosPage/components/TodoList/TodoList.types.ts new file mode 100644 index 00000000..e1ed9a06 --- /dev/null +++ b/web/src/features/todos/pages/TodosPage/components/TodoList/TodoList.types.ts @@ -0,0 +1,7 @@ +import type { Todo } from '@/features/todos/api' + +export type TodoListProps = { + todos: Todo[] + /** Shown when `todos` is empty. Owner picks the wording for context. */ + emptyMessage?: string +} diff --git a/web/src/features/todos/pages/TodosPage/components/TodosFilter/TodosFilter.tsx b/web/src/features/todos/pages/TodosPage/components/TodosFilter/TodosFilter.tsx new file mode 100644 index 00000000..1b3678b3 --- /dev/null +++ b/web/src/features/todos/pages/TodosPage/components/TodosFilter/TodosFilter.tsx @@ -0,0 +1,52 @@ +import { useId } from 'react' +import { cn } from '@/shared/utils/cn' +import type { TodosFilterProps } from './TodosFilter.types' +import { OPTIONS } from './TodosFilter.utils' + +/** + * Segmented control for status filter. Built on real `` + * elements so we get keyboard arrow navigation, form semantics, and screen + * reader announcements for free — the visible buttons are just styled + * `