diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbbb3ee..c7de58b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,6 @@ name: CI on: - push: - branches: [main] pull_request: branches: [main] @@ -50,12 +48,28 @@ jobs: - name: Install dependencies run: npm ci + - name: Check formatting + run: npm run format:check + + - name: Lint + run: npm run lint + + - name: Verify registry signatures + run: npm audit signatures + - name: Run tests run: npm test - name: Build run: npm run build + # src/routeTree.gen.ts is generated but committed, and carries + # @ts-nocheck — so a deleted route file with a stale tree still type + # checks. The build above regenerates it; this fails if that produced + # a diff, which is the only signal that the committed tree is stale. + - name: Generated route tree is up to date + run: git diff --exit-code -- src/routeTree.gen.ts + # Builds the MAUI host (src/AppName.Maui) per target platform. Each platform's # SDK is only available on certain OSes: iOS and MacCatalyst require Xcode on a # macOS runner, Windows (WinUI) requires the Windows SDK on a windows runner, and diff --git a/CLAUDE.md b/CLAUDE.md index f6c67d1..65fdf16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,3 +27,7 @@ These rules apply **only to the C# project** (not the React/web code). ## C# unit testing - When writing, reviewing, or refactoring **C# unit tests**, follow [docs/TestingConventionGuidance.md](docs/TestingConventionGuidance.md) — it is the authoritative guidance for this solution's xUnit + Moq conventions (the nested `Fixture` pattern, when to mock vs. use a real/hand-written double, test naming, and file placement). Read it before adding or changing tests. + + +## Typescript Styling +Format the code for human readability after implementing it. Group related statements into logical sections separated by a single blank line. Prefer multi-line if statements over one-line returns, and optimize for maintainability rather than minimal diff. diff --git a/justfile b/justfile index ff71a39..fa43453 100644 --- a/justfile +++ b/justfile @@ -1,10 +1,5 @@ -# justfile — reusable dev commands for the MAUI + React solution. -# Install `just`: `cargo install just`, or grab a binary from https://github.com/casey/just/releases -# Run `just` (or `just --list`) to see all recipes. - set shell := ["bash", "-uc"] -# Host has memory pressure: dotnet must run single-proc with the MSBuild server/node-reuse off. export DOTNET_CLI_USE_MSBUILD_SERVER := "0" export MSBUILDDISABLENODEREUSE := "1" @@ -74,33 +69,3 @@ migrate-update: # List migrations and which are applied (`--no-connect` to skip the database round-trip). migrate-list: dotnet ef migrations list --project src/AppName.Infrastructure --startup-project src/AppName.Infrastructure - -# ---- Docker (Android) --------------------------------------------------------- -# Builds the Android head inside a container that carries the Android SDK + -# `maui-android` workload, so the host never needs the Android SDK/Java -# installed. The repo is bind-mounted in so build outputs (and the APK) land -# in the normal working tree. See docker/README.md for details/caveats. - -# Build the docker image (slow first time; several GB). Re-run after editing docker/android.Dockerfile. -docker-android-image: - docker build -t appname-maui-android -f docker/android.Dockerfile . - -# Build the Android head inside the container; output lands in src/AppName.Maui/bin/Release/net10.0-android/. -docker-build-android: - docker run --rm -v "$PWD":/src -w /src appname-maui-android dotnet build src/AppName.Maui/AppName.Maui.csproj -f net10.0-android -c Release - -# Open an interactive shell in the container (SDK + Android tooling + workload available). -docker-shell: - docker run --rm -it -v "$PWD":/src -w /src appname-maui-android bash - -# ---- Housekeeping ------------------------------------------------------------ - -# Restore NuGet + npm dependencies. -restore: - dotnet restore -m:1 - cd src/web && npm install - -# Remove build outputs. -clean: - dotnet clean -m:1 - rm -rf src/web/node_modules/.vite src/AppName.Maui/Resources/Raw/web diff --git a/src/AppName.Api/Endpoints/AuthEndpoints.cs b/src/AppName.Api/Endpoints/AuthEndpoints.cs index d5a5295..5340a53 100644 --- a/src/AppName.Api/Endpoints/AuthEndpoints.cs +++ b/src/AppName.Api/Endpoints/AuthEndpoints.cs @@ -2,6 +2,7 @@ using System.Security.Claims; using System.Text.Json.Serialization; using AppName.Api.Authentication; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.IdentityModel.Tokens; namespace AppName.Api.Endpoints; @@ -20,7 +21,7 @@ public static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group) // and MAUI clients can obtain a bearer token while developing locally. // This must be replaced by a real identity provider (e.g. OIDC/Entra ID) // before any non-local deployment — there is no credential check here. - group.MapPost("/auth/token", (IConfiguration config) => + group.MapPost("/auth/token", Ok (IConfiguration config) => { var jwt = JwtSettings.FromConfiguration(config); @@ -45,7 +46,7 @@ public static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group) var accessToken = new JwtSecurityTokenHandler().WriteToken(token); - return Results.Ok(new TokenResponse(accessToken, (int)TimeSpan.FromHours(1).TotalSeconds)); + return TypedResults.Ok(new TokenResponse(accessToken, (int)TimeSpan.FromHours(1).TotalSeconds)); }) .AllowAnonymous() .WithName("MintDevToken"); @@ -59,7 +60,7 @@ public static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group) /// /// The minted bearer token. /// Token lifetime in seconds. - private sealed record TokenResponse( + internal sealed record TokenResponse( [property: JsonPropertyName("access_token")] string AccessToken, [property: JsonPropertyName("expires_in")] int ExpiresIn); } diff --git a/src/AppName.Api/Endpoints/BondEndpoints.cs b/src/AppName.Api/Endpoints/BondEndpoints.cs index 5b0c35c..e09e665 100644 --- a/src/AppName.Api/Endpoints/BondEndpoints.cs +++ b/src/AppName.Api/Endpoints/BondEndpoints.cs @@ -1,4 +1,6 @@ +using AppName.Application.Dtos; using AppName.Application.UseCases.Bonds; +using Microsoft.AspNetCore.Http.HttpResults; namespace AppName.Api.Endpoints; @@ -13,34 +15,34 @@ public static class BondEndpoints /// public static RouteGroupBuilder MapBondEndpoints(this RouteGroupBuilder group) { - group.MapGet("/valuations", async (GetValuationsUseCase useCase, CancellationToken ct) => + group.MapGet("/valuations", async Task>> (GetValuationsUseCase useCase, CancellationToken ct) => { var valuations = await useCase.ExecuteAsync(ct); - return Results.Ok(valuations); + return TypedResults.Ok(valuations); }) .RequireAuthorization() .WithName("GetValuations"); - group.MapGet("/valuations/{symbol}", async (string symbol, GetConversionValuationUseCase useCase, CancellationToken ct) => + group.MapGet("/valuations/{symbol}", async Task, NotFound>> (string symbol, GetConversionValuationUseCase useCase, CancellationToken ct) => { var valuation = await useCase.ExecuteAsync(symbol, ct); - return valuation is null ? Results.NotFound() : Results.Ok(valuation); + return valuation is null ? TypedResults.NotFound() : TypedResults.Ok(valuation); }) .RequireAuthorization() .WithName("GetValuationBySymbol"); - group.MapPost("/valuations/refresh", async (IRefreshAllBondsUseCase useCase, CancellationToken ct) => + group.MapPost("/valuations/refresh", async Task (IRefreshAllBondsUseCase useCase, CancellationToken ct) => { await useCase.ExecuteAsync(ct); - return Results.Accepted(); + return TypedResults.Accepted((string?)null); }) .RequireAuthorization() .WithName("RefreshAllValuations"); - group.MapPost("/bonds/{symbol}/refresh", async (string symbol, RefreshBondDataUseCase useCase, CancellationToken ct) => + group.MapPost("/bonds/{symbol}/refresh", async Task (string symbol, RefreshBondDataUseCase useCase, CancellationToken ct) => { await useCase.ExecuteAsync(symbol, ct); - return Results.Accepted(); + return TypedResults.Accepted((string?)null); }) .RequireAuthorization() .WithName("RefreshBondData"); diff --git a/src/AppName.Api/OpenApi/DecimalSchemaTransformer.cs b/src/AppName.Api/OpenApi/DecimalSchemaTransformer.cs new file mode 100644 index 0000000..74f6fe0 --- /dev/null +++ b/src/AppName.Api/OpenApi/DecimalSchemaTransformer.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace AppName.Api.OpenApi; + +/// +/// Narrows the OpenAPI schema for and of +/// from "number or string" down to "number" (or "number or null"). +/// By default, .NET's OpenAPI document generator describes every decimal as a +/// union of number and string plus a validation pattern, because a +/// decimal-typed property can in principle be serialized either way depending on +/// JsonSerializerOptions. This API never sets +/// , so +/// System.Text.Json always emits decimals as plain JSON numbers. Leaving the +/// string member in the schema is not defensive, it is inaccurate, and it forces +/// generated TypeScript clients to add a number | string coercion layer for values +/// that can never actually arrive as strings. Do not remove this transformer as dead code: +/// removing it silently reintroduces that phantom string arm into every generated client. +/// +public sealed class DecimalSchemaTransformer : IOpenApiSchemaTransformer +{ + /// + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + var type = context.JsonTypeInfo.Type; + + if (type != typeof(decimal) && type != typeof(decimal?)) + { + return Task.CompletedTask; + } + + schema.Type = type == typeof(decimal?) + ? JsonSchemaType.Number | JsonSchemaType.Null + : JsonSchemaType.Number; + + schema.Pattern = null; + + return Task.CompletedTask; + } +} diff --git a/src/AppName.Api/Program.cs b/src/AppName.Api/Program.cs index ad6c84f..1b5061e 100644 --- a/src/AppName.Api/Program.cs +++ b/src/AppName.Api/Program.cs @@ -1,6 +1,7 @@ using AppName.Api.Authentication; using AppName.Api.Cors; using AppName.Api.Endpoints; +using AppName.Api.OpenApi; using AppName.Application; using AppName.Infrastructure; using Scalar.AspNetCore; @@ -14,7 +15,10 @@ // --- OpenAPI ----------------------------------------------------------- -builder.Services.AddOpenApi(); +builder.Services.AddOpenApi(options => +{ + options.AddSchemaTransformer(); +}); // --- Auth (JWT bearer, dev stub) ---------------------------------------- diff --git a/src/web/.env.development b/src/web/.env.development new file mode 100644 index 0000000..54c522f --- /dev/null +++ b/src/web/.env.development @@ -0,0 +1 @@ +VITE_API_BASE_URL=http://localhost:5000 diff --git a/src/web/.gitignore b/src/web/.gitignore index a547bf3..ddb4ba9 100644 --- a/src/web/.gitignore +++ b/src/web/.gitignore @@ -12,6 +12,10 @@ dist dist-ssr *.local +# @tanstack/router-generator writes temp files here before renaming +# src/routeTree.gen.ts into place; a killed build leaves them behind. +.tanstack/ + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/src/web/.npmrc b/src/web/.npmrc new file mode 100644 index 0000000..6017760 --- /dev/null +++ b/src/web/.npmrc @@ -0,0 +1,24 @@ +# Supply chain: only install versions published more than 14 days ago. +# Malicious npm releases are typically detected and unpublished within hours to +# a few days, so a cooldown means we never resolve one. This governs lockfile +# construction only — `npm ci` replays the lockfile and is unaffected. +# Override consciously for a genuine emergency: npm install --min-release-age=0 +min-release-age=14 + +# No package in this tree needs an install script (fsevents is macOS-only and +# optional). Lifecycle hooks are the main supply-chain execution vector, so +# block them. Revisit if a dependency ever legitimately needs one. +ignore-scripts=true + +# openapi-typescript declares peerDependencies.typescript: ^5.x, but this repo +# pins typescript to ~6.0.2. That mismatch makes a plain `npm install` fail +# with ERESOLVE. Do NOT reach for --legacy-peer-deps to get past it: that flag +# disables peer resolution for the whole tree, not just this one conflict, and +# it silently prunes other packages' peer deps that legitimately need +# resolving (that is how @testing-library/dom and friends went missing here). +# Instead, package.json carries a scoped "overrides" entry that pins +# openapi-typescript's own view of typescript to the repo's version: +# "overrides": { "openapi-typescript": { "typescript": "$typescript" } } +# That satisfies npm's resolver for just this one edge without touching peer +# resolution anywhere else. Remove the override if openapi-typescript ships a +# release with a typescript@6 peer range. diff --git a/src/web/.oxlintrc.json b/src/web/.oxlintrc.json index 6fa991d..14553a8 100644 --- a/src/web/.oxlintrc.json +++ b/src/web/.oxlintrc.json @@ -1,8 +1,30 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", "plugins": ["react", "typescript", "oxc"], + "ignorePatterns": ["src/routeTree.gen.ts", "src/api/schema.gen.ts"], "rules": { "react/rules-of-hooks": "error", - "react/only-export-components": ["warn", { "allowConstantExport": true }] - } + "react/only-export-components": ["warn", { "allowConstantExport": true }], + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "react-router", + "message": "Routing is TanStack Router — import from @tanstack/react-router." + }, + { + "name": "react-router-dom", + "message": "Routing is TanStack Router — import from @tanstack/react-router." + } + ] + } + ] + }, + "overrides": [ + { + "files": ["src/routes/**"], + "rules": { "react/only-export-components": "off" } + } + ] } diff --git a/src/web/.prettierignore b/src/web/.prettierignore new file mode 100644 index 0000000..4abe88e --- /dev/null +++ b/src/web/.prettierignore @@ -0,0 +1,18 @@ +dist +node_modules +package-lock.json + +# Agent tooling scratch, not source. These are created in whatever directory a +# sandboxed command runs in, and .mcp.json is a /dev/null bind-mount that +# prettier cannot read (EACCES). Never present in a clean CI checkout. +.claude +.mcp.json + +# Generated by @tanstack/router-plugin from src/routes/. +src/routeTree.gen.ts + +# Generated by openapi-typescript from the API's OpenAPI document. +src/api/schema.gen.ts + +# Generated by `msw init public/`. +public/mockServiceWorker.js diff --git a/src/web/.prettierrc b/src/web/.prettierrc new file mode 100644 index 0000000..7d32a49 --- /dev/null +++ b/src/web/.prettierrc @@ -0,0 +1,5 @@ +{ + "semi": true, + "singleQuote": false, + "printWidth": 100 +} diff --git a/src/web/index.html b/src/web/index.html index 5e3836a..29e4b4a 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -9,5 +9,12 @@
+ +
diff --git a/src/web/package-lock.json b/src/web/package-lock.json index d4a9e45..97c057a 100644 --- a/src/web/package-lock.json +++ b/src/web/package-lock.json @@ -8,18 +8,44 @@ "name": "web", "version": "0.0.0", "dependencies": { + "@glideapps/glide-data-grid": "6.0.4-alpha24", + "@radix-ui/themes": "^3.3.0", + "@reduxjs/toolkit": "^2.12.0", + "@tanstack/react-pacer": "^0.22.1", + "@tanstack/react-query": "^5.101.2", + "@tanstack/react-router": "^1.170.17", + "axios": "^1.18.1", + "clsx": "^2.1.1", + "lodash": "^4.18.1", + "lucide-react": "^1.23.0", + "marked": "^16.4.2", "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-day-picker": "^9.14.0", + "react-dom": "^19.2.7", + "react-number-format": "^5.4.5", + "react-phone-number-input": "^3.4.17", + "react-redux": "^9.3.0", + "react-responsive-carousel": "^3.2.23", + "tailwind-merge": "^3.6.0" }, "devDependencies": { + "@tailwindcss/vite": "^4.3.2", + "@tanstack/react-query-devtools": "^5.101.2", + "@tanstack/react-router-devtools": "^1.167.0", + "@tanstack/router-plugin": "^1.168.19", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.13.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", "jsdom": "^29.1.1", + "msw": "^2.15.0", + "openapi-typescript": "^7.13.0", "oxlint": "^1.71.0", + "prettier": "^3.9.4", + "tailwindcss": "^4.3.2", "typescript": "~6.0.2", "vite": "^8.1.1", "vitest": "^4.1.9" @@ -89,7 +115,6 @@ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", @@ -99,17 +124,189 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", @@ -120,6 +317,53 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -273,6 +517,12 @@ "node": ">=20.19.0" } }, + "node_modules/@date-fns/tz": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", + "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", + "license": "MIT" + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -307,6 +557,21 @@ "tslib": "^2.4.0" } }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, "node_modules/@exodus/bytes": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", @@ -325,127 +590,393 @@ } } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dev": true, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", - "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "react": ">=16.8.0", + "react-dom": ">=16.8.0" } }, - "node_modules/@oxc-project/types": { - "version": "0.137.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", - "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", - "dev": true, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@glideapps/glide-data-grid": { + "version": "6.0.4-alpha24", + "resolved": "https://registry.npmjs.org/@glideapps/glide-data-grid/-/glide-data-grid-6.0.4-alpha24.tgz", + "integrity": "sha512-o3hq7n5XKS1R7lXZ5vGyTG6O/11qPmu9DQyxPnqOej3UMuQDEe4bJth75pcnYueLCAoffpa8vTB2hz5Q0qZLHg==", "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" + "dependencies": { + "@linaria/react": "^6.3.0", + "canvas-hypertxt": "^1.0.3", + "react-number-format": "^5.4.4" + }, + "peerDependencies": { + "lodash": "^4.17.19", + "marked": "^16.0.10", + "react": "^16.12.0 || 17.x || 18.x || 19.x", + "react-dom": "^16.12.0 || 17.x || 18.x || 19.x", + "react-responsive-carousel": "^3.2.7" } }, - "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.72.0.tgz", - "integrity": "sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==", - "cpu": [ - "arm" - ], + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } }, - "node_modules/@oxlint/binding-android-arm64": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.72.0.tgz", - "integrity": "sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==", - "cpu": [ - "arm64" - ], + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.72.0.tgz", - "integrity": "sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==", - "cpu": [ - "arm64" - ], + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.72.0.tgz", - "integrity": "sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==", - "cpu": [ - "x64" - ], + "node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } }, - "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.72.0.tgz", - "integrity": "sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==", - "cpu": [ - "x64" - ], + "node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@linaria/core": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@linaria/core/-/core-6.3.0.tgz", + "integrity": "sha512-fs1aQyX4DmJpeGMyobuSznAo/Y02+Q8vhm+c/jr2WHDmqKHEJpCqH2Mhu1End+SdiUvZIUgb4MNiZryWtZLD5g==", + "license": "MIT", + "dependencies": { + "@wyw-in-js/processor-utils": "^0.6.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@linaria/react": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@linaria/react/-/react-6.3.0.tgz", + "integrity": "sha512-CjdEPwQCIRB3BRecnwK0g9mEWow3M1zAJTKI7h/B6j3yRLQRelitcoLd19MxZNz89pc0NNPALqy2I+eBGH0lJA==", + "license": "MIT", + "dependencies": { + "@emotion/is-prop-valid": "^1.2.0", + "@linaria/core": "^6.3.0", + "@wyw-in-js/processor-utils": "^0.6.0", + "@wyw-in-js/shared": "^0.6.0", + "minimatch": "^9.0.3", + "react-html-attributes": "^1.4.6", + "resolve": "^1.22.8", + "ts-invariant": "^0.10.3" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "react": ">=16" + } + }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.72.0.tgz", + "integrity": "sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.72.0.tgz", + "integrity": "sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.72.0.tgz", + "integrity": "sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.72.0.tgz", + "integrity": "sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.72.0.tgz", + "integrity": "sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { "version": "1.72.0", "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.72.0.tgz", @@ -708,82 +1239,1812 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", - "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + "node_modules/@radix-ui/colors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-3.0.0.tgz", + "integrity": "sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==", + "license": "MIT" }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", - "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@radix-ui/number": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", + "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", + "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.11.tgz", + "integrity": "sha512-HQDOFTKwSnmUij6l54wYJJtxTAnxI71+YJLOrjm2ladFB8HAV5Jt7hwaZPhWTGBkYoW4+ZAOfNZrLDh/qvxSYA==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", - "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.15.tgz", + "integrity": "sha512-24Zz/0SYx8F2bSVThBnQrdJs2VbKelyuJordcFRRdA0fRAhrq/wSegGCqaQz34VQoiWqSMGYCYXEhynLSlyQlg==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collapsible": "1.1.15", + "@radix-ui/react-collection": "1.1.11", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.18.tgz", + "integrity": "sha512-6c2cXpNlAgHDhKguK24XcWHHayMpK+lk7/WwBXBco+ZJ4Dv7xP++GBM280KgTD/HCRu3jSdfe8WQiZssonYaIA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dialog": "1.1.18", + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.11.tgz", + "integrity": "sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.11.tgz", + "integrity": "sha512-IUAhIVpBUvP5NNICjlaB1OFmtRLGqQqTF3ZOSGPoq3XeLXRFtHiWTRxSVEULgOd9GQR2c7tsYqDnhUennapZnw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.1.tgz", + "integrity": "sha512-+8PWoLLZv3AVb5m0pvoiOca/bQGzc9vPVb+982HB2x3Un0DpYEPM3zLMl4oqRpBsocJuNqLkiv/HXTnTrlwr4g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.6.tgz", + "integrity": "sha512-eUEUoGMDpfkgHWSE97ZZaUJtzR1M7EKnNIpD1Q16+8JR9NWghcaqMulx9PuCQ720w0UclfYn6FEbCdd5Hx087g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.15.tgz", + "integrity": "sha512-8A1zibu5skAQ+UVbaeNH5hVMibiFCRJzgMuM14LTWGttnTZKQL9jwYnhAbHRuxrtCqPXa4JvvnVUq1pTNgyZYw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.11.tgz", + "integrity": "sha512-djW9+zeg137KQdlPtmE8xnaD+K2rcXXMWFrSg0hsmYZ6HRbdTA7tDHFgpaW9+huWVEu0RCabL+985T4TA0BE7g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", + "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.2.tgz", + "integrity": "sha512-qzsA/ZPhF6yMxBOTIk1nlCkoy2mswSbwYL+ErBa2iP0s4WWrlxmczArYqMcpVfEjmM7KJj/ADPXky0yZfbSxtQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-menu": "2.1.19", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.18.tgz", + "integrity": "sha512-apa28mldjMgORmE6g/w3sCcA0Y9UAVeeDVoozN4i7kOw12mLl9RBchfzK3Nn6qxOWjrZhK1Lfy7f07kyzxtnBw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.14", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.11", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-portal": "1.1.13", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", + "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.14.tgz", + "integrity": "sha512-4lUhWTWAjbDIqFrAPWJ3WqBOpO5YchVZ88X3nh6H9Lu5AFi5nCUeTPj3D8FSDmabmFeRe9ME0BDA4MwKTha5GQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-effect-event": "0.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.19.tgz", + "integrity": "sha512-HZccBkbK0LOi8nYKIp5jll/zIRW0cCOmG6WWyqsSpmXCU+ZlcBbTqIwlBvPCu886C5RVu6c/kHV7xSP8IgYNHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.19", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", + "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.11.tgz", + "integrity": "sha512-Mn88Vg2whaRocGJNOH+DKFqYm6ySFPQaiwHNxZPyjn99B52KAEJWWY9NP83+nWdk2HM3rdov+STu9AG471Rt9w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.11.tgz", + "integrity": "sha512-0mTMJHv1gQAuEQoq5VDpTD3MRgmfUFdXAVFhpqR7wBeUr+tyRsof0wv/4XdPHLwQrefhoH2FiGHCggrCJhalIw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-label": "2.1.11", + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.18.tgz", + "integrity": "sha512-rt+Fx4HoCeEwFL2IdoV2QaPltqDLlzxN77i9nwB3Y70scFlfAHh1QCdE2TXKuFJtA1TNygb0oivnFBZifgtZOw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.14", + "@radix-ui/react-popper": "1.3.2", + "@radix-ui/react-portal": "1.1.13", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.11.tgz", + "integrity": "sha512-3PKvDDxOn62k0oV1n4QtNtD2vpu+zYjXR7ojLBPaO6SPvhy53yg0vAmgNeBQeJW5rV3dffoRG+HYfLBZuzw0CQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.19.tgz", + "integrity": "sha512-Mht9BVd1AIsNFVQr4KG3bIK7XQn5IXF0TL/2ObsrzOdc1loaly/+kBDL5roSCYn8j8XZkvpOD0WYLz2FQtH1Eg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.11", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.14", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.11", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.2", + "@radix-ui/react-portal": "1.1.13", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.14", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.19.tgz", + "integrity": "sha512-Glt6mebxcgQvLeVkH3HiqV5bgQubE+31ELxLs7q0GlYI5k0XYkOkeuPrhXoylxK8eufvIt9CJjzY1TfFMXK3qw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.11", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.19", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.14", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.17.tgz", + "integrity": "sha512-fYeYQvbeNn5AQk2RBbpO7koLm2YbS00UYxC/IL2sgLlninEH5UNIv+X3E0KJ1Vy4WIo+dhN9w8GNqSHhbHWCIg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.11", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.14", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.11.tgz", + "integrity": "sha512-Rsgab65u73E5kPVh8OS6PgPwJgPyf08GFfJDGAbMdF4DL7CgDhFOaDnXuk/DiMEVF6kgQwl0oJmFklvipmiOLg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.11", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.14", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.6.tgz", + "integrity": "sha512-pQ3xGp/uemomASPH97Eb3shfXX8QlG11bBJyEvRBV+vwtO4HvQlS06Yj9f31Ao7XepvF98SFrRgVDQ7jv+2xjQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-is-hydrated": "0.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.18.tgz", + "integrity": "sha512-qdXDes+eHlnMUGlBAAAe5EG7oOQvqsXuq4mq585diMudg80iB+jHbsSeG3+Q4eWNsogNyhqU2p/3i+Y0iEepqg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.14", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.11", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.2", + "@radix-ui/react-portal": "1.1.13", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.2.tgz", + "integrity": "sha512-3QXNeMkdshed1MR3LNoiCirBywRFPkD8ETJa/HlPuLwSajaQixf2ro+isoDNJlGABg9ug41XuZpINZJIle4XWg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.11", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-rect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.13.tgz", + "integrity": "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", + "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", + "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.11.tgz", + "integrity": "sha512-KqiGJcFaZDc+BvveAgU3ZhACg2MvSUDrCBx4lRR/ZVRNal0bvt8lBpvnSkep9heeOuF8Qfw3fszLDX4OpQ2NVw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.2.tgz", + "integrity": "sha512-W8Uo9riHnlzLLWy+r2mVHUyuEWqD/+be4PZzbEvaGoFSBDHkm+GYWjtcE6u3AmPKNyfanWpnVfpZ2GqPCdzzsw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.14", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.14.tgz", + "integrity": "sha512-8Qcnx9447tx/aCBgw6Jenfqg4Skq+vqab9mCBmuGNipIS5YXvL275wbKEu7+ICYHIlAPgCduUMJH1XOYewKF6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.11", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.13.tgz", + "integrity": "sha512-7tncSubo2G0UY1e8rk+72qe3XRzrGnOLtZQ1PL1KoBfRUNX0NrJT5akb+0kfwSCc3gVR4wdHqyhAQBDpDNOwDw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.2.tgz", + "integrity": "sha512-brXD6C/V0fVK0DDbscLVw6LsXrjQ+ay8jdOBaN+tLb4vsHsAMm6Gt6eT77wHX1Eq8GPtD5rJ+RxFtfDozsb4+Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.11", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.14", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.11", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.2", + "@radix-ui/react-portal": "1.1.13", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.7", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.11.tgz", + "integrity": "sha512-jRhe86+8PF7VZ1u14eOWVOuh2BuAhALg/FT1VcMC4OHedMTRUazDnDlKTt+yxo5cRNKHMfmvZ4sSQtWDeMV4CQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.2.tgz", + "integrity": "sha512-qt5C1ppJz66aUDrH1VccjPrq7aFchK0wBrn6xsxlCHNUyE57dRRQ7lp1QFpF7OscMexZF8MCGBTVBlENHPkNiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.11", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", + "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.2.tgz", + "integrity": "sha512-tgRBI3DdNwAJYE4BBZyZcz/HRRCvAsPkRvG1wvKc+41tBGMxPn/a87T/wikXAvyDypNQ9kaZwHbeZe+veHCGpA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.16.tgz", + "integrity": "sha512-v3Ab2l7z6U7tRB4xA0IyKdq0OsqaO1o9ZjsIEoKKnSZ/l96mZz8aCTX0NCXw+YVHJXr8Km4d+Mn6/Q8YjXa+gw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.14", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.18.tgz", + "integrity": "sha512-YNEnTHV47hPep+U0QvVM02OJNka9uygREc+k4Nh5VSZBg4MmE+myI442x3hCGfRpX7N2WSSYSJKws4gE+Z8lgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.11", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.14", + "@radix-ui/react-portal": "1.1.13", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.13.tgz", + "integrity": "sha512-bI2ILJrzwgmAsH05TsJ9pVrzqQwAip7OM2/krqAdYn0R16bl86UPWbe5VPHsALat0EnqpV01cGtkleaUKPNdNg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.14.tgz", + "integrity": "sha512-TK1vusNKb8IRhF23FTbRgUNZ9zfs5rGIyI7LfR3h26p9LrQ060i0uW9QWeD8baZMddaaP0DBGlIa6pbZG+mitg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.14", + "@radix-ui/react-toggle": "1.1.13", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.14.tgz", + "integrity": "sha512-L/EkWVqlnj3lL2toHh4C7PwH2jxfa7OCq6lGfXSCii99ve2S4Ux5rc9HnOa7LN9exHa/Nl9kmCAmP9BuDPy5UA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.14", + "@radix-ui/react-separator": "1.1.11", + "@radix-ui/react-toggle-group": "1.1.14" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.11.tgz", + "integrity": "sha512-8XZ6Py3y3W2nEzAUGCN5cfVKaUi+CVApcz1d6lrNVVf2hvYEixMRkq8k9ggPKnQUpRRuOV5avt8uvxViH2jLwA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.14", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.2", + "@radix-ui/react-portal": "1.1.13", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-visually-hidden": "1.2.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", + "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.3.tgz", + "integrity": "sha512-3wEkMiPHXha/2VadZ68rYBcmYnPINVGl4Y3gtcM7fKRjANk0OscK+cdqBgUWdozb7YJxsh0vefM7vgAMHXOjqg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.1.tgz", + "integrity": "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", + "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", + "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", + "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.7.tgz", + "integrity": "sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", + "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", + "license": "MIT" + }, + "node_modules/@radix-ui/themes": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/themes/-/themes-3.3.0.tgz", + "integrity": "sha512-I0/h2CRNTpYNB7Mi3xFIvSsQq5a108d7kK8dTO5zp5b9HR5QJXKag6B8tjpz2ITkVYkFdkGk45doNkSr7OxwNw==", + "license": "MIT", + "dependencies": { + "@radix-ui/colors": "^3.0.0", + "classnames": "^2.3.2", + "radix-ui": "^1.1.3", + "react-remove-scroll-bar": "^2.3.8" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.17", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.17.tgz", + "integrity": "sha512-wsV2keCt6B806XpSdezbWZ9aFJYf14YVh+XQf0ESt7M90yqVuxH9//PxvtC70sgj9OCkRM3nRaLfu4MsGQZRig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.2.0", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-freebsd-x64": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", - "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ - "freebsd" + "linux" ], "engines": { "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "node_modules/@rolldown/binding-linux-x64-musl": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", - "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", "cpu": [ - "arm" + "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -793,36 +3054,239 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { + "node_modules/@rolldown/binding-openharmony-arm64": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", - "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tabby_ai/hijri-converter": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz", + "integrity": "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 20" } }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", - "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", "cpu": [ "arm64" ], "dev": true, "libc": [ - "musl" + "glibc" ], "license": "MIT", "optional": true, @@ -830,19 +3294,19 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 20" } }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", - "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", "cpu": [ - "ppc64" + "arm64" ], "dev": true, "libc": [ - "glibc" + "musl" ], "license": "MIT", "optional": true, @@ -850,15 +3314,15 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 20" } }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", - "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", "cpu": [ - "s390x" + "x64" ], "dev": true, "libc": [ @@ -870,19 +3334,19 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 20" } }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", - "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", "cpu": [ "x64" ], "dev": true, "libc": [ - "glibc" + "musl" ], "license": "MIT", "optional": true, @@ -890,113 +3354,484 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" } }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", - "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 20" } }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", - "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "openharmony" + "win32" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/devtools-event-client": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.4.tgz", + "integrity": "sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==", + "license": "MIT", + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/history": { + "version": "1.162.0", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.0.tgz", + "integrity": "sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/pacer": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@tanstack/pacer/-/pacer-0.21.1.tgz", + "integrity": "sha512-hB01dd4rlsYcTCNP7wK186jgAe6K5qimgM1Y5Jtvz+9PUaILvpmeLLjmQNUNSO1l23lIt+CeQR6mO1mjlPvRtQ==", + "license": "MIT", + "dependencies": { + "@tanstack/devtools-event-client": "^0.4.3", + "@tanstack/store": "^0.11.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/pacer/node_modules/@tanstack/store": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.0.tgz", + "integrity": "sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", + "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.101.2.tgz", + "integrity": "sha512-o+wHcqgN7Pp0s8v1i0UGq/ZrrEKrxdIiMQmKRdYb2w7NPtylYSJ4+wg/tIn71m9DLstwUwdEGAvROdly6HXP6w==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-pacer": { + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-pacer/-/react-pacer-0.22.1.tgz", + "integrity": "sha512-CenQqK0GluSPIrnsG1yuD7w5uMSQ/4lI9AcGEFxBrRd66r260boWcYRIsS5+eHtXb238FoZYhKmJPGlhRzmHRw==", + "license": "MIT", + "dependencies": { + "@tanstack/pacer": "0.21.1", + "@tanstack/react-store": "^0.11.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/react-pacer/node_modules/@tanstack/react-store": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.0.tgz", + "integrity": "sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.11.0", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/react-pacer/node_modules/@tanstack/store": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.0.tgz", + "integrity": "sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", + "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.101.2.tgz", + "integrity": "sha512-eU7HctdA9gDjqoERoEdzLbw9DiqnBDfh5+Hu0u26gjqoHJezOpQAuiesDL2VvkU+2cPV76zgv0tMZsOrI4LjnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.101.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.101.2", + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.170.17", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.17.tgz", + "integrity": "sha512-ppLkjCfSMaeug9rmFRYzOd4TIqWV+yTE7tzIny7alJsSnM7w4lzEZm6eqCehG0SPetpZ0R3K+UnanSmBgOAVcQ==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.0", + "@tanstack/react-store": "^0.9.3", + "@tanstack/router-core": "1.171.14", + "isbot": "^5.1.22" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-router-devtools": { + "version": "1.167.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-router-devtools/-/react-router-devtools-1.167.0.tgz", + "integrity": "sha512-nGw095EG7IHx0h5NtlEmzf6vcCTaFNPWdTSuDKazajhN0ct/v/TkekJ9J6KYUCeV1a8/2ZmToc58M+0rrOyn7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/router-devtools-core": "1.168.0" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-router": "^1.170.0", + "@tanstack/router-core": "^1.170.0", + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/router-core": { + "optional": true + } + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.171.14", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.171.14.tgz", + "integrity": "sha512-Mo3hwx0qB0cJsVYGDjG0+Ouf7VV74h/vsoDMGztdlyzDanp4gBA2s7IVvm6hFrmQM6GpD9F0Z7SqD7OldfLE7g==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.0", + "cookie-es": "^3.0.0", + "seroval": "^1.5.4", + "seroval-plugins": "^1.5.4" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-devtools-core": { + "version": "1.168.0", + "resolved": "https://registry.npmjs.org/@tanstack/router-devtools-core/-/router-devtools-core-1.168.0.tgz", + "integrity": "sha512-wQoQhlBK7nlZgqzaqdYXKWNTpdHdsaREdaPhFZVH0/Ador+F+eM3/NF2i3f2LPeS0GgKraZUQXe1Q/1+KHyEYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/router-core": "^1.170.0", + "csstype": "^3.0.10" + }, + "peerDependenciesMeta": { + "csstype": { + "optional": true + } + } + }, + "node_modules/@tanstack/router-generator": { + "version": "1.167.18", + "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.167.18.tgz", + "integrity": "sha512-kFvM4caRds9Q3EXg64bZubJ6rbDxyV0YDSBSGvOGzmKspQPdz5Xrh0uj5T1Ov8avUUg+c761u04VQAaEzSBXRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5", + "@tanstack/router-core": "1.171.14", + "@tanstack/router-utils": "1.162.2", + "@tanstack/virtual-file-routes": "1.162.0", + "jiti": "^2.7.0", + "magic-string": "^0.30.21", + "prettier": "^3.5.0", + "zod": "^4.4.3" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-plugin": { + "version": "1.168.19", + "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.168.19.tgz", + "integrity": "sha512-aFglwLc+bbPTgZlkXn3PvOwpjJAfgUyPGSuql4MP3XrqTTh6WkBiy2RYb6oaG5h0s7EKwivEuq85K3Y4V0Mt1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "@tanstack/router-core": "1.171.14", + "@tanstack/router-generator": "1.167.18", + "@tanstack/router-utils": "1.162.2", + "chokidar": "^5.0.0", + "unplugin": "^3.0.0", + "zod": "^4.4.3" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@rsbuild/core": ">=1.0.2 || ^2.0.0", + "@tanstack/react-router": "^1.170.17", + "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", + "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", + "webpack": ">=5.92.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + }, + "@tanstack/react-router": { + "optional": true + }, + "vite": { + "optional": true + }, + "vite-plugin-solid": { + "optional": true + }, + "webpack": { + "optional": true + } } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", - "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", - "cpu": [ - "wasm32" - ], + "node_modules/@tanstack/router-utils": { + "version": "1.162.2", + "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.162.2.tgz", + "integrity": "sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" + "@babel/generator": "^7.28.5", + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "ansis": "^4.1.0", + "babel-dead-code-elimination": "^1.0.12", + "diff": "^8.0.2", + "pathe": "^2.0.3", + "tinyglobby": "^0.2.15" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", - "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", - "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", - "cpu": [ - "x64" - ], + "node_modules/@tanstack/virtual-file-routes": { + "version": "1.162.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.162.0.tgz", + "integrity": "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -1073,6 +3908,20 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -1131,7 +3980,7 @@ "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1141,12 +3990,35 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" } }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", @@ -1286,13 +4158,59 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@wyw-in-js/processor-utils": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@wyw-in-js/processor-utils/-/processor-utils-0.6.0.tgz", + "integrity": "sha512-5YAZMUmF+S2HaqheKfew6ybbYBMnF10PjIgI7ieyuFxCohyqJNF4xdo6oHftv2z5Z4vCQ0OZHtDOQyDImBYwmg==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.23.5", + "@wyw-in-js/shared": "0.6.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@wyw-in-js/shared": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@wyw-in-js/shared/-/shared-0.6.0.tgz", + "integrity": "sha512-BozBos29AuMWOvjjKf+bYYN+Vku0Nar6+y5oxrJXIZzUEKiWTVnIxO256vi8cUBGfo/DH44o+qZTkkdSN2pPXw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "find-up": "^5.0.0", + "minimatch": "^9.0.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -1311,6 +4229,35 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansis": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz", + "integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -1331,6 +4278,81 @@ "node": ">=12" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/babel-dead-code-elimination": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", + "integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.7", + "@babel/parser": "^7.23.6", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -1341,14 +4363,199 @@ "require-from-string": "^2.0.2" } }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001802", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001802.tgz", + "integrity": "sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvas-hypertxt": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/canvas-hypertxt/-/canvas-hypertxt-1.0.3.tgz", + "integrity": "sha512-+VsMpRr64jYgKq2IeFUNel3vCZH/IzS+iXSHxmUV3IUH5dXlC9xHz4AwtPZisDxZ5MWcuK0V+TXgPKFPiZnxzg==", + "license": "MIT" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, "engines": { - "node": ">=18" + "node": ">= 0.8" } }, "node_modules/convert-source-map": { @@ -1358,6 +4565,32 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, + "node_modules/country-flag-icons": { + "version": "1.6.20", + "resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.6.20.tgz", + "integrity": "sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw==", + "license": "MIT" + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -1383,7 +4616,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/data-urls": { @@ -1400,6 +4633,39 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -1407,6 +4673,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1427,6 +4702,22 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -1435,6 +4726,48 @@ "license": "MIT", "peer": true }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.387", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz", + "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -1442,72 +4775,383 @@ "dev": true, "license": "BSD-2-Clause", "engines": { - "node": ">=20.19.0" + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz", + "integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/goober": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.19.tgz", + "integrity": "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz", - "integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==", + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true, - "license": "MIT" + "license": "ISC" }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", "dev": true, "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, - "node_modules/expect-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", - "dev": true, - "license": "Apache-2.0", + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", "engines": { - "node": ">=12.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, "engines": { - "node": ">=12.0.0" + "node": ">= 0.4" }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "engines": { + "node": ">= 0.4" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" } }, + "node_modules/html-element-attributes": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/html-element-attributes/-/html-element-attributes-1.3.1.tgz", + "integrity": "sha512-UrRKgp5sQmRnDy4TEwAUsu14XBUlzKB8U3hjIYDjcZ3Hbp86Jtftzxfgrv6E/ii/h78tsaZwAnAE8HwnHr0dPA==", + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -1521,6 +5165,30 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/immer": { + "version": "11.1.9", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.9.tgz", + "integrity": "sha512-sc/z0Cyti70bZa0ZU4sWfAElfovFb9Ni8tArJZLuklYWxegPiK3pDOql1Rq5H0FIRAW9LSQRG6OX4KqBldbhBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -1531,6 +5199,72 @@ "node": ">=8" } }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/input-format": { + "version": "0.3.14", + "resolved": "https://registry.npmjs.org/input-format/-/input-format-0.3.14.tgz", + "integrity": "sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">=18.1.0", + "react-dom": ">=18.1.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -1538,13 +5272,63 @@ "dev": true, "license": "MIT" }, + "node_modules/isbot": { + "version": "5.1.44", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.44.tgz", + "integrity": "sha512-PGEHtwMnKbZpeSEXW2Utx+/JWed7dp6DiH0WWg33vGSDA7RUvpUeJSVlLrVkQ1RCpvDOUc/eH9ql7VsdbBZ8pA==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", - "peer": true + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } }, "node_modules/jsdom": { "version": "29.1.1", @@ -1587,6 +5371,44 @@ } } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.13.7", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.7.tgz", + "integrity": "sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A==", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1860,6 +5682,39 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "11.5.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", @@ -1870,6 +5725,15 @@ "node": "20 || >=22" } }, + "node_modules/lucide-react": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz", + "integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -1891,6 +5755,27 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -1898,6 +5783,27 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -1905,7 +5811,99 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msw": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.15.0.tgz", + "integrity": "sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.11", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/type-fest": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/nanoid": { @@ -1927,6 +5925,25 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/obug": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", @@ -1941,6 +5958,34 @@ "node": ">=12.20.0" } }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/oxlint": { "version": "1.72.0", "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.72.0.tgz", @@ -1990,6 +6035,54 @@ } } }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -2003,6 +6096,28 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2030,6 +6145,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", @@ -2059,6 +6184,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prettier": { + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", + "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -2075,6 +6216,32 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2085,6 +6252,83 @@ "node": ">=6" } }, + "node_modules/radix-ui": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.1.tgz", + "integrity": "sha512-QXDXJtB6sK83mLASONYUZCauatcWb+knFviFpN1EhtdbbmlsRmzCLrbZSKztnNiem2KOHIBbiDbauVB7SORXMw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-accessible-icon": "1.1.11", + "@radix-ui/react-accordion": "1.2.15", + "@radix-ui/react-alert-dialog": "1.1.18", + "@radix-ui/react-arrow": "1.1.11", + "@radix-ui/react-aspect-ratio": "1.1.11", + "@radix-ui/react-avatar": "1.2.1", + "@radix-ui/react-checkbox": "1.3.6", + "@radix-ui/react-collapsible": "1.1.15", + "@radix-ui/react-collection": "1.1.11", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-context-menu": "2.3.2", + "@radix-ui/react-dialog": "1.1.18", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.14", + "@radix-ui/react-dropdown-menu": "2.1.19", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.11", + "@radix-ui/react-form": "0.1.11", + "@radix-ui/react-hover-card": "1.1.18", + "@radix-ui/react-label": "2.1.11", + "@radix-ui/react-menu": "2.1.19", + "@radix-ui/react-menubar": "1.1.19", + "@radix-ui/react-navigation-menu": "1.2.17", + "@radix-ui/react-one-time-password-field": "0.1.11", + "@radix-ui/react-password-toggle-field": "0.1.6", + "@radix-ui/react-popover": "1.1.18", + "@radix-ui/react-popper": "1.3.2", + "@radix-ui/react-portal": "1.1.13", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-progress": "1.1.11", + "@radix-ui/react-radio-group": "1.4.2", + "@radix-ui/react-roving-focus": "1.1.14", + "@radix-ui/react-scroll-area": "1.2.13", + "@radix-ui/react-select": "2.3.2", + "@radix-ui/react-separator": "1.1.11", + "@radix-ui/react-slider": "1.4.2", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-switch": "1.3.2", + "@radix-ui/react-tabs": "1.1.16", + "@radix-ui/react-toast": "1.2.18", + "@radix-ui/react-toggle": "1.1.13", + "@radix-ui/react-toggle-group": "1.1.14", + "@radix-ui/react-toolbar": "1.1.14", + "@radix-ui/react-tooltip": "1.2.11", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-escape-keydown": "1.1.3", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.7" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -2094,6 +6338,28 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.14.0.tgz", + "integrity": "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "@tabby_ai/hijri-converter": "1.0.5", + "date-fns": "^4.1.0", + "date-fns-jalali": "4.1.0-0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", @@ -2106,6 +6372,27 @@ "react": "^19.2.7" } }, + "node_modules/react-easy-swipe": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/react-easy-swipe/-/react-easy-swipe-0.0.21.tgz", + "integrity": "sha512-OeR2jAxdoqUMHIn/nS9fgreI5hSpgGoL5ezdal4+oO7YSSgJR8ga+PkYGJrSrJ9MKlPcQjMQXnketrD7WNmNsg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.5.8" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/react-html-attributes": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/react-html-attributes/-/react-html-attributes-1.4.6.tgz", + "integrity": "sha512-uS3MmThNKFH2EZUQQw4k5pIcU7XIr208UE5dktrj/GOH1CMagqxDl4DCLpt3o2l9x+IB5nVYBeN3Cr4IutBXAg==", + "license": "MIT", + "dependencies": { + "html-element-attributes": "^1.0.0" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -2114,6 +6401,150 @@ "license": "MIT", "peer": true }, + "node_modules/react-number-format": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.5.tgz", + "integrity": "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==", + "license": "MIT", + "peerDependencies": { + "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-phone-number-input": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/react-phone-number-input/-/react-phone-number-input-3.4.17.tgz", + "integrity": "sha512-1wcjhBAWHgEBAGLi5/XbeZI7Q3aEHNb2z/dHY6R2Gz70TQvu0ZoOT28NTdwtZf4lyRKXWufnTzVhLPBUD8LfmQ==", + "license": "MIT", + "dependencies": { + "classnames": "^2.5.1", + "country-flag-icons": "^1.6.14", + "input-format": "^0.3.14", + "libphonenumber-js": "^1.13.4", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-responsive-carousel": { + "version": "3.2.23", + "resolved": "https://registry.npmjs.org/react-responsive-carousel/-/react-responsive-carousel-3.2.23.tgz", + "integrity": "sha512-pqJLsBaKHWJhw/ItODgbVoziR2z4lpcJg+YwmRlSk4rKH32VE633mAtZZ9kDXjy4wFO+pgUZmDKPsPe1fPmHCg==", + "license": "MIT", + "dependencies": { + "classnames": "^2.2.5", + "prop-types": "^15.5.8", + "react-easy-swipe": "^0.0.21" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -2128,6 +6559,31 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2138,6 +6594,40 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rettime": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", + "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", @@ -2182,13 +6672,51 @@ "xmlchars": "^2.2.0" }, "engines": { - "node": ">=v12.22.7" + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/seroval": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.4.tgz", + "integrity": "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.4.tgz", + "integrity": "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" } }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, "license": "MIT" }, "node_modules/siginfo": { @@ -2198,6 +6726,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2215,6 +6756,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", @@ -2222,6 +6773,41 @@ "dev": true, "license": "MIT" }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -2235,6 +6821,31 @@ "node": ">=8" } }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -2242,6 +6853,50 @@ "dev": true, "license": "MIT" }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2332,13 +6987,36 @@ "node": ">=20" } }, + "node_modules/ts-invariant": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.10.3.tgz", + "integrity": "sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "dev": true, - "license": "0BSD", - "optional": true + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/typescript": { "version": "6.0.3", @@ -2371,6 +7049,161 @@ "dev": true, "license": "MIT" }, + "node_modules/unplugin": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.3.0.tgz", + "integrity": "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.4", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@farmfe/core": "*", + "@rspack/core": "*", + "bun-types-no-globals": "*", + "esbuild": "*", + "rolldown": "*", + "rollup": "*", + "unloader": "*", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "@farmfe/core": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "bun-types-no-globals": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "unloader": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz", @@ -2562,6 +7395,13 @@ "node": ">=20" } }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", @@ -2604,6 +7444,40 @@ "node": ">=8" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -2620,6 +7494,81 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/src/web/package.json b/src/web/package.json index fd192aa..36ee6f2 100644 --- a/src/web/package.json +++ b/src/web/package.json @@ -6,26 +6,65 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "codegen": "openapi-typescript http://localhost:5000/openapi/v1.json -o src/api/schema.gen.ts", "lint": "oxlint", + "format": "prettier --write .", + "format:check": "prettier --check .", "preview": "vite preview", "test": "vitest run", "test:watch": "vitest" }, "dependencies": { + "@glideapps/glide-data-grid": "6.0.4-alpha24", + "@radix-ui/themes": "^3.3.0", + "@reduxjs/toolkit": "^2.12.0", + "@tanstack/react-pacer": "^0.22.1", + "@tanstack/react-query": "^5.101.2", + "@tanstack/react-router": "^1.170.17", + "axios": "^1.18.1", + "clsx": "^2.1.1", + "lodash": "^4.18.1", + "lucide-react": "^1.23.0", + "marked": "^16.4.2", "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-day-picker": "^9.14.0", + "react-dom": "^19.2.7", + "react-number-format": "^5.4.5", + "react-phone-number-input": "^3.4.17", + "react-redux": "^9.3.0", + "react-responsive-carousel": "^3.2.23", + "tailwind-merge": "^3.6.0" }, "devDependencies": { + "@tailwindcss/vite": "^4.3.2", + "@tanstack/react-query-devtools": "^5.101.2", + "@tanstack/react-router-devtools": "^1.167.0", + "@tanstack/router-plugin": "^1.168.19", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.13.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", "jsdom": "^29.1.1", + "msw": "^2.15.0", + "openapi-typescript": "^7.13.0", "oxlint": "^1.71.0", + "prettier": "^3.9.4", + "tailwindcss": "^4.3.2", "typescript": "~6.0.2", "vite": "^8.1.1", "vitest": "^4.1.9" + }, + "overrides": { + "openapi-typescript": { + "typescript": "$typescript" + } + }, + "msw": { + "workerDirectory": [ + "public" + ] } } diff --git a/src/web/public/mockServiceWorker.js b/src/web/public/mockServiceWorker.js new file mode 100644 index 0000000..0c970ef --- /dev/null +++ b/src/web/public/mockServiceWorker.js @@ -0,0 +1,361 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.15.0' +const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Omit the body of server-sent event stream responses. + // Cloning such responses would prevent client-side stream cancelations + // from reaching the original stream (a teed stream only cancels its + // source once both of its branches cancel) and would buffer the + // entire stream into the unconsumed clone indefinitely. + const isEventStreamResponse = response.headers + .get('content-type') + ?.toLowerCase() + .startsWith('text/event-stream') + + // Clone the response so both the client and the library could consume it. + const responseClone = isEventStreamResponse ? null : response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: response.type, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseClone ? responseClone.body : null, + }, + }, + }, + responseClone && responseClone.body + ? [serializedRequest.body, responseClone.body] + : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/src/web/src/App.tsx b/src/web/src/App.tsx deleted file mode 100644 index 1bfaa41..0000000 --- a/src/web/src/App.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function App() { - return ( -
-

AppName

-
- ); -} diff --git a/src/web/src/api/schema.gen.ts b/src/web/src/api/schema.gen.ts new file mode 100644 index 0000000..b09da36 --- /dev/null +++ b/src/web/src/api/schema.gen.ts @@ -0,0 +1,226 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/auth/token": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["MintDevToken"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/valuations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["GetValuations"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/valuations/{symbol}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["GetValuationBySymbol"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/valuations/refresh": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["RefreshAllValuations"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/bonds/{symbol}/refresh": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["RefreshBondData"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + ConversionValuationDto: { + symbol: string; + /** Format: double */ + conversionShares: number; + /** Format: double */ + conversionValue: number; + /** Format: double */ + stockPrice: number; + /** Format: date */ + asOf: string; + /** Format: double */ + bondPrice: null | number; + isInTheMoney: null | boolean; + }; + TokenResponse: { + access_token: string; + /** Format: int32 */ + expires_in: number | string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + MintDevToken: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TokenResponse"]; + }; + }; + }; + }; + GetValuations: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConversionValuationDto"][]; + }; + }; + }; + }; + GetValuationBySymbol: { + parameters: { + query?: never; + header?: never; + path: { + symbol: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConversionValuationDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + RefreshAllValuations: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + RefreshBondData: { + parameters: { + query?: never; + header?: never; + path: { + symbol: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; +} diff --git a/src/web/src/api/schema.test.ts b/src/web/src/api/schema.test.ts new file mode 100644 index 0000000..553342d --- /dev/null +++ b/src/web/src/api/schema.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import type { components } from "./schema.gen"; + +type Valuation = components["schemas"]["ConversionValuationDto"]; + +describe("generated API schema", () => { + it("types a valuation with nullable bondPrice and isInTheMoney", () => { + const sample: Valuation = { + symbol: "12345", + conversionShares: 1000, + conversionValue: 50000, + stockPrice: 50, + asOf: "2026-07-21", + bondPrice: null, + isInTheMoney: null, + }; + + expect(sample.symbol).toBe("12345"); + expect(sample.bondPrice).toBeNull(); + }); + + it("types a valuation with a populated bond quote", () => { + const sample: Valuation = { + symbol: "12345", + conversionShares: 1000, + conversionValue: 50000, + stockPrice: 50, + asOf: "2026-07-21", + bondPrice: 102.5, + isInTheMoney: true, + }; + + expect(sample.bondPrice).toBe(102.5); + expect(sample.isInTheMoney).toBe(true); + }); +}); diff --git a/src/web/src/app/listener.test.ts b/src/web/src/app/listener.test.ts new file mode 100644 index 0000000..23a3937 --- /dev/null +++ b/src/web/src/app/listener.test.ts @@ -0,0 +1,46 @@ +import { configureStore } from "@reduxjs/toolkit"; +import { describe, expect, it, vi } from "vitest"; +import { appListener, startAppListening } from "./listener"; +import { rootReducer } from "./rootReducer"; + +describe("app listener", () => { + it("lets any feature register an effect, without touching store wiring", () => { + const store = configureStore({ + reducer: rootReducer, + middleware: (getDefault) => + getDefault({ serializableCheck: false }).concat(appListener.middleware), + }); + const seen = vi.fn(); + + const stop = startAppListening({ + predicate: (action) => action.type === "demo/ping", + effect: (_action, api) => { + // getState is typed as RootState, not unknown. + seen(api.getState().appearance.appearance); + }, + }); + store.dispatch({ type: "demo/ping" }); + + expect(seen).toHaveBeenCalledWith("light"); + + stop(); + }); + + it("stops delivering to a listener once its unsubscribe has run", () => { + const store = configureStore({ + reducer: rootReducer, + middleware: (getDefault) => + getDefault({ serializableCheck: false }).concat(appListener.middleware), + }); + const seen = vi.fn(); + + const stop = startAppListening({ + predicate: (action) => action.type === "demo/ping", + effect: () => seen(), + }); + stop(); + store.dispatch({ type: "demo/ping" }); + + expect(seen).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web/src/app/listener.ts b/src/web/src/app/listener.ts new file mode 100644 index 0000000..1b50bbc --- /dev/null +++ b/src/web/src/app/listener.ts @@ -0,0 +1,20 @@ +import { createListenerMiddleware } from "@reduxjs/toolkit"; +import type { AppDispatch, RootState } from "./rootReducer"; + +/** + * The single listener middleware for the whole app. + * + * One shared instance rather than one per feature: listeners already + * discriminate by action type or predicate, so a per-feature middleware buys + * nothing and forces the store to `.concat` a new entry for every feature + * added. Anything that needs action-driven side-effects — grids included — + * registers here. + */ +export const appListener = createListenerMiddleware(); + +/** + * Register an effect. Pre-typed with app state and dispatch, so effects get a + * typed `getState()`/`dispatch()` without restating the generics. Returns an + * unsubscribe. + */ +export const startAppListening = appListener.startListening.withTypes(); diff --git a/src/web/src/app/queryClient.ts b/src/web/src/app/queryClient.ts new file mode 100644 index 0000000..2d21238 --- /dev/null +++ b/src/web/src/app/queryClient.ts @@ -0,0 +1,27 @@ +import { QueryClient } from "@tanstack/react-query"; +import { ApiError } from "../lib/http/ApiError"; + +/** + * The app-wide remote-data cache. + * + * "Remote data" means data the server owns and this app holds a borrowed copy + * of. It is stale the moment it arrives, so it needs staleness, refetch and + * retry semantics that client-owned state (columns, selection, edits — all + * still in Redux) does not. + */ +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Market data: fresh enough to avoid refetch storms while scrolling, + // short enough that a left-open tab is not showing yesterday's prices. + staleTime: 60_000, + gcTime: 5 * 60_000, + refetchOnWindowFocus: true, + retry: (failureCount, error) => { + // A 4xx cannot succeed on retry — the request itself is the problem. + if (error instanceof ApiError && error.status >= 400 && error.status < 500) return false; + return failureCount < 3; + }, + }, + }, +}); diff --git a/src/web/src/app/rootReducer.test.ts b/src/web/src/app/rootReducer.test.ts new file mode 100644 index 0000000..1a051fc --- /dev/null +++ b/src/web/src/app/rootReducer.test.ts @@ -0,0 +1,37 @@ +import type { Reducer } from "@reduxjs/toolkit"; +import { describe, expect, it, vi } from "vitest"; +import { injectSlice, onSliceInjected, rootReducer } from "./rootReducer"; + +// injectSlice only accepts reducerPaths declared in LazyLoadedSlices, which real +// grids augment. These tests exercise the mechanism with a stub, so they cast. +const injectStub = injectSlice as unknown as (slice: { + reducerPath: string; + reducer: Reducer; +}) => void; + +// Order matters: the first test asserts the pre-injection shape, so it must run +// before the others inject anything. +describe("rootReducer", () => { + it("starts with only the eagerly combined slices", () => { + const state = rootReducer(undefined, { type: "@@init" }); + expect(Object.keys(state)).toEqual(["appearance"]); + }); + + it("serves an injected reducer under its reducerPath, alongside the eager ones", () => { + injectStub({ reducerPath: "demo", reducer: () => ({ hit: true }) }); + + const state = rootReducer(undefined, { type: "@@init" }) as Record; + + expect(state.demo).toEqual({ hit: true }); + expect(state.appearance).toBeDefined(); + }); + + it("notifies the registered listener so an already-built store can refresh", () => { + const notify = vi.fn(); + onSliceInjected(notify); + + injectStub({ reducerPath: "demo2", reducer: () => ({ hit: true }) }); + + expect(notify).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/web/src/app/rootReducer.ts b/src/web/src/app/rootReducer.ts new file mode 100644 index 0000000..e3498d9 --- /dev/null +++ b/src/web/src/app/rootReducer.ts @@ -0,0 +1,63 @@ +import { + combineSlices, + type Reducer, + type ThunkDispatch, + type UnknownAction, +} from "@reduxjs/toolkit"; +import appearance from "../theme/appearanceSlice"; + +/** + * Slices that register themselves at import time rather than being listed here. + * + * A grid module augments this interface with its own key (see bondGrid.ts) and + * calls `rootReducer.inject(...)`, so adding a grid never touches app/. The + * state type therefore comes from declaration merging instead of from + * inferring an object literal — which is what lets the key stay a literal + * without casting a `string`-typed descriptor name. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface LazyLoadedSlices {} + +/** + * The store's reducer. `appearance` is eager (app-wide, always present); + * everything else is injected by the feature that owns it, so its state only + * exists once that feature's module has loaded. + */ +export const rootReducer = combineSlices({ appearance }).withLazyLoadedSlices(); + +/** + * Derived from the reducer rather than from `store.getState`, so modules the + * store itself depends on (app/listener) can be typed against app state without + * a circular reference back through configureStore. + */ +export type RootState = ReturnType; + +/** Matches the dispatch configureStore produces with the default thunk middleware. */ +export type AppDispatch = ThunkDispatch; + +let notifyInjected: (() => void) | null = null; + +/** + * Registered by app/store so injections landing after the store was created can + * refresh its state. Lives here rather than in store.ts because rootReducer + * cannot import the store (the store imports this module). + */ +export function onSliceInjected(notify: () => void): void { + notifyInjected = notify; +} + +/** + * Add a slice to the store's reducer. Use this rather than `rootReducer.inject` + * directly: bare `inject` only updates the reducer map, so a slice injected + * after the store was built has no state until the next dispatch — which is too + * late for a component that reads it on its first render. + */ +export function injectSlice(slice: { + reducerPath: Path; + reducer: Reducer; +}): void { + // The declaration-merged Path/state pairing above is the real check; RTK's + // inject overloads don't accept it generically, hence the cast. + rootReducer.inject(slice as Parameters[0]); + notifyInjected?.(); +} diff --git a/src/web/src/app/routePathTypes.ts b/src/web/src/app/routePathTypes.ts new file mode 100644 index 0000000..bf9c988 --- /dev/null +++ b/src/web/src/app/routePathTypes.ts @@ -0,0 +1,16 @@ +import type { NavigateOptions, RegisteredRouter } from "@tanstack/react-router"; + +/** + * Compile-time only; nothing imports these values and nothing runs them. + * + * They exist so `tsc -b` fails loudly if the `Register` augmentation in + * router.ts stops taking effect. When that happens `RegisteredRouter` falls + * back to `AnyRouter`, `to` silently widens to `string`, the `@ts-expect-error` + * below has nothing to suppress, and TypeScript reports the unused directive. + */ +type AppNavigate = NavigateOptions; + +export const knownPathTypeChecks: AppNavigate = { to: "/bonds" }; + +// @ts-expect-error unknown route paths must not type-check +export const unknownPathIsRejected: AppNavigate = { to: "/bnods" }; diff --git a/src/web/src/app/router.ts b/src/web/src/app/router.ts new file mode 100644 index 0000000..e975874 --- /dev/null +++ b/src/web/src/app/router.ts @@ -0,0 +1,23 @@ +import { createHashHistory, createRouter } from "@tanstack/react-router"; +import { routeTree } from "../routeTree.gen"; + +/** + * Hash history is required: the MAUI HybridWebView serves this bundle from a + * virtual root, so path-based URLs never reach the app. Same reason + * vite.config.ts sets `base: "./"`. + */ +export const router = createRouter({ + routeTree, + history: createHashHistory(), +}); + +/** + * Registers this router as *the* app router. Without this, `to` on `Link`, + * `useNavigate`, and `redirect` degrades to plain `string` and the migration + * buys nothing. `routePathTypes.ts` fails the build if that happens. + */ +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} diff --git a/src/web/src/app/store.test.ts b/src/web/src/app/store.test.ts new file mode 100644 index 0000000..3d91f1a --- /dev/null +++ b/src/web/src/app/store.test.ts @@ -0,0 +1,16 @@ +// Import order is the point of this test: store.ts no longer imports any grid, +// so the store is created BEFORE bondGrid injects its reducer. A grid's state +// must still be readable on the very first render, without waiting for a +// dispatch to rebuild the combined state. +import { describe, expect, it } from "vitest"; +import { store } from "./store"; +import { bondGrid } from "../features/bonds/bondGrid"; + +describe("store", () => { + it("exposes a grid's state even though the grid injected itself after store creation", () => { + expect(bondGrid.selectRoot(store.getState())).toBeDefined(); + expect(bondGrid.selectRoot(store.getState()).columns.order).toEqual( + bondGrid.descriptor.columns.defaultOrder, + ); + }); +}); diff --git a/src/web/src/app/store.ts b/src/web/src/app/store.ts new file mode 100644 index 0000000..8c3688b --- /dev/null +++ b/src/web/src/app/store.ts @@ -0,0 +1,25 @@ +import { configureStore } from "@reduxjs/toolkit"; +import { useDispatch, useSelector, type TypedUseSelectorHook } from "react-redux"; +import { appListener } from "./listener"; +import { onSliceInjected, rootReducer, type AppDispatch, type RootState } from "./rootReducer"; + +// Nothing feature-specific is wired here: a feature injects its own reducer into +// rootReducer and registers its own effects on appListener when its module +// loads, so adding a feature never touches this file. +// +// serializableCheck is off because grid state is not serializable — row windows +// hold arbitrary TRow objects and group keys are caller-defined. +export const store = configureStore({ + reducer: rootReducer, + middleware: (getDefault) => + getDefault({ serializableCheck: false }).concat(appListener.middleware), +}); + +// A feature module may be imported after this file has run. Rebuild the store's +// state when that happens, so the feature's slice is present on first render +// rather than only after the next dispatch. +onSliceInjected(() => store.replaceReducer(rootReducer)); + +export type { AppDispatch, RootState }; +export const useAppDispatch = () => useDispatch(); +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/src/web/src/bridge/client.test.ts b/src/web/src/bridge/client.test.ts deleted file mode 100644 index e1340aa..0000000 --- a/src/web/src/bridge/client.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { invoke } from "./client"; - -declare global { - // eslint-disable-next-line no-var - var HybridWebView: { InvokeDotNet: (name: string, args?: unknown[]) => Promise }; -} - -describe("invoke", () => { - beforeEach(() => { - globalThis.HybridWebView = { InvokeDotNet: vi.fn() }; - }); - - it("calls InvokeDotNet with only the method name when no args are given", async () => { - (globalThis.HybridWebView.InvokeDotNet as ReturnType).mockResolvedValue("ok"); - const result = await invoke("Ping"); - expect(globalThis.HybridWebView.InvokeDotNet).toHaveBeenCalledWith("Ping"); - expect(result).toBe("ok"); - }); - - it("forwards args to InvokeDotNet when provided", async () => { - (globalThis.HybridWebView.InvokeDotNet as ReturnType).mockResolvedValue(42); - const result = await invoke("Add", [1, 2]); - expect(globalThis.HybridWebView.InvokeDotNet).toHaveBeenCalledWith("Add", [1, 2]); - expect(result).toBe(42); - }); -}); diff --git a/src/web/src/bridge/client.ts b/src/web/src/bridge/client.ts deleted file mode 100644 index f9af381..0000000 --- a/src/web/src/bridge/client.ts +++ /dev/null @@ -1,10 +0,0 @@ -// HybridWebView injects this global at runtime. -declare const HybridWebView: { - InvokeDotNet: (methodName: string, args?: unknown[]) => Promise; -}; - -export function invoke(method: string, args?: unknown[]): Promise { - return (args !== undefined - ? HybridWebView.InvokeDotNet(method, args) - : HybridWebView.InvokeDotNet(method)) as Promise; -} diff --git a/src/web/src/components/ui/calendar.tsx b/src/web/src/components/ui/calendar.tsx new file mode 100644 index 0000000..0c210cf --- /dev/null +++ b/src/web/src/components/ui/calendar.tsx @@ -0,0 +1,37 @@ +import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from "lucide-react"; +import { type ComponentProps } from "react"; +import { DayPicker } from "react-day-picker"; +import { type ChevronProps } from "react-day-picker"; +import "react-day-picker/style.css"; +import { cn } from "../../lib/utils"; + +export type CalendarProps = ComponentProps; + +/** + * shadcn-style Calendar: a thin react-day-picker wrapper. Accent colors come + * from react-day-picker's own `--rdp-accent-color`, bound to Radix's `--accent-*` + * in the `.date-cell-editor` scope (see index.css), so the calendar tracks the + * app's Radix Themes config at runtime. + */ +export function Calendar({ className, showOutsideDays = true, ...props }: CalendarProps) { + return ( + { + const Icon = + orientation === "left" + ? ChevronLeft + : orientation === "right" + ? ChevronRight + : orientation === "up" + ? ChevronUp + : ChevronDown; + return ; + }, + }} + {...props} + /> + ); +} diff --git a/src/web/src/components/ui/input.tsx b/src/web/src/components/ui/input.tsx new file mode 100644 index 0000000..c7f56fe --- /dev/null +++ b/src/web/src/components/ui/input.tsx @@ -0,0 +1,17 @@ +import { forwardRef, type InputHTMLAttributes } from "react"; +import { cn } from "../../lib/utils"; + +export const Input = forwardRef>( + ({ className, type, ...props }, ref) => ( + + ), +); +Input.displayName = "Input"; diff --git a/src/web/src/components/ui/numericInput.tsx b/src/web/src/components/ui/numericInput.tsx new file mode 100644 index 0000000..962b365 --- /dev/null +++ b/src/web/src/components/ui/numericInput.tsx @@ -0,0 +1,69 @@ +import { forwardRef, type InputHTMLAttributes } from "react"; +import { NumericFormat, type NumberFormatValues } from "react-number-format"; +import { resolveNumberFormat, type NumberFormatOptions } from "../../lib/number/numberUtils"; +import { Input } from "./input"; + +export interface NumericInputProps + extends + NumberFormatOptions, + Omit< + InputHTMLAttributes, + "value" | "onChange" | "prefix" | "min" | "max" | "type" | "defaultValue" + > { + value: number | null; + onChange: (value: number | null) => void; +} + +/** + * Numeric text input with configurable formatting (integer / decimal / currency) + * and value bounds. Emits `number | null` (empty → null); out-of-range input is + * blocked. Wraps react-number-format over the shared `Input` for consistent styling. + */ +export const NumericInput = forwardRef( + ( + { + value, + onChange, + format, + decimalScale, + currency, + min, + max, + thousandSeparator, + prefix, + suffix, + ...rest + }, + ref, + ) => { + const resolved = resolveNumberFormat({ + format, + decimalScale, + currency, + min, + max, + thousandSeparator, + prefix, + suffix, + }); + + return ( + onChange(values.floatValue ?? null)} + thousandSeparator={resolved.thousandSeparator} + decimalScale={resolved.decimalScale} + fixedDecimalScale={resolved.fixedDecimalScale} + prefix={resolved.prefix} + suffix={resolved.suffix} + allowNegative={resolved.allowNegative} + isAllowed={resolved.isAllowed} + {...rest} + /> + ); + }, +); + +NumericInput.displayName = "NumericInput"; diff --git a/src/web/src/components/ui/validatedInput.tsx b/src/web/src/components/ui/validatedInput.tsx new file mode 100644 index 0000000..1ddb2db --- /dev/null +++ b/src/web/src/components/ui/validatedInput.tsx @@ -0,0 +1,69 @@ +import { forwardRef, useEffect, useRef, useState, type InputHTMLAttributes } from "react"; +import { cn } from "../../lib/utils"; +import { Input } from "./input"; +import { validateText, type TextValidationOptions } from "../../lib/text/textValidation"; + +export interface ValidatedInputProps extends Omit< + InputHTMLAttributes, + "value" | "onChange" +> { + value: string; + onChange: (value: string) => void; + validation: TextValidationOptions; + onValidityChange?: (valid: boolean) => void; +} + +/** + * Text input with configurable validation (required, min/max length, email, + * regex, custom). Shows the first failing rule as a red border + message, but + * only after the field has been touched (blurred once). `maxLength` is enforced + * natively so over-typing is blocked; every other rule reports on change. + * Wraps the shared `Input` for consistent styling. + */ +export const ValidatedInput = forwardRef( + ( + { value, onChange, validation, onValidityChange, className, onBlur, maxLength, ...rest }, + ref, + ) => { + const [touched, setTouched] = useState(false); + const result = validateText(value, validation); + const valid = result.valid; + const error = result.valid ? null : result.error; + + // Fire onValidityChange when validity flips (and on mount), without + // re-firing when the parent passes a fresh callback identity each render. + const validityCb = useRef(onValidityChange); + + validityCb.current = onValidityChange; + + useEffect(() => { + validityCb.current?.(valid); + }, [valid]); + + const showError = touched && !valid; + + return ( +
+ onChange(e.target.value)} + onBlur={(e) => { + setTouched(true); + onBlur?.(e); + }} + maxLength={maxLength ?? validation.maxLength} + aria-invalid={showError || undefined} + className={cn( + showError && "border-[var(--red-9)] focus-visible:ring-[var(--red-8)]", + className, + )} + {...rest} + /> + {showError && {error}} +
+ ); + }, +); + +ValidatedInput.displayName = "ValidatedInput"; diff --git a/src/web/src/features/bonds/BondsPage.tsx b/src/web/src/features/bonds/BondsPage.tsx new file mode 100644 index 0000000..80c9789 --- /dev/null +++ b/src/web/src/features/bonds/BondsPage.tsx @@ -0,0 +1,19 @@ +import { DataGrid } from "../dataGrid/DataGrid"; +import { bondGrid } from "./bondGrid"; + +/** + * The convertible-bond valuations page: a read-only grid filling the view. The + * grid self-loads its first window and persisted column state on mount, so the + * page is pure layout. `flex: 1; min-height: 0` here only produces a real + * height because index.css also makes `.radix-themes` (the DOM node + * RadixThemeProvider renders between `#root` and this page) a flex column — + * without that rule this div's flex parent isn't actually a flex container, + * and the whole chain collapses. + */ +export function BondsPage() { + return ( +
+ +
+ ); +} diff --git a/src/web/src/features/bonds/api/BondDataSource.ts b/src/web/src/features/bonds/api/BondDataSource.ts deleted file mode 100644 index 9f617a5..0000000 --- a/src/web/src/features/bonds/api/BondDataSource.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ConversionValuation } from "../types"; - -/** - * Transport-agnostic access to convertible-bond valuations. Implemented over the - * MAUI HybridWebView bridge now, and (later) over an HTTP API — callers depend on - * this interface, never on a concrete transport. - */ -export interface BondDataSource { - /** - * Returns today's conversion valuation for every cached convertible bond. - */ - getValuations(): Promise; -} diff --git a/src/web/src/features/bonds/api/HybridBridgeSource.test.ts b/src/web/src/features/bonds/api/HybridBridgeSource.test.ts deleted file mode 100644 index 9b2a4e1..0000000 --- a/src/web/src/features/bonds/api/HybridBridgeSource.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { HybridBridgeSource } from "./HybridBridgeSource"; - -declare global { - // eslint-disable-next-line no-var - var HybridWebView: { InvokeDotNet: (name: string, args?: unknown[]) => Promise }; -} - -describe("HybridBridgeSource", () => { - beforeEach(() => { - globalThis.HybridWebView = { InvokeDotNet: vi.fn() }; - }); - - it("invokes GetValuations and maps the result to the domain type", async () => { - (globalThis.HybridWebView.InvokeDotNet as ReturnType).mockResolvedValue([ - { - symbol: "11011", - conversionShares: 2000, - conversionValue: 120000, - stockPrice: 60, - asOf: "2026-07-02", - bondPrice: null, - isInTheMoney: null, - }, - ]); - - const result = await new HybridBridgeSource().getValuations(); - - expect(globalThis.HybridWebView.InvokeDotNet).toHaveBeenCalledWith("GetValuations"); - expect(result).toEqual([ - { - symbol: "11011", - conversionShares: 2000, - conversionValue: 120000, - stockPrice: 60, - asOf: "2026-07-02", - bondPrice: null, - isInTheMoney: null, - }, - ]); - }); -}); diff --git a/src/web/src/features/bonds/api/HybridBridgeSource.ts b/src/web/src/features/bonds/api/HybridBridgeSource.ts deleted file mode 100644 index 916eefe..0000000 --- a/src/web/src/features/bonds/api/HybridBridgeSource.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { invoke } from "../../../bridge/client"; -import type { BondDataSource } from "./BondDataSource"; -import { mapValuations } from "./mapValuations"; -import type { ConversionValuation, ConversionValuationWire } from "../types"; - -/** - * {@link BondDataSource} implemented over the MAUI HybridWebView bridge. - */ -export class HybridBridgeSource implements BondDataSource { - async getValuations(): Promise { - const wire = await invoke("GetValuations"); - return mapValuations(wire); - } -} diff --git a/src/web/src/features/bonds/api/bondQueries.test.ts b/src/web/src/features/bonds/api/bondQueries.test.ts new file mode 100644 index 0000000..34c0a88 --- /dev/null +++ b/src/web/src/features/bonds/api/bondQueries.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import axios from "axios"; +import { bondKeys, fetchValuations, valuationsQuery } from "./bondQueries"; + +describe("bondKeys", () => { + it("namespaces valuations under the bonds root", () => { + expect(bondKeys.valuations()).toEqual(["bonds", "valuations"]); + }); +}); + +describe("valuationsQuery", () => { + beforeEach(() => { + vi.stubEnv("VITE_API_BASE_URL", "http://api.test"); + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it("uses the valuations key", () => { + expect(valuationsQuery().queryKey).toEqual(["bonds", "valuations"]); + }); + + it("fetches the valuations endpoint", async () => { + vi.mocked(fetch).mockResolvedValue( + new Response(JSON.stringify([{ symbol: "12345" }]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const rows = await fetchValuations(); + + expect(rows).toEqual([{ symbol: "12345" }]); + const request = vi.mocked(fetch).mock.calls[0][0] as Request; + expect(request.url).toBe("http://api.test/api/valuations"); + }); + + it("aborts in flight when the caller's signal fires", async () => { + vi.mocked(fetch).mockImplementation((...args: unknown[]) => { + const request = args[0] as Request; + return new Promise((_, reject) => { + request.signal.addEventListener("abort", () => { + reject(request.signal.reason as Error); + }); + }); + }); + + const controller = new AbortController(); + const promise = fetchValuations(controller.signal); + + controller.abort(); + + const error = await promise.catch((e: unknown) => e); + + expect(axios.isCancel(error)).toBe(true); + }); +}); diff --git a/src/web/src/features/bonds/api/bondQueries.ts b/src/web/src/features/bonds/api/bondQueries.ts new file mode 100644 index 0000000..6fa265a --- /dev/null +++ b/src/web/src/features/bonds/api/bondQueries.ts @@ -0,0 +1,32 @@ +import { queryOptions } from "@tanstack/react-query"; +import { apiClient } from "../../../lib/http/apiClient"; +import type { ConversionValuation } from "./types"; + +/** Cache keys for bond data. Every key that affects row order belongs here. */ +export const bondKeys = { + all: ["bonds"] as const, + valuations: () => [...bondKeys.all, "valuations"] as const, +}; + +/** + * Requests all current valuations. Exported separately from the query options so + * it can be tested without fabricating a TanStack query-function context. + */ +export async function fetchValuations(signal?: AbortSignal): Promise { + const { data } = await apiClient.get("/api/valuations", { signal }); + return data; +} + +/** + * All current valuations. + * + * The endpoint is not yet paged, so this fetches the whole collection and the + * grid windows over it client-side. When the API gains offset/limit/sort, this + * becomes a per-page query and the key gains those parameters. + */ +export function valuationsQuery() { + return queryOptions({ + queryKey: bondKeys.valuations(), + queryFn: ({ signal }) => fetchValuations(signal), + }); +} diff --git a/src/web/src/features/bonds/api/createBondDataSource.ts b/src/web/src/features/bonds/api/createBondDataSource.ts deleted file mode 100644 index ad5d61e..0000000 --- a/src/web/src/features/bonds/api/createBondDataSource.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { BondDataSource } from "./BondDataSource"; -import { HybridBridgeSource } from "./HybridBridgeSource"; - -/** - * Selects the live transport. Uses the HybridWebView bridge when running inside - * the MAUI host; the HTTP API source is not built yet. - */ -export function createBondDataSource(): BondDataSource { - const hasBridge = typeof (globalThis as { HybridWebView?: unknown }).HybridWebView !== "undefined"; - - if (hasBridge) { - return new HybridBridgeSource(); - } - - throw new Error("HTTP bond data source is not implemented yet."); -} diff --git a/src/web/src/features/bonds/api/mapValuations.test.ts b/src/web/src/features/bonds/api/mapValuations.test.ts deleted file mode 100644 index 26fdc03..0000000 --- a/src/web/src/features/bonds/api/mapValuations.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { mapValuations } from "./mapValuations"; -import type { ConversionValuationWire } from "../types"; - -describe("mapValuations", () => { - it("maps a fully-populated wire record to numbers", () => { - const wire: ConversionValuationWire[] = [ - { - symbol: "11011", - conversionShares: 2000, - conversionValue: 120000, - stockPrice: 60, - asOf: "2026-07-02", - bondPrice: 100000, - isInTheMoney: true, - }, - ]; - - const [v] = mapValuations(wire); - - expect(v).toEqual({ - symbol: "11011", - conversionShares: 2000, - conversionValue: 120000, - stockPrice: 60, - asOf: "2026-07-02", - bondPrice: 100000, - isInTheMoney: true, - }); - }); - - it("coerces string-encoded numbers", () => { - const wire: ConversionValuationWire[] = [ - { - symbol: "11011", - conversionShares: "2000", - conversionValue: "120000", - stockPrice: "60", - asOf: "2026-07-02", - bondPrice: "100000", - isInTheMoney: false, - }, - ]; - - const [v] = mapValuations(wire); - - expect(v.conversionValue).toBe(120000); - expect(v.bondPrice).toBe(100000); - expect(typeof v.conversionValue).toBe("number"); - }); - - it("preserves null bondPrice and isInTheMoney without coercing to a number or false", () => { - const wire: ConversionValuationWire[] = [ - { - symbol: "11011", - conversionShares: 2000, - conversionValue: 120000, - stockPrice: 60, - asOf: "2026-07-02", - bondPrice: null, - isInTheMoney: null, - }, - ]; - - const [v] = mapValuations(wire); - - expect(v.bondPrice).toBeNull(); - expect(v.isInTheMoney).toBeNull(); - }); -}); diff --git a/src/web/src/features/bonds/api/mapValuations.ts b/src/web/src/features/bonds/api/mapValuations.ts deleted file mode 100644 index 17a8a6b..0000000 --- a/src/web/src/features/bonds/api/mapValuations.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ConversionValuation, ConversionValuationWire } from "../types"; - -/** - * Maps raw bridge records to the domain type: coerces numeric fields to numbers - * and preserves null `bondPrice` / `isInTheMoney`. - */ -export function mapValuations(wire: ConversionValuationWire[]): ConversionValuation[] { - return wire.map((w) => ({ - symbol: w.symbol, - conversionShares: Number(w.conversionShares), - conversionValue: Number(w.conversionValue), - stockPrice: Number(w.stockPrice), - asOf: w.asOf, - bondPrice: w.bondPrice === null ? null : Number(w.bondPrice), - isInTheMoney: w.isInTheMoney, - })); -} diff --git a/src/web/src/features/bonds/api/types.ts b/src/web/src/features/bonds/api/types.ts new file mode 100644 index 0000000..4fa36ef --- /dev/null +++ b/src/web/src/features/bonds/api/types.ts @@ -0,0 +1,16 @@ +import type { components } from "../../../api/schema.gen"; + +/** + * The bonds API's wire types, given domain names. + * + * Generated schema types are used directly — there is no mapping layer, because + * the shapes are identical and a 1:1 mapper is drift risk with no benefit. This + * file exists so the rest of the feature never reaches into `schema.gen`: if a + * DTO is renamed upstream, only the alias here changes. + * + * Introduce a hand-written type (and a transform in the query) only when one is + * genuinely needed — a computed field, or parsing `asOf` into a `Date`. + */ + +/** A convertible bond's conversion valuation, exactly as the API defines it. */ +export type ConversionValuation = components["schemas"]["ConversionValuationDto"]; diff --git a/src/web/src/features/bonds/bondCells.test.ts b/src/web/src/features/bonds/bondCells.test.ts new file mode 100644 index 0000000..d3c02c6 --- /dev/null +++ b/src/web/src/features/bonds/bondCells.test.ts @@ -0,0 +1,37 @@ +import { GridCellKind, type CustomCell } from "@glideapps/glide-data-grid"; +import { describe, expect, it } from "vitest"; +import { bondCells } from "./bondCells"; + +const ctx = { editable: false, withTime: false }; +const cell = (type: string, raw: unknown) => + bondCells.makeCell(type, raw, ctx) as CustomCell>; +const data = (type: string, raw: unknown) => cell(type, raw).data; + +describe("bondCells", () => { + it("carries the numeric value for the currency and shares columns", () => { + expect(data("ntCurrency2", 128.5).value).toBe(128.5); + expect(data("ntCurrency0", 130000).value).toBe(130000); + expect(data("shares", 2000).value).toBe(2000); + }); + + it("passes the symbol and date through", () => { + expect(data("symbol", "2330").value).toBe("2330"); + expect(data("date", "2026-07-17").value).toBe("2026-07-17"); + }); + + it("maps the boolean status onto a red/green enum value; null stays null", () => { + expect(data("status", true).value).toBe(1); + expect(data("status", false).value).toBe(0); + expect(data("status", null).value).toBe(null); + }); + + it("produces read-only Custom cells: overlay off, empty flagged to draw —", () => { + const c = cell("symbol", "2330"); + expect(c.kind).toBe(GridCellKind.Custom); + expect(c.allowOverlay).toBe(false); + expect(cell("status", true).allowOverlay).toBe(false); + // A null value on a read-only cell carries the flag its renderer uses to draw "—". + expect(data("ntCurrency2", null).value).toBe(null); + expect(data("ntCurrency2", null).readOnly).toBe(true); + }); +}); diff --git a/src/web/src/features/bonds/bondCells.ts b/src/web/src/features/bonds/bondCells.ts new file mode 100644 index 0000000..3dfaf69 --- /dev/null +++ b/src/web/src/features/bonds/bondCells.ts @@ -0,0 +1,128 @@ +import type { CustomRenderer } from "@glideapps/glide-data-grid"; +import { + createCellRegistry, + type CellContext, + type CellRegistry, + type CellTypeDef, +} from "../../lib/grid/cellRegistry"; +import { createDateCell } from "../../lib/grid/createDateCell"; +import { createEnumCell } from "../../lib/grid/createEnumCell"; +import { createNumberCell } from "../../lib/grid/createNumberCell"; +import { createTextCell } from "../../lib/grid/createTextCell"; + +// Shared cell primitives, built once at module scope (renderer/editor identity is +// tied to the create* call — never build these inside a React render). Every bond +// column is non-editable, so each `make` passes `ctx.editable` as `allowOverlay` +// (false here): the editor never opens and an empty value draws "—". +const symbolCell = createTextCell({ kind: "bond-symbol" }); + +const sharesCell = createNumberCell({ + kind: "bond-shares", + format: "integer", + thousandSeparator: true, +}); + +const nt0Cell = createNumberCell({ + kind: "bond-nt0", + format: "currency", + currency: "TWD", + decimalScale: 0, +}); + +const nt2Cell = createNumberCell({ + kind: "bond-nt2", + format: "currency", + currency: "TWD", + decimalScale: 2, +}); + +const dateCell = createDateCell({ kind: "bond-date", nullable: true }); + +const statusCell = createEnumCell({ + kind: "bond-status", + nullable: true, + options: [ + { value: 0, label: "Out of the money", color: "red" }, + { value: 1, label: "In the money", color: "green" }, + ], +}); + +const asNum = (raw: unknown): number | null => (raw == null ? null : Number(raw)); +const asStr = (raw: unknown): string | null => (raw == null ? null : String(raw)); + +/** + * Left unannotated (not `CellTypeDef[]`) so each `renderer` keeps its precise + * `CustomRenderer>` type instead of widening to the + * registry's unparameterized `CustomRenderer`. `make`'s parameters are + * annotated by hand since they lose the `CellTypeDef` contextual type here. + * Everything but `renderer` (`type`, `kind`, `make`'s params/return, `validate`) + * is still checked structurally against `CellTypeDef` when this array is mapped + * below — only the `renderer` field gets an escape hatch. + */ +const bondCellDefs = [ + { + type: "symbol", + kind: "bond-symbol", + renderer: symbolCell.renderer, + make: (raw: unknown, ctx: CellContext) => symbolCell.makeCell(asStr(raw), ctx.editable), + }, + { + type: "shares", + kind: "bond-shares", + renderer: sharesCell.renderer, + make: (raw: unknown, ctx: CellContext) => sharesCell.makeCell(asNum(raw), ctx.editable), + }, + { + type: "ntCurrency0", + kind: "bond-nt0", + renderer: nt0Cell.renderer, + make: (raw: unknown, ctx: CellContext) => nt0Cell.makeCell(asNum(raw), ctx.editable), + }, + { + type: "ntCurrency2", + kind: "bond-nt2", + renderer: nt2Cell.renderer, + make: (raw: unknown, ctx: CellContext) => nt2Cell.makeCell(asNum(raw), ctx.editable), + }, + { + type: "date", + kind: "bond-date", + renderer: dateCell.renderer, + make: (raw: unknown, ctx: CellContext) => + dateCell.makeCell(asStr(raw), ctx.withTime, ctx.editable), + }, + { + type: "status", + kind: "bond-status", + renderer: statusCell.renderer, + make: (raw: unknown, ctx: CellContext) => + statusCell.makeCell(raw == null ? null : raw ? 1 : 0, ctx.editable), + }, +]; + +/** + * Cell registry for the read-only convertible-bond grid, reusing the shared + * lib/grid primitives. Column `type` keys: "symbol", "shares", "ntCurrency0", + * "ntCurrency2", "date", "status". Each `make` builds its cell with + * `allowOverlay: ctx.editable` — false for every bond column — so the cell is + * non-editable and a null value renders as "—". + */ +// `bondCellDefs`'s renderers are typed per-cell (e.g. +// `CustomRenderer>`), but `CellTypeDef.renderer` is +// the unparameterized `CustomRenderer>`. `strictFunctionTypes` +// makes those incomparable in either direction (the `draw`/`onPaste` +// parameters are contravariant), so a direct `as CellTypeDef[]` is rejected +// as non-overlapping. The escape hatch is narrowed to just the `renderer` +// field — via a per-def `as unknown as CustomRenderer` inside `.map` — instead +// of routing the whole array (and thus every other field: `type`, `kind`, +// `make`'s params/return, `validate`) through `unknown`. The `: CellTypeDef` +// return annotation on the mapper still checks all of those fields structurally; +// only `renderer`'s contravariant mismatch is suppressed. Runtime safety for +// that field is unaffected either way: routing (by `data.kind` via each +// renderer's `isMatch`) is what actually keeps this safe at runtime. +export const bondCells: CellRegistry = createCellRegistry( + bondCellDefs.map((d): CellTypeDef => ({ + ...d, + renderer: d.renderer as unknown as CustomRenderer, + })), +); diff --git a/src/web/src/features/bonds/bondGrid.test.ts b/src/web/src/features/bonds/bondGrid.test.ts new file mode 100644 index 0000000..9358329 --- /dev/null +++ b/src/web/src/features/bonds/bondGrid.test.ts @@ -0,0 +1,180 @@ +import { configureStore } from "@reduxjs/toolkit"; +import { QueryClient } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { rootReducer } from "../../app/rootReducer"; +import { appListener } from "../../app/listener"; +import { queryClient } from "../../app/queryClient"; +import { bondKeys } from "./api/bondQueries"; +import { bondGrid, createBondGridApi, GRID_NAME } from "./bondGrid"; +import type { ConversionValuation } from "./api/types"; + +const sample: ConversionValuation[] = [ + { + symbol: "B", + conversionShares: 1, + conversionValue: 300, + stockPrice: 3, + asOf: "2026-07-17", + bondPrice: 110, + isInTheMoney: true, + }, + { + symbol: "A", + conversionShares: 2, + conversionValue: 100, + stockPrice: 1, + asOf: "2026-07-17", + bondPrice: null, + isInTheMoney: null, + }, + { + symbol: "C", + conversionShares: 3, + conversionValue: 200, + stockPrice: 2, + asOf: "2026-07-17", + bondPrice: 95, + isInTheMoney: false, + }, +]; + +// Seeding the cache keeps these tests free of fetch stubbing. The transport has +// its own tests (src/lib/http); re-testing it here would couple grid behaviour +// to HTTP details. +function seededClient(rows: ConversionValuation[]): QueryClient { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData(bondKeys.valuations(), rows); + return client; +} + +/** The whole list, unsorted — the collection's default ask. */ +const wholeList = { offset: 0, limit: 10, sort: null, collapsedGroups: [] as never[] }; + +describe("createBondGridApi", () => { + it("returns rows in source order and reports the total", async () => { + const api = createBondGridApi(seededClient(sample)); + const rows = await api.fetchRows(wholeList); + expect(await api.fetchCount()).toBe(3); + expect(rows.map((r) => r.symbol)).toEqual(["B", "A", "C"]); + }); + + it("slices to the requested range", async () => { + const api = createBondGridApi(seededClient(sample)); + const rows = await api.fetchRows({ ...wholeList, offset: 1, limit: 1 }); + expect(rows.map((r) => r.symbol)).toEqual(["A"]); + expect(await api.fetchCount()).toBe(3); + }); + + it("sorts ascending by a numeric field", async () => { + const rows = await createBondGridApi(seededClient(sample)).fetchRows({ + ...wholeList, + sort: { field: "conversionValue", direction: "asc", nulls: "last" }, + }); + expect(rows.map((r) => r.conversionValue)).toEqual([100, 200, 300]); + expect(rows.map((r) => r.symbol)).toEqual(["A", "C", "B"]); + }); + + it("sorts descending", async () => { + const rows = await createBondGridApi(seededClient(sample)).fetchRows({ + ...wholeList, + sort: { field: "conversionValue", direction: "desc", nulls: "last" }, + }); + expect(rows.map((r) => r.conversionValue)).toEqual([300, 200, 100]); + }); + + it("sorts null fields last regardless of direction", async () => { + const rows = await createBondGridApi(seededClient(sample)).fetchRows({ + ...wholeList, + sort: { field: "bondPrice", direction: "asc", nulls: "last" }, + }); + expect(rows.map((r) => r.bondPrice)).toEqual([95, 110, null]); + }); + + it("updateRow is a no-op returning ok:false (read-only)", async () => { + const api = createBondGridApi(seededClient(sample)); + expect(await api.updateRow({ id: "B", changes: { symbol: "X" } })).toEqual({ ok: false }); + }); + + it("does not mutate the cached list when sorting, so a later unsorted call returns source order", async () => { + const api = createBondGridApi(seededClient(sample)); + await api.fetchRows({ + ...wholeList, + sort: { field: "conversionValue", direction: "asc", nulls: "last" }, + }); + const rows = await api.fetchRows(wholeList); + expect(rows.map((r) => r.symbol)).toEqual(["B", "A", "C"]); + }); +}); + +describe("bondGrid instance", () => { + beforeEach(() => { + // bondGridDescriptor is bound to the app-wide client at module scope, so + // the store-level test seeds that client rather than a throwaway one. + queryClient.setQueryData(bondKeys.valuations(), sample); + }); + + it("injects itself into the root reducer on import, with no wiring in app/", () => { + const state = rootReducer(undefined, { type: "@@init" }); + expect(bondGrid.selectRoot(state).columns.order).toEqual( + bondGrid.descriptor.columns.defaultOrder, + ); + expect(GRID_NAME).toBe(bondGrid.descriptor.name); + }); + + it("reduces client-owned state only — no rows in the store", () => { + const store = configureStore({ + reducer: { [bondGrid.descriptor.name]: bondGrid.reducer }, + middleware: (getDefault) => + getDefault({ serializableCheck: false }).concat(appListener.middleware), + }); + expect(Object.keys(bondGrid.selectRoot(store.getState())).sort()).toEqual([ + "columns", + "edits", + "groups", + "selection", + ]); + }); + + it("starts with an empty store cell, and loads through the app-wide cache", async () => { + expect(bondGrid.storeRef.current).toBeNull(); + + const rows = await bondGrid.descriptor.api.fetchRows({ + offset: 0, + limit: 2, + sort: null, + collapsedGroups: [], + }); + expect(await bondGrid.descriptor.api.fetchCount({ collapsedGroups: [] })).toBe(3); + expect(rows.map((r) => r.symbol)).toEqual(["B", "A"]); + }); + + it("serves every slice from one whole-list request", async () => { + // The endpoint is not paged: asking for many slices must not multiply the + // network calls behind them. + vi.stubEnv("VITE_API_BASE_URL", "http://api.test"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify(sample), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + queryClient.clear(); + + const slices = await Promise.all( + [0, 1, 2].map((offset) => + bondGrid.descriptor.api.fetchRows({ offset, limit: 1, sort: null, collapsedGroups: [] }), + ), + ); + + expect(slices.map((s) => s[0].symbol)).toEqual(["B", "A", "C"]); + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1); + + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); +}); diff --git a/src/web/src/features/bonds/bondGrid.ts b/src/web/src/features/bonds/bondGrid.ts new file mode 100644 index 0000000..28c985c --- /dev/null +++ b/src/web/src/features/bonds/bondGrid.ts @@ -0,0 +1,136 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { queryClient } from "../../app/queryClient"; +import { injectSlice } from "../../app/rootReducer"; +import { compareBySpec } from "../dataGrid/data/sortSpec"; +import { createGridInstance } from "../dataGrid/store/createGridInstance"; +import { localStorageColumnsAdapter } from "../dataGrid/store/localStorageColumnsAdapter"; +import type { + ColumnDef, + FetchRowsParams, + GridDescriptor, + GridSliceState, + UpdateRowParams, +} from "../dataGrid/types"; +import { valuationsQuery } from "./api/bondQueries"; +import { bondCells } from "./bondCells"; +import type { ConversionValuation } from "./api/types"; + +/** A valuation as the grid sees it. `symbol` is the stable row key. */ +export type BondRow = ConversionValuation; + +export const GRID_NAME = "bonds"; + +/** + * Adapts the whole-list valuations query onto the grid's slice API: fetch + * through the cache, sort client-side per the requested sort, serve the slice. + * + * The endpoint is not paged, so every slice resolves from one shared + * whole-list query — `fetchQuery` dedupes them into a single request no matter + * how many slices the collection asks for. When the API gains offset/limit this + * becomes a real per-slice request and nothing above it changes. + * + * `updateRow` is a no-op — the grid is read-only. + */ +export function createBondGridApi(client: QueryClient) { + return { + async fetchRows(p: FetchRowsParams): Promise { + const all = await client.fetchQuery(valuationsQuery()); + const rows = p.sort + ? [...all].sort((a, b) => compareBySpec(a, b, p.sort!, (r) => r.symbol)) + : all; + return rows.slice(p.offset, p.offset + p.limit); + }, + async fetchCount(): Promise { + const all = await client.fetchQuery(valuationsQuery()); + return all.length; + }, + async fetchRow(id: string): Promise { + const all = await client.fetchQuery(valuationsQuery()); + return all.find((r) => r.symbol === id) ?? null; + }, + async updateRow(_p: UpdateRowParams): Promise<{ ok: boolean }> { + return { ok: false }; + }, + }; +} + +const COLUMN_DEFS: Record = { + symbol: { field: "symbol", title: "Symbol", defaultWidth: 110, editable: false, type: "symbol" }, + conversionShares: { + field: "conversionShares", + title: "Conversion Shares", + defaultWidth: 150, + editable: false, + type: "shares", + }, + conversionValue: { + field: "conversionValue", + title: "Conversion Value", + defaultWidth: 160, + editable: false, + type: "ntCurrency0", + }, + stockPrice: { + field: "stockPrice", + title: "Stock Price", + defaultWidth: 130, + editable: false, + type: "ntCurrency2", + }, + bondPrice: { + field: "bondPrice", + title: "Bond Price", + defaultWidth: 130, + editable: false, + type: "ntCurrency2", + }, + isInTheMoney: { + field: "isInTheMoney", + title: "Status", + defaultWidth: 160, + editable: false, + type: "status", + }, + asOf: { field: "asOf", title: "As Of", defaultWidth: 120, editable: false, type: "date" }, +}; + +const columnsAdapter = localStorageColumnsAdapter(`${GRID_NAME}:columns`); + +export const bondGridDescriptor: GridDescriptor = { + name: GRID_NAME, + rowKey: (row) => row.symbol, + columns: { + defs: COLUMN_DEFS, + defaultOrder: [ + "symbol", + "conversionShares", + "conversionValue", + "stockPrice", + "bondPrice", + "isInTheMoney", + "asOf", + ], + // All columns are read-only; without this the default ("editable only") + // would make nothing sortable. + sortable: () => true, + }, + api: { + ...createBondGridApi(queryClient), + loadColumns: columnsAdapter.loadColumns, + saveColumns: columnsAdapter.saveColumns, + }, + cells: bondCells, +}; + +export const bondGrid = createGridInstance(bondGridDescriptor); + +// Claim this grid's slot in RootState. Declaring it here rather than in +// app/rootReducer keeps registration with the grid it belongs to, and makes the +// key a literal so no cast is needed to satisfy the reducer map's typing. +declare module "../../app/rootReducer" { + interface LazyLoadedSlices { + [GRID_NAME]: GridSliceState; + } +} + +injectSlice({ reducerPath: GRID_NAME, reducer: bondGrid.reducer }); diff --git a/src/web/src/features/bonds/index.ts b/src/web/src/features/bonds/index.ts index d685a4f..2ba05c4 100644 --- a/src/web/src/features/bonds/index.ts +++ b/src/web/src/features/bonds/index.ts @@ -1,3 +1,2 @@ -export { createBondDataSource } from "./api/createBondDataSource"; -export type { BondDataSource } from "./api/BondDataSource"; -export type { ConversionValuation } from "./types"; +export type { ConversionValuation } from "./api/types"; +export { BondsPage } from "./BondsPage"; diff --git a/src/web/src/features/bonds/types.ts b/src/web/src/features/bonds/types.ts deleted file mode 100644 index 2e0655b..0000000 --- a/src/web/src/features/bonds/types.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * A convertible bond's conversion valuation, as consumed by the UI. - * `bondPrice` / `isInTheMoney` are null when no bond quote was available — - * render "—", never a false "out-of-the-money". - */ -export interface ConversionValuation { - symbol: string; - conversionShares: number; - conversionValue: number; // NT$ - stockPrice: number; - asOf: string; // ISO date, "YYYY-MM-DD" - bondPrice: number | null; - isInTheMoney: boolean | null; -} - -/** - * Raw record shape as it arrives over the HybridWebView bridge (camelCased C# - * DTO). Numeric fields are typed loosely to guard against string-encoded - * decimals from any transport. - */ -export interface ConversionValuationWire { - symbol: string; - conversionShares: number | string; - conversionValue: number | string; - stockPrice: number | string; - asOf: string; - bondPrice: number | string | null; - isInTheMoney: boolean | null; -} diff --git a/src/web/src/features/dataGrid/DataGrid.tsx b/src/web/src/features/dataGrid/DataGrid.tsx new file mode 100644 index 0000000..589daa1 --- /dev/null +++ b/src/web/src/features/dataGrid/DataGrid.tsx @@ -0,0 +1,168 @@ +// src/features/dataGrid/DataGrid.tsx +import { DataEditor, type DataEditorRef, type Rectangle } from "@glideapps/glide-data-grid"; +import "@glideapps/glide-data-grid/dist/index.css"; +import { useCallback, useRef } from "react"; +import { useSelector } from "react-redux"; +import { useGridTheme } from "../../theme/useGridTheme"; +import { specFromGridSort } from "./data/sortSpec"; +import { useRowSync } from "./data/sync/useRowSync"; +import type { DisplayModel } from "./displayModel"; +import { GridStatusBar } from "./GridStatusBar"; +import { sortHeaderIcons } from "./headerIcons"; +import { useCellRenderer } from "./hooks/useCellRenderer"; +import { useDisplayModel } from "./hooks/useDisplayModel"; +import { useGridColumns } from "./hooks/useGridColumns"; +import { useGridData } from "./hooks/useGridData"; +import { useGridSelection } from "./hooks/useGridSelection"; +import { useGridSort } from "./hooks/useGridSort"; +import { useRepaintRows } from "./hooks/useRepaintRows"; +import { useWindowRange, type RangeLoaded } from "./hooks/useWindowRange"; +import type { GridInstance } from "./types"; +import { useGridDispatch } from "./useGridDispatch"; + +export function DataGrid({ + instance, +}: { + instance: GridInstance; +}) { + const gridRef = useRef(null); + + // `repaintRows` translates a data index into the display row glide draws it + // at, and it is created before the model exists in this render. It reads the + // model back out of this cell, which the publish below keeps current. + const modelRef = useRef | null>(null); + + // The same arrangement for the columns. A repaint damages every VISIBLE + // column of a changed row, and the column set is built after `repaintRows` in + // this render — and rebuilt whenever the user hides or shows a column. + const columnCountRef = useRef(null); + + // And once more, for the one question `useWindowRange` cannot answer itself: + // has the store already got this window. The predicate comes out of + // `useGridData`, which needs the range this hook produces, so the two can + // only meet over time — in the callback glide fires after both have run. + const rangeLoadedRef = useRef(null); + + const gridTheme = useGridTheme(); + const dispatch = useGridDispatch(); + const pageSize = instance.descriptor.pageSize ?? 100; + + const { sort, collapsedGroups } = useSelector((s: unknown) => instance.selectRoot(s).groups); + const lastError = useSelector((s: unknown) => instance.selectRoot(s).edits.lastError); + + const spec = specFromGridSort(sort); + + // Rows repaint through glide's damage API, not through React. This callback + // is the whole no-flash mechanism: rows already on screen are never + // re-rendered, and the rows that just changed are the only ones redrawn. + // All three writers share it — a page load, a settled save, and a pushed + // update. See `hooks/useRepaintRows.ts`. + const repaintRows = useRepaintRows(gridRef, modelRef, columnCountRef); + + // The range first, then the rows it loads, then the model they shape. Acyclic + // per render: only the callback handed to glide closes the loop, over time. + const { range, onRectChanged } = useWindowRange(pageSize, rangeLoadedRef); + + const { rowAt, isPending, isRangeLoaded, total, span, status, retry, isStale, overlay, store } = + useGridData(instance, range, spec, collapsedGroups, repaintRows); + + // Published during render, like the model and the column count below it. The + // predicate closes over the store the grid is reading NOW, and a window that + // tested itself against the store of an earlier render would skip the settle + // wait on pages a hold has since replaced. + rangeLoadedRef.current = isRangeLoaded; + + const { visibleFields, columns, onColumnResize, onColumnMoved } = useGridColumns(instance); + + // Published during render, for the same reason the model is: a repaint that + // ran against a stale count would either miss a column the user has just + // shown or name one the grid no longer draws. + columnCountRef.current = columns.length; + + const { model, onHeaderOrCellClicked } = useDisplayModel(instance, { total, span }); + + // A group collapse or a grown span rebuilds the model, and every rebuild moves + // which display row a data index sits at. Publishing it here, during render, + // is what stops a repaint translating against a mapping the grid has already + // stopped drawing. + modelRef.current = model; + const { gridSelection, onGridSelectionChange } = useGridSelection(instance, model, rowAt); + const { onHeaderClicked } = useGridSort(instance, visibleFields); + const { getCellContent, onCellEdited } = useCellRenderer(instance, { + model, + visibleFields, + columnCount: columns.length, + rowAt, + isPending, + overlay, + store, + repaint: repaintRows, + }); + + // The third writer of a row. A pushed update patches the store in place and + // moves nothing React watches, so it reaches the screen through the same + // damage callback the page loader and the save path use. + useRowSync(instance, collapsedGroups, repaintRows); + + const dismissError = useCallback( + () => dispatch(instance.actions.editErrorCleared()), + [dispatch, instance], + ); + + const onVisibleRegionChanged = useCallback( + (rect: Rectangle) => onRectChanged(model, rect), + [onRectChanged, model], + ); + + return ( +
+ + + {/* + Old rows with no signal read as a working grid. The dim, plus the line + in the bar above, is what tells the truth about the rows below while the + next sort or collapse state loads. + */} +
+ instance.descriptor.cells.validateCell(newValue)} + columns={columns} + rows={model.rowCount} + getCellContent={getCellContent} + onCellEdited={onCellEdited} + onCellClicked={onHeaderOrCellClicked} + onVisibleRegionChanged={onVisibleRegionChanged} + onColumnResize={onColumnResize} + onColumnMoved={onColumnMoved} + rowMarkers="checkbox-visible" + gridSelection={gridSelection} + onGridSelectionChange={onGridSelectionChange} + onHeaderClicked={onHeaderClicked} + width="100%" + height="100%" + smoothScrollX + smoothScrollY + /> +
+
+ ); +} diff --git a/src/web/src/features/dataGrid/GridStatusBar.test.tsx b/src/web/src/features/dataGrid/GridStatusBar.test.tsx new file mode 100644 index 0000000..a92b68c --- /dev/null +++ b/src/web/src/features/dataGrid/GridStatusBar.test.tsx @@ -0,0 +1,62 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { GridStatusBar } from "./GridStatusBar"; + +describe("GridStatusBar", () => { + it("renders nothing while rows load into a grid that already has rows", () => { + const { container } = render( + {}} />, + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("says a first load is loading, because a grid with no rows has no cells to say it", () => { + render( {}} />); + expect(screen.getByRole("status")).toHaveTextContent(/loading rows/i); + }); + + it("renders nothing when the grid is ready and has no error", () => { + const { container } = render( + {}} />, + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("offers a retry when a load fails", async () => { + const onRetry = vi.fn(); + render(); + + expect(screen.getByRole("alert")).toHaveTextContent(/could not load/i); + await userEvent.click(screen.getByRole("button", { name: /retry/i })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it("shows an edit error on top of a ready grid", () => { + render( + {}} + />, + ); + expect(screen.getByRole("alert")).toHaveTextContent("Failed to save price"); + }); + + it("offers a dismiss for an edit error, so a user who stops editing is not stuck with it", async () => { + const onDismissError = vi.fn(); + render( + {}} + onDismissError={onDismissError} + />, + ); + + await userEvent.click(screen.getByRole("button", { name: /dismiss/i })); + expect(onDismissError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/web/src/features/dataGrid/GridStatusBar.tsx b/src/web/src/features/dataGrid/GridStatusBar.tsx new file mode 100644 index 0000000..e245bf3 --- /dev/null +++ b/src/web/src/features/dataGrid/GridStatusBar.tsx @@ -0,0 +1,124 @@ +import { Button, Callout, Flex, Spinner } from "@radix-ui/themes"; +import { CircleAlert } from "lucide-react"; +import type { ReactNode } from "react"; + +/** + * The bar sits flush against the grid, so it drops the Callout radius and keeps + * only the bottom edge of the ring. `--accent-a*` follows the `color` prop, so + * one style serves both the red and the gray bar. + */ +function StatusCallout({ + color, + role, + icon, + children, +}: { + color: "red" | "gray"; + role: "alert" | "status"; + icon: ReactNode; + children: ReactNode; +}) { + return ( + + {icon} + {children} + + ); +} + +/** + * Where a grid failure becomes visible. + * + * Before this existed, a failed window left the grid drawing loading cells for + * ever, with no way to tell "still loading" from "gave up". + * + * A load stays silent while the grid HAS rows, because the loading cells + * already say it. `rowCount` is what tells the two apart: on the first load the + * total is not known yet, so there are no cells and no loading cells either, + * and a silent bar over an empty canvas reads as a grid that finished and found + * nothing. That case gets a line of its own. + * + * `stale` gets a line for the opposite reason. The grid then holds a full set of + * rows and draws no loading cell at all, because every one of those rows belongs + * to the state the user has just left. Nothing on the canvas says so. + */ +export function GridStatusBar({ + status, + error, + rowCount, + stale, + onRetry, + onDismissError, +}: { + status: "loading" | "ready" | "error"; + error: string | null; + /** Rows the grid is currently drawing. Zero means there are no cells to speak for a load. */ + rowCount: number; + /** The grid is drawing the previous state while the next one loads. */ + stale?: boolean; + onRetry: () => void; + /** Omit to render the edit error without a dismiss. */ + onDismissError?: () => void; +}) { + if (status === "error") { + return ( + }> + + Could not load rows. + + + + + ); + } + + // Above the edit error on purpose. The bar shows one line at a time, and a + // load speaks for every row on screen while an edit error speaks for one + // cell. + // + // The wait costs the user nothing. The edit error names its cell by ROW KEY — + // `hooks/useCellRenderer.ts` builds `${rowKey}:${field}` — and not by + // position, so a state change cannot make it point at the wrong row. It stays + // in the slice and comes back to the bar the moment the load settles. + if (stale) { + return ( + }> + Loading… + + ); + } + + if (error !== null) { + return ( + }> + + {error} + + {onDismissError !== undefined && ( + + )} + + + ); + } + + if (status === "loading" && rowCount === 0) { + return ( + }> + Loading rows… + + ); + } + + return null; +} diff --git a/src/web/src/features/dataGrid/data/editOverlay.test.ts b/src/web/src/features/dataGrid/data/editOverlay.test.ts new file mode 100644 index 0000000..39e8a07 --- /dev/null +++ b/src/web/src/features/dataGrid/data/editOverlay.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { createEditOverlay } from "./editOverlay"; + +interface Row { + id: number; + name: string; + note: string; +} + +const row: Row = { id: 1, name: "server", note: "kept" }; + +describe("createEditOverlay", () => { + it("returns the stored row untouched when nothing is in flight", () => { + const overlay = createEditOverlay(); + + expect(overlay.apply(1, row)).toBe(row); + expect(overlay.isPending(1)).toBe(false); + }); + + it("shows the optimistic value over the stored one while a save is in flight", () => { + const overlay = createEditOverlay(); + + overlay.begin(1, "name", "typed"); + + expect(overlay.apply(1, row).name).toBe("typed"); + expect(overlay.apply(1, row).note).toBe("kept"); + expect(overlay.isPending(1)).toBe(true); + }); + + it("leaves the stored row object unmutated", () => { + const overlay = createEditOverlay(); + + overlay.begin(1, "name", "typed"); + overlay.apply(1, row); + + expect(row.name).toBe("server"); + }); + + it("drops the optimistic value on rollback, so the stored row shows again", () => { + const overlay = createEditOverlay(); + + overlay.begin(1, "name", "typed"); + overlay.rollback(1, "name"); + + expect(overlay.apply(1, row).name).toBe("server"); + expect(overlay.isPending(1)).toBe(false); + }); + + it("drops the optimistic value on commit too, because the store now holds it", () => { + const overlay = createEditOverlay(); + + overlay.begin(1, "name", "typed"); + overlay.commit(1, "name"); + + expect(overlay.isPending(1)).toBe(false); + expect(overlay.apply(1, { ...row, name: "typed" }).name).toBe("typed"); + }); + + it("keeps a row pending while a SECOND field of it is still in flight", () => { + const overlay = createEditOverlay(); + + overlay.begin(1, "name", "typed"); + overlay.begin(1, "note", "also typed"); + + overlay.commit(1, "name"); + + expect(overlay.isPending(1)).toBe(true); + expect(overlay.apply(1, row).note).toBe("also typed"); + }); + + it("keeps two rows' edits apart", () => { + const overlay = createEditOverlay(); + + overlay.begin(1, "name", "one"); + overlay.begin(2, "name", "two"); + + overlay.rollback(1, "name"); + + expect(overlay.isPending(1)).toBe(false); + expect(overlay.isPending(2)).toBe(true); + }); + + it("lets a later edit of the same cell win over an earlier one", () => { + const overlay = createEditOverlay(); + + overlay.begin(1, "name", "first"); + overlay.begin(1, "name", "second"); + + expect(overlay.apply(1, row).name).toBe("second"); + }); + + it("reports every key with an edit in flight, so a reload can spare them", () => { + const overlay = createEditOverlay(); + + overlay.begin(1, "name", "typed"); + overlay.begin(2, "name", "typed"); + overlay.commit(2, "name"); + + expect(overlay.pendingKeys()).toEqual([1]); + }); + + it("forgets a key entirely once its last field settles", () => { + const overlay = createEditOverlay(); + + overlay.begin(1, "name", "typed"); + overlay.commit(1, "name"); + + expect(overlay.pendingKeys()).toEqual([]); + }); + + it("drops everything on clear", () => { + const overlay = createEditOverlay(); + + overlay.begin(1, "name", "typed"); + overlay.clear(); + + expect(overlay.isPending(1)).toBe(false); + expect(overlay.apply(1, row).name).toBe("server"); + }); +}); diff --git a/src/web/src/features/dataGrid/data/editOverlay.ts b/src/web/src/features/dataGrid/data/editOverlay.ts new file mode 100644 index 0000000..3a4a9aa --- /dev/null +++ b/src/web/src/features/dataGrid/data/editOverlay.ts @@ -0,0 +1,106 @@ +export interface EditOverlay { + /** Records an optimistic value for one cell. */ + begin: (key: TKey, field: string, value: unknown) => void; + + /** The save landed. The store holds the value now, so the overlay lets it go. */ + commit: (key: TKey, field: string) => void; + + /** The save was rejected. Dropping the entry restores the stored row. */ + rollback: (key: TKey, field: string) => void; + + /** The stored row with any in-flight values of that row laid over it. */ + apply: (key: TKey, row: TRow) => TRow; + + /** Whether any cell of this row has a save in flight. */ + isPending: (key: TKey) => boolean; + + /** Every row key with a save in flight. */ + pendingKeys: () => TKey[]; + + clear: () => void; +} + +/** + * Where an optimistic edit lives until the server answers. + * + * The point of a separate structure is the rollback. `data/rowStore.ts` holds + * only what the server sent, so a rejected save needs no saved copy and no + * restore — deleting the overlay entry IS the rollback, and it is correct even + * when a reload replaced the underlying row in between. + * + * It also replaces the virtual `synced` flag the previous row layer stamped on + * an optimistic row. `isPending` is the same signal, from a structure this + * codebase owns. + * + * Both `commit` and `rollback` delete the entry. They differ only in what the + * caller does to the store first, and they stay two functions because the call + * sites read as two different intentions. + */ +export function createEditOverlay(): EditOverlay< + TRow, + TKey +> { + const pendingByKey = new Map>(); + + const settle = (key: TKey, field: string) => { + const fields = pendingByKey.get(key); + + if (fields === undefined) { + return; + } + + fields.delete(field); + + // The key goes when its last field settles, so `pendingKeys` never reports + // a row that has nothing in flight and the map cannot grow without bound + // over a long editing session. + if (fields.size === 0) { + pendingByKey.delete(key); + } + }; + + return { + begin(key, field, value) { + const fields = pendingByKey.get(key) ?? new Map(); + + fields.set(field, value); + pendingByKey.set(key, fields); + }, + + commit: settle, + rollback: settle, + + apply(key, row) { + const fields = pendingByKey.get(key); + + // The SAME object when nothing is pending, not a copy. `getCellContent` + // runs this once per visible cell per paint, and a fresh object per call + // would defeat every identity check downstream of it. + if (fields === undefined || fields.size === 0) { + return row; + } + + const merged: Record = { ...(row as Record) }; + + for (const [field, value] of fields) { + merged[field] = value; + } + + return merged as TRow; + }, + + isPending(key) { + const fields = pendingByKey.get(key); + + return fields !== undefined && fields.size > 0; + }, + + pendingKeys() { + return [...pendingByKey.keys()]; + }, + + clear() { + pendingByKey.clear(); + }, + }; +} diff --git a/src/web/src/features/dataGrid/data/rowCount.test.tsx b/src/web/src/features/dataGrid/data/rowCount.test.tsx new file mode 100644 index 0000000..87b583b --- /dev/null +++ b/src/web/src/features/dataGrid/data/rowCount.test.tsx @@ -0,0 +1,77 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; +import type { GridDescriptor } from "../types"; +import { rowCountKey, useRowCount } from "./rowCount"; + +interface Row { + id: number; +} + +function harness(count = 42, fetchCountImpl?: () => Promise) { + const fetchCount = vi.fn(fetchCountImpl ?? (async () => count)); + const descriptor = { + name: "demo", + rowKey: (r: Row) => r.id, + api: { fetchCount }, + } as unknown as GridDescriptor; + + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return { descriptor, fetchCount, queryClient, wrapper }; +} + +describe("useRowCount", () => { + it("reports 0 before the count arrives, then the server count", async () => { + const { descriptor, wrapper } = harness(42); + const { result } = renderHook(() => useRowCount(descriptor, []), { wrapper }); + + expect(result.current.total).toBe(0); + await waitFor(() => expect(result.current.total).toBe(42)); + }); + + it("reports the count as unsettled until the one for this key lands", async () => { + const { descriptor, wrapper } = harness(42); + + const { result } = renderHook(() => useRowCount(descriptor, []), { wrapper }); + + // A total of 0 means "not back yet" here, and the hold in `useGridData` + // must be able to tell that from a table that really holds no rows. + expect(result.current.settled).toBe(false); + + await waitFor(() => expect(result.current.settled).toBe(true)); + expect(result.current.total).toBe(42); + }); + + it("counts a rejected request as settled, so a hold cannot run for ever", async () => { + const { descriptor, wrapper } = harness(42, async () => { + throw new Error("boom"); + }); + + const { result } = renderHook(() => useRowCount(descriptor, []), { wrapper }); + + await waitFor(() => expect(result.current.settled).toBe(true)); + expect(result.current.total).toBe(0); + }); + + it("asks again when the collapsed groups change", async () => { + const { descriptor, fetchCount, wrapper } = harness(); + const { rerender } = renderHook(({ groups }) => useRowCount(descriptor, groups), { + wrapper, + initialProps: { groups: [] as number[] }, + }); + + await waitFor(() => expect(fetchCount).toHaveBeenCalledTimes(1)); + rerender({ groups: [1] }); + await waitFor(() => expect(fetchCount).toHaveBeenCalledTimes(2)); + }); +}); + +describe("rowCountKey", () => { + it("keys the same groups alike whatever order they arrive in", () => { + expect(rowCountKey("demo", [2, 1])).toEqual(rowCountKey("demo", [1, 2])); + }); +}); diff --git a/src/web/src/features/dataGrid/data/rowCount.ts b/src/web/src/features/dataGrid/data/rowCount.ts new file mode 100644 index 0000000..aad397a --- /dev/null +++ b/src/web/src/features/dataGrid/data/rowCount.ts @@ -0,0 +1,42 @@ +import { queryOptions, useQuery } from "@tanstack/react-query"; +import type { GridDescriptor } from "../types"; + +/** + * Collapse state changes the row count, so it belongs in the key. Sorted for + * the same reason the row keys sort it: collapse order is not identity. + */ +export function rowCountKey(name: string, collapsedGroups: unknown[]): readonly unknown[] { + return [name, "count", [...collapsedGroups].sort()]; +} + +function rowCountQuery( + descriptor: GridDescriptor, + collapsedGroups: TGroup[], +) { + return queryOptions({ + queryKey: rowCountKey(descriptor.name, collapsedGroups), + queryFn: ({ signal }) => descriptor.api.fetchCount({ collapsedGroups, signal }), + }); +} + +/** + * The total glide needs to size its scroll bar, and whether the request for the + * CURRENT collapse set has answered. A live query returns rows, not a total, so + * this is a separate request by design. + * + * `settled` exists because the total falls to 0 while a new key loads, and a + * grid that adopts a total of 0 draws nothing. The hold in `useGridData` waits + * on this flag rather than on the number. + * + * A REJECTED request counts as settled, for the same reason a failed page counts + * in `useGridData`'s `isSettled`: a hold that never ends leaves the previous + * collapse state on screen for ever, and the user can act on none of it. + */ +export function useRowCount( + descriptor: GridDescriptor, + collapsedGroups: TGroup[], +): { total: number; settled: boolean } { + const { data, status } = useQuery(rowCountQuery(descriptor, collapsedGroups)); + + return { total: data ?? 0, settled: status !== "pending" }; +} diff --git a/src/web/src/features/dataGrid/data/rowStore.test.ts b/src/web/src/features/dataGrid/data/rowStore.test.ts new file mode 100644 index 0000000..a8c586d --- /dev/null +++ b/src/web/src/features/dataGrid/data/rowStore.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "vitest"; +import { carryPagesBefore, createRowStore } from "./rowStore"; + +interface Row { + id: number; + name: string; +} + +const rowKey = (r: Row) => r.id; + +function page(start: number, count: number): Row[] { + return Array.from({ length: count }, (_, i) => ({ id: start + i, name: `row-${start + i}` })); +} + +describe("createRowStore", () => { + it("serves a written page by absolute row index", () => { + const store = createRowStore(100, rowKey); + + store.writePage(3, page(300, 100)); + + expect(store.getRow(300)?.name).toBe("row-300"); + expect(store.getRow(399)?.name).toBe("row-399"); + expect(store.getRow(299)).toBeUndefined(); + }); + + it("reports which absolute indexes a write touched, for the damage list", () => { + const store = createRowStore(100, rowKey); + + const written = store.writePage(2, page(200, 3)); + + expect(written).toEqual([200, 201, 202]); + }); + + it("tracks page state through a load", () => { + const store = createRowStore(100, rowKey); + + expect(store.pageState(0)).toBe("missing"); + + store.markLoading(0); + expect(store.pageState(0)).toBe("loading"); + + store.writePage(0, page(0, 100)); + expect(store.pageState(0)).toBe("loaded"); + }); + + it("keeps a failed page distinguishable from one nobody asked for", () => { + const store = createRowStore(100, rowKey); + + store.markLoading(4); + store.markFailed(4); + + expect(store.pageState(4)).toBe("failed"); + expect(store.getRow(400)).toBeUndefined(); + }); + + it("finds a row's index by its key, so a push can damage it", () => { + const store = createRowStore(100, rowKey); + + store.writePage(1, page(100, 100)); + + expect(store.indexOfKey(150)).toBe(150); + expect(store.indexOfKey(9_999)).toBeUndefined(); + }); + + it("patches one row in place and reports where it sits", () => { + const store = createRowStore(100, rowKey); + + store.writePage(0, page(0, 100)); + + const index = store.patchRow(7, { name: "patched" }); + + expect(index).toBe(7); + expect(store.getRow(7)?.name).toBe("patched"); + }); + + it("answers undefined when a patch names a row it does not hold", () => { + const store = createRowStore(100, rowKey); + + expect(store.patchRow(7, { name: "patched" })).toBeUndefined(); + }); + + it("evicts the page furthest from the one just written", () => { + const store = createRowStore(100, rowKey, 2); + + store.writePage(0, page(0, 100)); + store.writePage(1, page(100, 100)); + + // Page 0 is 10 pages from the write; page 1 is 9. The further one goes. + store.writePage(10, page(1_000, 100)); + + expect(store.getRow(0)).toBeUndefined(); + expect(store.pageState(0)).toBe("missing"); + expect(store.getRow(100)?.name).toBe("row-100"); + expect(store.getRow(1_000)?.name).toBe("row-1000"); + }); + + it("keeps the pages nearest the write when the user scrolls back", () => { + const store = createRowStore(100, rowKey, 2); + + store.writePage(10, page(1_000, 100)); + store.writePage(11, page(1_100, 100)); + + // Back to the top. Page 11 is the furthest from page 0, so it goes and + // page 10 stays — the one the user would meet first on the way back down. + store.writePage(0, page(0, 100)); + + expect(store.getRow(1_100)).toBeUndefined(); + expect(store.getRow(1_000)?.name).toBe("row-1000"); + expect(store.getRow(0)?.name).toBe("row-0"); + }); + + it("never evicts the page it just wrote", () => { + const store = createRowStore(100, rowKey, 1); + + store.writePage(50, page(5_000, 100)); + store.writePage(0, page(0, 100)); + + expect(store.getRow(0)?.name).toBe("row-0"); + expect(store.getRow(5_000)).toBeUndefined(); + }); + + it("forgets an evicted page's keys, so indexOfKey cannot name a dropped row", () => { + const store = createRowStore(100, rowKey, 1); + + store.writePage(0, page(0, 100)); + store.writePage(1, page(100, 100)); + + expect(store.indexOfKey(5)).toBeUndefined(); + expect(store.indexOfKey(105)).toBe(105); + }); + + it("puts a failed page back to missing so it can be retried", () => { + const store = createRowStore(100, rowKey); + + store.markFailed(2); + store.markMissing(2); + + expect(store.pageState(2)).toBe("missing"); + }); + + it("accepts a short final page without claiming the rows beyond it", () => { + const store = createRowStore(100, rowKey); + + store.writePage(9, page(900, 12)); + + expect(store.getRow(911)?.name).toBe("row-911"); + expect(store.getRow(912)).toBeUndefined(); + expect(store.pageState(9)).toBe("loaded"); + }); +}); + +describe("carryPagesBefore", () => { + it("lists the pages it holds, in order", () => { + const store = createRowStore(10, rowKey); + + store.writePage(3, page(30, 10)); + store.writePage(1, page(10, 10)); + + expect(store.loadedPages()).toEqual([1, 3]); + }); + + it("hands back the rows of a loaded page, and nothing for a missing one", () => { + const store = createRowStore(10, rowKey); + + store.writePage(2, page(20, 10)); + + expect(store.pageRows(2)?.[0].id).toBe(20); + expect(store.pageRows(5)).toBeUndefined(); + }); + + it("carries only the pages that end above the boundary", () => { + const source = createRowStore(10, rowKey); + const target = createRowStore(10, rowKey); + + [0, 1, 2, 3].forEach((p) => source.writePage(p, page(p * 10, 10))); + + // The boundary sits at index 25, inside page 2. + const carried = carryPagesBefore(source, target, 25); + + expect(carried).toBe(2); + expect(target.loadedPages()).toEqual([0, 1]); + expect(target.getRow(19)?.id).toBe(19); + }); + + it("leaves the straddling page behind, because a store cannot split one", () => { + const source = createRowStore(10, rowKey); + const target = createRowStore(10, rowKey); + + source.writePage(2, page(20, 10)); + + carryPagesBefore(source, target, 25); + + expect(target.pageState(2)).toBe("missing"); + }); + + it("carries the key map with the rows, so a push can still find them", () => { + const source = createRowStore(10, rowKey); + const target = createRowStore(10, rowKey); + + source.writePage(0, page(0, 10)); + + carryPagesBefore(source, target, 100); + + expect(target.indexOfKey(7)).toBe(7); + }); + + it("carries nothing at all when the boundary is the top of the table", () => { + const source = createRowStore(10, rowKey); + const target = createRowStore(10, rowKey); + + [0, 1, 2].forEach((p) => source.writePage(p, page(p * 10, 10))); + + expect(carryPagesBefore(source, target, 0)).toBe(0); + expect(target.loadedPages()).toEqual([]); + }); + + it("keeps the pages nearest the boundary when it carries past the cap", () => { + const source = createRowStore(10, rowKey); + const target = createRowStore(10, rowKey, 2); + + [0, 1, 2, 3].forEach((p) => source.writePage(p, page(p * 10, 10))); + + // Every page ends above index 0 and below index 40, so all four are + // eligible and the target can hold two of them. + expect(carryPagesBefore(source, target, 40)).toBe(4); + expect(target.loadedPages()).toEqual([2, 3]); + }); +}); diff --git a/src/web/src/features/dataGrid/data/rowStore.ts b/src/web/src/features/dataGrid/data/rowStore.ts new file mode 100644 index 0000000..d87f1ae --- /dev/null +++ b/src/web/src/features/dataGrid/data/rowStore.ts @@ -0,0 +1,271 @@ +/** + * How many pages of rows stay in memory at once. + * + * The grid targets millions of rows, so nothing here may grow with the table. + * At the default page size of 100 this holds 2,400 rows, which is far more than + * any viewport plus its scroll-ahead, and it bounds the store no matter how far + * the user scrolls. + * + * It also has to stay well above the number of pages ONE WINDOW spans, which is + * three or four (`hooks/useWindowRange.ts` takes the pages the viewport touches + * plus one either side). Eviction drops the page furthest from the page just + * written, so a page of the current window could only be chosen once every + * other page sat nearer than it — and at most five pages lie within two of any + * given page. A cap of 24 therefore makes "the loading window evicts itself" + * unreachable rather than merely unlikely. + */ +export const MAX_LOADED_PAGES = 24; + +/** + * What the store knows about one page. + * + * `failed` is separate from `missing` on purpose. A page nobody asked for and a + * page whose request was rejected both hold no rows, and only the second one + * must show an error instead of a spinner. + */ +export type PageState = "missing" | "loading" | "loaded" | "failed"; + +export interface RowStore { + /** Rows per page. Fixed for the life of the store. */ + readonly pageSize: number; + + /** The page an absolute row index belongs to. */ + pageOf: (index: number) => number; + + /** The row at an absolute index, or undefined when the store does not hold it. */ + getRow: (index: number) => TRow | undefined; + + /** + * Where a row sits, by its stable key. A push names a row by key and the grid + * repaints by index, so this is the bridge between them. + */ + indexOfKey: (key: TKey) => number | undefined; + + pageState: (page: number) => PageState; + + /** The pages the store holds rows for, ascending. */ + loadedPages: () => number[]; + + /** The rows of one loaded page, or undefined when the store lacks it. */ + pageRows: (page: number) => readonly TRow[] | undefined; + + markLoading: (page: number) => void; + + markFailed: (page: number) => void; + + /** Puts a page back to `missing`, so the loader asks for it again. */ + markMissing: (page: number) => void; + + /** + * Stores one page and answers with the absolute indexes it wrote, which is + * exactly the damage list the caller hands to glide. + */ + writePage: (page: number, rows: readonly TRow[]) => number[]; + + /** + * Merges a patch into one stored row and answers with its index, or undefined + * when the store does not hold that row. + */ + patchRow: (key: TKey, patch: Partial) => number | undefined; +} + +/** + * The grid's row cache: pages of rows held by absolute index, bounded by a + * fixed cap on how many pages stay in memory. + * + * It holds SERVER TRUTH only. An edit that has not landed lives in + * `data/editOverlay.ts`, never here, which is what makes a rollback a delete + * rather than a restore. + * + * Eviction is by DISTANCE, not by recency. A grid scrolls, so the page most + * worth keeping is the one nearest where the user is looking, and the page just + * written is the best evidence of where that is. An LRU would answer nearly the + * same question while needing a touched-page order maintained on every read — + * and `getRow` runs once per visible cell per paint, roughly 400 times a frame. + * Distance needs no bookkeeping at all: it is a subtraction over the keys the + * store already holds. + */ +export function createRowStore( + pageSize: number, + rowKey: (row: TRow) => TKey, + maxPages: number = MAX_LOADED_PAGES, +): RowStore { + const pages = new Map(); + const states = new Map(); + const indexByKey = new Map(); + + const pageOf = (index: number) => Math.floor(index / pageSize); + + const forgetPage = (page: number) => { + const rows = pages.get(page); + + if (rows === undefined) { + return; + } + + // The key map must lose the dropped page's keys, or `indexOfKey` would + // answer with an index the store cannot serve — and a push would then + // damage a cell that holds nothing. + for (const row of rows) { + indexByKey.delete(rowKey(row)); + } + + pages.delete(page); + states.delete(page); + }; + + /** + * Brings the store back to the cap by dropping the page furthest from + * `nearPage`, repeatedly. + * + * `nearPage` is never a candidate: its own distance is 0, so it can only be + * chosen when it is the sole page left, and the loop stops above the cap + * before that. + */ + const evictFurthestFrom = (nearPage: number) => { + while (pages.size > maxPages) { + let furthest = -1; + let worstDistance = -1; + + for (const page of pages.keys()) { + const distance = Math.abs(page - nearPage); + + if (distance > worstDistance) { + worstDistance = distance; + furthest = page; + } + } + + if (furthest < 0) { + return; + } + + forgetPage(furthest); + } + }; + + return { + pageSize, + pageOf, + + getRow(index) { + const rows = pages.get(pageOf(index)); + + if (rows === undefined) { + return undefined; + } + + return rows[index % pageSize]; + }, + + indexOfKey(key) { + return indexByKey.get(key); + }, + + pageState(page) { + return states.get(page) ?? "missing"; + }, + + loadedPages() { + return [...pages.keys()].sort((a, b) => a - b); + }, + + pageRows(page) { + return pages.get(page); + }, + + markLoading(page) { + states.set(page, "loading"); + }, + + markFailed(page) { + states.set(page, "failed"); + }, + + markMissing(page) { + states.delete(page); + }, + + writePage(page, rows) { + forgetPage(page); + + const stored = [...rows]; + const start = page * pageSize; + const written: number[] = []; + + for (let i = 0; i < stored.length; i += 1) { + indexByKey.set(rowKey(stored[i]), start + i); + written.push(start + i); + } + + pages.set(page, stored); + states.set(page, "loaded"); + + evictFurthestFrom(page); + + return written; + }, + + patchRow(key, patch) { + const index = indexByKey.get(key); + + if (index === undefined) { + return undefined; + } + + const rows = pages.get(pageOf(index)); + + if (rows === undefined) { + return undefined; + } + + const slot = index % pageSize; + rows[slot] = { ...rows[slot], ...patch }; + + return index; + }, + }; +} + +/** + * Copies the pages that a change does not move from one store into another. + * + * `boundaryIndex` is the first data index the change moves. A page that ends + * above it holds the same rows at the same indexes on both sides of the change, + * so the new view may start with it already loaded. Answers with how many pages + * carried, which the tests read and nothing else does. + * + * Rows go through `writePage`, never through the maps directly. That is what + * puts the keys in the target's key map, and a push finds a row by key. + * + * Carrying more pages than `MAX_LOADED_PAGES` is safe. `writePage` evicts as it + * goes, and it evicts the page furthest from the one just written. The loop runs + * ascending, so the pages nearest the boundary — the ones the window is most + * likely to want — are the ones that survive. + */ +export function carryPagesBefore( + source: RowStore, + target: RowStore, + boundaryIndex: number, +): number { + let carried = 0; + + for (const page of source.loadedPages()) { + const lastIndex = (page + 1) * source.pageSize - 1; + + // The page that straddles the boundary holds rows on both sides of the + // move, and a store cannot split a page. It reloads instead. + if (lastIndex >= boundaryIndex) { + continue; + } + + const rows = source.pageRows(page); + + if (rows !== undefined) { + target.writePage(page, rows); + carried += 1; + } + } + + return carried; +} diff --git a/src/web/src/features/dataGrid/data/sortSpec.test.ts b/src/web/src/features/dataGrid/data/sortSpec.test.ts new file mode 100644 index 0000000..c33fcfd --- /dev/null +++ b/src/web/src/features/dataGrid/data/sortSpec.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { compareBySpec, specFromGridSort, type SortSpec } from "./sortSpec"; + +interface Row { + id: number; + price: number | null; + name: string; +} + +const key = (r: Row) => r.id; +const spec = (over: Partial = {}): SortSpec => ({ + field: "price", + direction: "asc", + nulls: "last", + ...over, +}); + +describe("compareBySpec", () => { + it("orders numbers ascending", () => { + const a = { id: 1, price: 10, name: "a" }; + const b = { id: 2, price: 20, name: "b" }; + expect(compareBySpec(a, b, spec(), key)).toBeLessThan(0); + }); + + it("orders numbers descending", () => { + const a = { id: 1, price: 10, name: "a" }; + const b = { id: 2, price: 20, name: "b" }; + expect(compareBySpec(a, b, spec({ direction: "desc" }), key)).toBeGreaterThan(0); + }); + + it("puts nulls last in both directions", () => { + const a = { id: 1, price: null, name: "a" }; + const b = { id: 2, price: 20, name: "b" }; + expect(compareBySpec(a, b, spec(), key)).toBeGreaterThan(0); + expect(compareBySpec(a, b, spec({ direction: "desc" }), key)).toBeGreaterThan(0); + }); + + it("breaks ties on the row key, so equal values never swap", () => { + const a = { id: 7, price: 5, name: "a" }; + const b = { id: 3, price: 5, name: "b" }; + expect(compareBySpec(a, b, spec(), key)).toBeGreaterThan(0); + expect(compareBySpec(b, a, spec(), key)).toBeLessThan(0); + }); + + it("breaks ties the same way whatever the direction", () => { + const a = { id: 7, price: 5, name: "a" }; + const b = { id: 3, price: 5, name: "b" }; + expect(compareBySpec(a, b, spec({ direction: "desc" }), key)).toBeGreaterThan(0); + }); + + it("compares strings with localeCompare", () => { + const a = { id: 1, price: 0, name: "apple" }; + const b = { id: 2, price: 0, name: "banana" }; + expect(compareBySpec(a, b, spec({ field: "name" }), key)).toBeLessThan(0); + }); +}); + +describe("specFromGridSort", () => { + it("returns null when nothing is sorted", () => { + expect(specFromGridSort(null)).toBeNull(); + }); + + it("carries the field and direction, and always puts nulls last", () => { + expect(specFromGridSort({ field: "price", dir: "desc" })).toEqual({ + field: "price", + direction: "desc", + nulls: "last", + }); + }); +}); diff --git a/src/web/src/features/dataGrid/data/sortSpec.ts b/src/web/src/features/dataGrid/data/sortSpec.ts new file mode 100644 index 0000000..9454003 --- /dev/null +++ b/src/web/src/features/dataGrid/data/sortSpec.ts @@ -0,0 +1,67 @@ +import type { GridSort } from "../types"; + +/** + * One description of an order, read by three parties that must agree: the live + * query that orders loaded rows, the api mapper that asks the server for a + * slice, and the fake server in tests. + * + * `nulls` and the tie-break exist because a disagreement between client and + * server ordering shows up as rows that swap or jump under a still viewport. + * The tie-break on the row key makes every order total. + */ +export interface SortSpec { + field: string; + direction: "asc" | "desc"; + nulls: "first" | "last"; +} + +/** + * Translates the grid's stored sort into a spec. Nulls always sort last, in + * both directions, matching the previous `compareRows` behaviour in bondGrid. + * + * It takes no key field. A `SortSpec` describes one column's order and nothing + * else, and the tie-break on the row key is applied by whoever holds the rows: + * `compareBySpec` takes `rowKey` as its own argument. A key field on the spec + * would therefore be set by the one producer and read by nobody, so the + * parameter was dead by construction, not merely unread. + */ +export function specFromGridSort(sort: GridSort | null | undefined): SortSpec | null { + if (!sort) return null; + return { field: sort.field, direction: sort.dir, nulls: "last" }; +} + +/** + * The one comparator. Total: equal values fall back to the row key, so no two + * distinct rows ever compare equal. + */ +export function compareBySpec( + a: T, + b: T, + spec: SortSpec, + rowKey: (row: T) => string | number, +): number { + const av = (a as Record)[spec.field]; + const bv = (b as Record)[spec.field]; + const sign = spec.direction === "asc" ? 1 : -1; + const nullSign = spec.nulls === "last" ? 1 : -1; + + if (av == null && bv == null) return tieBreak(a, b, rowKey); + if (av == null) return nullSign; + if (bv == null) return -nullSign; + + let cmp: number; + if (typeof av === "number" && typeof bv === "number") cmp = av - bv; + else cmp = String(av).localeCompare(String(bv)); + + if (cmp !== 0) return cmp * sign; + + return tieBreak(a, b, rowKey); +} + +/** Direction-independent, so a tie resolves to one stable order. */ +function tieBreak(a: T, b: T, rowKey: (row: T) => string | number): number { + const ak = rowKey(a); + const bk = rowKey(b); + if (typeof ak === "number" && typeof bk === "number") return ak - bk; + return String(ak).localeCompare(String(bk)); +} diff --git a/src/web/src/features/dataGrid/data/spanFromStore.test.ts b/src/web/src/features/dataGrid/data/spanFromStore.test.ts new file mode 100644 index 0000000..34df9d4 --- /dev/null +++ b/src/web/src/features/dataGrid/data/spanFromStore.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { createRowStore } from "./rowStore"; +import { spanFromStore } from "./spanFromStore"; + +interface Row { + id: number; + g: number; +} + +const rowKey = (r: Row) => r.id; + +const grouping = { + field: "g", + of: (r: Row) => r.g, + order: (g: number) => g, + label: String, +}; + +/** Rows 0-9 are group 0, 10-19 group 1, and so on. */ +function page(start: number, count: number): Row[] { + return Array.from({ length: count }, (_, i) => ({ + id: start + i, + g: Math.floor((start + i) / 10), + })); +} + +describe("spanFromStore", () => { + it("reads the whole window when the store holds it", () => { + const store = createRowStore(100, rowKey); + store.writePage(0, page(0, 100)); + + const span = spanFromStore(store, 0, 100, undefined); + + expect(span.offset).toBe(0); + expect(span.rows).toHaveLength(100); + expect(span.rows[0].id).toBe(0); + }); + + it("stops at the first hole rather than reporting rows across it", () => { + const store = createRowStore(100, rowKey); + store.writePage(0, page(0, 100)); + + // Page 1 is missing, so the run ends at index 99. + const span = spanFromStore(store, 0, 300, undefined); + + expect(span.rows).toHaveLength(100); + expect(span.rows.at(-1)?.id).toBe(99); + }); + + it("answers with an empty run when the window's first row is missing", () => { + const store = createRowStore(100, rowKey); + store.writePage(1, page(100, 100)); + + const span = spanFromStore(store, 0, 100, undefined); + + expect(span.rows).toEqual([]); + expect(span.offset).toBe(0); + }); + + it("reads the group of the row directly above the window", () => { + const store = createRowStore(100, rowKey); + store.writePage(0, page(0, 100)); + + const span = spanFromStore(store, 50, 20, grouping); + + expect(span.offset).toBe(50); + expect(span.rows[0].id).toBe(50); + expect(span.precedingGroupKey).toBe(4); + }); + + it("reports no preceding group at row 0, because no row precedes it", () => { + const store = createRowStore(100, rowKey); + store.writePage(0, page(0, 100)); + + const span = spanFromStore(store, 0, 20, grouping); + + expect(span.precedingGroupKey).toBeNull(); + }); + + it("reports no preceding group when the store does not hold that row", () => { + const store = createRowStore(100, rowKey); + store.writePage(1, page(100, 100)); + + const span = spanFromStore(store, 100, 20, grouping); + + expect(span.rows[0].id).toBe(100); + expect(span.precedingGroupKey).toBeNull(); + }); + + it("reports no preceding group for a grid with no grouping", () => { + const store = createRowStore(100, rowKey); + store.writePage(0, page(0, 100)); + + const span = spanFromStore(store, 50, 20, undefined); + + expect(span.precedingGroupKey).toBeNull(); + }); +}); diff --git a/src/web/src/features/dataGrid/data/spanFromStore.ts b/src/web/src/features/dataGrid/data/spanFromStore.ts new file mode 100644 index 0000000..45d142a --- /dev/null +++ b/src/web/src/features/dataGrid/data/spanFromStore.ts @@ -0,0 +1,73 @@ +import type { GridGrouping } from "../types"; +import type { RowStore } from "./rowStore"; + +/** + * The run of loaded rows the display model reads, and the group of the row just + * above it. + * + * `rows` is CONTIGUOUS from `offset`. `detectBoundaries` walks it and reports a + * group start at each change, so a gap inside it would place a header at a row + * that does not open a group. + */ +export interface LoadedSpan { + rows: TRow[]; + offset: number; + precedingGroupKey: TGroup | null; +} + +/** + * Reads one window out of the store as a contiguous run. + * + * The run stops at the first index the store does not hold. That is stricter + * than "every row in the window", and it has to be: `data/rowStore.ts` evicts a + * page at a time, so a window that outlives one eviction really can hold a + * hole, and `displayModel.detectBoundaries` cannot see one. + * + * `precedingGroupKey` is the group of the row at `offset - 1`. It never reaches + * the display. It exists so `buildDisplayModel` knows whether the first visible + * row opens a group or continues one. It is null at row 0, for a grid with no + * grouping, and when the store does not hold that row — the last case reads as + * "cannot tell", and a wrong guess would draw a header that does not belong. + */ +export function spanFromStore( + store: RowStore, + offset: number, + limit: number, + grouping: GridGrouping | undefined, +): LoadedSpan { + const rows: TRow[] = []; + + for (let i = offset; i < offset + limit; i += 1) { + const row = store.getRow(i); + + if (row === undefined) { + break; + } + + rows.push(row); + } + + return { + rows, + offset, + precedingGroupKey: precedingGroupOf(store, offset, grouping), + }; +} + +function precedingGroupOf( + store: RowStore, + offset: number, + grouping: GridGrouping | undefined, +): TGroup | null { + if (!grouping || offset === 0) { + return null; + } + + const preceding = store.getRow(offset - 1); + + if (preceding === undefined) { + return null; + } + + return grouping.of(preceding); +} diff --git a/src/web/src/features/dataGrid/data/sync/planPushes.test.ts b/src/web/src/features/dataGrid/data/sync/planPushes.test.ts new file mode 100644 index 0000000..a8b7088 --- /dev/null +++ b/src/web/src/features/dataGrid/data/sync/planPushes.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import type { RowChange } from "../../types"; +import { planPushes } from "./planPushes"; + +const change = ( + kind: RowChange["kind"], + id: number, + sequence: number, +): RowChange => ({ kind, id, sequence }); + +const loaded = + (...ids: number[]) => + (key: number) => + ids.includes(key); + +describe("planPushes", () => { + it("refetches an update for a row that is loaded", () => { + const plan = planPushes([change("update", 1, 1)], loaded(1), 0); + expect(plan).toEqual({ refetchIds: [1], deleteIds: [], reload: false, recount: false }); + }); + + it("ignores an update for a row that is not loaded", () => { + const plan = planPushes([change("update", 9, 1)], loaded(1), 0); + expect(plan.refetchIds).toEqual([]); + expect(plan.reload).toBe(false); + }); + + it("reloads and recounts on a create, because positions shift", () => { + const plan = planPushes([change("create", 5, 1)], loaded(1), 0); + expect(plan.reload).toBe(true); + expect(plan.recount).toBe(true); + }); + + it("deletes and recounts on a delete", () => { + const plan = planPushes([change("delete", 1, 1)], loaded(1), 0); + expect(plan.deleteIds).toEqual([1]); + expect(plan.recount).toBe(true); + expect(plan.reload).toBe(false); + }); + + it("deletes a row it never loaded without complaint", () => { + const plan = planPushes([change("delete", 9, 1)], loaded(1), 0); + expect(plan.deleteIds).toEqual([9]); + }); + + it("keeps only the last change for one id", () => { + const plan = planPushes( + [change("update", 1, 1), change("update", 1, 2), change("delete", 1, 3)], + loaded(1), + 0, + ); + expect(plan.refetchIds).toEqual([]); + expect(plan.deleteIds).toEqual([1]); + }); + + it("reloads everything when the sequence has a gap", () => { + const plan = planPushes([change("update", 1, 7)], loaded(1), 3); + expect(plan.reload).toBe(true); + expect(plan.recount).toBe(true); + }); + + it("does not treat the first message as a gap", () => { + const plan = planPushes([change("update", 1, 7)], loaded(1), null); + expect(plan.reload).toBe(false); + expect(plan.refetchIds).toEqual([1]); + }); + + it("returns an empty plan for no messages", () => { + expect(planPushes([], loaded(1), 0)).toEqual({ + refetchIds: [], + deleteIds: [], + reload: false, + recount: false, + }); + }); + + it("reloads everything when the sequence has a gap inside the buffer", () => { + const plan = planPushes([change("update", 1, 1), change("update", 2, 3)], loaded(1, 2), 0); + expect(plan.reload).toBe(true); + expect(plan.recount).toBe(true); + expect(plan.refetchIds).toEqual([]); + }); + + it("handles out-of-order arrival with contiguous sequences", () => { + const plan = planPushes( + [change("update", 1, 3), change("update", 2, 1), change("update", 3, 2)], + loaded(1, 2, 3), + 0, + ); + expect(plan.reload).toBe(false); + expect(plan.recount).toBe(false); + expect(plan.refetchIds.sort((a, b) => a - b)).toEqual([1, 2, 3]); + }); + + it("keeps the last change when delete comes before update", () => { + const plan = planPushes([change("delete", 1, 1), change("update", 1, 2)], loaded(1), 0); + expect(plan.refetchIds).toEqual([1]); + expect(plan.deleteIds).toEqual([]); + expect(plan.recount).toBe(false); + expect(plan.reload).toBe(false); + }); +}); diff --git a/src/web/src/features/dataGrid/data/sync/planPushes.ts b/src/web/src/features/dataGrid/data/sync/planPushes.ts new file mode 100644 index 0000000..dfaea59 --- /dev/null +++ b/src/web/src/features/dataGrid/data/sync/planPushes.ts @@ -0,0 +1,115 @@ +import type { RowChange } from "../../types"; + +export interface PushPlan { + /** Rows to fetch again and upsert by key. */ + refetchIds: TKey[]; + /** Rows to drop from the collection. */ + deleteIds: TKey[]; + /** Reload the loaded span: positions moved. */ + reload: boolean; + /** Ask the server for the total again. */ + recount: boolean; +} + +/** + * Turns a buffer of change notifications into one plan. Pure, so every rule + * below is a test case rather than a behaviour someone has to reproduce in a + * browser. + * + * Two rules carry the design: + * + * - An update for a key the collection does not hold is dropped. Under + * on-demand sync an absent row means "not loaded", not "does not exist", and + * upserting it would push a row into a span that must not contain it. + * - A create reloads instead of upserting. A new row shifts every position + * after it, so the span has to come again from the server. + */ +export function planPushes( + changes: RowChange[], + isLoaded: (key: TKey) => boolean, + lastSequence: number | null, +): PushPlan { + const plan: PushPlan = { + refetchIds: [], + deleteIds: [], + reload: false, + recount: false, + }; + + if (changes.length === 0) { + return plan; + } + + // A hole in the sequence means the buffer is incomplete, so rebuild the span. + if (lastSequence !== null && hasGap(changes, lastSequence)) { + return { + refetchIds: [], + deleteIds: [], + reload: true, + recount: true, + }; + } + + for (const change of latestChangePerId(changes)) { + if (change.kind === "delete") { + plan.deleteIds.push(change.id); + plan.recount = true; + continue; + } + + if (change.kind === "create") { + plan.reload = true; + plan.recount = true; + continue; + } + + if (isLoaded(change.id)) { + plan.refetchIds.push(change.id); + } + } + + return plan; +} + +/** + * Keeps one change per id: the one with the highest sequence. An update + * followed by a delete is a delete. + */ +function latestChangePerId( + changes: RowChange[], +): RowChange[] { + const ordered = [...changes].sort((a, b) => a.sequence - b.sequence); + + const latest = new Map>(); + + for (const change of ordered) { + latest.set(change.id, change); + } + + return [...latest.values()]; +} + +function hasGap( + changes: RowChange[], + lastSequence: number, +): boolean { + // Deduplicate before the scan. Redelivery of an already-seen sequence is + // normal, and a repeated number can never hide a genuinely missing integer. + const unique = new Set(changes.map((change) => change.sequence)); + + const sequences = [...unique].sort((a, b) => a - b); + + // The buffer must start directly after the last sequence seen. + if (sequences[0] > lastSequence + 1) { + return true; + } + + // Each later sequence must be exactly 1 more than the one before it. + for (let i = 1; i < sequences.length; i++) { + if (sequences[i] !== sequences[i - 1] + 1) { + return true; + } + } + + return false; +} diff --git a/src/web/src/features/dataGrid/data/sync/useRowSync.test.tsx b/src/web/src/features/dataGrid/data/sync/useRowSync.test.tsx new file mode 100644 index 0000000..84cd432 --- /dev/null +++ b/src/web/src/features/dataGrid/data/sync/useRowSync.test.tsx @@ -0,0 +1,602 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createFakeRowServer } from "../../testing/fakeRowServer"; +import type { GridDescriptor } from "../../types"; +import { rowsKeyPrefix } from "../../hooks/useRowPages"; +import { rowCountKey } from "../rowCount"; +import { createRowStore, type RowStore } from "../rowStore"; +import { PUSH_BUFFER_MS, useRowSync } from "./useRowSync"; + +interface Row { + id: number; + price: number; +} + +const PAGE = 100; + +const INITIAL: Row[] = [ + { id: 1, price: 10 }, + { id: 2, price: 20 }, +]; + +/** + * A store already holding the grid's first page, which is the state every case + * here starts from: the subscriber only acts on rows some window has loaded. + */ +function loadedStore(rows: Row[] = INITIAL): RowStore { + const store = createRowStore(PAGE, (r) => r.id); + + store.writePage(0, rows); + + return store; +} + +function harness(store: RowStore = loadedStore()) { + const server = createFakeRowServer({ + rows: [...INITIAL], + rowKey: (r) => r.id, + }); + + const descriptor = { + name: "demo", + rowKey: (r: Row) => r.id, + columns: { defs: {}, defaultOrder: [] }, + api: server.api, + cells: { makeCell: () => ({}) as never, customRenderers: [], validateCell: () => true }, + } as unknown as GridDescriptor; + + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + const storeRef: { current: RowStore | null } = { current: store }; + + // Null outside a hold, which is where most cases here sit. A case that wants + // the two-store behaviour assigns it. + const pendingStoreRef: { current: RowStore | null } = { current: null }; + + // The grid's data generation. A create or a delete bumps it instead of + // emptying the store, so the rows on screen stay there while `useGridData` + // loads their replacements behind them. + const bumpDataGeneration = vi.fn(); + + // The grid's one damage callback. A patched row changes nothing React + // watches, so this call is the only thing that would put a pushed update on + // screen. + const repaint = vi.fn<(dataIndexes: number[]) => void>(); + + const instance = { descriptor, storeRef, pendingStoreRef, bumpDataGeneration } as never; + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + return { + server, + descriptor, + store, + storeRef, + pendingStoreRef, + bumpDataGeneration, + queryClient, + repaint, + instance, + wrapper, + }; +} + +/** The row the store holds at a key, or undefined once it has dropped it. */ +function rowOf(store: RowStore, key: number): Row | undefined { + const index = store.indexOfKey(key); + + if (index === undefined) { + return undefined; + } + + return store.getRow(index); +} + +describe("useRowSync", () => { + beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true })); + afterEach(() => vi.useRealTimers()); + + it("patches a loaded row after an update push", async () => { + const { server, store, instance, repaint, wrapper } = harness(); + + renderHook(() => useRowSync(instance, [], repaint), { wrapper }); + + server.setRows([ + { id: 1, price: 999 }, + { id: 2, price: 20 }, + ]); + server.push({ kind: "update", id: 1 }); + + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + await waitFor(() => expect(rowOf(store, 1)?.price).toBe(999)); + }); + + it("ignores an update for a row it never loaded", async () => { + const { server, descriptor, instance, repaint, wrapper } = harness(); + const fetchRow = vi.spyOn(descriptor.api, "fetchRow"); + + renderHook(() => useRowSync(instance, [], repaint), { wrapper }); + + server.push({ kind: "update", id: 42 }); + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + + expect(fetchRow).not.toHaveBeenCalled(); + }); + + it("patches a row only the pending store holds, and asks it before dropping an update", async () => { + // During a hold the window may move, so the pending store can hold a page + // the displayed one never fetched. Asking the displayed store alone answers + // "nobody has this row" and the update is dropped with no fetch at all — + // and adoption then puts the stale value on screen. + const { server, store, pendingStoreRef, instance, repaint, wrapper } = harness( + loadedStore([{ id: 1, price: 10 }]), + ); + + const pendingStore = loadedStore([{ id: 2, price: 20 }]); + pendingStoreRef.current = pendingStore; + + renderHook(() => useRowSync(instance, [], repaint), { wrapper }); + + server.setRows([ + { id: 1, price: 10 }, + { id: 2, price: 777 }, + ]); + server.push({ kind: "update", id: 2 }); + + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + await waitFor(() => expect(rowOf(pendingStore, 2)?.price).toBe(777)); + + // The displayed store does not hold row 2, so `patchRow` finds nothing + // there and writes nothing. + expect(rowOf(store, 2)).toBeUndefined(); + expect(rowOf(store, 1)?.price).toBe(10); + }); + + it("repaints the displayed store's index after an update push", async () => { + // The patch bumps no generation and invalidates no query, so nothing + // re-renders. Without this damage call the row keeps the value it was drawn + // with until its page is evicted. + const { server, store, instance, repaint, wrapper } = harness(); + + renderHook(() => useRowSync(instance, [], repaint), { wrapper }); + + server.setRows([ + { id: 1, price: 10 }, + { id: 2, price: 555 }, + ]); + server.push({ kind: "update", id: 2 }); + + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + await waitFor(() => expect(rowOf(store, 2)?.price).toBe(555)); + + // Row 2 sits at index 1, so a repaint hardcoded to the first row cannot + // satisfy this. + expect(repaint).toHaveBeenCalledWith([1]); + }); + + it("repaints nothing for a row only the pending store holds", async () => { + // The pending store is drawn by nothing, and it numbers its rows in its own + // index space — a different sort, a different collapse set, or the same + // rows shifted by a create. Its answer reaching the damage callback would + // repaint whichever row of the DISPLAYED view happens to carry that number. + const { server, pendingStoreRef, instance, repaint, wrapper } = harness( + loadedStore([{ id: 1, price: 10 }]), + ); + + const pendingStore = loadedStore([{ id: 2, price: 20 }]); + pendingStoreRef.current = pendingStore; + + renderHook(() => useRowSync(instance, [], repaint), { wrapper }); + + server.setRows([ + { id: 1, price: 10 }, + { id: 2, price: 777 }, + ]); + server.push({ kind: "update", id: 2 }); + + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + await waitFor(() => expect(rowOf(pendingStore, 2)?.price).toBe(777)); + + // The pending store answered index 0. The displayed store holds row 1 + // there, and that row did not change. + expect(repaint).not.toHaveBeenCalled(); + }); + + it("drops nothing when no grid is mounted, because there is no store to write to", async () => { + const { server, descriptor, storeRef, instance, repaint, wrapper } = harness(); + storeRef.current = null; + + const fetchRow = vi.spyOn(descriptor.api, "fetchRow"); + + renderHook(() => useRowSync(instance, [], repaint), { wrapper }); + + server.push({ kind: "update", id: 1 }); + + // No throw, no fetch. The next mount loads fresh rows anyway. + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + + expect(fetchRow).not.toHaveBeenCalled(); + }); + + it("invalidates every row on a delete, and keeps the other rows on an update", async () => { + // A create and a delete both move every position after them, and a + // page-indexed cache cannot be shifted in place. An update moves nothing, + // so it patches the one row it names and every other row stays. + const { server, store, bumpDataGeneration, instance, repaint, wrapper } = harness(); + + renderHook(() => useRowSync(instance, [], repaint), { wrapper }); + + server.setRows([ + { id: 1, price: 999 }, + { id: 2, price: 20 }, + ]); + server.push({ kind: "update", id: 1 }); + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + + await waitFor(() => expect(rowOf(store, 1)?.price).toBe(999)); + expect(rowOf(store, 2)?.price).toBe(20); + + // An update moves no position, so it invalidates nothing. + expect(bumpDataGeneration).not.toHaveBeenCalled(); + + server.push({ kind: "delete", id: 2 }); + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + + await waitFor(() => expect(bumpDataGeneration).toHaveBeenCalledTimes(1)); + + // The store the user is looking at is NOT emptied. Clearing it here left + // every page reading "missing" with no load effect re-running to ask for + // them again, so the grid stayed blank until the window moved. + expect(store.getRow(0)).toBeDefined(); + expect(store.pageState(0)).toBe("loaded"); + }); + + it("invalidates every row on a create push, without blanking the store on screen", async () => { + const { server, store, bumpDataGeneration, instance, repaint, wrapper } = harness(); + + renderHook(() => useRowSync(instance, [], repaint), { wrapper }); + + server.push({ kind: "create", id: 3 }); + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + + await waitFor(() => expect(bumpDataGeneration).toHaveBeenCalledTimes(1)); + + expect(store.getRow(0)).toBeDefined(); + }); + + it("drops the cached pages before it bumps, so the replacement rows come from the server", async () => { + // The page cache is a second copy of the rows, keyed by page and not by + // generation. Leave it and the store `useGridData` builds for the new + // generation refills itself from the very response the create invalidated. + const { server, descriptor, queryClient, instance, repaint, wrapper } = harness(); + + const removeQueries = vi.spyOn(queryClient, "removeQueries"); + + renderHook(() => useRowSync(instance, [], repaint), { wrapper }); + + server.push({ kind: "create", id: 3 }); + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + + await waitFor(() => + expect(removeQueries).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: rowsKeyPrefix(descriptor.name) }), + ), + ); + }); + + it("makes one fetch for several updates to the same row", async () => { + const { server, descriptor, instance, repaint, wrapper } = harness(); + + const fetchRow = vi.spyOn(descriptor.api, "fetchRow"); + renderHook(() => useRowSync(instance, [], repaint), { wrapper }); + + server.push({ kind: "update", id: 1 }); + server.push({ kind: "update", id: 1 }); + server.push({ kind: "update", id: 1 }); + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + + expect(fetchRow).toHaveBeenCalledTimes(1); + }); + + it("stops listening when it unmounts", async () => { + const { server, descriptor, instance, repaint, wrapper } = harness(); + const fetchRow = vi.spyOn(descriptor.api, "fetchRow"); + + const { unmount } = renderHook(() => useRowSync(instance, [], repaint), { wrapper }); + unmount(); + + server.push({ kind: "update", id: 1 }); + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + + expect(fetchRow).not.toHaveBeenCalled(); + }); + + it("keeps the newer fetch result when the older fetch resolves last", async () => { + const { server, descriptor, store, instance, repaint, wrapper } = harness(); + + // Two separate buffer windows, each triggering its own `fetchRow` call, + // with resolution under our control rather than timer luck: the FIRST + // call (older push) is made to resolve LAST, deliberately out of order. + let resolveOlder!: (row: Row) => void; + let resolveNewer!: (row: Row) => void; + const olderFetch = new Promise((resolve) => { + resolveOlder = resolve; + }); + const newerFetch = new Promise((resolve) => { + resolveNewer = resolve; + }); + + const fetchRow = vi.spyOn(descriptor.api, "fetchRow"); + fetchRow.mockImplementationOnce(() => olderFetch); + fetchRow.mockImplementationOnce(() => newerFetch); + + renderHook(() => useRowSync(instance, [], repaint), { wrapper }); + + server.push({ kind: "update", id: 1 }); // flush A -> fetchRow call #1 (older) + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + + server.push({ kind: "update", id: 1 }); // flush B -> fetchRow call #2 (newer) + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + + expect(fetchRow).toHaveBeenCalledTimes(2); + + // The NEWER flush's fetch resolves first. + resolveNewer({ id: 1, price: 222 }); + await waitFor(() => expect(rowOf(store, 1)?.price).toBe(222)); + + // The OLDER flush's fetch resolves after — it must not win. + resolveOlder({ id: 1, price: 111 }); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(0); + + expect(rowOf(store, 1)?.price).toBe(222); + }); + + it("still applies a later push while an earlier fetch never resolves", async () => { + const { server, descriptor, store, instance, repaint, wrapper } = harness(); + + const fetchRow = vi.spyOn(descriptor.api, "fetchRow"); + // The first call (id 1) never settles. The second call (id 2) is left + // to fall through to the real fake-server implementation. + fetchRow.mockImplementationOnce(() => new Promise(() => {})); + + renderHook(() => useRowSync(instance, [], repaint), { wrapper }); + + server.push({ kind: "update", id: 1 }); + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + expect(fetchRow).toHaveBeenCalledTimes(1); + + server.setRows([ + { id: 1, price: 10 }, + { id: 2, price: 888 }, + ]); + server.push({ kind: "update", id: 2 }); + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + + await waitFor(() => expect(rowOf(store, 2)?.price).toBe(888)); + }); + + it("invalidates the count with the current collapsed groups after a rerender", async () => { + const { server, descriptor, queryClient, instance, repaint, wrapper } = harness(); + + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries"); + + renderHook(({ groups }) => useRowSync(instance, groups, repaint), { + wrapper, + initialProps: { groups: [] as number[] }, + }).rerender({ groups: [7] }); + + server.push({ kind: "create", id: 3 }); // a create always sets `recount` + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + + await waitFor(() => + expect(invalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: rowCountKey(descriptor.name, [7]) }), + ), + ); + }); + + it("still applies a slow flush's write when a later flush's plan is empty", async () => { + const { server, descriptor, store, instance, repaint, wrapper } = harness(); + + // Flush A (id 1, loaded) is left pending on a controlled fetch. Flush B + // (id 99, never loaded) has an entirely empty plan — no delete, no + // refetch, no reload, no recount — so it must not be able to affect + // flush A's ordering state at all, whether or not it runs first. + let resolveSlow!: (row: Row) => void; + const slowFetch = new Promise((resolve) => { + resolveSlow = resolve; + }); + const fetchRow = vi.spyOn(descriptor.api, "fetchRow"); + fetchRow.mockImplementationOnce(() => slowFetch); + + renderHook(() => useRowSync(instance, [], repaint), { wrapper }); + + server.push({ kind: "update", id: 1 }); // flush A: loaded row, slow fetch + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + + server.push({ kind: "update", id: 99 }); // flush B: unloaded row, empty plan + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + + resolveSlow({ id: 1, price: 777 }); + await waitFor(() => expect(rowOf(store, 1)?.price).toBe(777)); + }); + + it("still runs reload and recount for a slow create flush overtaken by a later flush", async () => { + const { + server, + descriptor, + store, + bumpDataGeneration, + queryClient, + instance, + repaint, + wrapper, + } = harness(); + + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries"); + + // Flush A carries BOTH pushes in ONE buffer window, so its plan holds a + // `fetchRow` this test can keep pending AND the `reload` + `recount` a + // create sets. That is what makes it the OLDER, SLOWER flush. + let resolveSlow!: (row: Row) => void; + const slowFetch = new Promise((resolve) => { + resolveSlow = resolve; + }); + const fetchRow = vi.spyOn(descriptor.api, "fetchRow"); + fetchRow.mockImplementationOnce(() => slowFetch); + + renderHook(() => useRowSync(instance, [], repaint), { wrapper }); + + server.push({ kind: "update", id: 1 }); + server.push({ kind: "create", id: 3 }); + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + + expect(fetchRow).toHaveBeenCalledTimes(1); + expect(invalidateQueries).not.toHaveBeenCalled(); + + // Flush B is younger and faster, and commits an ordinary per-key update + // while flush A is still waiting on its fetch. + server.setRows([ + { id: 1, price: 10 }, + { id: 2, price: 55 }, + ]); + server.push({ kind: "update", id: 2 }); + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + await waitFor(() => expect(rowOf(store, 2)?.price).toBe(55)); + + // Flush A lands last. Nothing about `reload` or `recount` is a per-key + // overwrite, so neither may be gated behind the per-key staleness check + // that a newer flush advanced. + resolveSlow({ id: 1, price: 777 }); + + await waitFor(() => + expect(invalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: rowCountKey(descriptor.name, []) }), + ), + ); + + // The reload ran too: a create moves every position after it, so every + // loaded row is invalidated rather than shifted. The rows on screen are + // untouched — the replacement loads behind them. + await waitFor(() => expect(bumpDataGeneration).toHaveBeenCalledTimes(1)); + expect(rowOf(store, 2)?.price).toBe(55); + }); + + it("still applies a later push after an earlier fetch rejects", async () => { + const { server, descriptor, store, instance, repaint, wrapper } = harness(); + + const fetchRow = vi.spyOn(descriptor.api, "fetchRow"); + fetchRow.mockImplementationOnce(() => Promise.reject(new Error("network error"))); + + renderHook(() => useRowSync(instance, [], repaint), { wrapper }); + + server.push({ kind: "update", id: 1 }); // flush A: fetchRow rejects + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + + server.setRows([ + { id: 1, price: 10 }, + { id: 2, price: 321 }, + ]); + server.push({ kind: "update", id: 2 }); // flush B: real fetchRow, must still apply + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + + await waitFor(() => expect(rowOf(store, 2)?.price).toBe(321)); + }); + + it("keeps one subscription across a store swap, and writes land on the new store", async () => { + const { + server, + descriptor, + store: firstStore, + storeRef, + instance, + repaint, + wrapper, + } = harness(); + + // Wraps the fake server's real `subscribe` so the test can also see + // whether the UNSUBSCRIBE function it hands back ever runs, not just how + // many times `subscribe` itself was called. `descriptor.api` IS + // `server.api` (see `harness`), so the original implementation is + // captured BEFORE `vi.spyOn` replaces it — calling `server.api.subscribe` + // from inside the mock would otherwise call the mock itself and recurse. + const originalSubscribe = descriptor.api.subscribe!; + const unsubscribeSpy = vi.fn(); + const subscribe = vi + .spyOn(descriptor.api, "subscribe") + .mockImplementation( + (handler: Parameters>[0]) => { + const stop = originalSubscribe(handler); + + return () => { + unsubscribeSpy(); + stop(); + }; + }, + ); + + const { rerender } = renderHook(() => useRowSync(instance, [], repaint), { wrapper }); + expect(subscribe).toHaveBeenCalledTimes(1); + + // A remount hands the ref a different store without the ref's own identity + // ever changing. + const secondStore = loadedStore(); + storeRef.current = secondStore; + + rerender(); + + // The swap must not tear the subscription down and rebuild it. + expect(subscribe).toHaveBeenCalledTimes(1); + expect(unsubscribeSpy).not.toHaveBeenCalled(); + + server.setRows([ + { id: 1, price: 999 }, + { id: 2, price: 20 }, + ]); + server.push({ kind: "update", id: 1 }); + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + + // The write lands on the store that is current AT FLUSH TIME — the second + // one — and never touches the first, retired store. + await waitFor(() => expect(rowOf(secondStore, 1)?.price).toBe(999)); + expect(rowOf(firstStore, 1)?.price).toBe(10); + }); + + it("clears its per-key ordering state once every flush settles", async () => { + const { server, descriptor, store, instance, repaint, wrapper } = harness(); + + renderHook(() => useRowSync(instance, [], repaint), { wrapper }); + + // First cycle: drains fully with the real (fast) `fetchRow`, so the + // in-flight count returns to 0 and the per-key ordering state should be + // cleared before the second cycle starts. + server.push({ kind: "update", id: 1 }); + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + await waitFor(() => expect(rowOf(store, 1)?.price).toBe(10)); + await vi.advanceTimersByTimeAsync(0); // let the first flush's `finally` settle + + // Second, independent cycle for the SAME key, with a controlled slow + // fetch. This is the observable consequence of clearing: if the first + // cycle's per-key entry (and the reused, reset generation counter) were + // never cleared, this cycle's write would collide with that leftover + // entry and be wrongly discarded as "stale" even though it is the only + // write in flight. + let resolveSecond!: (row: Row) => void; + const secondFetch = new Promise((resolve) => { + resolveSecond = resolve; + }); + const fetchRow = vi.spyOn(descriptor.api, "fetchRow"); + fetchRow.mockImplementationOnce(() => secondFetch); + + server.push({ kind: "update", id: 1 }); + await vi.advanceTimersByTimeAsync(PUSH_BUFFER_MS + 1); + + resolveSecond({ id: 1, price: 999 }); + await waitFor(() => expect(rowOf(store, 1)?.price).toBe(999)); + }); +}); diff --git a/src/web/src/features/dataGrid/data/sync/useRowSync.ts b/src/web/src/features/dataGrid/data/sync/useRowSync.ts new file mode 100644 index 0000000..2b3c055 --- /dev/null +++ b/src/web/src/features/dataGrid/data/sync/useRowSync.ts @@ -0,0 +1,388 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; +import { rowsKeyPrefix } from "../../hooks/useRowPages"; +import type { GridDescriptor, RowChange } from "../../types"; +import { rowCountKey } from "../rowCount"; +import type { RowStore } from "../rowStore"; +import { planPushes, type PushPlan } from "./planPushes"; + +/** How long changes collect before the grid acts on them. */ +export const PUSH_BUFFER_MS = 200; + +/** + * The minimal shape this hook reads off a grid instance. `GridInstance` + * satisfies it structurally, which keeps the hook testable against a bare + * object. + */ +export interface RowsSource { + descriptor: GridDescriptor; + storeRef: { current: RowStore | null }; + pendingStoreRef: { current: RowStore | null }; + bumpDataGeneration: () => void; +} + +/** Reports whether a key was already written by a newer flush. */ +type IsStale = (key: TKey) => boolean; + +/** + * Subscribes to the descriptor's change feed and keeps the row store current. + * + * Messages buffer for a moment, then `planPushes` decides what to do. The + * grid owns no transport: a SignalR hub, a WebSocket, or a poll all satisfy + * `descriptor.api.subscribe`. When the descriptor declares no `subscribe`, + * the hook does nothing. + * + * `collapsedGroups` is read through a ref rather than the effect's own + * dependency array. A caller that passes a fresh array literal on every + * render (the common case) would otherwise give the effect a new dependency + * identity each render, tearing the subscription down and re-creating it — + * dropping whatever is mid-buffer and churning the transport. The ref always + * holds the latest value, so a genuine collapse-state change still reaches + * the recount invalidation the next time a flush runs; only the subscribe/ + * unsubscribe lifecycle is decoupled from it. + * + * The stores are read the same way, but through the two cells `GridInstance` + * already carries: a store belongs to whichever grid is mounted, and both cells + * change value while the subscription lives — `storeRef` when a grid mounts or + * a hold adopts, `pendingStoreRef` whenever a hold opens or closes. If the + * effect depended on either store VALUE, every one of those moments would tear + * the subscription down and rebuild it — the exact failure `collapsedGroups` + * avoids above, and worse here because it hits a real-time transport instead of + * a recount. Depending on the refs instead means the effect depends on two + * identities that never change, so the subscription outlives whatever stores + * are current; `flush` reads both cells fresh each time it runs. + * + * `repaintRows` is the grid's one damage callback (`hooks/useRepaintRows.ts`). + * An update patches a stored row in place, bumps no generation and invalidates + * no query, so nothing re-renders and nothing else can put the new value on + * screen: without this call the row keeps the value it was drawn with until its + * page is evicted. + */ +export function useRowSync( + instance: RowsSource, + collapsedGroups: TGroup[], + repaintRows: (dataIndexes: number[]) => void, +): void { + const { descriptor, storeRef, pendingStoreRef, bumpDataGeneration } = instance; + + const queryClient = useQueryClient(); + + const collapsedGroupsRef = useRef(collapsedGroups); + collapsedGroupsRef.current = collapsedGroups; + + // Read through a ref for the same reason the stores are. A caller that builds + // a fresh callback per render would otherwise tear the subscription down and + // rebuild it on every render. `DataGrid` passes a memoised one, but this hook + // has to be correct on its own. + const repaintRowsRef = useRef(repaintRows); + repaintRowsRef.current = repaintRows; + + const buffer = useRef[]>([]); + const timer = useRef | null>(null); + const lastSequence = useRef(null); + + useEffect(() => { + const subscribe = descriptor.api.subscribe; + + if (!subscribe) { + return; + } + + // `cancelled` guards every write a flush makes after an `await`. Without + // it, an unmount that lands while `flush` is mid-fetch would still write + // to a store the caller has already torn down (and, in a test, one the + // assertions have finished with). + let cancelled = false; + + const runOrdered = createWriteOrdering(); + + /** + * Declares every loaded row wrong, for a create or a delete. + * + * It clears NOTHING the user is looking at. `useGridData` folds the + * generation into its state key, so the bump builds a second store and + * fills it behind the rows already on screen, exactly as a sort change + * does. Emptying the displayed store here was the old answer, and it + * blanked the grid twice over: once because every visible cell fell back to + * a loading cell, and once because a bare reset re-runs no load effect, so + * every page read `missing` with nobody left to ask for it. + * + * The page cache goes first. It is a second copy of the same rows, keyed by + * page and not by generation, so leaving it would let the replacement store + * refill itself from the very response the push just invalidated. + * `rowsKeyPrefix` is a PREFIX, so this catches every page under every sort + * and collapse set. + */ + const invalidateEveryRow = () => { + queryClient.removeQueries({ queryKey: rowsKeyPrefix(descriptor.name) }); + + bumpDataGeneration(); + }; + + const flush = async () => { + timer.current = null; + + const changes = buffer.current; + buffer.current = []; + + if (changes.length === 0) { + return; + } + + // Read once per flush, not once per statement below, so one flush's + // writes land on one set of stores. A grid that remounts mid-flush, or a + // hold that adopts mid-flush, then cannot split a single plan's work + // across two different pairs. + // + // The displayed store is kept apart from the pair because only ITS + // numbering names a row on screen. See the damage list below. + const displayedStore = storeRef.current; + const stores = liveStores(displayedStore, pendingStoreRef.current); + + // No grid is mounted, so there is nothing on screen for this push to + // correct. The next mount loads fresh rows anyway. + if (stores.length === 0) { + return; + } + + // ANY live store counts as "loaded". During a hold the pending store may + // hold a page the displayed one never fetched — the window moved while + // the hold was open — and asking the displayed store alone would drop + // that row's update as a row nobody has. + const plan = planPushes( + changes, + (key) => stores.some((store) => store.indexOfKey(key) !== undefined), + lastSequence.current, + ); + lastSequence.current = Math.max(...changes.map((change) => change.sequence)); + + if (!hasWork(plan)) { + return; + } + + await runOrdered(async (isStale) => { + const rows = + plan.refetchIds.length > 0 + ? await Promise.all(plan.refetchIds.map((id) => descriptor.api.fetchRow(id))) + : []; + + if (cancelled) { + return; + } + + const deleteIds = plan.deleteIds.filter((id) => !isStale(id)); + + // A delete shifts every position after it, so the loaded pages are + // wrong. The generation bump loads their replacements behind the rows + // already on screen; clearing here would blank the grid instead. + if (deleteIds.length > 0) { + invalidateEveryRow(); + } + + const present = rows.filter((row) => row !== null) as TRow[]; + + const upsertRows = present.filter((row) => !isStale(descriptor.rowKey(row))); + + // An UPDATE moves nothing, so it patches the one row it names in every + // live store rather than invalidating the window for one cell. That + // patch bumps no generation and invalidates no query, so the damage + // call below is the only thing that puts the new value on screen. + // + // `patchRow` answers with the index it wrote, and the two stores number + // their rows differently: the pending store may hold a different sort, + // a different collapse set, or the same rows shifted by a create. Only + // the DISPLAYED store's answer may reach the damage callback, because + // only that store's numbering describes what the grid is drawing. The + // pending store is drawn by nothing, so its answer is dropped. + // + // `patchRow` answers undefined when a store does not hold the row at + // all, which is a row nobody can see. That is skipped, not an error. + if (upsertRows.length > 0) { + const damagedIndexes: number[] = []; + + for (const row of upsertRows) { + const key = descriptor.rowKey(row); + + for (const store of stores) { + const index = store.patchRow(key, row); + + if (store === displayedStore && index !== undefined) { + damagedIndexes.push(index); + } + } + } + + // One call for the whole flush, not one per row: the damage list is + // bounded by the rows one buffer window names, and glide walks it + // once. + if (damagedIndexes.length > 0) { + repaintRowsRef.current(damagedIndexes); + } + } + + // A create moves every position after it, for the same reason a delete + // does. + if (plan.reload) { + invalidateEveryRow(); + } + + if (plan.recount) { + await queryClient.invalidateQueries({ + queryKey: rowCountKey(descriptor.name, collapsedGroupsRef.current), + }); + } + }); + }; + + const stop = subscribe((change) => { + buffer.current.push(change); + + if (timer.current !== null) { + return; + } + + timer.current = setTimeout(() => { + // A flush that throws (e.g. a rejected `fetchRow`) must not stop + // later flushes — each is independent, but an unhandled rejection + // would still surface as a console error, so it is swallowed here. + void flush().catch(() => {}); + }, PUSH_BUFFER_MS); + }); + + return () => { + cancelled = true; + stop(); + + if (timer.current !== null) { + clearTimeout(timer.current); + } + + timer.current = null; + buffer.current = []; + }; + }, [descriptor, storeRef, pendingStoreRef, bumpDataGeneration, queryClient]); +} + +/** + * Every store one push must keep current, the displayed one first. + * + * Outside a hold that is one store. During a hold it is two, and both need the + * write for different reasons: the displayed store is what the user reads until + * adoption, and the pending store is what adoption puts on screen straight + * afterwards. Patch only the first and the value is right for one round trip + * and then wrong for good, because `useRowPages` fetches with no observer, so + * nothing ever refetches a page that already reads `loaded`. + * + * It is never three. `hooks/useGridData.ts` holds exactly two view states, and a + * second state key replaces the pending one rather than adding to it. + * + * It takes the two store VALUES, not the two cells. The caller reads each cell + * once per flush and keeps the displayed one by name, because only that store's + * indexes may reach the damage callback. + */ +function liveStores( + displayed: RowStore | null, + pending: RowStore | null, +): RowStore[] { + const stores: RowStore[] = []; + + if (displayed !== null) { + stores.push(displayed); + } + + // The identity check covers the one render between an adoption writing + // `storeRef` and the next render clearing this cell. Writing the same store + // twice is harmless, but reading it twice would let one row count as two. + if (pending !== null && pending !== displayed) { + stores.push(pending); + } + + return stores; +} + +/** Reports whether a plan asks for anything at all. */ +function hasWork(plan: PushPlan): boolean { + return plan.deleteIds.length > 0 || plan.refetchIds.length > 0 || plan.reload || plan.recount; +} + +/** + * Orders flush *commits* without serializing flush *execution*, and does so + * per key. + * + * The only real hazard is "an older `fetchRow` result overwrites a newer value + * for the SAME row", not "this flush conflicts with every other flush". Two + * pushes to the same row, far enough apart to land in separate buffer windows, + * start two independent `fetchRow` calls whose responses can resolve in either + * order; the older one landing last must not overwrite the newer one for THAT + * row. + * + * A global guard — an older flush discards ALL of its writes if any newer + * flush committed anything, anywhere — is wrong on two counts. An unrelated + * flush that touches different keys, or does no per-key work at all (e.g. an + * update for a row nobody has loaded), would wrongly drop a legitimate write. + * And `reload`/`recount` are not per-key overwrites, so nothing about them can + * be "stale" the way a row write can; gating them the same way silently drops + * a recount (wrong total on screen) or a reload (a new row never appears). + * + * So the rules are: + * + * - `appliedGenerationByKey` tracks, per key, the generation that last wrote + * it. An upsert or delete for a key is skipped only if a strictly newer + * generation already wrote THAT key — every other key is untouched. + * - `reload` and `recount` run unconditionally whenever their flush's plan + * asked for them, regardless of what any other flush did. + * - A flush whose plan is entirely empty never reaches this function, so it + * cannot advance the ordering state for work that never happened. + * + * `appliedGenerationByKey` only needs to hold a key's generation while an + * OLDER flush might still be in flight and about to write that key. Once + * nothing is in flight, no future ordering decision can depend on any past + * generation, so the whole map is dead weight — one entry per distinct row key + * ever touched, held for the life of the effect, is unbounded retention on a + * long session over a large grid. `inFlight` counts the flushes now doing real + * work, and it drops in a `finally`, so it comes back down whether the flush + * finishes normally, returns early, or its `fetchRow` rejects. When it returns + * to zero, the map is cleared. + * + * A `fetchRow` that never settles never reaches its `finally`, so `inFlight` + * never returns to zero and the map never clears while it hangs. That is + * correct: the stuck flush might still resolve and need its own per-key check + * honored, so nothing is safe to forget yet. + */ +function createWriteOrdering() { + let latestGeneration = 0; + let inFlight = 0; + + const appliedGenerationByKey = new Map(); + + return async function runOrdered( + commit: (isStale: IsStale) => Promise, + ): Promise { + latestGeneration += 1; + inFlight += 1; + + const generation = latestGeneration; + + const isStale: IsStale = (key) => { + const appliedGeneration = appliedGenerationByKey.get(key) ?? 0; + + if (generation <= appliedGeneration) { + return true; + } + + appliedGenerationByKey.set(key, generation); + + return false; + }; + + try { + await commit(isStale); + } finally { + inFlight -= 1; + + if (inFlight === 0) { + appliedGenerationByKey.clear(); + } + } + }; +} diff --git a/src/web/src/features/dataGrid/displayModel.test.ts b/src/web/src/features/dataGrid/displayModel.test.ts new file mode 100644 index 0000000..374b9e6 --- /dev/null +++ b/src/web/src/features/dataGrid/displayModel.test.ts @@ -0,0 +1,401 @@ +import { describe, expect, it } from "vitest"; +import { + buildDisplayModel, + buildFlatModel, + dataRangeInDisplayRange, + detectBoundaries, + displayRowsOfData, + displayToData, + firstAffectedDataIndex, + type Boundary, +} from "./displayModel"; + +const order = (g: number) => g; + +describe("detectBoundaries", () => { + it("emits a boundary at index 0 and at each group change", () => { + const rows = [{ g: 0 }, { g: 0 }, { g: 1 }]; + const b = detectBoundaries(rows, null, 0, (r) => r.g); + expect(b).toEqual([ + { dataIndex: 0, group: 0 }, + { dataIndex: 2, group: 1 }, + ]); + }); + + it("emits nothing for a window that sits wholly inside one group", () => { + const rows = [{ g: 4 }, { g: 4 }, { g: 4 }]; + const b = detectBoundaries(rows, 4, 200, (r) => r.g); + expect(b).toEqual([]); + }); +}); + +describe("buildDisplayModel (grouped)", () => { + it("inserts one header per group; header at segment start, data after", () => { + const boundaries: Boundary[] = [ + { dataIndex: 0, group: 0 }, + { dataIndex: 2, group: 1 }, + ]; + const m = buildDisplayModel( + { boundaries, leadingGroup: null, total: 3, collapsedGroups: [], discoveredGroups: [0, 1] }, + order, + ); + // header(0) d d header(1) d => 5 display rows + expect(m.rowCount).toBe(5); + expect(displayToData(m, 0)).toEqual({ kind: "header", group: 0 }); + expect(displayToData(m, 1)).toEqual({ kind: "data", dataIndex: 0 }); + expect(displayToData(m, 3)).toEqual({ kind: "header", group: 1 }); + expect(displayToData(m, 4)).toEqual({ kind: "data", dataIndex: 2 }); + }); +}); + +describe("buildFlatModel (ungrouped)", () => { + it("has no headers; display index === data index", () => { + const m = buildFlatModel(3); + expect(m.rowCount).toBe(3); + expect(displayToData(m, 0)).toEqual({ kind: "data", dataIndex: 0 }); + expect(displayToData(m, 2)).toEqual({ kind: "data", dataIndex: 2 }); + }); +}); + +describe("buildDisplayModel (cold start fallback)", () => { + it("falls back to a flat, headerless span when it knows a total but no boundaries or groups", () => { + const m = buildDisplayModel( + { boundaries: [], leadingGroup: null, total: 100, collapsedGroups: [], discoveredGroups: [] }, + order, + ); + expect(m.rowCount).toBe(100); + expect(m.segments).toHaveLength(1); + expect(m.segments[0].hasHeader).toBe(false); + }); + + it("still reports zero rows when the total itself is zero", () => { + const m = buildDisplayModel( + { boundaries: [], leadingGroup: null, total: 0, collapsedGroups: [], discoveredGroups: [] }, + order, + ); + expect(m.rowCount).toBe(0); + }); + + it("does not apply the fallback when collapsed groups must still draw their headers", () => { + const m = buildDisplayModel( + { + boundaries: [], + leadingGroup: null, + total: 0, + collapsedGroups: ["a", "b"], + discoveredGroups: ["a", "b"], + }, + (g: string) => (g === "a" ? 0 : 1), + ); + const headerSegments = m.segments.filter((segment) => segment.hasHeader); + expect(headerSegments).toHaveLength(2); + }); + + it("is unchanged for the normal grouped case where boundaries are present", () => { + const boundaries: Boundary[] = [ + { dataIndex: 0, group: 0 }, + { dataIndex: 2, group: 1 }, + ]; + const m = buildDisplayModel( + { boundaries, leadingGroup: null, total: 3, collapsedGroups: [], discoveredGroups: [0, 1] }, + order, + ); + expect(m.rowCount).toBe(5); + }); +}); + +describe("buildDisplayModel (window scrolled past the first group)", () => { + it("keeps every row when the window sits wholly inside one group", () => { + const m = buildDisplayModel( + { + boundaries: [], + leadingGroup: 0, + total: 100_000, + collapsedGroups: [], + discoveredGroups: [0], + }, + order, + ); + + expect(m.rowCount).toBe(100_000); + expect(displayToData(m, 0)).toEqual({ kind: "data", dataIndex: 0 }); + expect(displayToData(m, 500)).toEqual({ kind: "data", dataIndex: 500 }); + }); + + it("keeps a viewport commit alive, so the window can move again", () => { + const m = buildDisplayModel( + { + boundaries: [], + leadingGroup: 0, + total: 100_000, + collapsedGroups: [], + discoveredGroups: [0], + }, + order, + ); + + expect(dataRangeInDisplayRange(m, 200, 260)).toEqual({ min: 200, max: 260 }); + }); + + it("covers the rows before the first boundary it can see", () => { + const boundaries: Boundary[] = [{ dataIndex: 10, group: 1 }]; + const m = buildDisplayModel( + { boundaries, leadingGroup: 0, total: 20, collapsedGroups: [], discoveredGroups: [0, 1] }, + order, + ); + + // 10 leading rows, then the group-1 header, then its 10 rows. + expect(m.rowCount).toBe(21); + expect(displayToData(m, 0)).toEqual({ kind: "data", dataIndex: 0 }); + expect(displayToData(m, 10)).toEqual({ kind: "header", group: 1 }); + expect(displayToData(m, 11)).toEqual({ kind: "data", dataIndex: 10 }); + }); + + it("draws no header on the leading run, because it does not know where that group starts", () => { + const m = buildDisplayModel( + { boundaries: [], leadingGroup: 0, total: 50, collapsedGroups: [], discoveredGroups: [0] }, + order, + ); + + expect(m.segments[0].hasHeader).toBe(false); + expect(m.segments[0].group).toBe(0); + }); + + it("still draws the header of a collapsed group the leading run precedes", () => { + const m = buildDisplayModel( + { + boundaries: [], + leadingGroup: 1, + total: 50, + collapsedGroups: [2], + discoveredGroups: [1, 2], + }, + order, + ); + + expect(m.rowCount).toBe(51); + expect(displayToData(m, 50)).toEqual({ kind: "header", group: 2 }); + }); +}); + +describe("displayRowsOfData", () => { + /** Groups 0 and 1, 60 rows each, both with a header. */ + const grouped = () => + buildDisplayModel( + { + boundaries: [ + { dataIndex: 0, group: 0 }, + { dataIndex: 60, group: 1 }, + ], + leadingGroup: null, + total: 120, + collapsedGroups: [], + discoveredGroups: [0, 1], + }, + order, + ); + + it("is the identity for an ungrouped grid, where a display row IS a data index", () => { + expect(displayRowsOfData(buildFlatModel(1_000), [0, 5, 999])).toEqual([0, 5, 999]); + }); + + it("shifts a data index by the headers above it", () => { + const m = grouped(); + + // Group 0's header takes display row 0, so data 0 draws at 1 and data 59 at + // 60. Group 1's header then takes 61, so data 60 draws at 62. + expect(displayRowsOfData(m, [0])).toEqual([1]); + expect(displayRowsOfData(m, [59])).toEqual([60]); + expect(displayRowsOfData(m, [60])).toEqual([62]); + expect(displayRowsOfData(m, [119])).toEqual([121]); + }); + + it("agrees with displayToData in both directions", () => { + const m = grouped(); + + for (const dataIndex of [0, 1, 59, 60, 61, 119]) { + const display = displayRowsOfData(m, [dataIndex])[0]; + + expect(displayToData(m, display)).toEqual({ kind: "data", dataIndex }); + } + }); + + it("converts a whole page in one pass, in ascending order", () => { + const m = grouped(); + const page = Array.from({ length: 120 }, (_, i) => i); + + const rows = displayRowsOfData(m, page); + + expect(rows).toHaveLength(120); + expect(rows[0]).toBe(1); + expect(rows.at(-1)).toBe(121); + expect([...rows].sort((a, b) => a - b)).toEqual(rows); + }); + + it("drops a data index the model does not place, rather than guessing a row", () => { + const m = grouped(); + + // Past the end of every segment. Damaging a guessed row would repaint some + // other row instead. + expect(displayRowsOfData(m, [120, 5_000])).toEqual([]); + expect(displayRowsOfData(m, [119, 120])).toEqual([121]); + }); + + it("places nothing when the model holds no data rows", () => { + expect(displayRowsOfData(buildFlatModel(0), [0, 1])).toEqual([]); + expect(displayRowsOfData(grouped(), [])).toEqual([]); + }); + + it("skips a collapsed group's rows", () => { + const m = buildDisplayModel( + { + boundaries: [{ dataIndex: 0, group: 1 }], + leadingGroup: null, + total: 60, + collapsedGroups: [2], + discoveredGroups: [1, 2], + }, + order, + ); + + // Group 2 is header-only, so it holds no data row anything can map into. + expect(displayRowsOfData(m, [0])).toEqual([1]); + expect(displayRowsOfData(m, [60])).toEqual([]); + }); + + it("leaves the model's own segment order untouched", () => { + const m = grouped(); + const before = m.segments.map((segment) => segment.dataStart); + + displayRowsOfData(m, [119, 0, 60]); + + expect(m.segments.map((segment) => segment.dataStart)).toEqual(before); + }); +}); + +describe("firstAffectedDataIndex", () => { + it("answers with the group's own first row when the group holds rows", () => { + const model = buildDisplayModel( + { + boundaries: [ + { dataIndex: 0, group: 0 }, + { dataIndex: 60, group: 1 }, + ], + leadingGroup: null, + total: 120, + collapsedGroups: [], + discoveredGroups: [0, 1], + }, + order, + ); + + expect(firstAffectedDataIndex(model, 1)).toBe(60); + }); + + it("answers with the end of the group above when a group is placed below too", () => { + const model = buildDisplayModel( + { + boundaries: [ + { dataIndex: 0, group: 0 }, + { dataIndex: 60, group: 2 }, + ], + leadingGroup: null, + total: 120, + collapsedGroups: [1], + discoveredGroups: [0, 1, 2], + }, + order, + ); + + // Group 1 holds no rows while it is collapsed, so its own `dataStart` says + // nothing. Group 2 sits below it and holds rows, which proves group 0 ends + // at a real boundary rather than at `total`. Expanding group 1 therefore + // moves rows from 60 down, and rows 0 to 59 keep the index they have. + expect(firstAffectedDataIndex(model, 1)).toBe(60); + }); + + it("answers with 0 when the group above runs to the end of the table", () => { + const model = buildDisplayModel( + { + boundaries: [{ dataIndex: 0, group: 0 }], + leadingGroup: null, + total: 60, + collapsedGroups: [1], + discoveredGroups: [0, 1], + }, + order, + ); + + // Nothing is placed below group 1, so group 0's segment runs to `total` + // because that is where the model stops, not because group 0 stops there. + // Reading 60 as a boundary would carry every loaded page across the expand. + expect(firstAffectedDataIndex(model, 1)).toBe(0); + }); + + it("answers with 0 when the group above is the leading run of unplaced rows", () => { + const model = buildDisplayModel( + { + boundaries: [], + leadingGroup: null, + total: 1_000, + collapsedGroups: [1], + discoveredGroups: [0, 1, 2], + }, + order, + ); + + // The shape a moved window still loading leaves behind: one leading segment + // covering every row, and the collapsed headers drawn below all of them — + // which is exactly where the user clicks to expand. Group 1's rows return + // somewhere INSIDE 0 to 999, so a boundary of 1,000 would carry every page. + expect(firstAffectedDataIndex(model, 1)).toBe(0); + }); + + it("answers with 0 for a group the model does not hold", () => { + const model = buildDisplayModel( + { + boundaries: [{ dataIndex: 0, group: 0 }], + leadingGroup: null, + total: 60, + collapsedGroups: [], + discoveredGroups: [0], + }, + order, + ); + + expect(firstAffectedDataIndex(model, 9)).toBe(0); + }); + + it("answers with 0 when no group above it holds rows", () => { + const model = buildDisplayModel( + { + boundaries: [], + leadingGroup: null, + total: 0, + collapsedGroups: [0], + discoveredGroups: [0], + }, + order, + ); + + expect(firstAffectedDataIndex(model, 0)).toBe(0); + }); + + it("answers with 0 for the first group, so a collapse of it carries nothing", () => { + const model = buildDisplayModel( + { + boundaries: [ + { dataIndex: 0, group: 0 }, + { dataIndex: 60, group: 1 }, + ], + leadingGroup: null, + total: 120, + collapsedGroups: [], + discoveredGroups: [0, 1], + }, + order, + ); + + expect(firstAffectedDataIndex(model, 0)).toBe(0); + }); +}); diff --git a/src/web/src/features/dataGrid/displayModel.ts b/src/web/src/features/dataGrid/displayModel.ts new file mode 100644 index 0000000..b615900 --- /dev/null +++ b/src/web/src/features/dataGrid/displayModel.ts @@ -0,0 +1,436 @@ +export interface Boundary { + dataIndex: number; + group: TGroup; +} + +interface Segment { + group: TGroup | null; + collapsed: boolean; + hasHeader: boolean; + dataStart: number; + dataCount: number; + displayStart: number; +} + +export interface DisplayModel { + rowCount: number; + segments: Segment[]; +} + +export interface BuildArgs { + boundaries: Boundary[]; + + /** + * The group that owns the data rows BEFORE the first boundary, or null when + * nothing is known about them. + * + * `detectBoundaries` reports where a group STARTS, and it can only see the + * rows of one loaded window. A window that sits wholly inside one group holds + * no group change, so it reports no boundary at all — and a group of 10,000 + * rows read 300 at a time gives about 33 such windows in a row. Every data row + * before the first boundary therefore belongs to a group this build cannot + * place from `boundaries` alone. + * + * `LoadedSpan.precedingGroupKey` is that group: the group of the row directly + * above the window. It is what lets the leading run keep its rows. Without it + * the build made no segment for those rows, `rowCount` fell to 0, and the grid + * drew nothing — which then stopped any new viewport range from committing, + * because `dataRangeInDisplayRange` needs a segment to report a range. The + * grid could not recover on its own. + */ + leadingGroup: TGroup | null; + + total: number; + collapsedGroups: TGroup[]; + discoveredGroups: TGroup[]; +} + +export type DisplayCell = + { kind: "header"; group: TGroup } | { kind: "data"; dataIndex: number }; + +/** Display rows a segment spends on its header. */ +function headerRowCount(segment: Segment): number { + return segment.hasHeader ? 1 : 0; +} + +/** Display index of a segment's first data row. */ +function dataStartInDisplay(segment: Segment): number { + return segment.displayStart + headerRowCount(segment); +} + +/** + * Detect group boundaries in a freshly loaded window. `windowSkip` is the global + * data index of rows[0]; `precedingGroupKey` is the group of the row at + * (windowSkip - 1), or null at the top. `groupOf` extracts a row's group. + * Contract for non-zero-skip windows: when windowSkip > 0 and precedingGroupKey + * is null, the first-row boundary is NOT emitted (callers must supply + * precedingGroupKey for paged-in windows). + */ +export function detectBoundaries( + rows: TRow[], + precedingGroupKey: TGroup | null, + windowSkip: number, + groupOf: (row: TRow) => TGroup, +): Boundary[] { + const boundaries: Boundary[] = []; + let previousGroup = precedingGroupKey; + + rows.forEach((row, offset) => { + const group = groupOf(row); + const dataIndex = windowSkip + offset; + + const startsTheData = dataIndex === 0 && previousGroup === null; + const groupChanged = previousGroup !== null && group !== previousGroup; + + if (startsTheData || groupChanged) { + boundaries.push({ dataIndex, group }); + } + + previousGroup = group; + }); + + return boundaries; +} + +/** One segment per boundary. A segment runs to the next boundary, or to `total`. */ +function buildPresentSegments( + boundaries: Boundary[], + total: number, +): Segment[] { + const sorted = [...boundaries].sort((a, b) => a.dataIndex - b.dataIndex); + + return sorted.map((boundary, i) => { + const nextStart = i + 1 < sorted.length ? sorted[i + 1].dataIndex : total; + + return { + group: boundary.group, + collapsed: false, + hasHeader: true, + dataStart: boundary.dataIndex, + dataCount: Math.max(0, nextStart - boundary.dataIndex), + displayStart: 0, + }; + }); +} + +/** + * The segment for the data rows that sit before the first boundary, or null + * when there are none. + * + * It carries NO header. A boundary is the only thing that says where a group + * starts, and this run exists because no boundary for its group has loaded, so + * drawing a header at data index 0 would claim a start this build cannot know. + * The run therefore holds display index === data index, exactly as the + * ungrouped model does, and it gains its header the moment a window loads the + * boundary itself. + * + * Its group may be null. That happens while a moved window is still loading: + * the span is empty, so it offers neither a boundary nor a preceding group. The + * rows still have to be drawn, because a grid that draws nothing never commits + * another viewport range and never asks for the rows that would fill it. + */ +function buildLeadingSegment( + args: BuildArgs, + present: Segment[], +): Segment | null { + const firstStart = present.length > 0 ? present[0].dataStart : args.total; + + if (firstStart <= 0) { + return null; + } + + return { + group: args.leadingGroup, + collapsed: false, + hasHeader: false, + dataStart: 0, + dataCount: firstStart, + displayStart: 0, + }; +} + +/** A header-only segment for each collapsed group that has no loaded rows. */ +function buildCollapsedSegments( + args: BuildArgs, + present: Segment[], +): Segment[] { + const collapsed = new Set(args.collapsedGroups); + const presentGroups = new Set(present.map((segment) => segment.group)); + + return [...new Set(args.discoveredGroups)] + .filter((group) => collapsed.has(group) && !presentGroups.has(group)) + .map((group) => ({ + group, + collapsed: true, + hasHeader: true, + dataStart: 0, + dataCount: 0, + displayStart: 0, + })); +} + +/** Stack the segments in display space. Returns the total display row count. */ +function assignDisplayStarts(segments: Segment[]): number { + let nextStart = 0; + + for (const segment of segments) { + segment.displayStart = nextStart; + nextStart += headerRowCount(segment) + segment.dataCount; + } + + return nextStart; +} + +/** + * Grouped model: data rows + one header per present group + one header per + * collapsed (remembered) group, ordered by `order(group)`. + * + * EVERY data row gets a segment, and that is the rule this build is written + * around. A boundary can only be detected from a row that has loaded (see + * `detectBoundaries`), so the boundaries alone describe a fraction of the list: + * none at all on a cold start, and none again on any window that sits inside a + * single group. The rows below the first boundary are the rest, and + * `buildLeadingSegment` covers them. + * + * The rule is not cosmetic. A row with no segment is a row `rowCount` does not + * count, so the grid draws nothing; and a grid that draws nothing reports no + * visible region, so no viewport range commits and no request goes out for the + * rows that would fix it. The grid deadlocks rather than flickering. + */ +export function buildDisplayModel( + args: BuildArgs, + order: (g: TGroup) => number, +): DisplayModel { + const present = buildPresentSegments(args.boundaries, args.total); + const leading = buildLeadingSegment(args, present); + + const placed = leading ? [leading, ...present] : present; + const collapsedOnly = buildCollapsedSegments(args, placed); + + const segments = sortByGroupOrder([...placed, ...collapsedOnly], order); + const rowCount = assignDisplayStarts(segments); + + return { rowCount, segments }; +} + +/** + * Orders the segments the way the grid draws them. + * + * Only the leading segment may carry a null group, and only while a moved + * window is still loading. `order` cannot rank it, and it covers data index 0, + * so it stays first. + */ +function sortByGroupOrder( + segments: Segment[], + order: (g: TGroup) => number, +): Segment[] { + return [...segments].sort((a, b) => { + if (a.group === null || b.group === null) { + return a.group === b.group ? 0 : a.group === null ? -1 : 1; + } + + return order(a.group) - order(b.group); + }); +} + +/** Flat model for ungrouped grids: one headerless segment, display index === data index. */ +export function buildFlatModel(total: number): DisplayModel { + if (total === 0) { + return { rowCount: 0, segments: [] }; + } + + const segment: Segment = { + group: null, + collapsed: false, + hasHeader: false, + dataStart: 0, + dataCount: total, + displayStart: 0, + }; + + return { rowCount: total, segments: [segment] }; +} + +export function displayToData( + model: DisplayModel, + displayIndex: number, +): DisplayCell { + for (const segment of model.segments) { + if (segment.hasHeader && displayIndex === segment.displayStart) { + return { kind: "header", group: segment.group as TGroup }; + } + + const offset = displayIndex - dataStartInDisplay(segment); + + if (offset >= 0 && offset < segment.dataCount) { + return { kind: "data", dataIndex: segment.dataStart + offset }; + } + } + + // Defensive fallback. Reached at most at displayIndex === rowCount + // (a one-past-end prefetch hint); benign slight over-prefetch near the bottom. + return { kind: "data", dataIndex: displayIndex }; +} + +/** + * The first data index that a collapse or an expand of this group moves. + * + * A collapse drops the group's rows, so every index from its first row changes. + * An expand puts the same rows back in the same place. Either way the rows + * ABOVE it keep the index they already hold, and that is what a caller carries + * across. + * + * Answers with 0 — "everything moves" — whenever the model cannot tell. That is + * the safe direction: it costs a reload, and the opposite would show a row + * under an index it does not belong to. + */ +export function firstAffectedDataIndex(model: DisplayModel, group: TGroup): number { + const holdsRows = (segment: Segment) => segment.dataCount > 0; + + const at = model.segments.findIndex((segment) => segment.group === group); + + if (at < 0) { + return 0; + } + + const segment = model.segments[at]; + + if (holdsRows(segment)) { + return segment.dataStart; + } + + // A collapsed group holds no rows, so its own `dataStart` is 0 and means + // nothing. Its rows return directly after the last group above it that does + // hold some — but the model may only say so when it also places rows BELOW + // the collapsed group. + // + // The last segment that holds rows always runs to `total`, whether the rows + // under it have loaded or not. So `dataStart + dataCount` on that segment is + // the end of the TABLE, not the end of the group, and reading it as a + // boundary would carry every loaded page across a move. A segment placed + // below the collapsed group is the proof that the segment above it ends at a + // real boundary. Without that proof the answer is 0: everything moves. + const above = model.segments.slice(0, at).filter(holdsRows).pop(); + const anyBelow = model.segments.slice(at + 1).some(holdsRows); + + if (above === undefined || !anyBelow) { + return 0; + } + + return above.dataStart + above.dataCount; +} + +/** + * The display rows that show a set of data indexes, ascending. + * + * A group header occupies a display row, so in a grouped grid a data index and + * a display row are different numbers. Glide numbers everything it draws — and + * everything it DAMAGES — in display space, while both writers of the grid's + * damage callback speak data indexes: the page loader reports absolute store + * indexes, and a settled save finds its row with `RowStore.indexOfKey`. Without + * this translation a repaint lands on some other row, or on a header, and the + * row that actually changed is left as it was. + * + * An index the model does not place is DROPPED rather than damaged at a guessed + * row. A collapsed group's rows and a page that loaded past the current total + * both reach here, and there is no display row showing them. + * + * It walks the segments ONCE for the whole batch rather than searching them per + * index, because a page load hands over a page of indexes at a time. An + * ungrouped grid is one headerless segment starting at data index 0, so every + * answer is `index` and the mapping is the identity, at one subtraction and one + * addition per index. + */ +export function displayRowsOfData( + model: DisplayModel, + dataIndexes: readonly number[], +): number[] { + if (dataIndexes.length === 0) { + return []; + } + + // A copy, never the model's own array. Collapsed and empty segments show no + // data row at all, so nothing can map into them. + const placed = model.segments + .filter((segment) => !segment.collapsed && segment.dataCount > 0) + .sort((a, b) => a.dataStart - b.dataStart); + + if (placed.length === 0) { + return []; + } + + const ascending = [...dataIndexes].sort((a, b) => a - b); + const displayRows: number[] = []; + + let current = 0; + + for (const dataIndex of ascending) { + while ( + current < placed.length && + dataIndex >= placed[current].dataStart + placed[current].dataCount + ) { + current += 1; + } + + // Every remaining index is at least this one, and this one is past the last + // segment, so nothing else can map either. + if (current >= placed.length) { + break; + } + + const segment = placed[current]; + + // The index falls in a gap between two segments, which is a row the model + // does not place. + if (dataIndex < segment.dataStart) { + continue; + } + + displayRows.push(dataStartInDisplay(segment) + (dataIndex - segment.dataStart)); + } + + return displayRows; +} + +/** + * The span of data indices covered by a display range, or null when the range + * holds no data rows at all (every row in it is a group header, or it sits past + * the end). Scans segments rather than the range's two edge rows: a viewport can + * open on a header, close on a header, and still have data rows between them. + */ +export function dataRangeInDisplayRange( + model: DisplayModel, + fromDisplay: number, + toDisplay: number, +): { min: number; max: number } | null { + const rangeStart = Math.max(0, Math.min(fromDisplay, toDisplay)); + const rangeEnd = Math.max(fromDisplay, toDisplay); + + let min = Infinity; + let max = -Infinity; + + for (const segment of model.segments) { + if (segment.collapsed || segment.dataCount === 0) { + continue; + } + + const dataStart = dataStartInDisplay(segment); + const dataEnd = dataStart + segment.dataCount - 1; + + const overlapStart = Math.max(rangeStart, dataStart); + const overlapEnd = Math.min(rangeEnd, dataEnd); + + if (overlapStart > overlapEnd) { + continue; + } + + min = Math.min(min, segment.dataStart + (overlapStart - dataStart)); + max = Math.max(max, segment.dataStart + (overlapEnd - dataStart)); + } + + if (min === Infinity) { + return null; + } + + return { min, max }; +} diff --git a/src/web/src/features/dataGrid/gridComposition.test.tsx b/src/web/src/features/dataGrid/gridComposition.test.tsx new file mode 100644 index 0000000..edde237 --- /dev/null +++ b/src/web/src/features/dataGrid/gridComposition.test.tsx @@ -0,0 +1,1000 @@ +import { + GridCellKind, + type EditableGridCell, + type GridCell, + type Rectangle, +} from "@glideapps/glide-data-grid"; +import { configureStore } from "@reduxjs/toolkit"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useRef, type ReactNode } from "react"; +import { Provider, useSelector } from "react-redux"; +import { describe, expect, it, vi } from "vitest"; +import { specFromGridSort } from "./data/sortSpec"; +import { PUSH_BUFFER_MS, useRowSync } from "./data/sync/useRowSync"; +import { displayToData, type DisplayModel } from "./displayModel"; +import { useCellRenderer } from "./hooks/useCellRenderer"; +import { useDisplayModel } from "./hooks/useDisplayModel"; +import { useGridData } from "./hooks/useGridData"; +import { useRepaintRows, type GridDamageTarget } from "./hooks/useRepaintRows"; +import { useWindowRange, type RangeLoaded } from "./hooks/useWindowRange"; +import { createGridInstance } from "./store/createGridInstance"; +import { createFakeRowServer } from "./testing/fakeRowServer"; +import type { GridDescriptor, GridInstance } from "./types"; + +interface Row { + id: number; + name: string; + g: number; +} + +const PAGE = 100; + +/** Rows 0-59 are group 0, 60-119 group 1, and so on. */ +const GROUP_SIZE = 60; + +/** + * Visible columns. More than one on purpose: a repaint has to damage the whole + * ROW, and a one-column grid cannot tell "every visible column" apart from + * "column 0". + */ +const VISIBLE_FIELDS = ["name", "g"]; + +function textCell(_type: string, raw: unknown): GridCell { + const text = String(raw ?? ""); + + return { kind: GridCellKind.Text, data: text, displayData: text, allowOverlay: true }; +} + +function cellText(cell: GridCell): string { + if (cell.kind === GridCellKind.Text) { + return cell.displayData; + } + + return `<${cell.kind}>`; +} + +const typedCell: EditableGridCell = { + kind: GridCellKind.Text, + data: "typed", + displayData: "typed", + allowOverlay: true, +}; + +/** + * The hooks wired exactly as `DataGrid` wires them, minus the `DataEditor`: a + * real glide mount needs jsdom polyfills this project does not carry + * (ResizeObserver, canvas), so the cells are read through `getCellContent` + * directly and the damage calls are read off a stand-in editor. Both are the + * same functions glide would call, given the same display index, so nothing + * about the assertion is weaker. + * + * `useRepaintRows` is the REAL hook `DataGrid` uses, wired in the same order and + * with the same model publish, so the coordinate space these cases assert on is + * the production one rather than a copy of it. + */ +function useComposedGrid( + instance: GridInstance, + gridRef: { current: GridDamageTarget | null }, +) { + const { sort, collapsedGroups } = useSelector((s: unknown) => instance.selectRoot(s).groups); + + const spec = specFromGridSort(sort); + const rangeLoadedRef = useRef(null); + const { range, onRectChanged } = useWindowRange(PAGE, rangeLoadedRef); + + const modelRef = useRef | null>(null); + const columnCountRef = useRef(VISIBLE_FIELDS.length); + const repaint = useRepaintRows(gridRef, modelRef, columnCountRef); + + const { rowAt, isPending, isRangeLoaded, total, span, status, isStale, overlay, store } = + useGridData(instance, range, spec, collapsedGroups, repaint); + + rangeLoadedRef.current = isRangeLoaded; + + const { model } = useDisplayModel(instance, { total, span }); + + modelRef.current = model; + + const { getCellContent, onCellEdited } = useCellRenderer(instance, { + model, + visibleFields: VISIBLE_FIELDS, + columnCount: VISIBLE_FIELDS.length, + rowAt, + isPending, + overlay, + store, + repaint, + }); + + // The push subscriber, wired exactly where `DataGrid` wires it, so a pushed + // create or delete reaches the same hold a sort change takes — and a pushed + // update reaches the same damage callback the loader and the save path use. + useRowSync(instance, collapsedGroups, repaint); + + return { + rowAt, + total, + status, + isStale, + span, + model, + store, + getCellContent, + onCellEdited, + onVisibleRegionChanged: (rect: Rectangle) => onRectChanged(model, rect), + }; +} + +interface HarnessOptions { + /** + * Adds `descriptor.grouping`, so the display model carries group headers and + * a display index stops being a data index. + */ + grouped?: boolean; +} + +function makeHarness(total: number, { grouped = false }: HarnessOptions = {}) { + const server = createFakeRowServer({ + rows: Array.from({ length: total }, (_, i) => ({ + id: i, + name: `row-${i}`, + g: Math.floor(i / GROUP_SIZE), + })), + rowKey: (r) => r.id, + groupOf: (r) => r.g, + }); + + const updateRow = vi.fn(server.api.updateRow); + + // A gate around every page PAST the first, so a case can let page 0 settle on + // its own and hold the rest of the window back. + let openLaterPages: () => void = () => {}; + let laterPageGate: Promise | null = null; + + // A gate per page offset, so a case can decide the exact order in which the + // pages of ONE window land. Holding a hold half open is what exposes a push + // that arrives between a pending page settling and the view adopting. + const pageGates = new Map; open: () => void }>(); + + const fetchRows = vi.fn(async (p: Parameters[0]) => { + if (laterPageGate !== null && p.offset > 0) { + await laterPageGate; + } + + const gate = pageGates.get(p.offset); + + if (gate !== undefined) { + await gate.wait; + } + + return server.api.fetchRows(p); + }); + + const descriptor = { + name: "demo", + rowKey: (r: Row) => r.id, + pageSize: PAGE, + columns: { + defs: { + name: { field: "name", title: "Name", defaultWidth: 120, editable: true, type: "text" }, + g: { field: "g", title: "Group", defaultWidth: 80, editable: false, type: "text" }, + }, + defaultOrder: VISIBLE_FIELDS, + }, + grouping: grouped + ? { field: "g", of: (r: Row) => r.g, order: (g: number) => g, label: String } + : undefined, + api: { ...server.api, updateRow, fetchRows }, + cells: { makeCell: textCell, customRenderers: [], validateCell: () => true }, + } as unknown as GridDescriptor; + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 60_000 } }, + }); + + const instance = createGridInstance(descriptor); + + const store = configureStore({ + reducer: { demo: instance.reducer }, + middleware: (getDefault) => getDefault({ serializableCheck: false }), + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + const updateCells = vi.fn(); + const gridRef: { current: GridDamageTarget | null } = { + current: { updateCells } as unknown as GridDamageTarget, + }; + + return { + server, + instance, + store, + wrapper, + updateRow, + fetchRows, + updateCells, + gridRef, + + /** Every display row glide was asked to redraw, in call order. */ + damaged: () => + updateCells.mock.calls.flatMap((call) => + (call[0] as { cell: readonly [number, number] }[]).map((entry) => entry.cell[1]), + ), + + /** + * Every `[column, display row]` pair glide was asked to redraw, in call + * order. + * + * Glide damages the CELLS it is named and nothing else — `updateCells` + * builds a `CellSet` of these pairs, and the cell renderer skips any cell + * the set does not hold. A data cell carries no span, so a row is only + * repainted when every one of its visible columns is named. + */ + damagedCells: () => + updateCells.mock.calls.flatMap((call) => + (call[0] as { cell: readonly [number, number] }[]).map((entry) => [ + entry.cell[0], + entry.cell[1], + ]), + ), + + /** The columns damaged for one display row, ascending and deduplicated. */ + columnsDamagedFor: (displayRow: number) => { + const columns = updateCells.mock.calls + .flatMap((call) => call[0] as { cell: readonly [number, number] }[]) + .filter((entry) => entry.cell[1] === displayRow) + .map((entry) => entry.cell[0]); + + return [...new Set(columns)].sort((a, b) => a - b); + }, + + lastError: () => instance.selectRoot(store.getState()).edits.lastError, + requested: () => fetchRows.mock.calls.map((c) => ({ offset: c[0].offset, limit: c[0].limit })), + + holdLaterPages() { + laterPageGate = new Promise((resolve) => { + openLaterPages = resolve; + }); + }, + + releaseLaterPages() { + openLaterPages(); + laterPageGate = null; + }, + + /** Blocks every later request for the page starting at this offset. */ + holdPage(offset: number) { + let open: () => void = () => {}; + const wait = new Promise((resolve) => { + open = resolve; + }); + + pageGates.set(offset, { wait, open }); + }, + + releasePage(offset: number) { + pageGates.get(offset)?.open(); + pageGates.delete(offset); + }, + }; +} + +/** Long enough for a released page to resolve and reach the store. */ +const settleTick = () => act(() => new Promise((r) => setTimeout(r, 30))); + +/** Long enough for one push buffer to close and its flush to finish. */ +const flushPush = () => act(() => new Promise((r) => setTimeout(r, PUSH_BUFFER_MS + 60))); + +/** Glide reports the region in display rows; `height` is a row count. */ +const scrollTo = (y: number): Rectangle => ({ x: 0, y, width: 5, height: 20 }); + +/** Whether the model draws a header row for a group. */ +function hasHeaderFor(model: DisplayModel, group: TGroup): boolean { + for (let display = 0; display < model.rowCount; display += 1) { + const cell = displayToData(model, display); + + if (cell.kind === "header" && cell.group === group) { + return true; + } + } + + return false; +} + +describe("the composed grid, on a row store", () => { + it("serves data index 999,700 of 1,000,000 with narrow page requests", async () => { + const h = makeHarness(1_000_000); + const { result } = renderHook(() => useComposedGrid(h.instance, h.gridRef), { + wrapper: h.wrapper, + }); + + await waitFor(() => expect(result.current.total).toBe(1_000_000), { timeout: 10_000 }); + await waitFor(() => expect(result.current.rowAt(0)).toBeDefined(), { timeout: 10_000 }); + + act(() => result.current.onVisibleRegionChanged(scrollTo(999_800))); + + await waitFor(() => expect(result.current.rowAt(999_800)).toBeDefined(), { timeout: 10_000 }); + + expect(cellText(result.current.getCellContent([0, 999_800]))).toBe("row-999800"); + expect(result.current.model.rowCount).toBe(1_000_000); + + for (const request of h.requested()) { + expect(request.limit).toBe(PAGE); + } + }, 30_000); + + it("commits a scroll back onto cached rows with no settle wait", async () => { + const h = makeHarness(100_000); + const { result } = renderHook(() => useComposedGrid(h.instance, h.gridRef), { + wrapper: h.wrapper, + }); + + await waitFor(() => expect(result.current.rowAt(0)).toBeDefined(), { timeout: 10_000 }); + + // Walk forward two windows so pages 0, 1, 2 and 3 all reach the store. + act(() => result.current.onVisibleRegionChanged(scrollTo(150))); + await waitFor(() => expect(result.current.rowAt(250)).toBeDefined(), { timeout: 10_000 }); + + act(() => result.current.onVisibleRegionChanged(scrollTo(250))); + await waitFor(() => expect(result.current.rowAt(350)).toBeDefined(), { timeout: 10_000 }); + + expect(result.current.span.offset).toBe(100); + + const before = h.requested().length; + + // Back onto pages the store already holds. No timer advanced, and nothing + // awaited: the assertion runs on the same tick the scroll arrived. + act(() => result.current.onVisibleRegionChanged(scrollTo(150))); + + expect(result.current.span.offset).toBe(0); + + // And it stayed free. A cached window commits precisely because it asks + // the server for nothing. + expect(h.requested().length).toBe(before); + }, 30_000); + + it("still holds a scroll into cold rows for the settle wait", async () => { + const h = makeHarness(100_000); + const { result } = renderHook(() => useComposedGrid(h.instance, h.gridRef), { + wrapper: h.wrapper, + }); + + await waitFor(() => expect(result.current.rowAt(0)).toBeDefined(), { timeout: 10_000 }); + + // Rows nothing has loaded. The window must not move on this tick. + act(() => result.current.onVisibleRegionChanged(scrollTo(50_000))); + + expect(result.current.span.offset).toBe(0); + + await waitFor(() => expect(result.current.span.offset).toBe(49_900), { timeout: 10_000 }); + }, 30_000); + + it("does NOT blank a loaded row when the window moves — the flash regression", async () => { + const h = makeHarness(100_000); + const { result } = renderHook(() => useComposedGrid(h.instance, h.gridRef), { + wrapper: h.wrapper, + }); + + await waitFor(() => expect(result.current.rowAt(0)).toBeDefined(), { timeout: 10_000 }); + expect(cellText(result.current.getCellContent([0, 5]))).toBe("row-5"); + + act(() => result.current.onVisibleRegionChanged(scrollTo(150))); + + // Sampled repeatedly across the whole window move. Row 5 must never once + // read as a loading cell. + for (let i = 0; i < 20; i += 1) { + expect(cellText(result.current.getCellContent([0, 5]))).toBe("row-5"); + await act(() => new Promise((r) => setTimeout(r, 15))); + } + + await waitFor(() => expect(result.current.rowAt(200)).toBeDefined(), { timeout: 10_000 }); + expect(cellText(result.current.getCellContent([0, 5]))).toBe("row-5"); + }, 30_000); + + it("shows an edit at once and keeps it when the server accepts", async () => { + const h = makeHarness(1_000); + const { result } = renderHook(() => useComposedGrid(h.instance, h.gridRef), { + wrapper: h.wrapper, + }); + + await waitFor(() => expect(result.current.rowAt(5)).toBeDefined(), { timeout: 10_000 }); + + act(() => result.current.onCellEdited([0, 5], typedCell)); + + expect(cellText(result.current.getCellContent([0, 5]))).toBe("typed"); + + await waitFor(() => expect(h.updateRow).toHaveBeenCalled()); + await waitFor(() => expect(cellText(result.current.getCellContent([0, 5]))).toBe("typed")); + + expect(h.lastError()).toBeNull(); + + // This grid has no grouping, so a display row IS a data index and the + // translation must be the identity. The settled save damaged row 5. + expect(h.damaged()).toContain(5); + }, 30_000); + + it("rolls the cell back and reports the failure when the server rejects", async () => { + const h = makeHarness(1_000); + h.updateRow.mockImplementation(async () => ({ ok: false })); + + const { result } = renderHook(() => useComposedGrid(h.instance, h.gridRef), { + wrapper: h.wrapper, + }); + + await waitFor(() => expect(result.current.rowAt(5)).toBeDefined(), { timeout: 10_000 }); + + act(() => result.current.onCellEdited([0, 5], typedCell)); + + expect(cellText(result.current.getCellContent([0, 5]))).toBe("typed"); + + await waitFor(() => expect(cellText(result.current.getCellContent([0, 5]))).toBe("row-5"), { + timeout: 10_000, + }); + + expect(h.lastError()?.message).toBe("Failed to save name"); + }, 30_000); + + it("rolls back when updateRow rejects outright, not only when it answers ok:false", async () => { + const h = makeHarness(1_000); + h.updateRow.mockImplementation(async () => { + throw new Error("network"); + }); + + const { result } = renderHook(() => useComposedGrid(h.instance, h.gridRef), { + wrapper: h.wrapper, + }); + + await waitFor(() => expect(result.current.rowAt(5)).toBeDefined(), { timeout: 10_000 }); + + act(() => result.current.onCellEdited([0, 5], typedCell)); + + await waitFor(() => expect(cellText(result.current.getCellContent([0, 5]))).toBe("row-5"), { + timeout: 10_000, + }); + + expect(h.lastError()?.message).toBe("Failed to save name"); + }, 30_000); + + it("keeps the viewport loaded when the sort changes, without another scroll", async () => { + const TOTAL = 1_000; + const h = makeHarness(TOTAL); + const { result } = renderHook(() => useComposedGrid(h.instance, h.gridRef), { + wrapper: h.wrapper, + }); + + await waitFor(() => expect(result.current.total).toBe(TOTAL), { timeout: 10_000 }); + + act(() => result.current.onVisibleRegionChanged(scrollTo(500))); + await waitFor(() => expect(result.current.rowAt(505)).toBeDefined(), { timeout: 10_000 }); + + // A header click changes the sort but not the scroll offset, and `total` is + // unchanged, so glide's rect is identical and `onVisibleRegionChanged` never + // fires again. Nothing else can ask for the viewport's rows. + act(() => void h.store.dispatch(h.instance.actions.setSort({ field: "id", dir: "desc" }))); + + await waitFor( + () => + expect(result.current.rowAt(505)).toEqual(expect.objectContaining({ id: TOTAL - 1 - 505 })), + { timeout: 10_000 }, + ); + }, 30_000); + + it("reloads the rows, not just the total, when a group collapses after mount", async () => { + // The count rekeys on collapse on its own, so a grid that reloads only the + // total would show the new total beside the old rows — wrong rows under a + // total that excludes them. + const TOTAL = 1_000; + const h = makeHarness(TOTAL); + const { result } = renderHook(() => useComposedGrid(h.instance, h.gridRef), { + wrapper: h.wrapper, + }); + + await waitFor(() => expect(result.current.total).toBe(TOTAL), { timeout: 10_000 }); + await waitFor( + () => expect(result.current.rowAt(0)).toEqual(expect.objectContaining({ id: 0 })), + { + timeout: 10_000, + }, + ); + + act(() => void h.store.dispatch(h.instance.actions.toggleCollapse(0))); + + // The server hides group 0, so the total drops by one group's rows AND the + // row at data index 0 becomes the first row of group 1. + await waitFor(() => expect(result.current.total).toBe(TOTAL - GROUP_SIZE), { timeout: 10_000 }); + await waitFor( + () => + expect(result.current.rowAt(0)).toEqual(expect.objectContaining({ id: GROUP_SIZE, g: 1 })), + { timeout: 10_000 }, + ); + + // Expanding again has to bring them back, or the reload is one-way. + act(() => void h.store.dispatch(h.instance.actions.toggleCollapse(0))); + + await waitFor(() => expect(result.current.total).toBe(TOTAL), { timeout: 10_000 }); + await waitFor( + () => expect(result.current.rowAt(0)).toEqual(expect.objectContaining({ id: 0 })), + { + timeout: 10_000, + }, + ); + }, 30_000); + it("draws the header of a group that opens past the window's first page", async () => { + // `statusOf` answers "ready" as soon as the FIRST page of a window lands + // and never moves again while pages 2 and 3 arrive. A span memo keyed on + // `status` therefore freezes at 100 rows, and `useDisplayModel` reads every + // group boundary out of `span.rows` — so the header of a group opening past + // row 100 would not draw until the window moved. Cells stay right either + // way, because `rowAt` reads the store. + const h = makeHarness(1_000, { grouped: true }); + const { result } = renderHook(() => useComposedGrid(h.instance, h.gridRef), { + wrapper: h.wrapper, + }); + + await waitFor(() => expect(result.current.total).toBe(1_000), { timeout: 10_000 }); + await waitFor(() => expect(result.current.span.rows.length).toBe(PAGE), { timeout: 10_000 }); + + // Group 2 opens at row 120, past the first page. It cannot be known yet. + expect(hasHeaderFor(result.current.model, 2)).toBe(false); + + // Widen to three pages, and hold the two new ones until page 0 has long + // since settled — the exact order that makes `status` stop moving. + h.holdLaterPages(); + act(() => result.current.onVisibleRegionChanged(scrollTo(150))); + + await waitFor(() => expect(h.requested().length).toBeGreaterThan(1), { timeout: 10_000 }); + + h.releaseLaterPages(); + + await waitFor(() => expect(result.current.span.rows.length).toBe(300), { timeout: 10_000 }); + await waitFor(() => expect(hasHeaderFor(result.current.model, 2)).toBe(true), { + timeout: 10_000, + }); + }, 30_000); + it("damages the DISPLAY rows of a loaded page, not its data indexes", async () => { + // Glide numbers what it draws — and what it damages — in display space, and + // a group header occupies a display row. The page loader reports absolute + // store indexes. Hand those straight to `updateCells` and the repaint lands + // on some other row, or on a header, while the rows that just arrived stay + // as they were. + const h = makeHarness(1_000, { grouped: true }); + const { result } = renderHook(() => useComposedGrid(h.instance, h.gridRef), { + wrapper: h.wrapper, + }); + + // Page 0 first, so the model already carries the headers of groups 0 and 1 + // when the next page lands. Group 0 opens at row 0 and group 1 at row 60, + // so two headers sit above data index 100. + await waitFor(() => expect(result.current.rowAt(99)).toBeDefined(), { timeout: 10_000 }); + await waitFor(() => expect(hasHeaderFor(result.current.model, 1)).toBe(true), { + timeout: 10_000, + }); + + h.updateCells.mockClear(); + + // Widen the window so page 1 (data 100-199) loads. + act(() => result.current.onVisibleRegionChanged(scrollTo(150))); + await waitFor(() => expect(result.current.rowAt(199)).toBeDefined(), { timeout: 10_000 }); + + const damaged = h.damaged(); + + // Data 100 draws at display 102 and data 199 at display 201: two headers + // above them, so the shift is 2 and an off-by-one cannot satisfy this. + expect(damaged).toContain(102); + expect(damaged).toContain(201); + + // The raw data indexes must NOT be damaged. 100 and 101 are display rows of + // OTHER data rows (98 and 99), which this load did not change. + expect(damaged).not.toContain(100); + expect(damaged).not.toContain(101); + }, 30_000); + + it("damages the DISPLAY row of a saved cell in a grouped grid", async () => { + const h = makeHarness(1_000, { grouped: true }); + const { result } = renderHook(() => useComposedGrid(h.instance, h.gridRef), { + wrapper: h.wrapper, + }); + + await waitFor(() => expect(result.current.rowAt(65)).toBeDefined(), { timeout: 10_000 }); + await waitFor(() => expect(hasHeaderFor(result.current.model, 1)).toBe(true), { + timeout: 10_000, + }); + + // Display row 67 holds data row 65: group 0's header, its 60 rows, then + // group 1's header, then group 1's rows. The two differ by 2. + expect(displayToData(result.current.model, 67)).toEqual({ kind: "data", dataIndex: 65 }); + + h.updateCells.mockClear(); + + act(() => result.current.onCellEdited([0, 67], typedCell)); + + await waitFor(() => expect(h.updateRow).toHaveBeenCalled()); + await waitFor(() => expect(h.damaged().length).toBeGreaterThan(0), { timeout: 10_000 }); + + // `store.indexOfKey` answers 65. The damage must be display row 67, and no + // other row may be damaged at all. + expect(new Set(h.damaged())).toEqual(new Set([67])); + expect(cellText(result.current.getCellContent([0, 67]))).toBe("typed"); + }, 30_000); + + it("damages every visible column of a loaded page, not column 0 alone", async () => { + // Glide redraws exactly the cells `updateCells` names: it builds a + // `CellSet` of the `[col, row]` pairs, and the cell renderer skips every + // cell outside it. A data cell carries no span, so column 0 does not pull + // its row with it. + // + // This grid is FLAT, which is the case that made the defect visible. One + // memoised model keeps `getCellContent` identical across a page load, so + // glide blits instead of redrawing and this damage call is the only repaint + // there is. Name column 0 alone and a landed page shows one filled column + // beside a row of loading skeletons. + const h = makeHarness(1_000); + const { result } = renderHook(() => useComposedGrid(h.instance, h.gridRef), { + wrapper: h.wrapper, + }); + + await waitFor(() => expect(result.current.rowAt(99)).toBeDefined(), { timeout: 10_000 }); + + h.updateCells.mockClear(); + + // Widen the window so page 1 (data 100-199) loads. The grid is flat, so a + // display row IS a data index. + act(() => result.current.onVisibleRegionChanged(scrollTo(150))); + await waitFor(() => expect(result.current.rowAt(199)).toBeDefined(), { timeout: 10_000 }); + + expect(h.columnsDamagedFor(100)).toEqual([0, 1]); + expect(h.columnsDamagedFor(150)).toEqual([0, 1]); + expect(h.columnsDamagedFor(199)).toEqual([0, 1]); + + // No column outside the visible set. A damage entry for a column the grid + // does not draw is work glide does for nothing. + for (const [column] of h.damagedCells()) { + expect(column).toBeLessThan(VISIBLE_FIELDS.length); + } + }, 30_000); + + it("damages every visible column of a settled save, not column 0 alone", async () => { + // Glide's edit flash lasts 500 ms and hides the tail of a fast save. A + // slower one leaves the grey pending tint on every column this call does + // not name. + const h = makeHarness(1_000); + const { result } = renderHook(() => useComposedGrid(h.instance, h.gridRef), { + wrapper: h.wrapper, + }); + + await waitFor(() => expect(result.current.rowAt(5)).toBeDefined(), { timeout: 10_000 }); + + h.updateCells.mockClear(); + + act(() => result.current.onCellEdited([0, 5], typedCell)); + + await waitFor(() => expect(h.updateRow).toHaveBeenCalled()); + await waitFor(() => expect(h.damaged().length).toBeGreaterThan(0), { timeout: 10_000 }); + + expect(h.columnsDamagedFor(5)).toEqual([0, 1]); + }, 30_000); + + it("keeps an edit started on the held view, and lands it on the view that adopts", async () => { + // The hold leaves the grid editable for the whole load, which the old + // blank-and-reload design did not. Two things then have to hold. The + // overlay is keyed by ROW KEY, so the typed value survives the change of + // index space; and the settle path has to patch the store now on screen, + // not the one `saveCell` started under. Miss either and the user watches + // the edit vanish with no error. + const h = makeHarness(1_000); + + let landSave: () => void = () => {}; + const saveGate = new Promise((resolve) => { + landSave = resolve; + }); + + h.updateRow.mockImplementation(async ({ id, changes }) => { + await saveGate; + + return { ok: true, row: { id, name: String(changes.name), g: Math.floor(id / GROUP_SIZE) } }; + }); + + const { result } = renderHook(() => useComposedGrid(h.instance, h.gridRef), { + wrapper: h.wrapper, + }); + + await waitFor(() => expect(result.current.rowAt(5)).toBeDefined(), { timeout: 10_000 }); + + const heldStore = result.current.store; + + act(() => result.current.onCellEdited([0, 5], typedCell)); + expect(cellText(result.current.getCellContent([0, 5]))).toBe("typed"); + + // Sorting by group is stable over these rows and the comparator breaks ties + // on the row key, so data index 5 still holds id 5 afterwards. The STORE is + // replaced all the same, which is the whole point of the case. + act(() => void h.store.dispatch(h.instance.actions.setSort({ field: "g", dir: "asc" }))); + + await waitFor(() => expect(result.current.isStale).toBe(false), { timeout: 10_000 }); + expect(result.current.store).not.toBe(heldStore); + + // The overlay crossed the swap, so the typed value is still on screen while + // the save is still in flight. + expect(cellText(result.current.getCellContent([0, 5]))).toBe("typed"); + + landSave(); + + // The adopted store took the server's row. The discarded one is not what + // the grid draws, so a patch that landed there would leave "row-5" here. + await waitFor(() => expect(result.current.store.getRow(5)?.name).toBe("typed"), { + timeout: 10_000, + }); + + expect(cellText(result.current.getCellContent([0, 5]))).toBe("typed"); + expect(h.lastError()).toBeNull(); + }, 30_000); + + it("still rolls an edit back on the adopted view when the save is rejected", async () => { + // The mirror of the case above. Carrying the overlay across a swap must not + // turn a rejected save into a value that stays on screen. + const h = makeHarness(1_000); + + let landSave: () => void = () => {}; + const saveGate = new Promise((resolve) => { + landSave = resolve; + }); + + h.updateRow.mockImplementation(async () => { + await saveGate; + + return { ok: false }; + }); + + const { result } = renderHook(() => useComposedGrid(h.instance, h.gridRef), { + wrapper: h.wrapper, + }); + + await waitFor(() => expect(result.current.rowAt(5)).toBeDefined(), { timeout: 10_000 }); + + act(() => result.current.onCellEdited([0, 5], typedCell)); + + act(() => void h.store.dispatch(h.instance.actions.setSort({ field: "g", dir: "asc" }))); + + await waitFor(() => expect(result.current.isStale).toBe(false), { timeout: 10_000 }); + expect(cellText(result.current.getCellContent([0, 5]))).toBe("typed"); + + landSave(); + + await waitFor(() => expect(cellText(result.current.getCellContent([0, 5]))).toBe("row-5"), { + timeout: 10_000, + }); + + expect(h.lastError()?.message).toBe("Failed to save name"); + }, 30_000); + + it("keeps rows on screen while a pushed create reloads the window", async () => { + const h = makeHarness(1_000); + const { result } = renderHook(() => useComposedGrid(h.instance, h.gridRef), { + wrapper: h.wrapper, + }); + + await waitFor(() => expect(result.current.rowAt(0)).toBeDefined(), { timeout: 10_000 }); + expect(cellText(result.current.getCellContent([0, 5]))).toBe("row-5"); + + act(() => { + h.server.setRows([{ id: -1, name: "inserted", g: 0 }, ...h.server.rows]); + h.server.push({ kind: "create", id: -1 }); + }); + + // Sampled across the whole buffer and reload. A create shifts every + // position, so the rows MUST be refetched — and they must never blank + // while that happens. + for (let i = 0; i < 20; i += 1) { + expect(result.current.getCellContent([0, 5]).kind).toBe(GridCellKind.Text); + await act(() => new Promise((r) => setTimeout(r, 15))); + } + + await waitFor(() => expect(cellText(result.current.getCellContent([0, 0]))).toBe("inserted"), { + timeout: 10_000, + }); + }, 30_000); + + it("patches a pushed update in place, without reloading the window", async () => { + const h = makeHarness(1_000); + const { result } = renderHook(() => useComposedGrid(h.instance, h.gridRef), { + wrapper: h.wrapper, + }); + + await waitFor(() => expect(result.current.rowAt(0)).toBeDefined(), { timeout: 10_000 }); + + const before = h.requested().length; + + act(() => { + h.server.setRows(h.server.rows.map((r) => (r.id === 5 ? { ...r, name: "pushed" } : r))); + h.server.push({ kind: "update", id: 5 }); + }); + + await waitFor(() => expect(cellText(result.current.getCellContent([0, 5]))).toBe("pushed"), { + timeout: 10_000, + }); + + // An update moves no position, so it costs no page request. + expect(h.requested()).toHaveLength(before); + }, 30_000); + + it("repaints the display row of a pushed update, not only the store", async () => { + // A pure update raises no generation bump and no query invalidation, so + // nothing re-renders and nothing else damages. Reading the cell through + // `getCellContent` proves the STORE; only the damage call proves the + // SCREEN, and the row keeps its old value on screen without it. + // + // Grouped, so the store's index and the display row are different numbers + // and a repaint that skipped the translation could not pass. + const h = makeHarness(1_000, { grouped: true }); + const { result } = renderHook(() => useComposedGrid(h.instance, h.gridRef), { + wrapper: h.wrapper, + }); + + await waitFor(() => expect(result.current.rowAt(65)).toBeDefined(), { timeout: 10_000 }); + await waitFor(() => expect(hasHeaderFor(result.current.model, 1)).toBe(true), { + timeout: 10_000, + }); + + // Group 0's header, its 60 rows, then group 1's header: data 65 draws at + // display 67. + expect(displayToData(result.current.model, 67)).toEqual({ kind: "data", dataIndex: 65 }); + + h.updateCells.mockClear(); + + act(() => { + h.server.setRows(h.server.rows.map((r) => (r.id === 65 ? { ...r, name: "pushed" } : r))); + h.server.push({ kind: "update", id: 65 }); + }); + + await waitFor(() => expect(cellText(result.current.getCellContent([0, 67]))).toBe("pushed"), { + timeout: 10_000, + }); + + // `store.indexOfKey` answers 65. The damage must be display row 67, and it + // must cover the whole row. + expect(new Set(h.damaged())).toEqual(new Set([67])); + expect(h.columnsDamagedFor(67)).toEqual([0, 1]); + }, 30_000); + + it("lands a pushed update on the view that adopts, when a create opened the hold", async () => { + // A create opens a hold of the push feed's own making, and an update + // arriving before that hold adopts used to be written to the displayed + // store alone — the one store adoption throws away. Nothing heals it + // afterwards: `useRowPages` fetches with no observer, so a page that + // already reads "loaded" is never fetched again. + const h = makeHarness(1_000); + const { result } = renderHook(() => useComposedGrid(h.instance, h.gridRef), { + wrapper: h.wrapper, + }); + + // The model needs its row count before a scroll can name any data row. + await waitFor(() => expect(result.current.total).toBe(1_000), { timeout: 10_000 }); + + // A three-page window, so the hold can be held half open below. + act(() => result.current.onVisibleRegionChanged(scrollTo(150))); + await waitFor(() => expect(result.current.rowAt(250)).toBeDefined(), { timeout: 10_000 }); + expect(cellText(result.current.getCellContent([0, 5]))).toBe("row-5"); + + h.holdPage(0); + h.holdPage(100); + h.holdPage(200); + + // A create at the END moves no index this case reads, and it still sets + // `reload`, so the hold opens without disturbing the arithmetic. + act(() => { + h.server.setRows([...h.server.rows, { id: 1_000, name: "appended", g: 16 }]); + h.server.push({ kind: "create", id: 1_000 }); + }); + + await flushPush(); + expect(result.current.isStale).toBe(true); + + // Page 0 of the PENDING store lands with the OLD value of row 5, and pages + // 1 and 2 keep the hold open. That gap is where the defect lived. + h.releasePage(0); + await settleTick(); + expect(result.current.isStale).toBe(true); + + act(() => { + h.server.setRows(h.server.rows.map((r) => (r.id === 5 ? { ...r, name: "pushed" } : r))); + h.server.push({ kind: "update", id: 5 }); + }); + + await flushPush(); + + h.releasePage(100); + h.releasePage(200); + + await waitFor(() => expect(result.current.isStale).toBe(false), { timeout: 10_000 }); + + // The adopted view is the pending store. Patching the displayed store alone + // leaves "row-5" here. + expect(cellText(result.current.getCellContent([0, 5]))).toBe("pushed"); + }, 30_000); + + it("lands a pushed update the pending store alone has loaded, during a hold", async () => { + // The window moves while the hold is open, so the pending store fetches a + // page the displayed store never held. Asking the displayed store alone + // answers "nobody has row 400" and the update is dropped with no fetch. + const h = makeHarness(1_000); + const { result } = renderHook(() => useComposedGrid(h.instance, h.gridRef), { + wrapper: h.wrapper, + }); + + await waitFor(() => expect(result.current.rowAt(0)).toBeDefined(), { timeout: 10_000 }); + + // Every page the hold will want, gated up front except page 4. + h.holdPage(0); + h.holdPage(500); + h.holdPage(600); + + act(() => { + h.server.setRows([...h.server.rows, { id: 1_000, name: "appended", g: 16 }]); + h.server.push({ kind: "create", id: 1_000 }); + }); + + await flushPush(); + expect(result.current.isStale).toBe(true); + + // The window moves to rows 400-699 while the hold is open, so only the + // PENDING store ever asks for them. + act(() => result.current.onVisibleRegionChanged(scrollTo(500))); + await waitFor(() => expect(h.requested().some((r) => r.offset === 400)).toBe(true), { + timeout: 10_000, + }); + + h.releasePage(0); + await settleTick(); + expect(result.current.isStale).toBe(true); + + act(() => { + h.server.setRows(h.server.rows.map((r) => (r.id === 400 ? { ...r, name: "pushed" } : r))); + h.server.push({ kind: "update", id: 400 }); + }); + + await flushPush(); + + h.releasePage(500); + h.releasePage(600); + + await waitFor(() => expect(result.current.isStale).toBe(false), { timeout: 10_000 }); + + expect(cellText(result.current.getCellContent([0, 400]))).toBe("pushed"); + }, 30_000); + + it("applies a pushed update with no hold open, and opens no hold to do it", async () => { + // The mirror of the two above. An update moves no position, so it must + // never take the create's path: bumping the generation for one cell would + // throw away every loaded page and refetch the whole window. + const h = makeHarness(1_000); + const { result } = renderHook(() => useComposedGrid(h.instance, h.gridRef), { + wrapper: h.wrapper, + }); + + await waitFor(() => expect(result.current.rowAt(5)).toBeDefined(), { timeout: 10_000 }); + + const before = h.requested().length; + + act(() => { + h.server.setRows(h.server.rows.map((r) => (r.id === 5 ? { ...r, name: "pushed" } : r))); + h.server.push({ kind: "update", id: 5 }); + }); + + // Sampled across the whole buffer and the flush. No hold may open at any + // point, and the rows must never blank. + for (let i = 0; i < 20; i += 1) { + expect(result.current.isStale).toBe(false); + expect(result.current.getCellContent([0, 5]).kind).toBe(GridCellKind.Text); + await act(() => new Promise((r) => setTimeout(r, 15))); + } + + expect(cellText(result.current.getCellContent([0, 5]))).toBe("pushed"); + expect(h.requested()).toHaveLength(before); + }, 30_000); +}); diff --git a/src/web/src/features/dataGrid/gridStatusWiring.test.tsx b/src/web/src/features/dataGrid/gridStatusWiring.test.tsx new file mode 100644 index 0000000..02b9ba0 --- /dev/null +++ b/src/web/src/features/dataGrid/gridStatusWiring.test.tsx @@ -0,0 +1,294 @@ +import { GridCellKind, type EditableGridCell } from "@glideapps/glide-data-grid"; +import { configureStore } from "@reduxjs/toolkit"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { Provider, useSelector } from "react-redux"; +import { describe, expect, it, vi } from "vitest"; +import { buildFlatModel } from "./displayModel"; +import { GridStatusBar } from "./GridStatusBar"; +import { useCellRenderer } from "./hooks/useCellRenderer"; +import { useGridData } from "./hooks/useGridData"; +import { createGridInstance } from "./store/createGridInstance"; +import { createFakeRowServer } from "./testing/fakeRowServer"; +import type { GridDescriptor, GridInstance } from "./types"; +import { useGridDispatch } from "./useGridDispatch"; + +interface Row { + id: number; +} + +const WINDOW = { offset: 0, limit: 100 }; + +const noRepaint = () => {}; + +/** + * Renders exactly what `DataGrid` renders above the grid body: `GridStatusBar` + * wired to `useGridData`'s `status`/`retry` and `edits.lastError` off the store. + * A real `DataEditor` mount needs jsdom polyfills this project doesn't carry + * (ResizeObserver, canvas), so this proves the same wiring without it. + */ +function StatusBarUnderTest({ instance }: { instance: GridInstance }) { + const { status, total, retry } = useGridData(instance, WINDOW, null, [], noRepaint); + const lastError = useSelector((s: unknown) => instance.selectRoot(s).edits.lastError); + + return ( + + ); +} + +function makeHarness(fetchRows: ReturnType) { + const descriptor = { + name: "demo", + rowKey: (r: Row) => r.id, + pageSize: 100, + columns: { defs: {}, defaultOrder: [] }, + api: { + fetchRows, + fetchCount: async () => 0, + fetchRow: async () => null, + updateRow: async () => ({ ok: false }), + subscribe: () => () => {}, + }, + cells: { makeCell: () => ({}) as never, customRenderers: [], validateCell: () => true }, + } as unknown as GridDescriptor; + + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const instance = createGridInstance(descriptor); + const store = configureStore({ + reducer: { demo: instance.reducer }, + middleware: (getDefault) => getDefault({ serializableCheck: false }), + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + return { instance, wrapper }; +} + +describe("DataGrid status wiring", () => { + it("shows the alert and a retry when the initial row load fails, instead of rendering as empty", async () => { + const server = createFakeRowServer({ + rows: [{ id: 1 }], + rowKey: (r) => r.id, + }); + const fetchRows = vi.fn().mockRejectedValue(new Error("boom")); + const { instance, wrapper } = makeHarness(fetchRows); + + const { container } = render(, { wrapper }); + + await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/could not load/i)); + // A failed load is no longer indistinguishable from an empty, settled grid. + expect(container).not.toBeEmptyDOMElement(); + + fetchRows.mockImplementation((p: Parameters[0]) => + server.api.fetchRows(p), + ); + await userEvent.click(screen.getByRole("button", { name: /retry/i })); + + await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument()); + }); +}); + +/** + * The edit banner end to end: a real row store and edit overlay whose + * `updateRow` decides the outcome, `useCellRenderer` turning that outcome into + * `edits.lastError`, and `GridStatusBar` turning that into the banner the user + * actually sees. + * + * It asserts on the banner rather than on `lastError`, because "the banner + * goes" is the claim; a cleared field that still painted would satisfy the + * weaker assertion. + */ +function EditBannerUnderTest({ instance }: { instance: GridInstance }) { + const dispatch = useGridDispatch(); + + const { rowAt, isPending, total, status, overlay, store } = useGridData( + instance, + WINDOW, + null, + [], + noRepaint, + ); + + const lastError = useSelector((s: unknown) => instance.selectRoot(s).edits.lastError); + + const { onCellEdited } = useCellRenderer(instance, { + model: buildFlatModel(total), + visibleFields: ["name"], + columnCount: 1, + rowAt, + isPending, + overlay, + store, + repaint: noRepaint, + }); + + return ( + <> + {}} + onDismissError={() => dispatch(instance.actions.editErrorCleared())} + /> + + + + ); +} + +interface EditRow { + id: number; + name: string; +} + +const typedCell: EditableGridCell = { + kind: GridCellKind.Text, + data: "typed", + displayData: "typed", + allowOverlay: true, +}; + +function makeEditHarness() { + // Two rows, so a save on one can be answered while the other's failure is + // still on screen. Display row 0 is A (id 1), display row 1 is B (id 2). + const rows: EditRow[] = [ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + ]; + + // Keyed by row id, so a test can fail one row's save and accept the other's. + const outcomes = new Map([ + [1, false], + [2, false], + ]); + + // An accepted save really writes, so a test can wait on the persisted value + // rather than on a timer. + const updateRow = vi.fn(async ({ id, changes }: { id: number; changes: Partial }) => { + if (outcomes.get(id) !== true) { + return { ok: false }; + } + + const i = rows.findIndex((r) => r.id === id); + rows[i] = { ...rows[i], ...changes }; + + return { ok: true, row: rows[i] }; + }); + + const descriptor = { + name: "demo", + rowKey: (r: EditRow) => r.id, + pageSize: 100, + columns: { + defs: { + name: { field: "name", title: "Name", defaultWidth: 80, editable: true, type: "text" }, + }, + defaultOrder: ["name"], + }, + api: { + fetchRows: async () => rows, + fetchCount: async () => rows.length, + fetchRow: async () => null, + updateRow, + }, + cells: { + makeCell: () => ({}) as never, + customRenderers: [], + validateCell: () => true, + }, + } as unknown as GridDescriptor; + + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const instance = createGridInstance(descriptor); + const store = configureStore({ + reducer: { demo: instance.reducer }, + middleware: (getDefault) => getDefault({ serializableCheck: false }), + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + const accept = (id: number) => outcomes.set(id, true); + + return { instance, accept, wrapper }; +} + +/** Both rows must reach the store, or an edit reads a row that is not there yet + * and a test would prove the wrong failure path. */ +const bothRowsLoaded = (instance: GridInstance) => + waitFor(() => { + expect(instance.storeRef.current?.indexOfKey(1)).toBe(0); + expect(instance.storeRef.current?.indexOfKey(2)).toBe(1); + }); + +describe("DataGrid edit-error wiring", () => { + it("takes the banner down once the SAME cell saves successfully", async () => { + const { instance, accept, wrapper } = makeEditHarness(); + render(, { wrapper }); + await bothRowsLoaded(instance); + + await userEvent.click(screen.getByRole("button", { name: /save A/i })); + await waitFor(() => + expect(screen.getByRole("alert")).toHaveTextContent(/failed to save name/i), + ); + + accept(1); + await userEvent.click(screen.getByRole("button", { name: /save A/i })); + + await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument()); + }); + + it("keeps the banner when a DIFFERENT cell saves successfully", async () => { + const { instance, accept, wrapper } = makeEditHarness(); + render(, { wrapper }); + await bothRowsLoaded(instance); + + await userEvent.click(screen.getByRole("button", { name: /save A/i })); + await waitFor(() => + expect(screen.getByRole("alert")).toHaveTextContent(/failed to save name/i), + ); + + // B lands. That says nothing about A, whose save did not — and the user has + // been told nothing else about A. An unconditional clear here would take + // the only notice of A's failure off the screen. + accept(2); + await userEvent.click(screen.getByRole("button", { name: /save B/i })); + await waitFor(() => expect(instance.storeRef.current?.getRow(1)?.name).toBe("typed")); + + expect(screen.getByRole("alert")).toHaveTextContent(/failed to save name/i); + }); + + it("lets a user dismiss the banner without editing again", async () => { + const { instance, wrapper } = makeEditHarness(); + render(, { wrapper }); + await bothRowsLoaded(instance); + + await userEvent.click(screen.getByRole("button", { name: /save A/i })); + await waitFor(() => expect(screen.getByRole("alert")).toBeInTheDocument()); + + // Dismiss is unconditional on purpose: clearing on success alone strands a + // user who stops editing, and "I am done with this message" holds whatever + // cell it came from. + await userEvent.click(screen.getByRole("button", { name: /dismiss/i })); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); +}); diff --git a/src/web/src/features/dataGrid/headerIcons.ts b/src/web/src/features/dataGrid/headerIcons.ts new file mode 100644 index 0000000..43b0092 --- /dev/null +++ b/src/web/src/features/dataGrid/headerIcons.ts @@ -0,0 +1,40 @@ +// src/features/dataGrid/headerIcons.ts +import type { SpriteMap } from "@glideapps/glide-data-grid"; + +/** + * Sort chevrons for the column headers. + * + * Glide has no sort state of its own — it hands over `onHeaderClicked` and + * nothing else — but it does own the header's rendering, so the indicator has + * to go in through the slot it provides: a named sprite here, referenced by + * `indicatorIcon` on the column. Drawing over the canvas ourselves would fight + * the grid's own repaints. + * + * Sprites are SVG strings on the 20×20 box the built-in header icons use, and + * are handed the resolved theme colours at paint time, so the chevron inherits + * the header's foreground and stays right in light and dark without knowing + * which is active. + */ + +export const SORT_ASC_ICON = "sortAsc"; +export const SORT_DESC_ICON = "sortDesc"; + +/** + * A bare chevron: no box, no fill, 1.75px stroke with round joins. The header + * already says which column this is — the indicator only has to say which way, + * so it reads as punctuation on the title rather than a badge beside it. + */ +function chevron(fgColor: string, direction: "up" | "down"): string { + const d = direction === "up" ? "M6.5 11.75 10 8.25l3.5 3.5" : "M6.5 8.25 10 11.75l3.5-3.5"; + + return ` + + `; +} + +/** Pass to `` to make the names above resolvable. */ +export const sortHeaderIcons: SpriteMap = { + // Ascending points up: the arrow follows the values, smallest at the top. + [SORT_ASC_ICON]: (props) => chevron(props.fgColor, "up"), + [SORT_DESC_ICON]: (props) => chevron(props.fgColor, "down"), +}; diff --git a/src/web/src/features/dataGrid/hooks/useCellRenderer.test.tsx b/src/web/src/features/dataGrid/hooks/useCellRenderer.test.tsx new file mode 100644 index 0000000..b481234 --- /dev/null +++ b/src/web/src/features/dataGrid/hooks/useCellRenderer.test.tsx @@ -0,0 +1,354 @@ +// src/features/dataGrid/hooks/useCellRenderer.test.tsx +import { GridCellKind, type EditableGridCell, type GridCell } from "@glideapps/glide-data-grid"; +import { combineReducers, configureStore } from "@reduxjs/toolkit"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { Provider } from "react-redux"; +import { describe, expect, it, vi } from "vitest"; +import { createEditOverlay } from "../data/editOverlay"; +import { createRowStore, MAX_LOADED_PAGES } from "../data/rowStore"; +import { buildFlatModel } from "../displayModel"; +import { createEditsSlice } from "../store/editsSlice"; +import type { GridInstance } from "../types"; +import { useCellRenderer } from "./useCellRenderer"; + +interface Row { + id: number; + name: string; +} + +const ROW_A: Row = { id: 1, name: "a" }; +const ROW_B: Row = { id: 2, name: "b" }; + +const typed: EditableGridCell = { + kind: GridCellKind.Text, + data: "typed", + displayData: "typed", + allowOverlay: true, +}; + +interface HarnessOptions { + /** Rejects every save, the way the api does when it answers `{ ok: false }`. */ + rejectSave?: boolean; + + /** A theme the cell type itself supplies, independent of the pending grey. */ + cellTheme?: Record; +} + +function makeHarness({ rejectSave = false, cellTheme }: HarnessOptions = {}) { + // Which keys reject their save. Per key and mutable, so one harness can fail + // one cell's save and accept another's — the sequence the cell identity on + // `lastError` exists for. + const rejecting = new Set(rejectSave ? [ROW_A.id, ROW_B.id] : []); + + // A gate the test opens, so a save can be held in flight while the pending + // tint is read off the cell. + let openSaves: () => void = () => {}; + let gate: Promise = Promise.resolve(); + + const rowsByKey = (id: number): Row => (id === ROW_A.id ? ROW_A : ROW_B); + + const updateRow = vi.fn(async ({ id, changes }: { id: number; changes: Partial }) => { + await gate; + + if (rejecting.has(id)) { + return { ok: false }; + } + + return { ok: true, row: { ...rowsByKey(id), ...changes } }; + }); + + const rowStore = createRowStore(100, (r) => r.id); + rowStore.writePage(0, [ROW_A, ROW_B]); + + const overlay = createEditOverlay(); + + // Stands in for `DataGrid`'s `updateCells` call. A settled save changes no + // state React or glide watches, so this callback is the ONLY thing that + // redraws the cell. + const repaint = vi.fn(); + + const edits = createEditsSlice("demo"); + const store = configureStore({ + reducer: { demo: combineReducers({ edits: edits.reducer }) }, + middleware: (getDefault) => getDefault({ serializableCheck: false }), + }); + const lastError = () => store.getState().demo.edits.lastError; + + const instance = { + descriptor: { + rowKey: (r: Row) => r.id, + columns: { + defs: { + name: { field: "name", title: "Name", defaultWidth: 80, editable: true, type: "text" }, + }, + }, + api: { updateRow }, + cells: { + makeCell: (_type: string, raw: unknown): GridCell => ({ + kind: GridCellKind.Text, + data: String(raw), + displayData: String(raw), + allowOverlay: true, + ...(cellTheme ? { themeOverride: cellTheme } : {}), + }), + }, + }, + actions: edits.actions, + } as unknown as GridInstance; + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + const { result } = renderHook( + () => + useCellRenderer(instance, { + model: buildFlatModel(2), + visibleFields: ["name"], + columnCount: 1, + rowAt: (dataIndex: number) => { + const row = rowStore.getRow(dataIndex); + + if (row === undefined) { + return undefined; + } + + return overlay.apply(row.id, row); + }, + isPending: (key: number) => overlay.isPending(key), + overlay, + store: rowStore, + repaint, + }), + { wrapper }, + ); + + const acceptSave = (key: number) => rejecting.delete(key); + + const holdSaves = () => { + gate = new Promise((resolve) => { + openSaves = resolve; + }); + }; + + const releaseSaves = () => openSaves(); + + /** + * Pushes page 0 out of the store under the real eviction cap, the way a scroll + * to a far window does. It writes one row per page so the arrange step stays + * cheap; only the page COUNT decides what is evicted. + */ + const evictFirstPage = () => { + for (let page = 1; page <= MAX_LOADED_PAGES; page += 1) { + rowStore.writePage(page, [{ id: 1_000 + page, name: `far-${page}` }]); + } + }; + + return { + instance, + updateRow, + result, + lastError, + acceptSave, + holdSaves, + releaseSaves, + evictFirstPage, + repaint, + rowStore, + overlay, + }; +} + +describe("useCellRenderer", () => { + it("renders a row whose edit is still in flight differently from a settled one", () => { + const { result, overlay } = makeHarness(); + + // The overlay is the only signal that an edit has not landed yet, and it is + // silent when it breaks. So assert both sides: the settled cell must carry + // NO override, the pending one must. + overlay.begin(ROW_B.id, "name", "typed"); + + const settled = result.current.getCellContent([0, 0]); + const pending = result.current.getCellContent([0, 1]); + + expect(settled.themeOverride).toBeUndefined(); + expect(pending.themeOverride).toEqual({ textDark: "#8a8a8a" }); + expect(pending).not.toEqual(settled); + }); + + it("keeps the cell's own theme and its edit flash while the save is pending", () => { + // The pending grey is one key on `themeOverride`, not the whole object: a + // cell type that themes itself (a status colour, say) must keep that theme + // on the one cell being saved, and `lastUpdated` — the edit flash — must + // survive the same round trip. + const h = makeHarness({ cellTheme: { bgCell: "#ffeeee" } }); + + h.holdSaves(); + h.result.current.onCellEdited([0, 1], typed); + + const cell = h.result.current.getCellContent([0, 1]); + + expect(cell.themeOverride).toEqual({ bgCell: "#ffeeee", textDark: "#8a8a8a" }); + expect(cell.lastUpdated).toBeDefined(); + + h.releaseSaves(); + }); + + it("shows the typed value before the server answers, then writes it into the store", async () => { + const { result, rowStore, updateRow } = makeHarness(); + + result.current.onCellEdited([0, 1], typed); + + // Optimistic: the overlay answers straight away, and the store still holds + // server truth. + expect(result.current.getCellContent([0, 1])).toMatchObject({ displayData: "typed" }); + expect(rowStore.getRow(1)?.name).toBe("b"); + + expect(updateRow).toHaveBeenCalledTimes(1); + expect(updateRow.mock.calls[0][0]).toEqual({ id: ROW_B.id, changes: { name: "typed" } }); + + // Settled: the server's row lands in the store and the overlay lets go. + await waitFor(() => expect(rowStore.getRow(1)?.name).toBe("typed")); + expect(result.current.getCellContent([0, 1])).toMatchObject({ displayData: "typed" }); + }); + + it("reports a rejected save as an error instead of losing it", async () => { + const { result, lastError } = makeHarness({ rejectSave: true }); + expect(lastError()).toBeNull(); + + result.current.onCellEdited([0, 1], typed); + + // The `cell` is the ROW KEY and the field, not the display row or the data + // index: display row 1 holds row id 2. + await waitFor(() => + expect(lastError()).toEqual({ cell: "2:name", message: "Failed to save name" }), + ); + }); + + it("drops the optimistic value when the save is rejected", async () => { + const { result, rowStore } = makeHarness({ rejectSave: true }); + + result.current.onCellEdited([0, 1], typed); + + await waitFor(() => + expect(result.current.getCellContent([0, 1])).toMatchObject({ displayData: "b" }), + ); + + // The rollback is a delete from the overlay, never a restore into the + // store — the store never held the typed value at all. + expect(rowStore.getRow(1)?.name).toBe("b"); + }); + + it("reports a thrown save instead of throwing into glide", async () => { + const { result, lastError, updateRow } = makeHarness(); + + updateRow.mockImplementation(async () => { + throw new Error("network"); + }); + + expect(() => result.current.onCellEdited([0, 1], typed)).not.toThrow(); + + await waitFor(() => expect(lastError()?.message).toBe("Failed to save name")); + }); + + it("clears the last error when the SAME cell saves successfully", async () => { + const { result, lastError, acceptSave } = makeHarness({ rejectSave: true }); + + result.current.onCellEdited([0, 1], typed); + await waitFor(() => expect(lastError()?.cell).toBe("2:name")); + + acceptSave(ROW_B.id); + result.current.onCellEdited([0, 1], typed); + + // A save that landed is proof the earlier failure on THAT cell no longer + // describes the grid. Without a dispatch on success there is no other + // producer to say so, so the message would outlive its subject for the life + // of the page. + await waitFor(() => expect(lastError()).toBeNull()); + }); + + it("leaves one cell's error standing when a DIFFERENT cell saves successfully", async () => { + const { result, lastError, acceptSave } = makeHarness({ rejectSave: true }); + + // Cell B (display row 1, row id 2) fails. + result.current.onCellEdited([0, 1], typed); + await waitFor(() => expect(lastError()?.cell).toBe("2:name")); + + // Cell A (display row 0, row id 1) then succeeds. It says nothing about B, + // and the user has been told nothing else about B — so B's message must + // survive, or a failed save disappears unseen. + acceptSave(ROW_A.id); + result.current.onCellEdited([0, 0], typed); + await new Promise((r) => setTimeout(r, 0)); + + expect(lastError()).toEqual({ cell: "2:name", message: "Failed to save name" }); + }); + + it("records no error when the save succeeds", async () => { + const { result, lastError } = makeHarness(); + + result.current.onCellEdited([0, 1], typed); + await new Promise((r) => setTimeout(r, 0)); + + expect(lastError()).toBeNull(); + }); + + it("repaints the row when a save commits, because nothing else redraws it", async () => { + const { result, repaint, rowStore } = makeHarness(); + + result.current.onCellEdited([0, 1], typed); + + await waitFor(() => expect(rowStore.getRow(1)?.name).toBe("typed")); + + // Every input `getCellContent` reads is stable after mount, and glide + // re-runs it only when its own draw inputs move. Without this damage call + // the grey pending tint would sit on a cell that has already saved. + expect(repaint).toHaveBeenCalledTimes(1); + expect(repaint).toHaveBeenCalledWith([1]); + }); + + it("repaints the row when a save rolls back, so the rejected value leaves the screen", async () => { + const { result, repaint, lastError } = makeHarness({ rejectSave: true }); + + result.current.onCellEdited([0, 1], typed); + + await waitFor(() => expect(lastError()).not.toBeNull()); + + // Without this the banner says the save failed while the cell still shows + // the value the server rejected. + expect(repaint).toHaveBeenCalledTimes(1); + expect(repaint).toHaveBeenCalledWith([1]); + }); + + it("settles without error when the row's page left the store mid-save", async () => { + // A window move, a sort change, or a pushed create can drop the row's page + // while its save is in flight. The save still lands on the server, and the + // grid must neither raise an error nor try to damage a cell that is not + // there. + const h = makeHarness(); + + h.holdSaves(); + h.result.current.onCellEdited([0, 1], typed); + + h.evictFirstPage(); + expect(h.rowStore.indexOfKey(ROW_B.id)).toBeUndefined(); + + h.releaseSaves(); + + await waitFor(() => expect(h.overlay.isPending(ROW_B.id)).toBe(false)); + + expect(h.lastError()).toBeNull(); + expect(h.repaint).not.toHaveBeenCalled(); + }); + + it("ignores an edit to a read-only column", () => { + // A fresh harness per test, so flipping this def affects nothing else. + const { instance, updateRow, result } = makeHarness(); + instance.descriptor.columns.defs.name.editable = false; + + result.current.onCellEdited([0, 1], typed); + + expect(updateRow).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web/src/features/dataGrid/hooks/useCellRenderer.ts b/src/web/src/features/dataGrid/hooks/useCellRenderer.ts new file mode 100644 index 0000000..712e569 --- /dev/null +++ b/src/web/src/features/dataGrid/hooks/useCellRenderer.ts @@ -0,0 +1,328 @@ +// src/features/dataGrid/hooks/useCellRenderer.ts +import { + GridCellKind, + type EditableGridCell, + type GridCell, + type Item, +} from "@glideapps/glide-data-grid"; +import { useCallback, useRef } from "react"; +import { useEditHighlight } from "../../../lib/grid/useEditHighlight"; +import type { EditOverlay } from "../data/editOverlay"; +import type { RowStore } from "../data/rowStore"; +import { displayToData, type DisplayModel } from "../displayModel"; +import type { GridDescriptor, GridInstance } from "../types"; +import { useGridDispatch } from "../useGridDispatch"; + +const GROUP_HEADER_THEME = { bgCell: "#eef2f7", textDark: "#1d6fb8" }; +const PENDING_TEXT = "#8a8a8a"; + +interface Args { + model: DisplayModel; + visibleFields: string[]; + columnCount: number; + + /** The row at a data index, with any in-flight edit already laid over it. */ + rowAt: (dataIndex: number) => TRow | undefined; + + /** Whether a row has a save in flight. Drives the grey tint. */ + isPending: (key: TKey) => boolean; + + /** Where an optimistic value lives until the server answers. */ + overlay: EditOverlay; + + /** + * Server truth. A committed save writes the server's row here. + * + * It changes identity whenever `useGridData` adopts a new sort or collapse + * state, so a save in flight must re-read it rather than keep the one its own + * render saw. + */ + store: RowStore; + + /** + * Repaints the given data-row indexes through glide's damage API. It is the + * SAME callback `useGridData` takes as `onRowsLoaded` and `useRowSync` takes + * for a pushed update. + * + * A settled save changes no React state that `getCellContent` reads, and + * glide re-runs `getCellContent` only when its own draw inputs move. So + * without this call the rejected value stays on screen after a rollback and + * the grey pending tint stays after a commit — hidden only by glide's own + * 500 ms edit flash, and visible on any save slower than that. + * + * `rowAt`, `store`, and `model` DO change identity, but only when a hold + * adopts, which is not something a save does. Between two adoptions a settled + * save reaches React through nothing at all. + */ + repaint: (indexes: number[]) => void; +} + +/** Reports how a save ended. Exactly one of the two runs. */ +interface SaveOutcome { + succeeded: () => void; + failed: () => void; +} + +function extractEditedValue(newValue: EditableGridCell): unknown { + if (newValue.kind === GridCellKind.Custom) { + return (newValue.data as { value?: unknown }).value; + } + + return "data" in newValue ? (newValue as { data: unknown }).data : undefined; +} + +/** The full-width row that names a group and spans every column. */ +function groupHeaderCell(label: string, columnCount: number): GridCell { + return { + kind: GridCellKind.Text, + data: label, + displayData: `▾ ${label}`, + allowOverlay: false, + span: [0, Math.max(0, columnCount - 1)], + themeOverride: { ...GROUP_HEADER_THEME }, + }; +} + +/** + * Greys the text of a cell whose save has not landed yet. + * + * MERGE, never replace: `withHighlight` puts its own theme on the cell it just + * stamped, and that is the very cell being saved. A replaced override would + * delete the edit flash exactly where it matters most. + */ +function withPendingTint(cell: GridCell): GridCell { + return { + ...cell, + themeOverride: { ...cell.themeOverride, textDark: PENDING_TEXT }, + }; +} + +/** + * Writes one optimistic cell value and reports the outcome. + * + * The overlay holds the typed value and the store keeps server truth, so a + * rejection needs no saved copy: dropping the overlay entry restores the stored + * row, and it stays correct even when a reload replaced that row in between. + * + * A rejected `{ ok: false }` and a thrown request are the same outcome to the + * user, so both arms roll back and report. Nothing escapes into glide's event + * handler. + * + * Every exit repaints the row. Neither `commit` nor `rollback` changes anything + * React or glide watches, so the cell would otherwise keep painting the value + * the save started with. + */ +async function saveCell( + { + overlay, + currentStore, + updateRow, + repaint, + }: { + overlay: EditOverlay; + /** + * The store the grid is drawing RIGHT NOW, read at settle time. + * + * It is a function, not the store itself, because `useGridData` swaps the + * displayed store when a new sort or collapse state is adopted. A save that + * started before the swap would otherwise patch the discarded store, and the + * user would watch the edit disappear with no error to explain it. + */ + currentStore: () => RowStore; + updateRow: GridDescriptor["api"]["updateRow"]; + repaint: (indexes: number[]) => void; + }, + rowKey: TKey, + field: string, + value: unknown, + { succeeded, failed }: SaveOutcome, +): Promise { + /** + * Damages the saved row, if the store still holds it. + * + * `indexOfKey` answers undefined when the row's page was evicted while the + * save was in flight, or when the adopted view does not hold this row at all + * — a window move, a sort change, or a pushed create. There is then no cell on + * screen showing this row, so there is nothing to repaint and nothing has gone + * wrong: the window that comes back loads the row fresh. + */ + const repaintRow = () => { + const index = currentStore().indexOfKey(rowKey); + + if (index === undefined) { + return; + } + + repaint([index]); + }; + + overlay.begin(rowKey, field, value); + + try { + const result = await updateRow({ id: rowKey, changes: { [field]: value } as Partial }); + + if (!result.ok) { + overlay.rollback(rowKey, field); + repaintRow(); + failed(); + + return; + } + + // Read now, not when the save started. `patchRow` answers undefined when + // the store on screen does not hold this row, which writes nothing and is + // the right answer: no cell shows it. + const store = currentStore(); + + // The server's row wins when it sends one, because a derived column may + // have moved with the edit. Otherwise the typed value becomes truth. + if (result.row) { + store.patchRow(rowKey, result.row); + } else { + store.patchRow(rowKey, { [field]: value } as Partial); + } + + overlay.commit(rowKey, field); + repaintRow(); + succeeded(); + } catch { + overlay.rollback(rowKey, field); + repaintRow(); + failed(); + } +} + +export function useCellRenderer( + instance: GridInstance, + { + model, + visibleFields, + columnCount, + rowAt, + isPending, + overlay, + store, + repaint, + }: Args, +): { + getCellContent: (cell: Item) => GridCell; + onCellEdited: (cell: Item, newValue: EditableGridCell) => void; +} { + const dispatch = useGridDispatch(); + const { descriptor } = instance; + const defs = descriptor.columns.defs; + const { markEdited, withHighlight } = useEditHighlight(); + + // `useGridData` hands a DIFFERENT store over when it adopts a new sort or + // collapse state, and a save started before that swap settles after it. The + // ref is what lets the settle path find the store now on screen instead of + // the one this render captured. + const storeRef = useRef(store); + storeRef.current = store; + + const getCellContent = useCallback( + ([col, displayRow]: Item): GridCell => { + const cell = displayToData(model, displayRow); + + if (cell.kind === "header") { + const label = descriptor.grouping?.label(cell.group) ?? ""; + + return groupHeaderCell(label, columnCount); + } + + const row = rowAt(cell.dataIndex); + + // No fetch starts here. The loaded range follows the visible region + // (see useWindowRange); rendering stays a pure read. + if (row === undefined) { + return { kind: GridCellKind.Loading, allowOverlay: false }; + } + + const field = visibleFields[col]; + const def = defs[field]; + const raw = (row as unknown as Record)[field]; + + const built = descriptor.cells.makeCell(def.type, raw, { + editable: def.editable, + withTime: def.withTime ?? false, + }); + + const content = withHighlight(`${cell.dataIndex}:${field}`, built); + + return isPending(descriptor.rowKey(row)) ? withPendingTint(content) : content; + }, + [model, visibleFields, columnCount, rowAt, isPending, withHighlight, descriptor, defs], + ); + + const onCellEdited = useCallback( + ([col, displayRow]: Item, newValue: EditableGridCell) => { + const cell = displayToData(model, displayRow); + + if (cell.kind !== "data") { + return; + } + + const field = visibleFields[col]; + + if (!defs[field].editable) { + return; + } + + const value = extractEditedValue(newValue); + + markEdited(`${cell.dataIndex}:${field}`); + + const row = rowAt(cell.dataIndex); + + if (row === undefined) { + return; + } + + const rowKey = descriptor.rowKey(row); + + // The row KEY, not `cell.dataIndex`: a data index names a position, and a + // reload can put a different row there while this save is in flight, + // which would then let a success on one row clear another row's error. + const cellId = `${String(rowKey)}:${field}`; + + // Success retracts the message, but only its OWN. Both arms carry the + // same `cellId`, and the slice clears on a match, so a success on one + // cell cannot erase a failure the user has not seen answered on another. + // The overlay already carries "this cell is not saved yet", so nothing + // here re-states it. + void saveCell( + { + overlay, + currentStore: () => storeRef.current, + updateRow: descriptor.api.updateRow, + repaint, + }, + rowKey, + field, + value, + { + succeeded: () => dispatch(instance.actions.editSucceeded(cellId)), + failed: () => + dispatch( + instance.actions.editFail({ cell: cellId, message: `Failed to save ${field}` }), + ), + }, + ); + }, + [ + model, + visibleFields, + instance, + descriptor, + rowAt, + markEdited, + defs, + dispatch, + overlay, + storeRef, + repaint, + ], + ); + + return { getCellContent, onCellEdited }; +} diff --git a/src/web/src/features/dataGrid/hooks/useDisplayModel.test.tsx b/src/web/src/features/dataGrid/hooks/useDisplayModel.test.tsx new file mode 100644 index 0000000..fa50af5 --- /dev/null +++ b/src/web/src/features/dataGrid/hooks/useDisplayModel.test.tsx @@ -0,0 +1,176 @@ +// src/features/dataGrid/hooks/useDisplayModel.test.tsx +import { configureStore } from "@reduxjs/toolkit"; +import { act, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { Provider } from "react-redux"; +import { describe, expect, it } from "vitest"; +import { createGridInstance } from "../store/createGridInstance"; +import type { GridDescriptor } from "../types"; +import type { LoadedSpan } from "../data/spanFromStore"; +import { useDisplayModel } from "./useDisplayModel"; + +interface Row { + id: number; + g: number; +} + +/** Six rows, two groups: ids 0-2 in group 0, ids 3-5 in group 1. */ +const ALL: Row[] = Array.from({ length: 6 }, (_, i) => ({ id: i, g: Math.floor(i / 3) })); +const TOTAL = ALL.length; + +const spanOf = (rows: Row[]): LoadedSpan => ({ + rows, + offset: 0, + precedingGroupKey: null, +}); + +/** A span the grid loaded after scrolling: it starts below row 0. */ +const windowAt = ( + rows: Row[], + offset: number, + preceding: number | null, +): LoadedSpan => ({ + rows, + offset, + precedingGroupKey: preceding, +}); + +function makeHarness() { + const descriptor = { + name: "demo", + rowKey: (r: Row) => r.id, + grouping: { field: "g", of: (r: Row) => r.g, order: (g: number) => g, label: String }, + columns: { + defs: { id: { field: "id", title: "Id", defaultWidth: 80, editable: false, type: "number" } }, + defaultOrder: ["id"], + }, + api: { + fetchRows: async () => [], + fetchCount: async () => TOTAL, + fetchRow: async () => null, + updateRow: async () => ({ ok: true }), + }, + cells: { makeCell: () => ({}) as never, customRenderers: [], validateCell: () => true }, + } as unknown as GridDescriptor; + + const instance = createGridInstance(descriptor); + const store = configureStore({ + reducer: { demo: instance.reducer }, + middleware: (getDefault) => getDefault({ serializableCheck: false }), + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + return { instance, store, wrapper }; +} + +const groupsOf = (instance: ReturnType["instance"], state: unknown) => + instance.selectRoot(state).groups; + +describe("useDisplayModel", () => { + it("records the groups a loaded span carries", () => { + const { instance, store, wrapper } = makeHarness(); + + renderHook(() => useDisplayModel(instance, { total: TOTAL, span: spanOf(ALL) }), { wrapper }); + + expect(groupsOf(instance, store.getState()).discoveredGroups).toEqual([0, 1]); + }); + + it("keeps a collapsed group expandable after its rows leave the span", () => { + // The regression this guards: the server excludes a collapsed group's rows, + // so if nothing remembered the group, its header — the only way back — would + // vanish from the model and the collapse would be one-way. + const { instance, store, wrapper } = makeHarness(); + + const remaining = ALL.filter((r) => r.g === 0); + + const { result, rerender } = renderHook( + ({ span, total }) => useDisplayModel(instance, { total, span }), + { wrapper, initialProps: { span: spanOf(ALL), total: TOTAL } }, + ); + + act(() => void store.dispatch(instance.actions.toggleCollapse(1))); + + // The next load comes back without group 1's rows, exactly as the server + // sends it once the group is collapsed. + rerender({ span: spanOf(remaining), total: remaining.length }); + + const collapsedSegment = result.current.model.segments.find((s) => s.group === 1); + expect(collapsedSegment).toBeDefined(); + expect(collapsedSegment?.hasHeader).toBe(true); + expect(collapsedSegment?.collapsed).toBe(true); + + // And the header is clickable back into an expanded group. + act(() => result.current.onHeaderOrCellClicked([0, collapsedSegment!.displayStart])); + + expect(groupsOf(instance, store.getState()).collapsedGroups).not.toContain(1); + }); + + it("keeps its rows when the window scrolls into the middle of one group", () => { + // The regression this guards: a window inside one group holds no group + // change, so `detectBoundaries` reports nothing. The model then made no + // segment for those rows, `rowCount` fell to 0, and the grid emptied itself + // — scroll bar included. It could not recover, because a grid that draws no + // rows commits no viewport range and so never asks for more rows. + const { instance, wrapper } = makeHarness(); + + const { result, rerender } = renderHook( + ({ span }) => useDisplayModel(instance, { total: TOTAL, span }), + { wrapper, initialProps: { span: spanOf(ALL) } }, + ); + + // The window moves to rows 4-5, which sit wholly inside group 1. + rerender({ span: windowAt(ALL.slice(4), 4, 1) }); + + expect(result.current.model.rowCount).toBeGreaterThanOrEqual(TOTAL); + }); + + it("measures where the collapse starts before it dispatches", () => { + // `useGridData` reads this cell on the render the collapse lands in, and it + // keeps every page that ends above the index in it. Nobody else can measure + // it: only this hook holds the display model. + const { instance, wrapper } = makeHarness(); + + const { result } = renderHook( + () => useDisplayModel(instance, { total: TOTAL, span: spanOf(ALL) }), + { wrapper }, + ); + + const header = result.current.model.segments.find((s) => s.group === 1); + + act(() => result.current.onHeaderOrCellClicked([0, header!.displayStart])); + + // Group 1 opens at data row 3. Rows 0 to 2 keep their index either way. + expect(instance.carryFromRef.current).toBe(3); + }); + + it("leaves the boundary alone when the click lands on a data row", () => { + const { instance, wrapper } = makeHarness(); + + const { result } = renderHook( + () => useDisplayModel(instance, { total: TOTAL, span: spanOf(ALL) }), + { wrapper }, + ); + + act(() => result.current.onHeaderOrCellClicked([0, 1])); + + expect(instance.carryFromRef.current).toBeNull(); + }); + + it("keeps its rows while a moved window is still loading", () => { + // An empty span carries neither a boundary nor a preceding group. The rows + // must stay drawn anyway, or the same deadlock closes before the rows land. + const { instance, wrapper } = makeHarness(); + + const { result, rerender } = renderHook( + ({ span }) => useDisplayModel(instance, { total: TOTAL, span }), + { wrapper, initialProps: { span: spanOf(ALL) } }, + ); + + rerender({ span: windowAt([], 4, null) }); + + expect(result.current.model.rowCount).toBe(TOTAL); + }); +}); diff --git a/src/web/src/features/dataGrid/hooks/useDisplayModel.ts b/src/web/src/features/dataGrid/hooks/useDisplayModel.ts new file mode 100644 index 0000000..a4998e3 --- /dev/null +++ b/src/web/src/features/dataGrid/hooks/useDisplayModel.ts @@ -0,0 +1,143 @@ +// src/features/dataGrid/hooks/useDisplayModel.ts +import type { Item } from "@glideapps/glide-data-grid"; +import { useCallback, useEffect, useMemo } from "react"; +import { useSelector } from "react-redux"; +import { + buildDisplayModel, + buildFlatModel, + detectBoundaries, + displayToData, + firstAffectedDataIndex, + type Boundary, + type DisplayModel, +} from "../displayModel"; +import { useGridDispatch } from "../useGridDispatch"; +import type { GridGrouping, GridInstance } from "../types"; +import type { LoadedSpan } from "../data/spanFromStore"; + +interface Args { + total: number; + span: LoadedSpan; +} + +interface Result { + model: DisplayModel; + onHeaderOrCellClicked: (cellIndex: Item) => void; +} + +/** What the current span says about groups: where they start, and which ones it holds. */ +interface SpanGroups { + boundaries: Boundary[]; + groupsInSpan: TGroup[]; +} + +const NO_GROUPS: SpanGroups = { boundaries: [], groupsInSpan: [] }; + +/** + * Reads groups out of the loaded span. + * + * Both results are derived, not stored. The span is the one loaded run of rows, + * so no boundary is found twice; and a span that reloads takes its boundaries + * with it instead of leaving stale ones behind. + */ +function useSpanGroups( + span: LoadedSpan, + grouping: GridGrouping | undefined, +): SpanGroups { + return useMemo(() => { + if (!grouping) { + return NO_GROUPS; + } + + return { + boundaries: detectBoundaries(span.rows, span.precedingGroupKey, span.offset, grouping.of), + groupsInSpan: [...new Set(span.rows.map(grouping.of))], + }; + }, [grouping, span]); +} + +/** + * Remembers every group the grid has ever seen. + * + * The server excludes a collapsed group's rows, so the model can only keep + * drawing its header — the sole way to expand it again — from this monotonic + * memory. Without this dispatch a collapse is one-way. + * + * Discovery lives here because this is the hook that already turns rows into + * groups. `useGridData` sees the same rows first, but it takes a + * `GridDataSource` rather than a `GridInstance` and touches no Redux — giving + * it a dispatch would trade that boundary away for nothing. + */ +function useGroupDiscovery( + instance: GridInstance, + groupsInSpan: TGroup[], +): void { + const dispatch = useGridDispatch(); + + useEffect(() => { + if (groupsInSpan.length > 0) { + dispatch(instance.actions.groupsDiscovered(groupsInSpan)); + } + }, [groupsInSpan, dispatch, instance]); +} + +export function useDisplayModel( + instance: GridInstance, + { total, span }: Args, +): Result { + const dispatch = useGridDispatch(); + const { grouping } = instance.descriptor; + const { collapsedGroups, discoveredGroups } = useSelector( + (s: unknown) => instance.selectRoot(s).groups, + ); + + const { boundaries, groupsInSpan } = useSpanGroups(span, grouping); + + useGroupDiscovery(instance, groupsInSpan); + + const model = useMemo(() => { + if (!grouping) { + return buildFlatModel(total) as DisplayModel; + } + + // `span.precedingGroupKey` is the group of the row directly above the + // window, and it is the only thing that identifies the rows before the + // first boundary. A window inside one group reports no boundary at all, so + // without it those rows get no segment and the grid draws nothing — see + // `BuildArgs.leadingGroup`. + return buildDisplayModel( + { + boundaries, + leadingGroup: span.precedingGroupKey, + total, + collapsedGroups, + discoveredGroups, + }, + grouping.order, + ); + }, [grouping, boundaries, span.precedingGroupKey, total, collapsedGroups, discoveredGroups]); + + const onHeaderOrCellClicked = useCallback( + (cellIndex: Item) => { + const cell = displayToData(model, cellIndex[1]); + + if (cell.kind !== "header") { + return; + } + + // Where the collapse starts, in the coordinates of the store the screen + // is reading right now. Only the model knows it, and the model is in + // scope here alone. `useGridData` takes it on the next render and uses it + // to keep the pages ABOVE the group instead of asking for them again. + // + // This is the only dispatcher of `toggleCollapse`. A second one must + // write this cell too, or its collapse silently carries stale pages. + instance.carryFromRef.current = firstAffectedDataIndex(model, cell.group); + + dispatch(instance.actions.toggleCollapse(cell.group)); + }, + [model, dispatch, instance], + ); + + return { model, onHeaderOrCellClicked }; +} diff --git a/src/web/src/features/dataGrid/hooks/useGridColumns.test.tsx b/src/web/src/features/dataGrid/hooks/useGridColumns.test.tsx new file mode 100644 index 0000000..c7abede --- /dev/null +++ b/src/web/src/features/dataGrid/hooks/useGridColumns.test.tsx @@ -0,0 +1,113 @@ +// src/features/dataGrid/hooks/useGridColumns.test.tsx +import { configureStore } from "@reduxjs/toolkit"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { Provider } from "react-redux"; +import { describe, expect, it } from "vitest"; +import { SORT_ASC_ICON, SORT_DESC_ICON } from "../headerIcons"; +import { createGridInstance } from "../store/createGridInstance"; +import type { GridDescriptor } from "../types"; +import { useGridColumns } from "./useGridColumns"; + +interface Row { + id: number; + name: string; +} + +const FIELDS = ["id", "name"]; + +function makeHarness() { + const descriptor = { + name: "demo", + rowKey: (r: Row) => r.id, + columns: { + defs: { + id: { field: "id", title: "Id", defaultWidth: 80, editable: false, type: "number" }, + name: { field: "name", title: "Name", defaultWidth: 120, editable: false, type: "text" }, + }, + defaultOrder: FIELDS, + sortable: () => true, + }, + api: { + updateRow: async () => ({ ok: true }), + }, + cells: { makeCell: () => ({}) as never, customRenderers: [], validateCell: () => true }, + } as unknown as GridDescriptor; + + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const instance = createGridInstance(descriptor); + const store = configureStore({ + reducer: { demo: instance.reducer }, + middleware: (getDefault) => getDefault({ serializableCheck: false }), + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + return { instance, store, wrapper }; +} + +const render = (h: ReturnType) => + renderHook(() => useGridColumns(h.instance), { wrapper: h.wrapper }); + +const indicators = (columns: { indicatorIcon?: string }[]) => columns.map((c) => c.indicatorIcon); + +describe("useGridColumns sort indicator", () => { + it("shows no indicator on any column while the grid is unsorted", async () => { + const h = makeHarness(); + const { result } = render(h); + + await waitFor(() => expect(result.current.columns).toHaveLength(2)); + expect(indicators(result.current.columns)).toEqual([undefined, undefined]); + }); + + it("marks the sorted column ascending, and only that column", async () => { + const h = makeHarness(); + const { result } = render(h); + await waitFor(() => expect(result.current.columns).toHaveLength(2)); + + act(() => void h.store.dispatch(h.instance.actions.setSort({ field: "name", dir: "asc" }))); + + expect(indicators(result.current.columns)).toEqual([undefined, SORT_ASC_ICON]); + }); + + it("flips the chevron when the direction reverses", async () => { + const h = makeHarness(); + const { result } = render(h); + await waitFor(() => expect(result.current.columns).toHaveLength(2)); + + act(() => void h.store.dispatch(h.instance.actions.setSort({ field: "id", dir: "desc" }))); + + expect(indicators(result.current.columns)).toEqual([SORT_DESC_ICON, undefined]); + }); + + it("drops the indicator when the sort is cleared", async () => { + const h = makeHarness(); + const { result } = render(h); + await waitFor(() => expect(result.current.columns).toHaveLength(2)); + + act(() => void h.store.dispatch(h.instance.actions.setSort({ field: "id", dir: "asc" }))); + act(() => void h.store.dispatch(h.instance.actions.setSort(null))); + + expect(indicators(result.current.columns)).toEqual([undefined, undefined]); + }); + + it("still reports width and title alongside the indicator", async () => { + const h = makeHarness(); + const { result } = render(h); + await waitFor(() => expect(result.current.columns).toHaveLength(2)); + + act(() => void h.store.dispatch(h.instance.actions.setSort({ field: "name", dir: "asc" }))); + + expect(result.current.columns[1]).toMatchObject({ + id: "name", + title: "Name", + width: 120, + indicatorIcon: SORT_ASC_ICON, + }); + }); +}); diff --git a/src/web/src/features/dataGrid/hooks/useGridColumns.ts b/src/web/src/features/dataGrid/hooks/useGridColumns.ts new file mode 100644 index 0000000..6ef5506 --- /dev/null +++ b/src/web/src/features/dataGrid/hooks/useGridColumns.ts @@ -0,0 +1,63 @@ +// src/features/dataGrid/hooks/useGridColumns.ts +import type { GridColumn } from "@glideapps/glide-data-grid"; +import { useCallback, useEffect, useMemo } from "react"; +import { useSelector } from "react-redux"; +import { SORT_ASC_ICON, SORT_DESC_ICON } from "../headerIcons"; +import type { GridInstance } from "../types"; +import { useGridDispatch } from "../useGridDispatch"; + +export function useGridColumns( + instance: GridInstance, +): { + visibleFields: string[]; + columns: GridColumn[]; + onColumnResize: (col: GridColumn, newSize: number, colIndex: number) => void; + onColumnMoved: (from: number, to: number) => void; +} { + const dispatch = useGridDispatch(); + + const defs = instance.descriptor.columns.defs; + + const { order, widths, hidden } = useSelector((s: unknown) => instance.selectRoot(s).columns); + + const sort = useSelector((s: unknown) => instance.selectRoot(s).groups.sort); + + useEffect(() => { + dispatch(instance.thunks.loadColumns()); + }, [dispatch, instance]); + + const visibleFields = useMemo(() => order.filter((f) => !hidden.includes(f)), [order, hidden]); + + const columns: GridColumn[] = useMemo( + () => + visibleFields.map((f) => ({ + id: f, + title: defs[f].title, + width: widths[f] ?? defs[f].defaultWidth, + indicatorIcon: + sort?.field === f ? (sort.dir === "asc" ? SORT_ASC_ICON : SORT_DESC_ICON) : undefined, + })), + [visibleFields, widths, defs, sort], + ); + + const onColumnResize = useCallback( + (_col: GridColumn, newSize: number, colIndex: number) => { + dispatch(instance.actions.resizeColumn({ field: visibleFields[colIndex], width: newSize })); + }, + [dispatch, instance, visibleFields], + ); + + const onColumnMoved = useCallback( + (from: number, to: number) => { + dispatch( + instance.actions.moveColumn({ + from: order.indexOf(visibleFields[from]), + to: order.indexOf(visibleFields[to]), + }), + ); + }, + [dispatch, instance, order, visibleFields], + ); + + return { visibleFields, columns, onColumnResize, onColumnMoved }; +} diff --git a/src/web/src/features/dataGrid/hooks/useGridData.test.tsx b/src/web/src/features/dataGrid/hooks/useGridData.test.tsx new file mode 100644 index 0000000..7d14828 --- /dev/null +++ b/src/web/src/features/dataGrid/hooks/useGridData.test.tsx @@ -0,0 +1,706 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; +import type { SortSpec } from "../data/sortSpec"; +import { createFakeRowServer } from "../testing/fakeRowServer"; +import type { GridDescriptor } from "../types"; +import { useGridData, type GridDataSource } from "./useGridData"; + +interface Row { + id: number; + name: string; + g: number; +} + +const PAGE = 100; +const GROUP_SIZE = 60; + +function makeHarness(total: number, grouped = false) { + const server = createFakeRowServer({ + rows: Array.from({ length: total }, (_, i) => ({ + id: i, + name: `row-${i}`, + g: Math.floor(i / GROUP_SIZE), + })), + rowKey: (r) => r.id, + groupOf: (r) => r.g, + }); + + // A gate around every page PAST the first, so a case can let page 0 settle on + // its own and hold the rest of the window back. + let openLaterPages: () => void = () => {}; + let laterPageGate: Promise | null = null; + + // Armed by `failFetchRows()` and spent by the NEXT request only. One request, + // not a mode: a case arms it just before a sort change so that the request + // the change starts is the one that fails. + let failNext = false; + + // A gate around ONE page, named by the offset it is requested at, so a case + // can decide which page of a multi-page window lands last. + const pageGates = new Map; open: () => void }>(); + + const fetchRows = vi.fn(async (p: Parameters[0]) => { + if (failNext) { + failNext = false; + throw new Error("boom"); + } + + if (laterPageGate !== null && p.offset > 0) { + await laterPageGate; + } + + const gate = pageGates.get(p.offset); + + if (gate !== undefined) { + await gate.wait; + } + + return server.api.fetchRows(p); + }); + + // A gate around the COUNT request, so a case can let the rows of a new + // collapse set land while its total is still in flight. That order is the one + // carrying pages creates, and it is the order the old code never met. + let countGate: (() => void) | null = null; + let countFails = false; + + const fetchCount = async (p: Parameters[0]) => { + if (countGate !== null) { + await new Promise((resolve) => { + countGate = resolve; + }); + } + + if (countFails) { + throw new Error("count boom"); + } + + return server.api.fetchCount(p); + }; + + const descriptor = { + name: "composed", + rowKey: (r: Row) => r.id, + pageSize: PAGE, + columns: { defs: {}, defaultOrder: [] }, + grouping: grouped + ? { field: "g", of: (r: Row) => r.g, order: (g: number) => g, label: String } + : undefined, + api: { ...server.api, fetchRows, fetchCount }, + cells: { makeCell: () => ({}), customRenderers: [], validateCell: () => true }, + } as unknown as GridDescriptor; + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 60_000 } }, + }); + + const instance = { + descriptor, + storeRef: { current: null }, + pendingStoreRef: { current: null }, + carryFromRef: { current: null }, + subscribeDataGeneration: () => () => {}, + getDataGeneration: () => 0, + } as unknown as GridDataSource; + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + return { + descriptor, + instance, + wrapper, + fetchRows, + + holdLaterPages() { + laterPageGate = new Promise((resolve) => { + openLaterPages = resolve; + }); + }, + + releaseLaterPages() { + openLaterPages(); + laterPageGate = null; + }, + + /** Makes the next page request reject. */ + failFetchRows() { + failNext = true; + }, + + /** Blocks the next count request until `releaseCount()` runs. */ + holdCount() { + countGate = () => {}; + }, + + releaseCount() { + const open = countGate; + countGate = null; + open?.(); + }, + + /** Makes every count request from now on reject. */ + failCount() { + countFails = true; + }, + + /** Blocks every request for the page starting at this offset. */ + holdPage(offset: number) { + let open: () => void = () => {}; + const wait = new Promise((resolve) => { + open = resolve; + }); + + pageGates.set(offset, { wait, open }); + }, + + /** Removed BEFORE it opens, so a re-request of the same page runs free. */ + releasePage(offset: number) { + const gate = pageGates.get(offset); + pageGates.delete(offset); + gate?.open(); + }, + }; +} + +/** + * The store the hold is filling. + * + * It FAILS when there is no hold, rather than reading through an optional chain + * that answers undefined for "no store" and for "the store does not hold that + * row" alike. Every assertion about a carried page needs the two apart. + */ +function pendingStore(h: ReturnType) { + const store = h.instance.pendingStoreRef.current; + + expect(store).not.toBeNull(); + + return store!; +} + +describe("useGridData", () => { + it("serves a row by data index once its page lands", async () => { + const h = makeHarness(10_000); + + const { result } = renderHook( + () => useGridData(h.instance, { offset: 0, limit: PAGE }, null, [], () => {}), + { wrapper: h.wrapper }, + ); + + await waitFor(() => expect(result.current.rowAt(5)).toBeDefined()); + + expect(result.current.rowAt(5)?.name).toBe("row-5"); + expect(result.current.total).toBe(10_000); + expect(result.current.status).toBe("ready"); + }); + + it("keeps the same store across renders, so rows survive a window move", async () => { + const h = makeHarness(10_000); + + const { result, rerender } = renderHook( + ({ offset }: { offset: number }) => + useGridData(h.instance, { offset, limit: PAGE }, null, [], () => {}), + { wrapper: h.wrapper, initialProps: { offset: 0 } }, + ); + + await waitFor(() => expect(result.current.rowAt(0)).toBeDefined()); + + const store = result.current.store; + + rerender({ offset: 100 }); + await waitFor(() => expect(result.current.rowAt(100)).toBeDefined()); + + expect(result.current.store).toBe(store); + expect(result.current.rowAt(0)?.name).toBe("row-0"); + }); + + it("lays an optimistic value over the stored row", async () => { + const h = makeHarness(1_000); + + const { result } = renderHook( + () => useGridData(h.instance, { offset: 0, limit: PAGE }, null, [], () => {}), + { wrapper: h.wrapper }, + ); + + await waitFor(() => expect(result.current.rowAt(5)).toBeDefined()); + + result.current.overlay.begin(5, "name", "typed"); + + expect(result.current.rowAt(5)?.name).toBe("typed"); + expect(result.current.isPending(5)).toBe(true); + }); + + it("restores the stored value when an optimistic edit rolls back", async () => { + const h = makeHarness(1_000); + + const { result } = renderHook( + () => useGridData(h.instance, { offset: 0, limit: PAGE }, null, [], () => {}), + { wrapper: h.wrapper }, + ); + + await waitFor(() => expect(result.current.rowAt(5)).toBeDefined()); + + result.current.overlay.begin(5, "name", "typed"); + result.current.overlay.rollback(5, "name"); + + expect(result.current.rowAt(5)?.name).toBe("row-5"); + expect(result.current.isPending(5)).toBe(false); + }); + + it("reports a span the display model can place group headers from", async () => { + const h = makeHarness(1_000, true); + + const { result } = renderHook( + () => useGridData(h.instance, { offset: 100, limit: PAGE }, null, [], () => {}), + { wrapper: h.wrapper }, + ); + + await waitFor(() => expect(result.current.span.rows.length).toBeGreaterThan(0)); + + expect(result.current.span.offset).toBe(100); + expect(result.current.span.rows[0].id).toBe(100); + expect(result.current.span.precedingGroupKey).toBe(1); + }); + + it("throws away every row when the sort changes, because positions moved", async () => { + const h = makeHarness(1_000); + + const { result, rerender } = renderHook( + ({ sort }: { sort: SortSpec | null }) => + useGridData(h.instance, { offset: 0, limit: PAGE }, sort, [], () => {}), + { wrapper: h.wrapper, initialProps: { sort: null as SortSpec | null } }, + ); + + await waitFor(() => expect(result.current.rowAt(0)?.name).toBe("row-0")); + + rerender({ sort: { field: "name", direction: "desc", nulls: "last" } }); + + await waitFor(() => expect(result.current.rowAt(0)?.name).toBe("row-999")); + }); + + it("throws away every row when the collapse set changes", async () => { + const h = makeHarness(1_000, true); + + const { result, rerender } = renderHook( + ({ collapsed }: { collapsed: number[] }) => + useGridData(h.instance, { offset: 0, limit: PAGE }, null, collapsed, () => {}), + { wrapper: h.wrapper, initialProps: { collapsed: [] as number[] } }, + ); + + await waitFor(() => expect(result.current.total).toBe(1_000)); + + rerender({ collapsed: [0] }); + + await waitFor(() => expect(result.current.total).toBe(940)); + expect(result.current.rowAt(0)?.id).toBe(GROUP_SIZE); + }); + + it("reports an error for a failed visible page even though the widened preceding page loaded", async () => { + const h = makeHarness(1_000, true); + let failVisiblePage = true; + + const fetchRows = async (p: Parameters[0]) => { + if (p.offset === PAGE && failVisiblePage) { + throw new Error("boom"); + } + + return h.descriptor.api.fetchRows(p); + }; + + const descriptor = { ...h.descriptor, api: { ...h.descriptor.api, fetchRows } }; + const instance = { + descriptor, + storeRef: { current: null }, + pendingStoreRef: { current: null }, + carryFromRef: { current: null }, + subscribeDataGeneration: () => () => {}, + getDataGeneration: () => 0, + } as unknown as GridDataSource; + + const { result } = renderHook( + () => useGridData(instance, { offset: PAGE, limit: PAGE }, null, [], () => {}), + { wrapper: h.wrapper }, + ); + + // Page 0 (rows 0-99, fetched only to supply `precedingGroupKey`) loads + // fine; page 1 (rows 100-199, the whole visible window) fails. Without the + // fix, `status` reads "ready" because SOME page in the widened set loaded. + await waitFor(() => expect(result.current.status).toBe("error")); + expect(result.current.span.rows.length).toBe(0); + + failVisiblePage = false; + act(() => result.current.retry()); + + await waitFor(() => expect(result.current.status).toBe("ready")); + expect(result.current.rowAt(PAGE)?.name).toBe(`row-${PAGE}`); + }); + + it("requests only its own pages for an off-boundary grouped window, no extra one", async () => { + const h = makeHarness(1_000, true); + const fetchRows = vi.fn(h.descriptor.api.fetchRows); + const descriptor = { ...h.descriptor, api: { ...h.descriptor.api, fetchRows } }; + const instance = { + descriptor, + storeRef: { current: null }, + pendingStoreRef: { current: null }, + carryFromRef: { current: null }, + subscribeDataGeneration: () => () => {}, + getDataGeneration: () => 0, + } as unknown as GridDataSource; + + const { result } = renderHook( + () => useGridData(instance, { offset: 150, limit: PAGE }, null, [], () => {}), + { wrapper: h.wrapper }, + ); + + await waitFor(() => expect(result.current.span.rows.length).toBeGreaterThan(0)); + + const requestedOffsets = fetchRows.mock.calls + .map((call) => call[0].offset) + .sort((a, b) => a - b); + + // Offset 150 with a page size of 100 spans pages 1 and 2 (rows 100-299) + // without ever touching a page boundary, so no extra page is needed for + // `precedingGroupKey` and none should be requested. + expect(requestedOffsets).toEqual([100, 200]); + }); + + it("publishes its store on the instance, so the push subscriber can reach it", async () => { + const h = makeHarness(1_000); + + const { result } = renderHook( + () => useGridData(h.instance, { offset: 0, limit: PAGE }, null, [], () => {}), + { wrapper: h.wrapper }, + ); + + await waitFor(() => expect(result.current.rowAt(0)).toBeDefined()); + + expect(h.instance.storeRef.current).toBe(result.current.store); + }); + it("grows the span as the window's later pages settle, not only when status first moves", async () => { + // `statusOf` answers "ready" as soon as ONE page of the window is loaded, + // and a window covers three. A span memo keyed on `status` therefore + // recomputes once, at page 0, and never again — and `spanFromStore` stops + // at the first hole, so the span would stay 100 rows wide for a 300-row + // window. `useDisplayModel` reads every group boundary out of `span.rows`. + const h = makeHarness(1_000, true); + h.holdLaterPages(); + + const { result } = renderHook( + () => useGridData(h.instance, { offset: 0, limit: 300 }, null, [], () => {}), + { wrapper: h.wrapper }, + ); + + await waitFor(() => expect(result.current.span.rows.length).toBe(PAGE)); + expect(result.current.status).toBe("ready"); + + h.releaseLaterPages(); + + await waitFor(() => expect(result.current.span.rows.length).toBe(300)); + + // `status` never moved again, so only a settle-driven recompute can have + // produced this. Group 2 opens at row 120, past the first page. + expect(result.current.status).toBe("ready"); + expect(result.current.span.rows.some((row) => row.id === 120 && row.g === 2)).toBe(true); + }); + + it("keeps the previous rows on screen while a new sort loads", async () => { + const h = makeHarness(1_000); + + const { result, rerender } = renderHook( + ({ sort }: { sort: SortSpec | null }) => + useGridData(h.instance, { offset: 0, limit: PAGE }, sort, [], () => {}), + { wrapper: h.wrapper, initialProps: { sort: null as SortSpec | null } }, + ); + + await waitFor(() => expect(result.current.rowAt(0)?.name).toBe("row-0")); + + rerender({ sort: { field: "name", direction: "desc", nulls: "last" } }); + + // The old order is still readable, and the view says it is stale. + expect(result.current.rowAt(0)?.name).toBe("row-0"); + expect(result.current.isStale).toBe(true); + + await waitFor(() => expect(result.current.rowAt(0)?.name).toBe("row-999")); + expect(result.current.isStale).toBe(false); + }); + + it("keeps the total with the rows it describes, not ahead of them", async () => { + const h = makeHarness(1_000, true); + + const { result, rerender } = renderHook( + ({ collapsed }: { collapsed: number[] }) => + useGridData(h.instance, { offset: 0, limit: PAGE }, null, collapsed, () => {}), + { wrapper: h.wrapper, initialProps: { collapsed: [] as number[] } }, + ); + + await waitFor(() => expect(result.current.total).toBe(1_000)); + + rerender({ collapsed: [0] }); + + // The old total belongs to the rows still on screen. A total that moved + // first would size the scroll bar for rows the grid is not showing. + expect(result.current.total).toBe(1_000); + expect(result.current.rowAt(0)?.id).toBe(0); + + await waitFor(() => expect(result.current.total).toBe(940)); + expect(result.current.rowAt(0)?.id).toBe(GROUP_SIZE); + }); + + it("gives up the hold when the new view fails, rather than showing old rows for ever", async () => { + const h = makeHarness(1_000); + + const { result, rerender } = renderHook( + ({ sort }: { sort: SortSpec | null }) => + useGridData(h.instance, { offset: 0, limit: PAGE }, sort, [], () => {}), + { wrapper: h.wrapper, initialProps: { sort: null as SortSpec | null } }, + ); + + await waitFor(() => expect(result.current.rowAt(0)).toBeDefined()); + + h.failFetchRows(); + rerender({ sort: { field: "name", direction: "desc", nulls: "last" } }); + + await waitFor(() => expect(result.current.status).toBe("error"), { timeout: 10_000 }); + + // The hold ends with the failure. Old rows under a permanent error hide the + // banner and the retry behind data the user cannot act on. + await waitFor(() => expect(result.current.isStale).toBe(false), { timeout: 10_000 }); + expect(result.current.rowAt(0)).toBeUndefined(); + }); + + it("holds until the page the user LOOKS at lands, not the page above it", async () => { + // `rangeForViewport` starts the window one page ABOVE the viewport, so for + // any grid scrolled past the first page `range.offset` names a page nobody + // can see. A gate on that page alone adopts while the visible page is still + // in flight, and the whole viewport draws loading cells — the blank this + // hold exists to remove. Every case at offset 0 misses it, because there + // the page above the viewport IS the viewport's page. + const h = makeHarness(1_000); + + const VISIBLE = 2 * PAGE; + + const { result, rerender } = renderHook( + ({ sort }: { sort: SortSpec | null }) => + useGridData(h.instance, { offset: PAGE, limit: 3 * PAGE }, sort, [], () => {}), + { wrapper: h.wrapper, initialProps: { sort: null as SortSpec | null } }, + ); + + await waitFor(() => expect(result.current.rowAt(VISIBLE)?.id).toBe(VISIBLE)); + + // Pages 1 and 3 of the new order may land; page 2, the one the viewport + // shows, may not. + h.holdPage(VISIBLE); + rerender({ sort: { field: "id", direction: "desc", nulls: "last" } }); + + for (let i = 0; i < 10; i += 1) { + await act(() => new Promise((resolve) => setTimeout(resolve, 10))); + + expect(result.current.isStale).toBe(true); + expect(result.current.rowAt(VISIBLE)?.id).toBe(VISIBLE); + } + + h.releasePage(VISIBLE); + + await waitFor(() => expect(result.current.isStale).toBe(false), { timeout: 10_000 }); + expect(result.current.rowAt(VISIBLE)?.id).toBe(999 - VISIBLE); + }); + + it("carries the pages above the collapse into the pending view", async () => { + const h = makeHarness(1_000, true); + + const { result, rerender } = renderHook( + ({ collapsed }: { collapsed: number[] }) => + useGridData(h.instance, { offset: 0, limit: PAGE }, null, collapsed, () => {}), + { wrapper: h.wrapper, initialProps: { collapsed: [] as number[] } }, + ); + + await waitFor(() => expect(result.current.rowAt(0)?.id).toBe(0)); + + const requestsBefore = h.fetchRows.mock.calls.length; + + // Group 3 starts at row 180, so page 0 ends above it. + h.instance.carryFromRef.current = 180; + rerender({ collapsed: [3] }); + + // No hold at all: the window sits inside the pages that carried over, and + // not one request went out for them. + expect(result.current.isStale).toBe(false); + expect(result.current.rowAt(0)?.id).toBe(0); + expect(result.current.rowAt(99)?.id).toBe(99); + expect(h.fetchRows.mock.calls.length).toBe(requestsBefore); + }); + + it("drops the boundary when the user returns to the state on screen", async () => { + const h = makeHarness(1_000, true); + + const { result, rerender } = renderHook( + ({ collapsed, sort }: { collapsed: number[]; sort: SortSpec | null }) => + useGridData(h.instance, { offset: 300, limit: PAGE }, sort, collapsed, () => {}), + { + wrapper: h.wrapper, + initialProps: { collapsed: [] as number[], sort: null as SortSpec | null }, + }, + ); + + await waitFor(() => expect(result.current.rowAt(300)?.id).toBe(300)); + + // Collapse group 10, then expand it again before the hold ends. The second + // click writes its own boundary and opens NO pending view, because the state + // key is back to the one on screen. + h.instance.carryFromRef.current = 350; + rerender({ collapsed: [10], sort: null }); + + h.instance.carryFromRef.current = 700; + rerender({ collapsed: [], sort: null }); + + expect(h.instance.carryFromRef.current).toBeNull(); + + // A sort moves every row. A boundary left over from the collapse would carry + // page 3 into the new view, and the grid would draw the OLD order with no + // dim and no request — the pages read `loaded`, so nobody asks again. + rerender({ collapsed: [], sort: { field: "id", direction: "desc", nulls: "last" } }); + + expect(result.current.isStale).toBe(true); + expect(pendingStore(h).getRow(300)).toBeUndefined(); + }); + + it("carries nothing across a sort, because a sort moves every row", async () => { + const h = makeHarness(1_000); + + const { result, rerender } = renderHook( + ({ sort }: { sort: SortSpec | null }) => + useGridData(h.instance, { offset: 0, limit: PAGE }, sort, [], () => {}), + { wrapper: h.wrapper, initialProps: { sort: null as SortSpec | null } }, + ); + + await waitFor(() => expect(result.current.rowAt(0)?.name).toBe("row-0")); + + // The click handler writes the boundary only for a collapse, so a sort + // finds the cell null and the pending store starts empty. + rerender({ sort: { field: "name", direction: "desc", nulls: "last" } }); + + expect(result.current.isStale).toBe(true); + expect(pendingStore(h).getRow(0)).toBeUndefined(); + }); + + it("keeps the lower boundary when a second collapse lands during a hold", async () => { + const h = makeHarness(1_000, true); + + const { result, rerender } = renderHook( + ({ collapsed }: { collapsed: number[] }) => + useGridData(h.instance, { offset: 300, limit: PAGE }, null, collapsed, () => {}), + { wrapper: h.wrapper, initialProps: { collapsed: [] as number[] } }, + ); + + await waitFor(() => expect(result.current.rowAt(300)?.id).toBe(300)); + await waitFor(() => expect(result.current.rowAt(200)?.id).toBe(200)); + + // The first collapse moves rows from 350, so page 2 carries and page 3 — + // the window's own page — does not. The hold stays open on it. + h.instance.carryFromRef.current = 350; + rerender({ collapsed: [10] }); + + expect(pendingStore(h).getRow(200)?.id).toBe(200); + expect(pendingStore(h).getRow(300)).toBeUndefined(); + + // The second collapse measures 600 against the SAME displayed store. Taking + // it alone would carry page 3, whose rows the first collapse already moved. + h.instance.carryFromRef.current = 600; + rerender({ collapsed: [10, 2] }); + + expect(pendingStore(h).getRow(300)).toBeUndefined(); + expect(pendingStore(h).getRow(200)?.id).toBe(200); + expect(result.current.isStale).toBe(true); + }); + + it("holds the swap until the count describes the rows the swap brings on screen", async () => { + const h = makeHarness(1_000, true); + + const { result, rerender } = renderHook( + ({ collapsed }: { collapsed: number[] }) => + useGridData(h.instance, { offset: 0, limit: PAGE }, null, collapsed, () => {}), + { wrapper: h.wrapper, initialProps: { collapsed: [] as number[] } }, + ); + + await waitFor(() => expect(result.current.total).toBe(1_000)); + + // Group 0 opens at row 0, so this collapse moves every row of the window + // and nothing carries. The replacement rows land while the count is held. + h.holdCount(); + h.instance.carryFromRef.current = 0; + rerender({ collapsed: [0] }); + + await waitFor(() => expect(pendingStore(h).getRow(0)?.id).toBe(GROUP_SIZE)); + + // Those rows really did move, so they may not reach the screen under a + // total that still counts the collapsed group. + expect(result.current.isStale).toBe(true); + expect(result.current.rowAt(0)?.id).toBe(0); + + h.releaseCount(); + + await waitFor(() => expect(result.current.isStale).toBe(false)); + expect(result.current.rowAt(0)?.id).toBe(GROUP_SIZE); + expect(result.current.total).toBe(940); + }); + + it("never publishes a total of 0 while the count of a carried view is in flight", async () => { + const h = makeHarness(1_000, true); + + const { result, rerender } = renderHook( + ({ collapsed }: { collapsed: number[] }) => + useGridData(h.instance, { offset: 0, limit: PAGE }, null, collapsed, () => {}), + { wrapper: h.wrapper, initialProps: { collapsed: [] as number[] } }, + ); + + await waitFor(() => expect(result.current.total).toBe(1_000)); + + h.holdCount(); + h.instance.carryFromRef.current = 180; + rerender({ collapsed: [3] }); + + // The pages carried, so the rows are ready one render after the click. The + // count is not, and a total of 0 would empty a grid whose rows are all + // there. + expect(result.current.isStale).toBe(false); + expect(result.current.rowAt(0)?.id).toBe(0); + expect(result.current.total).toBe(1_000); + + h.releaseCount(); + + await waitFor(() => expect(result.current.total).toBe(940)); + expect(result.current.rowAt(0)?.id).toBe(0); + }); + + it("ends the hold on a rejected count and keeps the total it can still prove", async () => { + const h = makeHarness(1_000, true); + + const { result, rerender } = renderHook( + ({ collapsed }: { collapsed: number[] }) => + useGridData(h.instance, { offset: 0, limit: PAGE }, null, collapsed, () => {}), + { wrapper: h.wrapper, initialProps: { collapsed: [] as number[] } }, + ); + + await waitFor(() => expect(result.current.total).toBe(1_000)); + + // Nothing carries, so the swap has to pass the count gate. The count for the + // new collapse set never returns a number. + h.failCount(); + h.instance.carryFromRef.current = 0; + rerender({ collapsed: [0] }); + + // The hold ends. Old rows under a count that will never land hide the grid + // behind data the user cannot act on. + await waitFor(() => expect(result.current.isStale).toBe(false)); + expect(result.current.rowAt(0)?.id).toBe(GROUP_SIZE); + + // And the total stays the last number the grid could prove. A rejected + // count carries no number, and publishing its 0 empties a grid whose rows + // are all on screen — with no banner, because `status` speaks for pages. + expect(result.current.total).toBe(1_000); + }); +}); diff --git a/src/web/src/features/dataGrid/hooks/useGridData.ts b/src/web/src/features/dataGrid/hooks/useGridData.ts new file mode 100644 index 0000000..f2723fa --- /dev/null +++ b/src/web/src/features/dataGrid/hooks/useGridData.ts @@ -0,0 +1,686 @@ +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { createEditOverlay, type EditOverlay } from "../data/editOverlay"; +import { useRowCount } from "../data/rowCount"; +import { carryPagesBefore, createRowStore, type RowStore } from "../data/rowStore"; +import type { SortSpec } from "../data/sortSpec"; +import { spanFromStore, type LoadedSpan } from "../data/spanFromStore"; +import type { GridDescriptor, GridGrouping } from "../types"; +import { useRowPages } from "./useRowPages"; +import type { RangeLoaded, WindowRange } from "./useWindowRange"; + +/** + * The minimal shape this hook reads off a grid instance. `GridInstance` + * satisfies it structurally, which keeps the hook free of Redux and testable + * against a bare object. + */ +export interface GridDataSource { + /** What to fetch and how: the API, the page size, the row key, the grouping. */ + descriptor: GridDescriptor; + + /** + * Where this hook PUBLISHES the displayed store, for the push subscriber to + * find. + * + * A cell rather than a value, because the instance is created once at module + * scope while a store belongs to whichever grid is mounted. It reads null + * before the first mount, and a push that arrives then is dropped. + */ + storeRef: { current: RowStore | null }; + + /** + * The same, for the store a hold is filling. Null outside a hold. + * + * A push has to maintain BOTH: a patch that reached only the displayed store + * would be discarded by the very next adoption. + */ + pendingStoreRef: { current: RowStore | null }; + + /** + * The first data index the collapse change now landing moves, or null when + * everything moves. + * + * The header click writes it, because only the display model knows where a + * group's rows begin, and this hook reads it ONCE and clears it. Everything + * above that index sits at the same data index under both collapse sets, so + * those pages move into the new store instead of being fetched again. + */ + carryFromRef: { current: number | null }; + + // The read path SUBSCRIBES to invalidations; it never raises one. + // `bumpDataGeneration` is deliberately absent from this shape, so nothing + // reachable from here can invalidate the rows it is reading. + + /** Registers a listener for that counter and answers with the unsubscribe. */ + subscribeDataGeneration: (listener: () => void) => () => void; + + /** + * The grid's data generation: which SET OF ROWS the server is offering. + * + * A pushed create or delete bumps it, because both move every position after + * them, exactly as a sort or a collapse change does. + */ + getDataGeneration: () => number; +} + +export interface UseGridDataResult { + /** The row at a data index, with any in-flight edit of it laid over the top. */ + rowAt: (dataIndex: number) => TRow | undefined; + + /** Whether a row has a save in flight. Drives the grey cell text. */ + isPending: (key: TKey) => boolean; + + /** + * Whether committing a candidate window would start no request. + * + * `useWindowRange` reads it to decide whether that window pays the settle + * wait. Rows already in the store commit at once; a window that would fetch + * waits for the scroll to stop. + */ + isRangeLoaded: RangeLoaded; + + /** How many rows the view on screen holds. Glide sizes its scroll bar from it. */ + total: number; + + /** The loaded rows at the current window, for the display model to place headers from. */ + span: LoadedSpan; + + /** + * What the VISIBLE window is doing. It reads "ready" as soon as the FIRST + * page of that window lands, so it answers "is there anything to draw", never + * "is every row here". + */ + status: "loading" | "ready" | "error"; + + /** Loads the failed pages of the current window again. The error banner's button. */ + retry: () => void; + + /** + * True while the grid draws the PREVIOUS sort or collapse state because the + * next one has not loaded. The rows on screen are real and internally + * consistent; they are simply one state behind. + */ + isStale: boolean; + + /** + * The store the grid is drawing now. It is a DIFFERENT object after every + * adoption, so a caller that outlives one — the settle path of a save — must + * re-read it rather than capture it. + */ + store: RowStore; + + /** + * The optimistic edits, keyed by row key. One overlay serves both views and + * survives every swap, so an edit begun on the held rows still settles. + */ + overlay: EditOverlay; +} + +/** + * One state of the grid, whole. The rows, the total, and the key they were + * fetched under travel together, so nothing on screen can describe two states + * at once. + */ +interface ViewState { + /** The sort, the collapse set, and the data generation these rows answer for. */ + stateKey: string; + + /** This state's rows alone. A view never borrows a row from the other one. */ + store: RowStore; + + /** + * The count that belongs to those rows. It moves only while nothing is + * pending, so the scroll bar never sizes for rows the grid is not showing. + */ + total: number; + + /** + * The first index the change INTO this state moves, or null when it moves + * everything. Kept for two readers: a SECOND change during the same hold + * bounds itself by it, and the swap reads it to tell whether the window on + * screen moved at all. + */ + carriedFrom: number | null; +} + +/** + * How far up the store two changes agree, or null when they do not. + * + * `boundary` belongs to the change just made. `previous` is a hold already + * running. Both measure against the SAME displayed store, so the LOWER boundary + * bounds the rows that neither change moves. A null on either side means + * "everything moved", and nothing carries. + */ +function carryBoundary( + boundary: number | null, + previous: ViewState | null, +): number | null { + if (boundary === null) { + return null; + } + + if (previous === null) { + return boundary; + } + + if (previous.carriedFrom === null) { + return null; + } + + return Math.min(boundary, previous.carriedFrom); +} + +/** + * The grid's read path, composed. + * + * A store OUTLIVES every window move. That single fact removed the first flash: + * the old design built a collection per window, so a window move handed the + * renderer an empty result. A window move changes no row's position, so this + * hook clears nothing and re-reads nothing. + * + * A sort change, a collapse change, or a pushed create or delete DOES move every + * row, so the rows loaded under the old state are all wrong. Clearing the store + * in place answered that, and it was correct — but it blanked the grid for a + * whole round trip. This hook holds TWO view states instead: the displayed one + * stays on screen, dimmed, while the pending one fills up behind it, and they + * swap in a single render so the rows, the total, and the span never describe + * two states at once. + * + * A COLLAPSE change is the one that does not move every row. It moves the rows + * at and below one group, and the header click leaves the index of that group's + * first row in `carryFromRef`. The pages that end above it hold the same rows at + * the same indexes under both collapse sets, so they move straight into the new + * store. Collapsing a group the user is not looking at then costs one count + * request and no page request, and the swap repaints nothing. + * + * Memory stays bounded because each store carries its own `MAX_LOADED_PAGES` + * cap and at most two are REACHABLE from the hook: a second state key replaces + * the pending view rather than adding to it, and adoption drops the old one. A + * burst of pushes during one hold therefore costs two stores, not one per push. + * A dropped store outlives the drop while its own fetches are still in flight, + * because the `loadPage` closures hold it — one transient store per abandoned + * state key, each freed within a round trip. + */ +export function useGridData( + instance: GridDataSource, + range: WindowRange, + sort: SortSpec | null, + collapsedGroups: TGroup[], + onRowsLoaded: (indexes: number[]) => void, +): UseGridDataResult { + const { descriptor } = instance; + const pageSize = descriptor.pageSize ?? 100; + + const overlayRef = useRef | null>(null); + overlayRef.current ??= createEditOverlay(); + const overlay = overlayRef.current; + + const generation = useSyncExternalStore( + instance.subscribeDataGeneration, + instance.getDataGeneration, + instance.getDataGeneration, + ); + + // One value for "which rows sit where". Any of the three moving moves every + // row, so a view is only ever named by all three together. + // + // The generation joins the sort and the collapse set because a create or a + // delete changes which row sits at a given index just as they do. One key, + // one store, one hold — a push now takes the same path a sort does, and the + // rows on screen stay there while the replacement loads behind them. + const stateKey = JSON.stringify([sort, [...collapsedGroups].sort(), generation]); + + const newStore = () => createRowStore(pageSize, descriptor.rowKey); + + const { displayed, pending, adoptions, adopt } = useViewPair( + stateKey, + + // The first view of the grid. Nothing precedes it, so it carries nothing. + (key) => ({ stateKey: key, store: newStore(), total: 0, carriedFrom: null }), + + // Every view after it. A collapse moves the rows AT AND BELOW one group, so + // the pages that end above it hold the same rows at the same indexes under + // both collapse sets and move straight into the new store. A window sitting + // wholly above the group then costs one count request and no page request. + (key, from, holding) => { + const store = newStore(); + const carriedFrom = carryBoundary(instance.carryFromRef.current, holding); + + // Read once and cleared, so a re-render for any other reason cannot carry + // pages under a boundary that no longer describes anything. A sort leaves + // it null, and a sort therefore carries nothing. + instance.carryFromRef.current = null; + + if (carriedFrom !== null) { + carryPagesBefore(from.store, store, carriedFrom); + } + + return { stateKey: key, store, total: 0, carriedFrom }; + }, + ); + + // A boundary must never outlive the change that measured it. Opening a hold + // consumes the cell above; this clears it on every render that has no hold at + // all, which is every OTHER way a boundary can be left behind: the first + // render of a mount, the render after an adoption, and — the one that showed + // wrong data — the click that puts the user back on the state already on + // screen. That click writes a boundary and opens no pending view, so without + // this line the NEXT sort would find it and carry pages the sort itself moves. + if (pending === null) { + instance.carryFromRef.current = null; + } + + // The loader always fills the NEWEST state and the renderer always reads the + // adopted one. While the two differ, the grid shows old rows and loads new + // ones. + const loadingView = pending ?? displayed; + + // The push subscriber (data/sync/useRowSync.ts) reads the stores through these + // cells, so it survives without re-subscribing. `storeRef` names the DISPLAYED + // store, so a pushed row lands on rows the user can actually see. + instance.storeRef.current = displayed.store; + + // And this one names the store the hold is filling, or null outside a hold. + // An update moves no position, so it must not bump the generation and reload + // the window for one cell; it patches instead. But a patch that reached only + // the displayed store would be discarded by the very next adoption, and a row + // the pending store alone holds would be dropped as "not loaded". Publishing + // the second store is what lets one push maintain both. + instance.pendingStoreRef.current = pending?.store ?? null; + + const loadRange = useMemo( + () => widenedForPrecedingGroup(range, descriptor.grouping), + [descriptor.grouping, range], + ); + + // `statusRange` stays the caller's own, un-widened window. `loadRange` may + // hold an extra page fetched only for `precedingGroupKey`; that page is off + // screen, so whether it loads or fails must never change what `status` + // reports about the page(s) the user can actually see. + const { status, settled, retry } = useRowPages({ + descriptor, + store: loadingView.store, + range: loadRange, + statusRange: range, + sort, + collapsedGroups, + onRowsLoaded, + }); + + // Keyed on the CURRENT collapse set, so during a hold it describes the + // PENDING view, not the one on screen. `displayed.total` is what the grid + // draws with until adoption moves it. + const { total: liveTotal, settled: countSettled } = useRowCount(descriptor, collapsedGroups); + const knownTotal = totalIfKnown(liveTotal, loadingView.store, range.offset); + + // Only when nothing is pending, because then `liveTotal` and the displayed + // store answer for the same state. During a hold this assignment is exactly + // the bug the hold removes: a total that moves before its rows do sizes the + // scroll bar for rows the grid is not showing. + if (pending === null && knownTotal !== undefined) { + displayed.total = knownTotal; + } + + // THE SWAP. Everything above builds the pending view; this is the one place + // that puts it on screen. + useEffect(() => { + if (pending === null) { + return; + } + + // EVERY page of the un-widened window, not the first index alone. + // `rangeForViewport` starts the window one page ABOVE the viewport, so for + // any grid scrolled past the first page `range.offset` names a page nobody + // can see. Adopting when that page lands draws the whole viewport as + // loading cells — the blank this hold exists to remove. + // + // A failed page counts as settled. Holding old rows under a permanent error + // hides the banner and the retry behind data the user cannot act on, which + // is worse than an empty grid that says so. + if (!isSettled(pending.store, range)) { + return; + } + + // The rows and the total describe one state together, so the swap normally + // waits for the count of the new collapse set as well. The carried pages + // make the rows ready in the render the change lands in, long before that + // count returns. + // + // A window that lies WHOLLY above the boundary is the one exception. Those + // rows hold the same index under both collapse sets, so the swap changes no + // cell the user can see, and waiting would dim the grid for a round trip + // that moves nothing but the scroll bar. + if (!countSettled && !windowUnmoved(pending, range)) { + return; + } + + // The count request for the new state may still be in flight. Carrying the + // old number across for one render is wrong by less than blanking the grid, + // and the render-time assignment above corrects it the moment it lands. + pending.total = knownTotal ?? displayed.total; + + // Republished here as well as during render, so a push that flushes between + // this swap and the next render patches the store now on screen — and no + // longer patches it a second time as a store still loading behind it. + instance.storeRef.current = pending.store; + instance.pendingStoreRef.current = null; + + // The overlay is NOT swapped or cleared. It is keyed by row key, never by + // index, so a change of index space says nothing about the entries in it, + // and every entry in it is by construction still in flight — both `commit` + // and `rollback` delete. Clearing here dropped the optimistic value and the + // pending tint of a save the user started on the held view, and the save + // then settled into a store nothing draws: the edit vanished with no error. + adopt(pending); + // `settled` is what re-runs this: it bumps every time a page of the pending + // view lands or fails, and nothing else reports that the pending store + // moved. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + pending, + displayed, + instance, + range.offset, + range.limit, + knownTotal, + countSettled, + settled, + adopt, + ]); + + const span = useMemo( + () => + spanFromStore( + displayed.store, + range.offset, + range.limit, + descriptor.grouping, + ), + // `settled` stands in for "the store changed": it bumps every time a page + // of this window lands, and the span is a pure read of the store at this + // window. `status` cannot stand in for it — `statusOf` answers "ready" as + // soon as the FIRST page of the window is loaded and never moves again + // while pages 2 and 3 arrive, so a memo keyed on it would hold the span at + // one page. `spanFromStore` stops at the first hole and `useDisplayModel` + // reads every group boundary out of `span.rows`, so a frozen span means a + // header for a group opening past that first page never draws. + // + // `adoptions` is here for the other kind of change: a swap hands the + // renderer a different store, and the rows it holds settled before it was + // adopted. + // eslint-disable-next-line react-hooks/exhaustive-deps + [displayed.store, range.offset, range.limit, descriptor.grouping, settled, adoptions], + ); + + const rowAt = useCallback( + (dataIndex: number): TRow | undefined => { + const row = displayed.store.getRow(dataIndex); + + if (row === undefined) { + return undefined; + } + + return overlay.apply(descriptor.rowKey(row), row); + }, + [displayed.store, overlay, descriptor], + ); + + const isPending = useCallback((key: TKey) => overlay.isPending(key), [overlay]); + + /** + * Whether committing this window would start no request at all. + * + * `useWindowRange` asks before it decides to wait. Two stores have to answer, + * and only outside a hold are they the same object: + * + * - `displayed.store` is what `span` reads, so it decides whether the window + * can DRAW without a fetch. + * - `loadingView.store` is what `useRowPages` fills, so it decides whether + * the window would FETCH. During a hold this is the pending store, which + * is normally still filling — a held grid keeps the wait, which is right, + * because a hold is exactly when requests are in flight. + * + * The widened range, not the caller's: a grouped window also needs the row + * above it loaded, or `precedingGroupKey` reads null and the first header + * draws wrong. Committing early on a window missing that page would trade the + * delay for a flicker. + */ + const isRangeLoaded = useCallback( + (candidate: WindowRange): boolean => { + const widened = widenedForPrecedingGroup(candidate, descriptor.grouping); + + if (!holdsEveryPage(displayed.store, widened)) { + return false; + } + + if (loadingView.store !== displayed.store && !holdsEveryPage(loadingView.store, widened)) { + return false; + } + + return true; + }, + // `settled` and `adoptions` are what report that a store's page states + // moved; neither store changes identity when a page lands. + // eslint-disable-next-line react-hooks/exhaustive-deps + [displayed.store, loadingView.store, descriptor.grouping, settled, adoptions], + ); + + return { + rowAt, + isPending, + isRangeLoaded, + + // `displayed.total`, never `liveTotal`. The render-time assignment above is + // the only writer, and `totalIfKnown` guards it: a count of 0 reaches the + // screen only once the store PROVES the view is empty. `countSettled` may + // not be used here, because it also reads true for a REJECTED count, whose + // `liveTotal` is 0 — and publishing that empties a grid whose rows are all + // on screen, with no banner, because `status` speaks for pages alone. + total: displayed.total, + span, + status, + retry, + isStale: pending !== null, + store: displayed.store, + overlay, + }; +} + +/** The two view states, and the one way to move a state from pending to shown. */ +interface ViewPair { + /** What the screen reads. */ + displayed: ViewState; + + /** The new sort or collapse set filling up behind it, or null outside a hold. */ + pending: ViewState | null; + + /** Counts adoptions. A memo that reads the displayed store may key on it. */ + adoptions: number; + + /** Puts a view on screen and ends the hold. */ + adopt: (view: ViewState) => void; +} + +/** + * Holds TWO view states, never one, and keeps them matched to the current state + * key. + * + * The reconciliation runs DURING render, not in an effect. An effect runs after + * the paint, so on the first render of a new sort `pending` would still be + * null, the caller would still point the loader at the displayed store, and + * pages fetched under the new sort would land among rows fetched under the old + * one. That is wrong data on screen rather than a flash. + */ +function useViewPair( + stateKey: string, + createFirstView: (key: string) => ViewState, + createPendingView: ( + key: string, + from: ViewState, + holding: ViewState | null, + ) => ViewState, +): ViewPair { + // Bumped on adoption. It is the only thing that makes a swap reach React, + // because both views live in refs and a ref write re-renders nothing. + const [adoptions, setAdoptions] = useState(0); + + const displayedRef = useRef | null>(null); + displayedRef.current ??= createFirstView(stateKey); + + const pendingRef = useRef | null>(null); + + if (displayedRef.current.stateKey === stateKey) { + // Includes the case where the user goes back to the state on screen mid + // hold. The pending view is then dead weight, and dropping it ends the hold + // without a round trip. + pendingRef.current = null; + } else if (pendingRef.current?.stateKey !== stateKey) { + // The pending view is built FROM the displayed one, and it is told about + // the hold it replaces: the pages it may keep are the pages that neither + // change moves. Replacing rather than adding is what keeps two stores the + // most this hook can reach. + pendingRef.current = createPendingView(stateKey, displayedRef.current, pendingRef.current); + } + + const adopt = useCallback((view: ViewState) => { + displayedRef.current = view; + pendingRef.current = null; + + setAdoptions((n) => n + 1); + }, []); + + return { + displayed: displayedRef.current, + pending: pendingRef.current, + adoptions, + adopt, + }; +} + +/** + * The window to FETCH for a window to DRAW. + * + * A grouped grid's span needs the row just above the window loaded too, so + * `spanFromStore` can tell whether the window's first row opens a group or + * continues one (`precedingGroupKey`). `rangeForViewport` always starts a + * window on a page boundary, so that row sits in the page BEFORE the window's + * own pages — a page the window itself gives no other reason to load. A flat + * grid never reads `precedingGroupKey`, so widening only for a descriptor with + * `grouping` costs a flat grid nothing. + */ +function widenedForPrecedingGroup( + range: WindowRange, + grouping: GridGrouping | undefined, +): WindowRange { + if (!grouping || range.offset <= 0) { + return range; + } + + return { offset: range.offset - 1, limit: range.limit + 1 }; +} + +/** + * The row count of a view, or undefined while the count request for it is still + * in flight. + * + * `useRowCount` answers 0 for "the count is not back yet" and for "there are no + * rows" alike, and the grid must not draw the two the same way: reading the + * first as the second empties the grid for a render, which is the flash this + * hook exists to remove. The store settles it. Once the page holding the + * window's first index is loaded and still serves no row there, the view really + * is empty; until then a zero is only silence. + */ +function totalIfKnown( + liveTotal: number, + store: RowStore, + firstIndex: number, +): number | undefined { + if (liveTotal > 0) { + return liveTotal; + } + + const page = store.pageOf(firstIndex); + + if (store.pageState(page) === "loaded" && store.getRow(firstIndex) === undefined) { + return 0; + } + + return undefined; +} + +/** + * Whether the change into this view leaves every row of the window where it + * already sits. + * + * A collapse moves the rows AT AND BELOW one group, so a window that ends above + * that boundary shows the same rows at the same indexes on both sides of the + * change. Swapping such a view in repaints nothing, which is why the swap may + * run before the new count returns. + * + * It reads the boundary, not the carried pages. A page above the boundary that + * did NOT carry — the store never held it — is fetched under the new collapse + * set and comes back with those very same rows, so the answer is the same + * either way. + */ +function windowUnmoved( + view: ViewState, + range: WindowRange, +): boolean { + if (view.carriedFrom === null) { + return false; + } + + return range.offset + range.limit <= view.carriedFrom; +} + +/** + * Whether a store holds every page a window touches, loaded and readable. + * + * Stricter than `isSettled`, and the difference is the point. `isSettled` counts + * a FAILED page as an answer, because a hold has to end on an error as much as + * on success. This question is "would committing this window fetch anything", + * and a failed page is one the retry button asks for again — so it reads false + * and the window keeps its wait. + */ +function holdsEveryPage( + store: RowStore, + range: WindowRange, +): boolean { + const firstPage = store.pageOf(range.offset); + const lastPage = store.pageOf(Math.max(range.offset, range.offset + range.limit - 1)); + + for (let page = firstPage; page <= lastPage; page += 1) { + if (store.pageState(page) !== "loaded") { + return false; + } + } + + return true; +} + +/** + * Whether a store has finished answering for every page a window touches. + * + * `failed` counts, because a page that failed has finished. `missing` does not: + * the loader re-requests it, and a settle bump brings this question back. The + * hold therefore ends for every outcome a request can have — a page ends + * `loaded`, or `failed`, or goes back to `missing` and is asked again. The one + * thing that can hold it open is a fetch promise that never settles at all, + * which is the same single exposure a one-page gate had. + */ +function isSettled( + store: RowStore, + range: WindowRange, +): boolean { + const firstPage = store.pageOf(range.offset); + const lastPage = store.pageOf(Math.max(range.offset, range.offset + range.limit - 1)); + + for (let page = firstPage; page <= lastPage; page += 1) { + const state = store.pageState(page); + + if (state !== "loaded" && state !== "failed") { + return false; + } + } + + return true; +} diff --git a/src/web/src/features/dataGrid/hooks/useGridSelection.ts b/src/web/src/features/dataGrid/hooks/useGridSelection.ts new file mode 100644 index 0000000..80c10d4 --- /dev/null +++ b/src/web/src/features/dataGrid/hooks/useGridSelection.ts @@ -0,0 +1,50 @@ +// src/features/dataGrid/hooks/useGridSelection.ts +import { CompactSelection, type GridSelection } from "@glideapps/glide-data-grid"; +import { useCallback, useState } from "react"; +import { displayToData, type DisplayModel } from "../displayModel"; +import type { GridInstance } from "../types"; +import { useGridDispatch } from "../useGridDispatch"; + +export function useGridSelection< + TRow extends object, + TGroup, + TKey extends string | number = number, +>( + instance: GridInstance, + model: DisplayModel, + rowAt: (dataIndex: number) => TRow | undefined, +): { + gridSelection: GridSelection; + onGridSelectionChange: (sel: GridSelection) => void; +} { + const dispatch = useGridDispatch(); + const { rowKey } = instance.descriptor; + + const [gridSelection, setGridSelection] = useState({ + columns: CompactSelection.empty(), + rows: CompactSelection.empty(), + }); + + const onGridSelectionChange = useCallback( + (sel: GridSelection) => { + setGridSelection(sel); + + const ids: TKey[] = []; + + sel.rows.toArray().forEach((displayRow) => { + const cell = displayToData(model, displayRow); + + if (cell.kind !== "data") return; + + const row = rowAt(cell.dataIndex); + + if (row) ids.push(rowKey(row)); + }); + + dispatch(instance.actions.setSelectedIds(ids)); + }, + [model, rowAt, dispatch, instance, rowKey], + ); + + return { gridSelection, onGridSelectionChange }; +} diff --git a/src/web/src/features/dataGrid/hooks/useGridSort.test.tsx b/src/web/src/features/dataGrid/hooks/useGridSort.test.tsx new file mode 100644 index 0000000..1c72d52 --- /dev/null +++ b/src/web/src/features/dataGrid/hooks/useGridSort.test.tsx @@ -0,0 +1,122 @@ +// src/features/dataGrid/hooks/useGridSort.test.tsx +import { configureStore } from "@reduxjs/toolkit"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { Provider } from "react-redux"; +import { describe, expect, it } from "vitest"; +import { createGridInstance } from "../store/createGridInstance"; +import type { GridDescriptor } from "../types"; +import { useGridSort } from "./useGridSort"; + +interface Row { + id: number; + name: string; +} + +const FIELDS = ["id", "name", "notes"]; + +function makeHarness() { + const descriptor = { + name: "demo", + rowKey: (r: Row) => r.id, + columns: { + defs: { + id: { field: "id", title: "Id", defaultWidth: 80, editable: false, type: "number" }, + name: { field: "name", title: "Name", defaultWidth: 80, editable: false, type: "text" }, + notes: { field: "notes", title: "Notes", defaultWidth: 80, editable: false, type: "text" }, + }, + defaultOrder: FIELDS, + // `notes` is the unsortable one, so a click on it must change nothing. + sortable: (field: string) => field !== "notes", + }, + api: { + updateRow: async () => ({ ok: true }), + }, + cells: { makeCell: () => ({}) as never, customRenderers: [], validateCell: () => true }, + } as unknown as GridDescriptor; + + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const instance = createGridInstance(descriptor); + const store = configureStore({ + reducer: { demo: instance.reducer }, + middleware: (getDefault) => getDefault({ serializableCheck: false }), + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + const sortState = () => instance.selectRoot(store.getState()).groups.sort; + + return { instance, store, wrapper, sortState }; +} + +const render = (h: ReturnType) => + renderHook(() => useGridSort(h.instance, FIELDS), { wrapper: h.wrapper }); + +describe("useGridSort", () => { + it("sorts ascending on the first click", () => { + const h = makeHarness(); + const { result } = render(h); + + act(() => result.current.onHeaderClicked(0)); + + expect(h.sortState()).toEqual({ field: "id", dir: "asc" }); + }); + + it("reverses to descending when the same column is clicked again", () => { + const h = makeHarness(); + const { result } = render(h); + + act(() => result.current.onHeaderClicked(0)); + act(() => result.current.onHeaderClicked(0)); + + expect(h.sortState()).toEqual({ field: "id", dir: "desc" }); + }); + + it("clears the sort on the third click, so natural order is reachable", () => { + const h = makeHarness(); + const { result } = render(h); + + act(() => result.current.onHeaderClicked(0)); + act(() => result.current.onHeaderClicked(0)); + act(() => result.current.onHeaderClicked(0)); + + expect(h.sortState()).toBeNull(); + }); + + it("cycles from the start again after clearing", () => { + const h = makeHarness(); + const { result } = render(h); + + for (let i = 0; i < 4; i++) act(() => result.current.onHeaderClicked(0)); + + expect(h.sortState()).toEqual({ field: "id", dir: "asc" }); + }); + + it("starts a different column ascending rather than inheriting the direction", () => { + const h = makeHarness(); + const { result } = render(h); + + act(() => result.current.onHeaderClicked(0)); + act(() => result.current.onHeaderClicked(0)); + expect(h.sortState()).toEqual({ field: "id", dir: "desc" }); + + act(() => result.current.onHeaderClicked(1)); + + expect(h.sortState()).toEqual({ field: "name", dir: "asc" }); + }); + + it("ignores clicks on a column that is not sortable", () => { + const h = makeHarness(); + const { result } = render(h); + + act(() => result.current.onHeaderClicked(0)); + act(() => result.current.onHeaderClicked(2)); + + expect(h.sortState()).toEqual({ field: "id", dir: "asc" }); + }); +}); diff --git a/src/web/src/features/dataGrid/hooks/useGridSort.ts b/src/web/src/features/dataGrid/hooks/useGridSort.ts new file mode 100644 index 0000000..d28842f --- /dev/null +++ b/src/web/src/features/dataGrid/hooks/useGridSort.ts @@ -0,0 +1,49 @@ +// src/features/dataGrid/hooks/useGridSort.ts +import { useCallback, useMemo } from "react"; +import { useSelector } from "react-redux"; +import type { GridInstance, GridSort } from "../types"; +import { useGridDispatch } from "../useGridDispatch"; + +export function nextSort(current: GridSort | null, field: string): GridSort | null { + if (current?.field !== field) { + return { field, dir: "asc" }; + } + + return current.dir === "asc" ? { field, dir: "desc" } : null; +} + +export function useGridSort( + instance: GridInstance, + visibleFields: string[], +): { onHeaderClicked: (colIndex: number) => void } { + const dispatch = useGridDispatch(); + + const { descriptor } = instance; + + const sort = useSelector((s: unknown) => instance.selectRoot(s).groups.sort); + + // Default sortable predicate: editable columns only. Descriptors may override. + // Memoized so it stays referentially stable for the useCallback below + // (descriptor is fixed for the grid's lifetime). + const sortable = useMemo( + () => + descriptor.columns.sortable ?? + ((field: string) => descriptor.columns.defs[field]?.editable === true), + [descriptor], + ); + + const onHeaderClicked = useCallback( + (colIndex: number) => { + const field = visibleFields[colIndex]; + + if (!sortable(field)) return; + + // The sort is part of every window's cache key, so changing it is the + // whole refetch: the mounted windows re-request themselves on the new key. + dispatch(instance.actions.setSort(nextSort(sort, field))); + }, + [visibleFields, dispatch, instance, sortable, sort], + ); + + return { onHeaderClicked }; +} diff --git a/src/web/src/features/dataGrid/hooks/useRepaintRows.ts b/src/web/src/features/dataGrid/hooks/useRepaintRows.ts new file mode 100644 index 0000000..09e3c5d --- /dev/null +++ b/src/web/src/features/dataGrid/hooks/useRepaintRows.ts @@ -0,0 +1,108 @@ +import type { DataEditorRef } from "@glideapps/glide-data-grid"; +import { useCallback } from "react"; +import { displayRowsOfData, type DisplayModel } from "../displayModel"; + +/** + * The part of glide's editor handle this hook uses. + * + * Narrowed to the one method, so a test can stand in for the editor without a + * canvas and without the jsdom polyfills a real `DataEditor` mount needs. + */ +export type GridDamageTarget = Pick; + +/** A cell holding a value the caller replaces from outside this hook. */ +interface Cell { + readonly current: T | null; +} + +/** + * The grid's one repaint callback, shared by every writer of a row. + * + * There are three writers. A page load hands it the indexes the store just + * wrote, a settled save hands it the one row it touched, and a pushed update + * hands it the row it patched. None of the three changes any state + * `getCellContent` reads, and glide re-runs `getCellContent` only when its own + * draw inputs move, so this damage call is the only thing that puts any of the + * three on screen. + * + * `rowAt`, `store`, and `model` are NOT stable after mount — a hold that adopts + * replaces all three, and that change of identity is exactly what makes the swap + * itself repaint. But an adoption is a different event from a load, a save, or a + * push, and between two adoptions none of the three reaches React at all. That + * is the gap this callback fills. + * + * It is deliberately ONE callback: the three writers must not drift apart on + * which coordinate space they damage in. + * + * That coordinate space is glide's, which is DISPLAY rows. All three writers + * speak data indexes, so `displayRowsOfData` translates, and a group header + * sitting above the changed rows shifts every one of them. + * + * EVERY VISIBLE COLUMN of a changed row is damaged, not one cell of it. + * `updateCells` builds a `CellSet` of the `[col, row]` pairs it is handed, and + * the cell renderer draws only the cells that set holds. A data cell carries no + * span, so naming column 0 repaints column 0 and leaves the rest of the row as + * it was last drawn. On a flat grid — where the memoised model keeps + * `getCellContent` identical across a page load, so glide blits rather than + * redrawing — that left a landed page showing one filled column beside a row of + * loading skeletons. + * + * The allocation is bounded at ROWS × VISIBLE COLUMNS per call, and the largest + * writer is a page load, which reports at most one page at a time. The bound is + * therefore `pageSize × visible columns`: 700 entries for the bonds grid, at its + * default page size of 100 over 7 columns. It is built, handed to glide, and + * dropped — nothing here is retained between calls. + * + * The model and the column count arrive through cells rather than as arguments + * for two reasons. The callback has to exist BEFORE either does — `useGridData` + * takes it, and the model is built from what `useGridData` returns — and both + * are rebuilt while the grid lives: the model whenever a group collapses or the + * loaded span grows, the column count whenever a column is hidden or shown. A + * captured copy of either would damage against a layout the grid has stopped + * drawing. + */ +export function useRepaintRows( + gridRef: Cell, + modelRef: Cell>, + columnCountRef: Cell, +): (dataIndexes: number[]) => void { + return useCallback( + (dataIndexes: number[]) => { + const editor = gridRef.current; + const model = modelRef.current; + const columnCount = columnCountRef.current; + + // No grid is mounted, or no model has been published yet. There is + // nothing on screen for this write to correct. + if (editor === null || model === null) { + return; + } + + // No column has been published yet, or the user has hidden every one of + // them. Either way no cell of these rows is drawn, so there is nothing to + // damage. + if (columnCount === null || columnCount <= 0) { + return; + } + + const displayRows = displayRowsOfData(model, dataIndexes); + + // Every index was a row the model does not place — a collapsed group, or + // a page past the current total. Damaging nothing is the correct answer. + if (displayRows.length === 0) { + return; + } + + const damage: { cell: readonly [number, number] }[] = []; + + for (const row of displayRows) { + for (let column = 0; column < columnCount; column += 1) { + damage.push({ cell: [column, row] as const }); + } + } + + editor.updateCells(damage); + }, + [gridRef, modelRef, columnCountRef], + ); +} diff --git a/src/web/src/features/dataGrid/hooks/useRowPages.test.tsx b/src/web/src/features/dataGrid/hooks/useRowPages.test.tsx new file mode 100644 index 0000000..9e17db1 --- /dev/null +++ b/src/web/src/features/dataGrid/hooks/useRowPages.test.tsx @@ -0,0 +1,418 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRowStore } from "../data/rowStore"; +import type { SortSpec } from "../data/sortSpec"; +import { createFakeRowServer } from "../testing/fakeRowServer"; +import type { GridDescriptor } from "../types"; +import { MAX_CONCURRENT_PAGES, useRowPages } from "./useRowPages"; + +interface Row { + id: number; + name: string; +} + +const PAGE = 100; + +const DESC_BY_NAME: SortSpec = { field: "name", direction: "desc", nulls: "last" }; + +function makeHarness(total: number) { + const server = createFakeRowServer({ + rows: Array.from({ length: total }, (_, i) => ({ id: i, name: `row-${i}` })), + rowKey: (r) => r.id, + }); + + let openFetch: () => void = () => {}; + let fetchGate: Promise = Promise.resolve(); + let failNext = false; + + const fetchRows = vi.fn(async (p: Parameters[0]) => { + await fetchGate; + + if (failNext) { + failNext = false; + throw new Error("boom"); + } + + return server.api.fetchRows(p); + }); + + const descriptor = { + name: "pages", + rowKey: (r: Row) => r.id, + pageSize: PAGE, + columns: { defs: {}, defaultOrder: [] }, + api: { ...server.api, fetchRows }, + cells: { makeCell: () => ({}), customRenderers: [], validateCell: () => true }, + } as unknown as GridDescriptor; + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 60_000 } }, + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + return { + descriptor, + fetchRows, + wrapper, + holdFetch() { + fetchGate = new Promise((resolve) => { + openFetch = resolve; + }); + }, + releaseFetch() { + openFetch(); + }, + failNextFetch() { + failNext = true; + }, + requested() { + return fetchRows.mock.calls.map((c) => ({ offset: c[0].offset, limit: c[0].limit })); + }, + }; +} + +describe("useRowPages", () => { + it("loads the pages a window touches, and asks for a narrow slice", async () => { + const h = makeHarness(1_000_000); + const store = createRowStore(PAGE, (r) => r.id); + const onRowsLoaded = vi.fn(); + + renderHook( + () => + useRowPages({ + descriptor: h.descriptor, + store, + range: { offset: 999_700, limit: 300 }, + sort: null, + collapsedGroups: [], + onRowsLoaded, + }), + { wrapper: h.wrapper }, + ); + + await waitFor(() => expect(store.getRow(999_700)).toBeDefined()); + + expect(h.requested()).toEqual([ + { offset: 999_700, limit: PAGE }, + { offset: 999_800, limit: PAGE }, + { offset: 999_900, limit: PAGE }, + ]); + expect(store.getRow(999_999)?.name).toBe("row-999999"); + }); + + it("issues a whole grouped window in one round of requests", async () => { + // The widest window the grid builds: `rangeForViewport` takes the pages a + // viewport spans plus one either side, and `widenedForPrecedingGroup` adds + // one more below a grouped window. A cap under that number is not wrong, + // but it costs the leftover pages a full extra round trip on every jump. + const h = makeHarness(1_000_000); + const store = createRowStore(PAGE, (r) => r.id); + + const viewportPages = 2; + const windowPages = viewportPages + 3; + + expect(MAX_CONCURRENT_PAGES).toBeGreaterThanOrEqual(windowPages); + + h.holdFetch(); + + renderHook( + () => + useRowPages({ + descriptor: h.descriptor, + store, + range: { offset: 500_000, limit: windowPages * PAGE }, + sort: null, + collapsedGroups: [], + onRowsLoaded: () => {}, + }), + { wrapper: h.wrapper }, + ); + + // Every page is asked for while the gate is still shut, so they are all in + // flight together rather than queued behind one another. + await waitFor(() => expect(h.requested()).toHaveLength(windowPages)); + + h.releaseFetch(); + + await waitFor(() => expect(store.getRow(500_000 + windowPages * PAGE - 1)).toBeDefined()); + }); + + it("reports the loaded indexes so the caller can damage exactly those cells", async () => { + const h = makeHarness(1_000); + const store = createRowStore(PAGE, (r) => r.id); + const onRowsLoaded = vi.fn(); + + renderHook( + () => + useRowPages({ + descriptor: h.descriptor, + store, + range: { offset: 0, limit: PAGE }, + sort: null, + collapsedGroups: [], + onRowsLoaded, + }), + { wrapper: h.wrapper }, + ); + + await waitFor(() => expect(onRowsLoaded).toHaveBeenCalled()); + + const indexes = onRowsLoaded.mock.calls[0][0] as number[]; + + expect(indexes[0]).toBe(0); + expect(indexes).toHaveLength(PAGE); + }); + + it("never asks for a page it already holds", async () => { + const h = makeHarness(1_000); + const store = createRowStore(PAGE, (r) => r.id); + + const { rerender } = renderHook( + ({ offset }: { offset: number }) => + useRowPages({ + descriptor: h.descriptor, + store, + range: { offset, limit: PAGE }, + sort: null, + collapsedGroups: [], + onRowsLoaded: () => {}, + }), + { wrapper: h.wrapper, initialProps: { offset: 0 } }, + ); + + await waitFor(() => expect(store.getRow(0)).toBeDefined()); + + rerender({ offset: 100 }); + await waitFor(() => expect(store.getRow(100)).toBeDefined()); + + rerender({ offset: 0 }); + + // A settle window long enough for a wrong second request to appear. + await act(() => new Promise((r) => setTimeout(r, 60))); + + expect(h.requested()).toEqual([ + { offset: 0, limit: PAGE }, + { offset: 100, limit: PAGE }, + ]); + }); + + it("keeps the rows it already holds while the next window loads — no flash", async () => { + const h = makeHarness(10_000); + const store = createRowStore(PAGE, (r) => r.id); + + const { rerender } = renderHook( + ({ offset }: { offset: number }) => + useRowPages({ + descriptor: h.descriptor, + store, + range: { offset, limit: PAGE }, + sort: null, + collapsedGroups: [], + onRowsLoaded: () => {}, + }), + { wrapper: h.wrapper, initialProps: { offset: 0 } }, + ); + + await waitFor(() => expect(store.getRow(0)).toBeDefined()); + + h.holdFetch(); + rerender({ offset: 100 }); + + // THE HEADLINE ASSERTION. The next window is mid-flight and rows 0-99 are + // still there. Under the collection-per-window design they were gone. + await act(() => new Promise((r) => setTimeout(r, 30))); + expect(store.getRow(0)?.name).toBe("row-0"); + expect(store.getRow(99)?.name).toBe("row-99"); + + h.releaseFetch(); + await waitFor(() => expect(store.getRow(100)).toBeDefined()); + expect(store.getRow(0)?.name).toBe("row-0"); + }); + + it("reissues a page whose load was discarded mid-flight, instead of leaving it loading forever", async () => { + const h = makeHarness(10_000); + const store = createRowStore(PAGE, (r) => r.id); + + h.holdFetch(); + + const { rerender } = renderHook( + ({ sort }: { sort: SortSpec | null }) => + useRowPages({ + descriptor: h.descriptor, + store, + range: { offset: 0, limit: PAGE }, + sort, + collapsedGroups: [], + onRowsLoaded: () => {}, + }), + { wrapper: h.wrapper, initialProps: { sort: null as SortSpec | null } }, + ); + + await waitFor(() => expect(h.requested()).toEqual([{ offset: 0, limit: PAGE }])); + + // Sort by another column. Page 0's still-pending load answers for an order + // the grid no longer shows, so its result is discarded when it lands. The + // page reads "loading" until then, so this render asks for nothing new. + rerender({ sort: DESC_BY_NAME }); + await act(() => new Promise((r) => setTimeout(r, 20))); + expect(h.requested()).toHaveLength(1); + + // The blocked fetch settles now. It abandons page 0, and it must still + // nudge the hook to notice: nothing else will ever ask again on its own, + // and a page left "loading" is a page the loader skips for ever. + h.releaseFetch(); + + await waitFor(() => expect(h.requested()).toHaveLength(2)); + await waitFor(() => expect(store.pageState(0)).toBe("loaded")); + + // The reissue carries the order the grid actually shows now. + expect(h.fetchRows.mock.calls[1][0].sort).toEqual(DESC_BY_NAME); + }); + + it("keeps a page whose window moved while it was in flight, and never re-requests it", async () => { + // The regression this guards: the load effect re-runs whenever the window + // moves or a page settles, and it used to CANCEL the pages in flight when + // it did. Each cancelled page went back to "missing" and settled, which + // re-ran the effect and cancelled the next one — so a window of two pages + // re-requested the same two pages for as long as the grid stayed open, and + // not one of them was ever written. + const h = makeHarness(10_000); + const store = createRowStore(PAGE, (r) => r.id); + + h.holdFetch(); + + const { rerender } = renderHook( + ({ limit }: { limit: number }) => + useRowPages({ + descriptor: h.descriptor, + store, + range: { offset: 0, limit }, + sort: null, + collapsedGroups: [], + onRowsLoaded: () => {}, + }), + { wrapper: h.wrapper, initialProps: { limit: PAGE } }, + ); + + await waitFor(() => expect(h.requested()).toEqual([{ offset: 0, limit: PAGE }])); + + // The window widens onto a second page while the first is still in flight — + // exactly what glide's first visible-region callback does on mount. + rerender({ limit: 2 * PAGE }); + await waitFor(() => expect(h.requested()).toHaveLength(2)); + + h.releaseFetch(); + + await waitFor(() => expect(store.getRow(0)?.name).toBe("row-0")); + await waitFor(() => expect(store.getRow(100)?.name).toBe("row-100")); + + // Settled, and settled for good: one request per page, and no further + // request after the store went quiet. + await act(() => new Promise((r) => setTimeout(r, 80))); + + expect(h.requested()).toEqual([ + { offset: 0, limit: PAGE }, + { offset: 100, limit: PAGE }, + ]); + }); + + it("reports loading only while the window holds no rows at all", async () => { + const h = makeHarness(1_000); + const store = createRowStore(PAGE, (r) => r.id); + + const { result } = renderHook( + () => + useRowPages({ + descriptor: h.descriptor, + store, + range: { offset: 0, limit: PAGE }, + sort: null, + collapsedGroups: [], + onRowsLoaded: () => {}, + }), + { wrapper: h.wrapper }, + ); + + expect(result.current.status).toBe("loading"); + + await waitFor(() => expect(result.current.status).toBe("ready")); + }); + + it("reports an error when a page the window needs fails", async () => { + const h = makeHarness(1_000); + const store = createRowStore(PAGE, (r) => r.id); + h.failNextFetch(); + + const { result } = renderHook( + () => + useRowPages({ + descriptor: h.descriptor, + store, + range: { offset: 0, limit: PAGE }, + sort: null, + collapsedGroups: [], + onRowsLoaded: () => {}, + }), + { wrapper: h.wrapper }, + ); + + await waitFor(() => expect(result.current.status).toBe("error")); + expect(store.pageState(0)).toBe("failed"); + }); + + it("reloads a failed page on retry", async () => { + const h = makeHarness(1_000); + const store = createRowStore(PAGE, (r) => r.id); + h.failNextFetch(); + + const { result } = renderHook( + () => + useRowPages({ + descriptor: h.descriptor, + store, + range: { offset: 0, limit: PAGE }, + sort: null, + collapsedGroups: [], + onRowsLoaded: () => {}, + }), + { wrapper: h.wrapper }, + ); + + await waitFor(() => expect(result.current.status).toBe("error")); + + act(() => result.current.retry()); + + await waitFor(() => expect(result.current.status).toBe("ready")); + expect(store.getRow(0)?.name).toBe("row-0"); + }); + + it("sends the sort and the collapse set to the server", async () => { + const h = makeHarness(1_000); + const store = createRowStore(PAGE, (r) => r.id); + + renderHook( + () => + useRowPages({ + descriptor: h.descriptor, + store, + range: { offset: 0, limit: PAGE }, + sort: { field: "name", direction: "desc", nulls: "last" }, + collapsedGroups: [2], + onRowsLoaded: () => {}, + }), + { wrapper: h.wrapper }, + ); + + await waitFor(() => expect(h.fetchRows).toHaveBeenCalled()); + + const call = h.fetchRows.mock.calls[0][0]; + + expect(call.sort).toEqual({ field: "name", direction: "desc", nulls: "last" }); + expect(call.collapsedGroups).toEqual([2]); + }); +}); diff --git a/src/web/src/features/dataGrid/hooks/useRowPages.ts b/src/web/src/features/dataGrid/hooks/useRowPages.ts new file mode 100644 index 0000000..7767c4f --- /dev/null +++ b/src/web/src/features/dataGrid/hooks/useRowPages.ts @@ -0,0 +1,425 @@ +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { RowStore } from "../data/rowStore"; +import type { SortSpec } from "../data/sortSpec"; +import type { GridDescriptor } from "../types"; +import type { WindowRange } from "./useWindowRange"; + +/** + * How many page requests may be in flight at once. + * + * The number a window needs follows from two places, and both add to it: + * + * - `rangeForViewport` takes the pages the viewport touches plus one either + * side, so a viewport spanning N pages asks for N + 2. + * - `widenedForPrecedingGroup` adds one more below a GROUPED window, for the + * row whose group key the span reads. + * + * So a grouped grid wants N + 3. A viewport spans two pages whenever it + * straddles a page boundary, which at the default page size of 100 is most of + * the time, and three only at a viewport height no screen has. Six therefore + * loads a whole window in one round of requests, with a page of headroom, and + * still stops a fast scroll opening an unbounded number of sockets. + * + * A cap BELOW the window size is not a correctness problem — the leftover pages + * simply go in a second round — but it costs those pages a full extra round + * trip, and at 4 that penalty landed on nearly every jump. + */ +export const MAX_CONCURRENT_PAGES = 6; + +/** + * Every cached page of one grid sits under this prefix, so anything that has to + * drop a grid's rows in one call cannot drift from the key that builds on it. + */ +export function rowsKeyPrefix(name: string): readonly unknown[] { + return [name, "page"]; +} + +/** + * The cache key of one page. + * + * The sort and the collapse set are IN the key, not beside it. Both change + * which rows sit at a given offset, so a page fetched under one must never be + * served under another. + */ +export function rowPagesKey( + name: string, + page: number, + sort: SortSpec | null, + collapsedGroups: unknown[], +): readonly unknown[] { + return [...rowsKeyPrefix(name), page, sort, [...collapsedGroups].sort()]; +} + +export interface UseRowPagesArgs { + descriptor: GridDescriptor; + store: RowStore; + range: WindowRange; + sort: SortSpec | null; + collapsedGroups: TGroup[]; + + /** + * Called with the absolute indexes a page load wrote. The caller turns them + * into a glide damage list. This is the ONLY way a load reaches the screen — + * the hook stores no rows in React state, so no load re-renders the grid. + */ + onRowsLoaded: (indexes: number[]) => void; + + /** + * The range `status` reports on. Defaults to `range`. + * + * A caller may widen `range` to fetch a page outside the visible window — + * `hooks/useGridData.ts` does this to load the row just above a grouped + * window, purely so `spanFromStore` can read that row's group. That extra + * page is not on screen, so its own failure or success must never change + * what the user sees reported: pass the narrower, un-widened range here and + * a failed page nobody can see can neither hide behind a healthy extra page + * nor manufacture an error over rows that loaded fine. + */ + statusRange?: WindowRange; +} + +export interface UseRowPagesResult { + status: "loading" | "ready" | "error"; + + /** + * Counts page settlements. It names no page and carries no row, so nothing + * about a row's VALUE reaches React through it. + * + * A caller that reads the store through a `useMemo` must key that memo on + * this rather than on `status`. `status` reads "ready" the moment the FIRST + * page of a window lands and never moves again while the rest of that + * window arrives, so a memo keyed on it freezes at one page. + */ + settled: number; + + /** Clears the failed pages of the current window and loads them again. */ + retry: () => void; +} + +/** + * Fills the row store for whatever window is current. + * + * The design rule is that ROWS NEVER ENTER REACT STATE. A page lands, the store + * takes it, and `onRowsLoaded` damages those cells. React re-renders only when + * the status changes, and the status is three strings. + * + * That is what removes the flash. The old collection-per-window design put the + * rows behind a live query keyed on collection identity, so a window move + * handed the renderer an empty result and every visible cell fell back to + * `GridCellKind.Loading` until the fetch landed. Here a window move loads the + * pages it lacks and touches nothing the store already holds. + */ +export function useRowPages({ + descriptor, + store, + range, + statusRange = range, + sort, + collapsedGroups, + onRowsLoaded, +}: UseRowPagesArgs): UseRowPagesResult { + const queryClient = useQueryClient(); + + // Bumped whenever a page settles, which is what re-runs the status read + // below. It counts settlements rather than naming one, so nothing about a + // row's VALUE reaches React through it. + const [settled, setSettled] = useState(0); + + // The callback is read through a ref so a caller may pass a fresh closure per + // render without re-running the load effect and re-requesting every page. + const onRowsLoadedRef = useRef(onRowsLoaded); + onRowsLoadedRef.current = onRowsLoaded; + + const pages = pagesInRange(range, store.pageSize); + const pagesKey = pages.join(","); + const stateKey = JSON.stringify([sort, [...collapsedGroups].sort()]); + + // Read at WRITE time by a page load that started one or more renders ago, so + // it can tell whether the order it fetched under is still the order on + // screen. It is a ref rather than a closure over `stateKey` because the load + // needs the CURRENT value, not the value of the render that started it. + const stateKeyRef = useRef(stateKey); + stateKeyRef.current = stateKey; + + // `statusOf` reads THIS set, never `pages`. `pages` is what gets fetched — + // it may hold a page outside the visible window — and `status` must speak + // only for what the window actually shows. + const statusPages = pagesInRange(statusRange, store.pageSize); + + // What an in-flight page load reads to tell whether its result is still + // wanted. A load reaches back for it rather than closing over a flag of the + // effect that started it, because the two are invalidated by DIFFERENT + // things — see the epoch effect below. + const epochRef = useRef<{ cancelled: boolean }>({ cancelled: false }); + + // THE EPOCH. A page fetched for one store under one sort and collapse set is + // worthless to any other, and worthless after an unmount; it is worth exactly + // as much as ever to a window that has since moved, because a moved window + // changes no row's position and the store caps and evicts its own pages. + // + // So the flag that discards a result belongs to a hook-lifetime effect keyed + // on the store and the state key ALONE, never on the window or the settle + // counter. Tying it to the load effect below — which re-runs on both — is what + // made the loader unable to converge: every settle tore that effect down, + // cancelling the pages still in flight; each cancelled page went back to + // "missing" and settled, which tore the effect down again. With two pages in a + // window the two took turns cancelling each other and NO page was ever + // written, so the grid re-requested the same two pages for as long as it + // stayed open. + useEffect(() => { + const epoch = { cancelled: false }; + epochRef.current = epoch; + + return () => { + epoch.cancelled = true; + }; + }, [store, stateKey]); + + useEffect(() => { + const epoch = epochRef.current; + + const run = async () => { + for (const chunk of chunked(pages, MAX_CONCURRENT_PAGES)) { + if (epoch.cancelled) { + return; + } + + const wanted = chunk.filter((page) => store.pageState(page) === "missing"); + + if (wanted.length === 0) { + continue; + } + + await Promise.all( + wanted.map((page) => + loadPage({ + queryClient, + descriptor, + store, + page, + sort, + collapsedGroups, + stateKey, + currentStateKey: () => stateKeyRef.current, + isCancelled: () => epoch.cancelled, + onRowsLoaded: (indexes) => onRowsLoadedRef.current(indexes), + onSettled: () => setSettled((n) => n + 1), + }), + ), + ); + } + }; + + void run(); + + // No cleanup. This effect only STARTS loads; what makes one worthless is + // the epoch above, and a re-run here means the window moved or a page + // settled — neither of which invalidates a fetch already in the air. + // `pagesKey` and `stateKey` stand in for the arrays they describe, so a + // caller that rebuilds `collapsedGroups` per render does not re-request + // every page. `settled` re-runs the effect after a retry clears a page. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pagesKey, stateKey, store, descriptor, queryClient, settled]); + + const retry = useCallback(() => { + for (const page of pages) { + if (store.pageState(page) === "failed") { + queryClient.removeQueries({ + queryKey: rowPagesKey(descriptor.name, page, sort, collapsedGroups), + }); + + store.markMissing(page); + } + } + + setSettled((n) => n + 1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pagesKey, stateKey, store, descriptor, queryClient]); + + return { status: statusOf(store, statusPages), settled, retry }; +} + +/** Every page an inclusive window touches. */ +function pagesInRange(range: WindowRange, pageSize: number): number[] { + const first = Math.floor(range.offset / pageSize); + const last = Math.floor(Math.max(range.offset, range.offset + range.limit - 1) / pageSize); + + const pages: number[] = []; + + for (let page = first; page <= last; page += 1) { + pages.push(page); + } + + return pages; +} + +function chunked(items: T[], size: number): T[][] { + const chunks: T[][] = []; + + for (let i = 0; i < items.length; i += size) { + chunks.push(items.slice(i, i + size)); + } + + return chunks; +} + +/** + * A window that holds no rows and has a failed page reads as an error. A window + * that holds SOME rows never does: the loading cells already speak for the rest, + * and a banner over rows the user can read says the grid is broken when it is + * not. + */ +function statusOf( + store: RowStore, + pages: number[], +): "loading" | "ready" | "error" { + const anyLoaded = pages.some((page) => store.pageState(page) === "loaded"); + + if (anyLoaded) { + return "ready"; + } + + if (pages.some((page) => store.pageState(page) === "failed")) { + return "error"; + } + + return "loading"; +} + +async function loadPage({ + queryClient, + descriptor, + store, + page, + sort, + collapsedGroups, + stateKey, + currentStateKey, + isCancelled, + onRowsLoaded, + onSettled, +}: { + queryClient: QueryClient; + descriptor: GridDescriptor; + store: RowStore; + page: number; + sort: SortSpec | null; + collapsedGroups: TGroup[]; + stateKey: string; + currentStateKey: () => string; + isCancelled: () => boolean; + onRowsLoaded: (indexes: number[]) => void; + onSettled: () => void; +}): Promise { + /** + * Whether this run's result must be discarded rather than written. + * + * Two separate things invalidate it, and neither implies the other: + * + * - `isCancelled` — the EPOCH this run belongs to ended: the hook unmounted, + * or it was handed a different store. + * - the sort or the collapse set moved while the request was in flight, so + * the rows coming back answer for an order the grid no longer shows. + * Writing them would seat old-order rows at indexes the new order owns. + * + * A WINDOW MOVE is deliberately absent from that list. The rows coming back + * sit at the same indexes in the same store whether or not the user has + * scrolled since, so a moved window makes them no less true; the store's own + * page cap decides whether they are worth keeping. Discarding them here + * instead threw away a completed round trip AND, through the settle bump in + * `finally`, asked for the very same page again. + */ + const mustDiscard = () => isCancelled() || currentStateKey() !== stateKey; + + store.markLoading(page); + + try { + const rows = await queryClient.fetchQuery({ + queryKey: rowPagesKey(descriptor.name, page, sort, collapsedGroups), + queryFn: ({ signal }) => + descriptor.api.fetchRows({ + offset: page * store.pageSize, + limit: store.pageSize, + sort, + collapsedGroups, + signal, + }), + }); + + // The epoch ended, or the state key moved, while this was in flight. + // Writing now would put rows fetched under the OLD sort into a store the new + // one owns. The page must not stay "loading" forever, + // either: nothing else will ever move it out of that state, and the loader + // only re-requests a page whose state is "missing". Put it back to missing, + // and drop the query-cache entry along with it, or a later window's + // `fetchQuery` for this same key would be served this stale response + // straight from cache instead of ever calling `queryFn` again. + if (mustDiscard()) { + abandon({ queryClient, descriptor, page, sort, collapsedGroups, store }); + return; + } + + onRowsLoaded(store.writePage(page, rows)); + } catch { + if (mustDiscard()) { + abandon({ queryClient, descriptor, page, sort, collapsedGroups, store }); + return; + } + + // Swallowed on purpose. The page state carries the failure and + // `GridStatusBar` shows it; re-throwing here would surface as an unhandled + // rejection from an effect nobody awaits. + store.markFailed(page); + } finally { + // Called even when this run was discarded. `abandon()` above just put the + // page back to "missing", and a settle bump is the ONLY thing that makes + // the hook re-check: nothing else re-runs the load effect when a window + // parked on that exact page never itself changes. Without this, a page + // whose in-flight fetch is discarded — the sort moved away and straight + // back, say — starves forever: "missing", with nobody asking again. + // + // This cannot spin, and the distinction matters, because for one release it + // did. A settle bump re-runs the load effect, and that effect only calls + // `loadPage` for a page whose state reads "missing" at that moment. A page + // that just loaded, failed, or was abandoned is no longer in that state (or + // was put back into it on purpose), so a re-run finds nothing left to fetch + // and terminates without settling again. What broke that argument was the + // re-run also CANCELLING the pages still in flight: each cancellation put + // one back to "missing" and settled, which cancelled the next one, so two + // pages in a window kept each other missing for ever. The epoch keeps the + // re-run from cancelling anything, which is what makes the argument hold. + onSettled(); + } +} + +/** + * Undoes a page load that landed after its own effect was cancelled: the + * store is put back to "missing" so a later window can ask for it again, and + * the query-cache entry is dropped so that request actually reaches the + * network instead of being served this abandoned response from cache. + * + * Never call this for a page that was actually written — only for a page + * whose result this run is discarding. + */ +function abandon({ + queryClient, + descriptor, + page, + sort, + collapsedGroups, + store, +}: { + queryClient: QueryClient; + descriptor: GridDescriptor; + page: number; + sort: SortSpec | null; + collapsedGroups: TGroup[]; + store: RowStore; +}): void { + queryClient.removeQueries({ + queryKey: rowPagesKey(descriptor.name, page, sort, collapsedGroups), + }); + + store.markMissing(page); +} diff --git a/src/web/src/features/dataGrid/hooks/useWindowRange.test.tsx b/src/web/src/features/dataGrid/hooks/useWindowRange.test.tsx new file mode 100644 index 0000000..0e4dd32 --- /dev/null +++ b/src/web/src/features/dataGrid/hooks/useWindowRange.test.tsx @@ -0,0 +1,134 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildFlatModel, type DisplayModel } from "../displayModel"; +import { rangeForViewport, useWindowRange, WINDOW_COMMIT_WAIT_MS } from "./useWindowRange"; + +const flat = (total: number) => buildFlatModel(total) as DisplayModel; + +describe("rangeForViewport", () => { + it("rounds the start down to a page and pads one page each side", () => { + expect(rangeForViewport(flat(1000), 250, 260, 100)).toEqual({ offset: 100, limit: 300 }); + }); + + it("never returns a negative offset", () => { + expect(rangeForViewport(flat(1000), 0, 10, 100)).toEqual({ offset: 0, limit: 200 }); + }); + + it("returns null when the range holds no data rows", () => { + expect(rangeForViewport(flat(0), 0, 10, 100)).toBeNull(); + }); +}); + +describe("useWindowRange", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("starts on the first page", () => { + const { result } = renderHook(() => useWindowRange(100)); + expect(result.current.range).toEqual({ offset: 0, limit: 100 }); + }); + + it("commits one range after scrolling settles", () => { + const model = flat(1000); + const { result } = renderHook(() => useWindowRange(100)); + + act(() => { + result.current.onRectChanged(model, { x: 0, y: 100, width: 5, height: 10 }); + result.current.onRectChanged(model, { x: 0, y: 300, width: 5, height: 10 }); + result.current.onRectChanged(model, { x: 0, y: 500, width: 5, height: 10 }); + }); + + expect(result.current.range).toEqual({ offset: 0, limit: 100 }); + + act(() => void vi.advanceTimersByTime(WINDOW_COMMIT_WAIT_MS + 1)); + + expect(result.current.range).toEqual({ offset: 400, limit: 300 }); + }); + + it("commits while a steady scroll never rests for the full wait", () => { + const model = flat(100_000); + const { result } = renderHook(() => useWindowRange(100)); + + // A drag that reports a new rect more often than the debouncer can fire. + // The range recomputes to the same value on most of these, and passing + // those repeats to the debouncer restarted its timer for ever. + act(() => { + for (let i = 1; i <= 40; i += 1) { + result.current.onRectChanged(model, { x: 0, y: i * 5, width: 5, height: 10 }); + vi.advanceTimersByTime(WINDOW_COMMIT_WAIT_MS - 20); + } + }); + + expect(result.current.range).not.toEqual({ offset: 0, limit: 100 }); + }); + + it("commits a window the store already holds without any wait", () => { + const model = flat(1000); + const loaded = { current: () => true }; + + const { result } = renderHook(() => useWindowRange(100, loaded)); + + act(() => { + result.current.onRectChanged(model, { x: 0, y: 500, width: 5, height: 10 }); + }); + + // No timer advanced. Cached rows draw on the frame the scroll reaches them. + expect(result.current.range).toEqual({ offset: 400, limit: 300 }); + }); + + it("still waits out the scroll for a window that would fetch", () => { + const model = flat(1000); + const loaded = { current: () => false }; + + const { result } = renderHook(() => useWindowRange(100, loaded)); + + act(() => { + result.current.onRectChanged(model, { x: 0, y: 500, width: 5, height: 10 }); + }); + + expect(result.current.range).toEqual({ offset: 0, limit: 100 }); + + act(() => void vi.advanceTimersByTime(WINDOW_COMMIT_WAIT_MS + 1)); + + expect(result.current.range).toEqual({ offset: 400, limit: 300 }); + }); + + it("drops a pending cold window when the scroll lands back on cached rows", () => { + const model = flat(10_000); + const loaded = { current: (r: { offset: number }) => r.offset < 1000 }; + + const { result } = renderHook(() => useWindowRange(100, loaded)); + + // A flick into cold data arms the wait, then the user comes straight back. + act(() => { + result.current.onRectChanged(model, { x: 0, y: 5000, width: 5, height: 10 }); + result.current.onRectChanged(model, { x: 0, y: 300, width: 5, height: 10 }); + }); + + expect(result.current.range).toEqual({ offset: 200, limit: 300 }); + + // The armed window must never land on top of the one the user is reading. + act(() => void vi.advanceTimersByTime(WINDOW_COMMIT_WAIT_MS + 1)); + + expect(result.current.range).toEqual({ offset: 200, limit: 300 }); + }); + + it("keeps the previous range when the viewport describes no data rows", () => { + const model = flat(1000); + const { result } = renderHook(() => useWindowRange(100)); + + act(() => { + result.current.onRectChanged(model, { x: 0, y: 500, width: 5, height: 10 }); + vi.advanceTimersByTime(WINDOW_COMMIT_WAIT_MS + 1); + }); + + expect(result.current.range).toEqual({ offset: 400, limit: 300 }); + + act(() => { + result.current.onRectChanged(flat(0), { x: 0, y: 0, width: 5, height: 3 }); + vi.advanceTimersByTime(WINDOW_COMMIT_WAIT_MS + 1); + }); + + expect(result.current.range).toEqual({ offset: 400, limit: 300 }); + }); +}); diff --git a/src/web/src/features/dataGrid/hooks/useWindowRange.ts b/src/web/src/features/dataGrid/hooks/useWindowRange.ts new file mode 100644 index 0000000..615c195 --- /dev/null +++ b/src/web/src/features/dataGrid/hooks/useWindowRange.ts @@ -0,0 +1,147 @@ +import type { Rectangle } from "@glideapps/glide-data-grid"; +import { useDebouncer } from "@tanstack/react-pacer"; +import { useCallback, useRef, useState } from "react"; +import { dataRangeInDisplayRange, type DisplayModel } from "../displayModel"; + +/** + * How long scrolling must settle before a window that needs a FETCH is + * committed. Long enough that flinging through a long list requests nothing it + * passes. A window the store already holds skips this wait entirely — see + * `useWindowRange`. + */ +export const WINDOW_COMMIT_WAIT_MS = 120; + +export interface WindowRange { + offset: number; + limit: number; +} + +/** + * Whether the store already holds every row a candidate window needs, so + * committing it starts no request. + * + * The grid supplies this rather than the hook computing it: the hook is pure + * viewport arithmetic and holds no store, and the store that answers depends on + * whether a sort or collapse hold is running. See `useGridData.isRangeLoaded`. + */ +export type RangeLoaded = (range: WindowRange) => boolean; + +/** + * Which rows a visible display range needs: the pages it touches, plus one page + * either side so a small scroll does not stall on a fetch. Null when the range + * holds no data rows at all. Pure. + */ +export function rangeForViewport( + model: DisplayModel, + fromDisplay: number, + toDisplay: number, + pageSize: number, +): WindowRange | null { + const span = dataRangeInDisplayRange(model, fromDisplay, toDisplay); + if (span === null) return null; + + const firstStart = Math.floor(span.min / pageSize) * pageSize; + const lastStart = Math.floor(span.max / pageSize) * pageSize; + + const offset = Math.max(0, firstStart - pageSize); + const end = lastStart + 2 * pageSize; + + return { offset, limit: end - offset }; +} + +/** + * Translates glide's visible rectangle into the one range the grid loads. + * + * THE WAIT GUARDS THE NETWORK, NOT THE WINDOW. A window the store already + * holds commits on the frame the scroll reaches it, because committing it + * starts no request and there is nothing to guard against. Only a window that + * would fetch waits for the scroll to settle. + * + * That split is what makes scrolling back over rows the grid has already loaded + * feel immediate. Under one flat wait it did not: a grouped grid reads its + * headers out of `span`, `span` keys on this range, and so every scroll-back + * paid `WINDOW_COMMIT_WAIT_MS` before the headers caught up to rows that were + * sitting in memory the whole time. + * + * One rule carries over from the window-set model. A range that describes no + * data rows — a viewport of group headers only, or a collapsed view — keeps the + * previous range instead of committing nothing, because glide dedupes its + * region callback on the rectangle and would never ask again. + */ +export function useWindowRange( + pageSize: number, + isLoadedRef?: { current: RangeLoaded | null }, +): { + range: WindowRange; + onRectChanged: (model: DisplayModel, rect: Rectangle) => void; +} { + const [range, setRange] = useState({ offset: 0, limit: pageSize }); + + const apply = useCallback((next: WindowRange) => { + setRange((prev) => (prev.offset === next.offset && prev.limit === next.limit ? prev : next)); + }, []); + + // `useDebouncer` rather than `useDebouncedCallback`, for `cancel()` alone. The + // immediate path below has to drop a wait already armed: a flick into cold + // data arms one, and a scroll straight back to cached rows must not let that + // stale window land 120 ms later on top of the one the user is looking at. + const commit = useDebouncer(apply, { wait: WINDOW_COMMIT_WAIT_MS }); + + // The range the last call ASKED for, which is not the committed range: a + // request sits in the debouncer for `WINDOW_COMMIT_WAIT_MS` before it lands. + // Comparing against the committed range instead would re-ask for the pending + // one on every rect change and reset the timer, which is the starvation this + // ref exists to stop. + const requestedRef = useRef({ offset: 0, limit: pageSize }); + + // The model arrives per call, not per hook: this hook runs before the model + // exists in a render, and glide calls back after it does. + const onRectChanged = useCallback( + (model: DisplayModel, rect: Rectangle) => { + const next = rangeForViewport(model, rect.y, rect.y + rect.height, pageSize); + + if (next === null) { + return; + } + + // THE DEDUPE RUNS BEFORE THE TIMER, NOT INSIDE IT. + // + // `rangeForViewport` is page-aligned, so a scroll that stays inside one + // window recomputes the SAME range on every frame. Passing each of those + // to the debouncer restarts its timer, and a steady scroll restarts it + // faster than it can fire. Four seconds of slow dragging then committed + // nothing at all, and the user scrolled into blank cells that no request + // was ever going to fill. + // + // Dropping the repeat here leaves the debouncer only the calls that + // really move the window — one per page boundary the viewport crosses — + // so the timer runs out between them and the window commits. + const prev = requestedRef.current; + + if (prev.offset === next.offset && prev.limit === next.limit) { + return; + } + + requestedRef.current = next; + + // THE RULE: the wait is the price of a request, so a window that makes no + // request does not pay it. + // + // `isLoadedRef` is read here, at call time, rather than taken as a value. + // The predicate needs the store, `useGridData` needs this hook's range to + // build one, and glide calls back after both have run — the same + // over-time loop closure `DataGrid` uses for its model and column refs. + if (isLoadedRef?.current?.(next) === true) { + commit.cancel(); + apply(next); + + return; + } + + commit.maybeExecute(next); + }, + [pageSize, commit, apply, isLoadedRef], + ); + + return { range, onRectChanged }; +} diff --git a/src/web/src/features/dataGrid/store/columnsSlice.test.ts b/src/web/src/features/dataGrid/store/columnsSlice.test.ts new file mode 100644 index 0000000..d064714 --- /dev/null +++ b/src/web/src/features/dataGrid/store/columnsSlice.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { createColumnsSlice } from "./columnsSlice"; +import type { ColumnsState } from "../types"; + +const make = () => createColumnsSlice("test", { defaultOrder: ["a", "b", "c"] }); + +describe("createColumnsSlice", () => { + it("toggleColumn hides then shows a field", () => { + const { reducer, actions } = make(); + let s = reducer(undefined, { type: "@@init" }); + s = reducer(s, actions.toggleColumn("b")); + expect(s.hidden).toContain("b"); + s = reducer(s, actions.toggleColumn("b")); + expect(s.hidden).not.toContain("b"); + }); + + it("moveColumn ignores an out-of-range source index", () => { + const { reducer, actions } = make(); + const s0 = reducer(undefined, { type: "@@init" }); + const s1 = reducer(s0, actions.moveColumn({ from: 99, to: 0 })); + expect(s1.order).toEqual(s0.order); + }); + + it("resizeColumn records a width", () => { + const { reducer, actions } = make(); + const s = reducer( + reducer(undefined, { type: "@@init" }), + actions.resizeColumn({ field: "a", width: 321 }), + ); + expect(s.widths.a).toBe(321); + }); + + describe("setColumns", () => { + it("hydrates order/widths/hidden", () => { + const { reducer, actions } = make(); + let s = reducer(undefined, { type: "@@init" }); + s = reducer( + s, + actions.setColumns({ order: ["c", "b", "a"], widths: { b: 50 }, hidden: ["a"] }), + ); + expect(s.order).toEqual(["c", "b", "a"]); + expect(s.widths.b).toBe(50); + expect(s.hidden).toEqual(["a"]); + }); + + it("empty order: [] does NOT blank a populated default order (merge semantics)", () => { + const { reducer, actions } = make(); + let s = reducer(undefined, { type: "@@init" }); + expect(s.order).toEqual(["a", "b", "c"]); + s = reducer(s, actions.setColumns({ order: [], widths: { a: 10 }, hidden: [] })); + expect(s.order).toEqual(["a", "b", "c"]); + }); + }); + + describe("loadColumns thunk", () => { + const drain = async ( + thunk: ReturnType["loadColumns"]>, + ) => { + const dispatched: unknown[] = []; + await thunk((a: unknown) => dispatched.push(a)); + return dispatched; + }; + + it("dispatches setColumns with the loaded layout", async () => { + const loaded: ColumnsState = { order: ["c", "a", "b"], widths: { a: 12 }, hidden: ["b"] }; + const { loadColumns } = createColumnsSlice("test", { + defaultOrder: ["a", "b", "c"], + load: async () => loaded, + }); + const dispatched = await drain(loadColumns()); + expect(dispatched).toEqual([ + expect.objectContaining({ type: "test/columns/setColumns", payload: loaded }), + ]); + }); + + it("no-ops when no load hook is provided", async () => { + const { loadColumns } = make(); + expect(await drain(loadColumns())).toEqual([]); + }); + + it("no-ops when load resolves to null", async () => { + const { loadColumns } = createColumnsSlice("test", { + defaultOrder: ["a"], + load: async () => null, + }); + expect(await drain(loadColumns())).toEqual([]); + }); + }); +}); diff --git a/src/web/src/features/dataGrid/store/columnsSlice.ts b/src/web/src/features/dataGrid/store/columnsSlice.ts new file mode 100644 index 0000000..e666a7f --- /dev/null +++ b/src/web/src/features/dataGrid/store/columnsSlice.ts @@ -0,0 +1,52 @@ +import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; +import type { ColumnsState } from "../types"; + +export function createColumnsSlice( + name: string, + opts: { defaultOrder: string[]; load?: () => Promise }, +) { + const { defaultOrder, load } = opts; + const initialState: ColumnsState = { order: [...defaultOrder], widths: {}, hidden: [] }; + + const slice = createSlice({ + name: `${name}/columns`, + initialState, + reducers: { + moveColumn(state, action: PayloadAction<{ from: number; to: number }>) { + const { from, to } = action.payload; + if (from < 0 || from >= state.order.length) return; + const [m] = state.order.splice(from, 1); + state.order.splice(to, 0, m); + }, + resizeColumn(state, action: PayloadAction<{ field: string; width: number }>) { + state.widths[action.payload.field] = action.payload.width; + }, + toggleColumn(state, action: PayloadAction) { + const f = action.payload; + if (state.hidden.includes(f)) state.hidden = state.hidden.filter((x) => x !== f); + else state.hidden.push(f); + }, + // Hydration from a load. Merge: only overwrite a field when the loaded value + // is meaningful, so a partial/empty stored payload can't blank the grid. + setColumns(state, action: PayloadAction) { + const next = action.payload; + if (next.order?.length) state.order = next.order; + if (next.widths) state.widths = next.widths; + if (next.hidden) state.hidden = next.hidden; + }, + }, + }); + + // loadColumns thunk — hydrate from descriptor.api.loadColumns (if any) on mount. + // Single-slice: only dispatches this slice's setColumns, so it lives with the slice. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const loadColumns = () => async (dispatch: any) => { + if (!load) return; + + const loaded = await load(); + + if (loaded) dispatch(slice.actions.setColumns(loaded)); + }; + + return { reducer: slice.reducer, actions: slice.actions, loadColumns }; +} diff --git a/src/web/src/features/dataGrid/store/createGridInstance.test.ts b/src/web/src/features/dataGrid/store/createGridInstance.test.ts new file mode 100644 index 0000000..9c59421 --- /dev/null +++ b/src/web/src/features/dataGrid/store/createGridInstance.test.ts @@ -0,0 +1,54 @@ +// src/features/dataGrid/store/createGridInstance.test.ts +import { describe, expect, it } from "vitest"; +import { createGridInstance } from "./createGridInstance"; +import type { GridDescriptor } from "../types"; + +interface Row { + id: number; +} +const descriptor = { + name: "demo", + rowKey: (r: Row) => r.id, + columns: { + defs: { id: { field: "id", title: "Id", defaultWidth: 80, editable: false, type: "number" } }, + defaultOrder: ["id"], + }, + api: { + fetchRows: async () => [], + fetchCount: async () => 0, + fetchRow: async () => null, + updateRow: async () => ({ ok: true }), + }, + cells: { makeCell: () => ({}) as never, customRenderers: [], validateCell: () => true }, +} as unknown as GridDescriptor; + +const makeInstance = () => createGridInstance(descriptor); + +describe("createGridInstance", () => { + it("reduces only client-owned state — rows live in the row store", () => { + const inst = makeInstance(); + const state = inst.reducer(undefined, { type: "@@init" }); + expect(Object.keys(state).sort()).toEqual(["columns", "edits", "groups", "selection"]); + }); + + it("selectRoot reads the namespaced slice from a full store state", () => { + const inst = makeInstance(); + const sub = inst.reducer(undefined, { type: "@@init" }); + expect(inst.selectRoot({ demo: sub }).columns.order).toEqual(["id"]); + }); + + it("exposes an effects teardown and a loadColumns thunk", () => { + const inst = makeInstance(); + expect(typeof inst.stopEffects).toBe("function"); + expect(typeof inst.thunks.loadColumns).toBe("function"); + inst.stopEffects(); + }); + + it("starts with an empty store cell, so a push before any grid mounts is dropped", () => { + // The instance is created at module scope, long before a grid mounts, and + // `useGridData` is what fills this cell. `data/sync/useRowSync.ts` relies on + // the null: a push that arrives first has nothing on screen to correct. + const inst = makeInstance(); + expect(inst.storeRef.current).toBeNull(); + }); +}); diff --git a/src/web/src/features/dataGrid/store/createGridInstance.ts b/src/web/src/features/dataGrid/store/createGridInstance.ts new file mode 100644 index 0000000..afa52b5 --- /dev/null +++ b/src/web/src/features/dataGrid/store/createGridInstance.ts @@ -0,0 +1,136 @@ +// src/features/dataGrid/store/createGridInstance.ts +import { combineReducers } from "@reduxjs/toolkit"; +import type { RowStore } from "../data/rowStore"; +import type { GridDescriptor, GridInstance, GridSliceState } from "../types"; +import { createColumnsSlice } from "./columnsSlice"; +import { registerEffects } from "./effects"; +import type { GridEffectContext } from "./effects/types"; +import { createEditsSlice } from "./editsSlice"; +import { createGroupsSlice } from "./groupsSlice"; +import { createSelectionSlice } from "./selectionSlice"; + +/** + * Builds one grid's runtime. The reducer holds only client-owned state — + * columns, selection, grouping, and the last edit error. + * + * No row is here and no row is in Redux. Server truth lives in the row store + * (data/rowStore.ts), which a mounted grid creates through `hooks/useGridData.ts`, + * and an edit that has not landed lives in the edit overlay + * (data/editOverlay.ts) until the server answers. + * + * The instance carries that store through a stable ref cell, `storeRef`, rather + * than through a value. The store belongs to whichever grid is mounted, while + * the instance is created once at module scope, so the cell is what lets a + * long-lived subscriber (data/sync/useRowSync.ts) reach the current store + * without re-subscribing. + */ +export function createGridInstance< + TRow extends object, + TGroup, + TKey extends string | number = number, +>(descriptor: GridDescriptor): GridInstance { + const name = descriptor.name; + + // Published by `useGridData` when a grid mounts. It stays null until then, + // and a push that arrives first is dropped rather than written to a store no + // component is reading. + const storeRef: { current: RowStore | null } = { current: null }; + + // The second store of a hold, published by the same hook. A push has to keep + // BOTH current: `storeRef` is what the user reads now, and this one is what + // adoption puts on screen next. + const pendingStoreRef: { current: RowStore | null } = { current: null }; + + // Written by the header click, which is the only place that knows where a + // group's rows start, and read once by `useGridData` on the render the + // collapse change lands in. Null means "everything moves". + const carryFromRef: { current: number | null } = { current: null }; + + const groups = createGroupsSlice(name); + + const columns = createColumnsSlice(name, { + defaultOrder: descriptor.columns.defaultOrder, + load: descriptor.api.loadColumns, + }); + + const selection = createSelectionSlice(name); + + const edits = createEditsSlice(name); + + const reducer = combineReducers({ + groups: groups.reducer, + columns: columns.reducer, + selection: selection.reducer, + edits: edits.reducer, + }); + + const selectRoot = (s: unknown) => (s as Record>)[name]; + + // Register every GridEffect for this instance on the shared grid listener. + const ctx: GridEffectContext = { + name, + descriptor, + selectRoot, + actions: { + columns: columns.actions, + groups: groups.actions, + selection: selection.actions, + edits: edits.actions, + }, + }; + + const stopEffects = registerEffects(ctx); + + // A push that moves positions cannot patch a page-indexed cache in place, so + // it invalidates every loaded page instead. Bumping a number rather than + // clearing the store is what lets `useGridData` load the replacement behind + // the rows already on screen. + // + // This counter names WHICH SET OF ROWS the grid is showing, exactly as the + // sort and the collapse set do, so a change of it builds a new store rather + // than emptying the current one. No store is ever emptied in place. + let dataGeneration = 0; + const generationListeners = new Set<() => void>(); + + const bumpDataGeneration = () => { + dataGeneration += 1; + + for (const listener of generationListeners) { + listener(); + } + }; + + const subscribeDataGeneration = (listener: () => void) => { + generationListeners.add(listener); + + return () => { + generationListeners.delete(listener); + }; + }; + + const getDataGeneration = () => dataGeneration; + + return { + descriptor, + reducer, + selectRoot, + actions: { + ...groups.actions, + ...columns.actions, + ...selection.actions, + ...edits.actions, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + storeRef, + pendingStoreRef, + carryFromRef, + subscribeDataGeneration, + getDataGeneration, + bumpDataGeneration, + stopEffects, + thunks: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + loadColumns: columns.loadColumns as any, + }, + }; +} diff --git a/src/web/src/features/dataGrid/store/editsSlice.test.ts b/src/web/src/features/dataGrid/store/editsSlice.test.ts new file mode 100644 index 0000000..a1e588e --- /dev/null +++ b/src/web/src/features/dataGrid/store/editsSlice.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { createEditsSlice } from "./editsSlice"; + +/** Cell A and cell B: same field, different rows — the shape of two overlapping saves. */ +const A = { cell: "1:name", message: "Failed to save name" }; +const B = { cell: "2:name", message: "Failed to save name" }; + +describe("createEditsSlice", () => { + it("starts with no error", () => { + const { reducer } = createEditsSlice("test"); + expect(reducer(undefined, { type: "@@init" }).lastError).toBeNull(); + }); + + it("editFail records the message and the cell it belongs to", () => { + const { reducer, actions } = createEditsSlice("test"); + const s = reducer(undefined, actions.editFail(A)); + expect(s.lastError).toEqual(A); + }); + + it("a later failure replaces the earlier message", () => { + const { reducer, actions } = createEditsSlice("test"); + let s = reducer(undefined, actions.editFail({ cell: "1:name", message: "first" })); + s = reducer(s, actions.editFail({ cell: "1:name", message: "second" })); + expect(s.lastError?.message).toBe("second"); + }); + + it("a failure on another cell replaces the message too, because the banner shows one", () => { + const { reducer, actions } = createEditsSlice("test"); + let s = reducer(undefined, actions.editFail(A)); + s = reducer(s, actions.editFail(B)); + expect(s.lastError).toEqual(B); + }); + + it("editSucceeded clears the error when the cell that succeeded is the cell that failed", () => { + const { reducer, actions } = createEditsSlice("test"); + let s = reducer(undefined, actions.editFail(A)); + s = reducer(s, actions.editSucceeded(A.cell)); + expect(s.lastError).toBeNull(); + }); + + it("editSucceeded leaves another cell's error standing, so a failure is never erased unseen", () => { + const { reducer, actions } = createEditsSlice("test"); + let s = reducer(undefined, actions.editFail(A)); + s = reducer(s, actions.editSucceeded(B.cell)); + expect(s.lastError).toEqual(A); + }); + + it("editErrorCleared takes the message away whatever cell it came from", () => { + const { reducer, actions } = createEditsSlice("test"); + let s = reducer(undefined, actions.editFail(A)); + s = reducer(s, actions.editErrorCleared()); + expect(s.lastError).toBeNull(); + }); +}); diff --git a/src/web/src/features/dataGrid/store/editsSlice.ts b/src/web/src/features/dataGrid/store/editsSlice.ts new file mode 100644 index 0000000..8920947 --- /dev/null +++ b/src/web/src/features/dataGrid/store/editsSlice.ts @@ -0,0 +1,53 @@ +import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; +import type { EditError, EditState } from "../types"; + +/** + * `lastError` is the banner over the grid, so it needs a way in AND two ways + * out — and the two ways out are not the same, which is why they are two + * actions and not one. + * + * - `editFail` is the way in: `useCellRenderer` dispatches it when the + * collection's transaction rejects. It names the cell as well as the message. + * + * - `editSucceeded` is the CONDITIONAL way out, dispatched when a save + * persists. It clears the banner only when the cell that succeeded is the + * cell that failed. An unconditional clear here would let a success on one + * cell erase another cell's failure — two overlapping saves with mixed + * outcomes are enough — and the user would never learn the failed save did + * not land. Note that a save that succeeds while no error is showing is a + * no-op, which is what it should be. + * + * - `editErrorCleared` is the UNCONDITIONAL way out, dispatched by + * `GridStatusBar`'s dismiss. Dismiss means "I am done with this message", + * whatever cell it came from, so it carries no cell and matches nothing. + * Clearing on success alone would leave a user who gives up editing staring + * at the message for the life of the page; a dismiss is the only exit that + * does not require another edit. + * + * `editBegin` / `editResolve` used to track an in-flight edit per cell so the + * renderer could grey it. `data/editOverlay.ts` holds the optimistic value and + * `useCellRenderer` reads its `isPending` instead — always in step with the + * save, and with no key to keep aligned — so the `pending` map and both actions + * are gone rather than left without a dispatcher. The `cell` on `lastError` is + * NOT that map coming back: it is one string on the one error the banner is + * already showing, not a record of every edit in flight. + */ +export function createEditsSlice(name: string) { + const initialState: EditState = { lastError: null }; + const slice = createSlice({ + name: `${name}/edits`, + initialState, + reducers: { + editFail(state, action: PayloadAction) { + state.lastError = action.payload; + }, + editSucceeded(state, action: PayloadAction) { + if (state.lastError?.cell === action.payload) state.lastError = null; + }, + editErrorCleared(state) { + state.lastError = null; + }, + }, + }); + return { reducer: slice.reducer, actions: slice.actions }; +} diff --git a/src/web/src/features/dataGrid/store/effects/columnsPersistenceEffect.ts b/src/web/src/features/dataGrid/store/effects/columnsPersistenceEffect.ts new file mode 100644 index 0000000..92da682 --- /dev/null +++ b/src/web/src/features/dataGrid/store/effects/columnsPersistenceEffect.ts @@ -0,0 +1,28 @@ +import type { GridEffect } from "./types"; + +const SAVE_DEBOUNCE_MS = 500; + +/** Persist column layout via descriptor.api.saveColumns, debounced. No-ops when + * the grid declares no saveColumns. */ +export const columnsPersistenceEffect: GridEffect = (ctx, startListening) => { + const save = ctx.descriptor.api.saveColumns; + + if (!save) return; + + const { moveColumn, resizeColumn, toggleColumn } = ctx.actions.columns; + const watched = [moveColumn.type, resizeColumn.type, toggleColumn.type]; + + startListening({ + predicate: (action: { type: string }) => watched.includes(action.type), + effect: async ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _action: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + api: any, + ) => { + api.cancelActiveListeners(); // debounce: cancel any queued save + await api.delay(SAVE_DEBOUNCE_MS); // resolves only if not superseded + await save(ctx.selectRoot(api.getState()).columns); + }, + }); +}; diff --git a/src/web/src/features/dataGrid/store/effects/index.test.ts b/src/web/src/features/dataGrid/store/effects/index.test.ts new file mode 100644 index 0000000..ac44815 --- /dev/null +++ b/src/web/src/features/dataGrid/store/effects/index.test.ts @@ -0,0 +1,63 @@ +import { configureStore, type Reducer } from "@reduxjs/toolkit"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createGridInstance } from "../createGridInstance"; +import { appListener } from "../../../../app/listener"; +import type { GridDescriptor, GridSliceState } from "../../types"; + +interface Row { + id: number; +} + +function makeDescriptor(name: string, saveColumns: (columns: unknown) => Promise) { + return { + name, + rowKey: (r: Row) => r.id, + columns: { + defs: { id: { field: "id", title: "Id", defaultWidth: 80, editable: false, type: "number" } }, + defaultOrder: ["id"], + }, + api: { + updateRow: async () => ({ ok: true }), + saveColumns, + }, + cells: { makeCell: () => ({}) as never, customRenderers: [], validateCell: () => true }, + } as unknown as GridDescriptor; +} + +function makeStore(name: string, reducer: Reducer>) { + return configureStore({ + reducer: { [name]: reducer }, + middleware: (getDefault) => + getDefault({ serializableCheck: false }).concat(appListener.middleware), + }); +} + +describe("grid effects on the app listener", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("runs a grid's effects through the app listener, with no per-grid middleware", async () => { + const save = vi.fn().mockResolvedValue(undefined); + const inst = createGridInstance(makeDescriptor("sharedDemo", save)); + const store = makeStore("sharedDemo", inst.reducer); + + store.dispatch(inst.actions.resizeColumn({ field: "id", width: 200 })); + await vi.advanceTimersByTimeAsync(600); + + expect(save).toHaveBeenCalledWith(expect.objectContaining({ widths: { id: 200 } })); + + inst.stopEffects(); + }); + + it("stopEffects removes the grid's listeners from the app listener", async () => { + const save = vi.fn().mockResolvedValue(undefined); + const inst = createGridInstance(makeDescriptor("stoppedDemo", save)); + const store = makeStore("stoppedDemo", inst.reducer); + + inst.stopEffects(); + store.dispatch(inst.actions.resizeColumn({ field: "id", width: 200 })); + await vi.advanceTimersByTimeAsync(600); + + expect(save).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web/src/features/dataGrid/store/effects/index.ts b/src/web/src/features/dataGrid/store/effects/index.ts new file mode 100644 index 0000000..17a96ca --- /dev/null +++ b/src/web/src/features/dataGrid/store/effects/index.ts @@ -0,0 +1,37 @@ +import { startAppListening } from "../../../../app/listener"; +import type { GridEffect, GridEffectContext } from "./types"; +import { columnsPersistenceEffect } from "./columnsPersistenceEffect"; + +/** All action-driven side-effects a grid runs. Add new effects here. */ +export const gridEffects: GridEffect[] = [columnsPersistenceEffect]; + +/** + * Register every gridEffect for one grid instance against the app-wide listener + * middleware. Keeps the effect-wiring concern owned by effects/, the way each + * slice owns its reducer/actions. + * + * Returns a function that removes this instance's listeners again. The store + * never needs it — grids live as long as the app — but it keeps tests that + * build throwaway instances from leaking listeners into later tests. + */ +export function registerEffects( + ctx: GridEffectContext, +): () => void { + const unsubscribers: Array<() => void> = []; + + // GridEffect returns void so one effect may register several listeners; wrap + // startListening to collect every unsubscribe it hands back. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const startListening = (options: any) => { + const unsubscribe = startAppListening(options); + unsubscribers.push(unsubscribe); + return unsubscribe; + }; + + for (const effect of gridEffects) effect(ctx, startListening); + + return () => { + for (const unsubscribe of unsubscribers) unsubscribe(); + unsubscribers.length = 0; + }; +} diff --git a/src/web/src/features/dataGrid/store/effects/types.ts b/src/web/src/features/dataGrid/store/effects/types.ts new file mode 100644 index 0000000..67f8702 --- /dev/null +++ b/src/web/src/features/dataGrid/store/effects/types.ts @@ -0,0 +1,75 @@ +import type { GridDescriptor, GridSliceState } from "../../types"; + +/** + * The per-slice action creators an effect may react to / dispatch. Typed loosely + * (engine-factory layer), but carrying the `.type` tag RTK attaches to every action + * creator, so effects can match by action type without unsafe casts. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Actions = Record { type: string }) & { type: string }>; + +/** + * Everything an effect needs to reach one grid: its identity, its descriptor, + * its state, and its action creators. + */ +export interface GridEffectContext { + /** + * Store namespace of this grid. Same value as `descriptor.name`. + */ + name: string; + + /** + * The grid's static description, including the server calls. + */ + descriptor: GridDescriptor; + + /** + * Reads this grid's state out of the app store. + */ + selectRoot: (s: unknown) => GridSliceState; + + /** + * Action creators, one group per slice. + */ + actions: { + /** + * Column order, width, and visibility actions. + */ + columns: Actions; + + /** + * Group discovery, collapse, and sort actions. + */ + groups: Actions; + + /** + * Row selection actions. + */ + selection: Actions; + + /** + * Edit-error actions. + */ + edits: Actions; + }; +} + +/** + * An effect registers one (or more) listeners via `startListening`, or no-ops + * (e.g. when its descriptor api hook is absent). Returning void keeps the + * signature simple and lets one effect add several listeners if needed. + */ +export type GridEffect = ( + /** + * The grid the effect attaches to. + */ + ctx: GridEffectContext, + + /** + * RTK's listener registration function. Its real type is heavily generic, so + * we type it `any` here (engine factory layer — localized `any` is already + * sanctioned). Effects themselves stay readable. Keep the `any` in effects/. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + startListening: any, +) => void; diff --git a/src/web/src/features/dataGrid/store/groupsSlice.test.ts b/src/web/src/features/dataGrid/store/groupsSlice.test.ts new file mode 100644 index 0000000..f5d7a84 --- /dev/null +++ b/src/web/src/features/dataGrid/store/groupsSlice.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { createGroupsSlice } from "./groupsSlice"; + +describe("createGroupsSlice", () => { + it("starts empty", () => { + const { reducer } = createGroupsSlice("test"); + const s = reducer(undefined, { type: "@@init" }); + expect(s).toEqual({ discoveredGroups: [], collapsedGroups: [], sort: null }); + }); + + it("accumulates discovered groups without duplicating them", () => { + const { reducer, actions } = createGroupsSlice("test"); + let s = reducer(undefined, actions.groupsDiscovered([0, 1])); + s = reducer(s, actions.groupsDiscovered([1, 2])); + expect(s.discoveredGroups).toEqual([0, 1, 2]); + }); + + it("remembers a collapsed group's key after its rows stop being served", () => { + const { reducer, actions } = createGroupsSlice("test"); + let s = reducer(undefined, actions.groupsDiscovered([0, 1])); + s = reducer(s, actions.toggleCollapse(1)); + // A later window excludes group 1 entirely; the memory has to survive it, + // or the header the user expands from would vanish. + s = reducer(s, actions.groupsDiscovered([0])); + expect(s.collapsedGroups).toEqual([1]); + expect(s.discoveredGroups).toEqual([0, 1]); + }); + + it("toggleCollapse is symmetric", () => { + const { reducer, actions } = createGroupsSlice("test"); + let s = reducer(undefined, actions.toggleCollapse(3)); + expect(s.collapsedGroups).toEqual([3]); + s = reducer(s, actions.toggleCollapse(3)); + expect(s.collapsedGroups).toEqual([]); + }); + + it("setSort records the sort and nothing else", () => { + const { reducer, actions } = createGroupsSlice("test"); + let s = reducer(undefined, actions.groupsDiscovered([0])); + s = reducer(s, actions.setSort({ field: "id", dir: "asc" })); + expect(s.sort).toEqual({ field: "id", dir: "asc" }); + // Boundaries are derived from the loaded windows now, so a sort change has + // nothing to invalidate here. + expect(s.discoveredGroups).toEqual([0]); + }); +}); diff --git a/src/web/src/features/dataGrid/store/groupsSlice.ts b/src/web/src/features/dataGrid/store/groupsSlice.ts new file mode 100644 index 0000000..038db99 --- /dev/null +++ b/src/web/src/features/dataGrid/store/groupsSlice.ts @@ -0,0 +1,50 @@ +import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; +import type { GroupsState } from "../types"; + +/** + * Client-owned grouping state: what the user has collapsed, how they sorted, + * and which groups they have encountered. + * + * It holds no rows and no boundaries. Boundaries are derived from the loaded + * windows in `useDisplayModel`; nothing here has to be reset when rows change. + */ +export function createGroupsSlice(name: string) { + const initialState: GroupsState = { + discoveredGroups: [], + collapsedGroups: [], + sort: null, + }; + + const slice = createSlice({ + name: `${name}/groups`, + initialState, + reducers: { + toggleCollapse(state, action: PayloadAction) { + const g = action.payload; + if ((state.collapsedGroups as TGroup[]).includes(g)) { + state.collapsedGroups = state.collapsedGroups.filter((x) => x !== g) as never; + } else { + (state.collapsedGroups as TGroup[]).push(g); + } + }, + setSort(state, action: PayloadAction["sort"]>) { + state.sort = action.payload; + }, + /** + * Record groups seen in a loaded window. Monotonic on purpose: a group the + * user collapses is excluded from every later window by the server, so + * without this memory its header — and the only way to expand it again — + * would disappear. + */ + groupsDiscovered(state, action: PayloadAction) { + for (const g of action.payload) { + if (!(state.discoveredGroups as TGroup[]).includes(g)) { + (state.discoveredGroups as TGroup[]).push(g); + } + } + }, + }, + }); + + return { reducer: slice.reducer, actions: slice.actions }; +} diff --git a/src/web/src/features/dataGrid/store/localStorageColumnsAdapter.test.ts b/src/web/src/features/dataGrid/store/localStorageColumnsAdapter.test.ts new file mode 100644 index 0000000..0081d7a --- /dev/null +++ b/src/web/src/features/dataGrid/store/localStorageColumnsAdapter.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import type { ColumnsState } from "../types"; +import { localStorageColumnsAdapter } from "./localStorageColumnsAdapter"; + +// Fake storage backed by a Map +function createFakeStorage(): Pick { + const m = new Map(); + return { + getItem: (k) => m.get(k) ?? null, + setItem: (k, v) => { + m.set(k, v); + }, + }; +} + +describe("localStorageColumnsAdapter", () => { + it("saveColumns writes JSON", async () => { + const fake = createFakeStorage(); + const adapter = localStorageColumnsAdapter("testKey", fake); + const state: ColumnsState = { + order: ["a", "b"], + widths: { a: 120, b: 200 }, + hidden: ["c"], + }; + + await adapter.saveColumns(state); + + const raw = fake.getItem("testKey"); + expect(raw).toBeDefined(); + expect(JSON.parse(raw!)).toEqual(state); + }); + + it("loadColumns round-trips", async () => { + const fake = createFakeStorage(); + const adapter = localStorageColumnsAdapter("testKey", fake); + const state: ColumnsState = { + order: ["a", "b"], + widths: { a: 120 }, + hidden: ["c"], + }; + + await adapter.saveColumns(state); + const loaded = await adapter.loadColumns(); + + expect(loaded).toEqual(state); + }); + + it("loadColumns returns null on missing key", async () => { + const fake = createFakeStorage(); + const adapter = localStorageColumnsAdapter("testKey", fake); + + const result = await adapter.loadColumns(); + + expect(result).toBeNull(); + }); + + it("loadColumns returns null on malformed JSON", async () => { + const fake = createFakeStorage(); + const adapter = localStorageColumnsAdapter("testKey", fake); + fake.setItem("testKey", "not json{"); + + const result = await adapter.loadColumns(); + + expect(result).toBeNull(); + }); +}); diff --git a/src/web/src/features/dataGrid/store/localStorageColumnsAdapter.ts b/src/web/src/features/dataGrid/store/localStorageColumnsAdapter.ts new file mode 100644 index 0000000..fe2fb47 --- /dev/null +++ b/src/web/src/features/dataGrid/store/localStorageColumnsAdapter.ts @@ -0,0 +1,40 @@ +import type { ColumnsState } from "../types"; + +/** + * Builds `loadColumns`/`saveColumns` backed by Web Storage. This is the home of + * localStorage knowledge — the slice and engine never touch it directly. A future + * grid can supply a REST-backed adapter with the same shape (descriptor.api.*). + */ +export function localStorageColumnsAdapter( + storageKey: string, + storage: Pick = localStorage, +): { + loadColumns: () => Promise; + saveColumns: (s: ColumnsState) => Promise; +} { + return { + async loadColumns() { + try { + const raw = storage.getItem(storageKey); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + // Only hydrate fields that are present & well-formed; missing → caller keeps defaults. + if (!parsed || typeof parsed !== "object") return null; + return { + order: Array.isArray(parsed.order) && parsed.order.length ? parsed.order : [], + widths: parsed.widths ?? {}, + hidden: parsed.hidden ?? [], + }; + } catch { + return null; + } + }, + async saveColumns(state) { + try { + storage.setItem(storageKey, JSON.stringify(state)); + } catch { + /* ignore quota/unavailable */ + } + }, + }; +} diff --git a/src/web/src/features/dataGrid/store/selectionSlice.ts b/src/web/src/features/dataGrid/store/selectionSlice.ts new file mode 100644 index 0000000..189e1b3 --- /dev/null +++ b/src/web/src/features/dataGrid/store/selectionSlice.ts @@ -0,0 +1,21 @@ +import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; +import type { SelectionState } from "../types"; + +export function createSelectionSlice(name: string) { + const initialState: SelectionState = { selectedIds: [] }; + + const slice = createSlice({ + name: `${name}/selection`, + initialState, + reducers: { + setSelectedIds(state, action: PayloadAction) { + state.selectedIds = action.payload as typeof state.selectedIds; + }, + clearSelection(state) { + state.selectedIds = []; + }, + }, + }); + + return { reducer: slice.reducer, actions: slice.actions }; +} diff --git a/src/web/src/features/dataGrid/testing/fakeRowServer.test.ts b/src/web/src/features/dataGrid/testing/fakeRowServer.test.ts new file mode 100644 index 0000000..8332404 --- /dev/null +++ b/src/web/src/features/dataGrid/testing/fakeRowServer.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from "vitest"; +import { createFakeRowServer } from "./fakeRowServer"; + +interface Row { + id: number; + price: number | null; +} + +const seed = (): Row[] => [ + { id: 1, price: 30 }, + { id: 2, price: 10 }, + { id: 3, price: null }, + { id: 4, price: 20 }, +]; + +const make = () => createFakeRowServer({ rows: seed(), rowKey: (r) => r.id }); + +describe("createFakeRowServer", () => { + it("returns the requested slice in key order when nothing is sorted", async () => { + const server = make(); + const rows = await server.api.fetchRows({ + offset: 1, + limit: 2, + sort: null, + collapsedGroups: [], + }); + expect(rows.map((r) => r.id)).toEqual([2, 3]); + }); + + it("sorts by the spec, with nulls last", async () => { + const server = make(); + const rows = await server.api.fetchRows({ + offset: 0, + limit: 4, + sort: { field: "price", direction: "asc", nulls: "last" }, + collapsedGroups: [], + }); + expect(rows.map((r) => r.id)).toEqual([2, 4, 1, 3]); + }); + + it("reports the total count", async () => { + const server = make(); + expect(await server.api.fetchCount({ collapsedGroups: [] })).toBe(4); + }); + + it("returns one row by id, and null when it is gone", async () => { + const server = make(); + expect(await server.api.fetchRow(2)).toEqual({ id: 2, price: 10 }); + expect(await server.api.fetchRow(99)).toBeNull(); + }); + + it("applies an update and returns the stored row", async () => { + const server = make(); + const res = await server.api.updateRow({ id: 2, changes: { price: 99 } }); + expect(res).toEqual({ ok: true, row: { id: 2, price: 99 } }); + expect(await server.api.fetchRow(2)).toEqual({ id: 2, price: 99 }); + }); + + it("delivers pushes to a subscriber, with rising sequence numbers", () => { + const server = make(); + const handler = vi.fn(); + const stop = server.api.subscribe(handler); + + server.push({ kind: "update", id: 2 }); + server.push({ kind: "delete", id: 3 }); + + expect(handler.mock.calls.map((c) => c[0])).toEqual([ + { kind: "update", id: 2, sequence: 1 }, + { kind: "delete", id: 3, sequence: 2 }, + ]); + + stop(); + server.push({ kind: "update", id: 1 }); + expect(handler).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/web/src/features/dataGrid/testing/fakeRowServer.ts b/src/web/src/features/dataGrid/testing/fakeRowServer.ts new file mode 100644 index 0000000..baa4cd0 --- /dev/null +++ b/src/web/src/features/dataGrid/testing/fakeRowServer.ts @@ -0,0 +1,88 @@ +import { compareBySpec } from "../data/sortSpec"; +import type { FetchRowsParams, RowChange } from "../types"; + +interface Config { + rows: TRow[]; + rowKey: (row: TRow) => TKey; + /** + * A row's group. Supply it and the server hides a collapsed group's rows from + * BOTH `fetchRows` and `fetchCount`, which is what a real server does. Omit + * it and collapse state is ignored, as it is for a grid with no grouping. + */ + groupOf?: (row: TRow) => TGroup; +} + +/** + * The server every data-layer test runs against. + * + * It sorts with `compareBySpec`, the same function the live query uses. That is + * the point: if the two ever disagree, a test fails instead of a user seeing + * rows jump. + */ +export function createFakeRowServer( + config: Config, +) { + let rows = [...config.rows]; + let sequence = 0; + const handlers = new Set<(change: RowChange) => void>(); + + const visible = (collapsedGroups: TGroup[] | undefined): TRow[] => { + const groupOf = config.groupOf; + if (!groupOf || !collapsedGroups || collapsedGroups.length === 0) return [...rows]; + return rows.filter((r) => !collapsedGroups.includes(groupOf(r))); + }; + + const ordered = (p: FetchRowsParams): TRow[] => { + const subject = visible(p.collapsedGroups); + const sort = p.sort; + if (!sort) return subject; + return subject.sort((a, b) => compareBySpec(a, b, sort, config.rowKey)); + }; + + const api = { + fetchRows: async (p: FetchRowsParams): Promise => + ordered(p).slice(p.offset, p.offset + p.limit), + + fetchCount: async (p: { collapsedGroups: TGroup[]; signal?: AbortSignal }): Promise => + visible(p.collapsedGroups).length, + + fetchRow: async (id: TKey): Promise => + rows.find((r) => config.rowKey(r) === id) ?? null, + + updateRow: async ({ + id, + changes, + }: { + id: TKey; + changes: Partial; + }): Promise<{ ok: boolean; row?: TRow }> => { + const index = rows.findIndex((r) => config.rowKey(r) === id); + if (index < 0) return { ok: false }; + const next = { ...rows[index], ...changes }; + rows = [...rows.slice(0, index), next, ...rows.slice(index + 1)]; + return { ok: true, row: next }; + }, + + subscribe: (handler: (change: RowChange) => void) => { + handlers.add(handler); + return () => handlers.delete(handler); + }, + }; + + return { + api, + /** Emits a change to every subscriber, stamping the next sequence number. */ + push(change: Omit, "sequence">) { + sequence += 1; + const full = { ...change, sequence }; + for (const h of handlers) h(full); + }, + /** Mutates the server's rows directly, for arrange steps in tests. */ + setRows(next: TRow[]) { + rows = [...next]; + }, + get rows() { + return rows; + }, + }; +} diff --git a/src/web/src/features/dataGrid/types.ts b/src/web/src/features/dataGrid/types.ts new file mode 100644 index 0000000..11cc9ad --- /dev/null +++ b/src/web/src/features/dataGrid/types.ts @@ -0,0 +1,509 @@ +import type { Reducer } from "@reduxjs/toolkit"; +import type { CellRegistry } from "../../lib/grid/cellRegistry"; +import type { RowStore } from "./data/rowStore"; +import type { SortSpec } from "./data/sortSpec"; + +/** + * Generic column definition. + */ +export interface ColumnDef { + /** + * Row property this column reads. Also the column's id in every state slice. + */ + field: string; + + /** + * Header text. + */ + title: string; + + /** + * Width in pixels before the user resizes the column. + */ + defaultWidth: number; + + /** + * Whether a cell in this column accepts an edit. + */ + editable: boolean; + + /** + * Cell kind. Must match a cell registry entry. + */ + type: string; + + /** + * Date cells only: keep the time part instead of a floating calendar date. + */ + withTime?: boolean; +} + +/** + * A single-column ordering. `null` anywhere this appears means natural order. + */ +export interface GridSort { + /** + * Field to order by. + */ + field: string; + + /** + * Direction of the ordering. + */ + dir: "asc" | "desc"; +} + +/** + * A subset request. The loader asks for a slice of an order, not for a numbered + * page. + */ +export interface FetchRowsParams { + /** + * Index of the first row in the slice. + */ + offset: number; + + /** + * Number of rows in the slice. + */ + limit: number; + + /** + * Order the server applies before it cuts the slice. + */ + sort: SortSpec | null; + + /** + * Groups the server must exclude, because the user collapsed them. + */ + collapsedGroups: TGroup[]; + + /** + * Cancels the request when the grid drops the slice. + */ + signal?: AbortSignal; +} + +/** + * A cross-client change notification. It names the row, never carries it. + */ +export interface RowChange { + /** + * What happened to the row. + */ + kind: "create" | "update" | "delete"; + + /** + * Stable key of the changed row. + */ + id: TKey; + + /** + * Monotonic per connection. A gap means the client missed a message and must + * resync. + */ + sequence: number; +} + +/** + * A single cell edit, addressed by row key. + */ +export interface UpdateRowParams { + /** + * Stable key of the row to edit. + */ + id: TKey; + + /** + * Changed fields only. + */ + changes: Partial; +} + +/** + * Optional grouping config. Absent → the grid renders flat (no header rows). + */ +export interface GridGrouping { + /** + * Field the grouping reads. Header rows report it. + */ + field: string; + + /** + * Extracts a row's group key. + * + * `TGroup` MUST be a primitive (string, number, or boolean) or an otherwise + * stably-interned value. The engine tracks discovered/collapsed groups with + * `===`, `Array.includes`, and `Set`, i.e. it compares groups by identity — so + * if `of` returns a fresh object per call (e.g. `{ year, month }`), every row + * reads as a new group and collapse + dedup both silently no-op. Derive a + * primitive key instead (e.g. `` `${year}-${month}` ``). + */ + of: (row: TRow) => TGroup; + + /** + * Sort weight of a group. The grid orders header rows by this number. + * + * It MUST agree with the ascending order of `field`: group X sorts before + * group Y here exactly when `X.field < Y.field`. The client live query orders + * rows by `field` as a plain property reference — the only expression the + * query builder accepts — while the header rows are placed by this function. + * If the two disagree, the headers and the rows under them describe different + * orders. + * + * The simple way to satisfy this is to keep the group values in a sorted + * array and return `SORTED.indexOf(g)`. + */ + order: (g: TGroup) => number; + + /** + * Header text for a group. + */ + label: (g: TGroup) => string; +} + +/** + * The static description of one grid: its identity, columns, grouping, server + * calls, and cell renderers. + */ +export interface GridDescriptor { + /** + * Store namespace + storage-key prefix. Must be a stable, unique string. + */ + name: string; + + /** + * Stable identity of a row. Must not be derived from array position. + */ + rowKey: (row: TRow) => TKey; + + /** + * Column set and its default presentation. + */ + columns: { + /** + * All columns, keyed by field. + */ + defs: Record; + + /** + * Left-to-right field order before the user reorders the columns. + */ + defaultOrder: string[]; + + /** + * Whether a header click sorts this field. Defaults to "editable columns only". + */ + sortable?: (field: string) => boolean; + }; + + /** + * Group config. Omit it to render a flat list. + */ + grouping?: GridGrouping; + + /** + * Every server call the grid makes. + */ + api: { + /** + * Loads one ordered slice. `hooks/useRowPages.ts` calls this, never a + * component. + * + * A descriptor with `grouping` MUST have its server order every request by + * `grouping.field` ascending FIRST, then by `sort` second. The group order + * never appears in this request: `p.sort` carries only the user's column, + * so the server applies the group prefix itself, on every call, whether or + * not it is named here (see `GridGrouping.order` for the matching + * client-side rule). A server that ignores this returns the wrong rows for + * the window, and the client cannot repair that: it can only re-order the + * rows the window already holds, not fetch the rows it should have held. + */ + fetchRows: (p: FetchRowsParams) => Promise; + + /** + * Total rows under the current view. A live query returns no total. + */ + fetchCount: (p: { collapsedGroups: TGroup[]; signal?: AbortSignal }) => Promise; + + /** + * One row by id, for an id-only push notification. + */ + fetchRow: (id: TKey, signal?: AbortSignal) => Promise; + + /** + * Optional live change feed. Returns an unsubscribe function. The grid + * owns no transport: SignalR, WebSocket, or polling all fit here. + */ + subscribe?: (handler: (change: RowChange) => void) => () => void; + + /** + * Persist a single cell edit. On success may return the server's + * authoritative `row` (including derived columns), which + * `hooks/useCellRenderer.ts` writes into the row store over the optimistic + * value. `{ ok: false }` signals a rejected/read-only edit and triggers a + * rollback. + */ + updateRow: (p: UpdateRowParams) => Promise<{ ok: boolean; row?: TRow }>; + + /** + * Reads the user's saved column layout. Omit it to always start from the + * defaults. + */ + loadColumns?: () => Promise; + + /** + * Persists the user's column layout. + */ + saveColumns?: (state: ColumnsState) => Promise; + }; + + /** + * Cell renderers and editors, looked up by `ColumnDef.type`. + */ + cells: CellRegistry; + + /** + * Row-window page size. Defaults to 100. + */ + pageSize?: number; +} + +/** + * Client-owned grouping state. + * + * Boundaries are NOT here: they are a pure function of the currently loaded + * windows and are derived in `useDisplayModel`. Storing them meant they could + * outlive the rows they described. + */ +export interface GroupsState { + /** + * Monotonic memory of groups the user has seen. It stays here because a + * collapsed group's rows are excluded by the server, so this list cannot be + * re-derived from the loaded rows. + */ + discoveredGroups: TGroup[]; + + /** + * Groups the user collapsed. The grid sends them to every server call. + */ + collapsedGroups: TGroup[]; + + /** + * Current header-click ordering. + */ + sort: GridSort | null; +} + +/** + * Client-owned column layout. It is the only part of the presentation the user + * changes directly. + */ +export interface ColumnsState { + /** + * Left-to-right field order. + */ + order: string[]; + + /** + * Pixel width per field, for fields the user resized. + */ + widths: Record; + + /** + * Fields the user hid. + */ + hidden: string[]; +} + +/** + * Client-owned row selection. + */ +export interface SelectionState { + /** + * Stable keys of the selected rows. + */ + selectedIds: TKey[]; +} + +/** + * A rejected save: the message shown, and the cell it belongs to. + */ +export interface EditError { + /** + * `${rowKey}:${field}` — the row's stable key, never its position, so it + * still names the same cell after a reload moves the row. It exists so a + * success can clear an error only when the success is about the SAME cell; a + * save that landed on one cell says nothing about a save that failed on + * another. + */ + cell: string; + + /** + * Text the banner shows. + */ + message: string; +} + +/** + * The edit slice holds only what the UI cannot derive from the rows themselves. + * "This cell is not saved yet" is NOT here: `data/editOverlay.ts` holds the + * in-flight value and `useCellRenderer` reads its `isPending`. What survives is + * the last rejection, which no row carries. + */ +export interface EditState { + /** + * One error, not a map. The banner shows one message, so the state holds one; + * the cell on it is the minimum needed to decide whether a later success + * retracts that message, and is deliberately not a per-cell `pending` map — + * the overlay already answers that question. + */ + lastError: EditError | null; +} + +/** + * All client-owned state of one grid, mounted under `descriptor.name`. + */ +export interface GridSliceState { + /** + * Discovered groups, collapse state, and sort. + */ + groups: GroupsState; + + /** + * Column order, widths, and hidden fields. + */ + columns: ColumnsState; + + /** + * Selected row keys. + */ + selection: SelectionState; + + /** + * Last rejected save. + */ + edits: EditState; +} + +/** + * Everything a grid needs at runtime, created once at module scope per grid: + * the combined reducer (mount under `descriptor.name`) for client-owned state, + * a root selector, all slice action creators, the thunks, and the cell that + * holds the mounted grid's row store. Hooks and take this. + */ +export interface GridInstance { + /** + * The descriptor this instance was built from. + */ + descriptor: GridDescriptor; + + /** + * Combined reducer for the client-owned state. Mount it under + * `descriptor.name`. + */ + reducer: Reducer>; + + /** + * Reads this grid's state out of the app store. + */ + selectRoot: (state: unknown) => GridSliceState; + + /** + * Action creators of every slice, keyed by name. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + actions: Record { type: string; payload?: unknown }>; + + /** + * A stable cell holding the row store the grid is DRAWING, republished by + * `useGridData` on every render and again the moment a hold adopts. + * + * It is a cell rather than a value because `data/sync/useRowSync.ts` + * subscribes once, for the life of the grid, and must reach whichever store + * the mounted grid owns without re-subscribing. It is null until a grid + * mounts; a push that arrives before then has nothing to write to and is + * correctly dropped. + * + * It does NOT go back to null on unmount. Nothing clears it, so it keeps the + * store of the grid that mounted last, and that store is then reachable with + * no grid drawing it. Nothing writes through it in that state — `useRowSync` + * unsubscribes with the grid, and it is the only writer — and the next mount + * overwrites the cell on its first render. + * + * ONE MOUNTED GRID PER INSTANCE. This cell, `pendingStoreRef`, and + * `carryFromRef` are single slots on one module-scope object, so two grids + * mounted on the same instance at the same time overwrite each other: a push + * would land on whichever store rendered last, and a collapse boundary + * measured by one grid would be consumed by the other. A second view of the + * same rows needs a second `createGridInstance`. + */ + storeRef: { current: RowStore | null }; + + /** + * The store of the view loading BEHIND the screen during a hold, or null when + * no hold is open. + * + * A hold runs two stores at once, and adoption puts this one on screen. A + * push that maintained `storeRef` alone would therefore write the row the + * user is about to stop reading and miss the row the user is about to start + * reading, so `data/sync/useRowSync.ts` patches both. It is a second cell + * rather than a list because the two have different jobs, and the hook must + * be able to tell them apart: only the DISPLAYED store's indexes may reach + * the grid's damage callback. + * + * `useGridData` writes it on every render and clears it at adoption, so it + * reads null whenever no hold is open. Unmount is the exception, exactly as + * it is for `storeRef`: nothing clears it there, so a grid unmounted mid-hold + * leaves the pending store here until the next mount's first render replaces + * it. The one-mounted-grid rule above covers this cell too. + */ + pendingStoreRef: { current: RowStore | null }; + + /** + * The first data index the next collapse change moves, written by the header + * click and read once by `useGridData`. Null means "everything moves", which + * is the state a sort leaves it in. + * + * A cell rather than an action payload because only the display model knows + * where a group starts, and only the click handler holds that model. The rows + * ABOVE this index keep the index they already have, so the new view starts + * with those pages already loaded instead of asking for them again. + */ + carryFromRef: { current: number | null }; + + /** + * Subscribes to invalidations of every loaded row. A push that creates or + * deletes a row moves every position after it, which no page-indexed cache + * can repair in place. + */ + subscribeDataGeneration: (listener: () => void) => () => void; + + /** + * The current invalidation count. Part of the row store's identity, beside + * the sort and the collapse set. + */ + getDataGeneration: () => number; + + /** + * Invalidates every loaded row. Called by the push subscriber + * (`data/sync/useRowSync.ts`) and by the mock reset bar. + */ + bumpDataGeneration: () => void; + + /** + * Removes this instance's effects from the shared grid listener. + */ + stopEffects: () => void; + + /** + * Async actions that need the descriptor. + */ + thunks: { + /** + * Restores the saved column layout through `api.loadColumns`. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + loadColumns: () => any; + }; +} diff --git a/src/web/src/features/dataGrid/useGridDispatch.ts b/src/web/src/features/dataGrid/useGridDispatch.ts new file mode 100644 index 0000000..2b28817 --- /dev/null +++ b/src/web/src/features/dataGrid/useGridDispatch.ts @@ -0,0 +1,7 @@ +// src/features/dataGrid/useGridDispatch.ts +import { useDispatch } from "react-redux"; +import type { ThunkDispatch, UnknownAction } from "@reduxjs/toolkit"; + +/** Dispatch that accepts both plain actions and thunks, without importing the + * app store's typed dispatch (keeps the engine reusable across stores). */ +export const useGridDispatch = () => useDispatch>(); diff --git a/src/web/src/features/testGrid/MockControls.test.tsx b/src/web/src/features/testGrid/MockControls.test.tsx new file mode 100644 index 0000000..0a35c4d --- /dev/null +++ b/src/web/src/features/testGrid/MockControls.test.tsx @@ -0,0 +1,92 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MockControls } from "./MockControls"; +import { mockConfig, setMockConfig } from "./mocks/mockConfig"; + +// Spread the real module rather than returning a bare `{ resetTestRows }`. +// `MockControls` imports `GRID_NAME` from `./testGrid`, whose descriptor +// dereferences the four OTHER exports of this module at module-evaluation time. +// Vitest 4 throws `No "fetchTestRows" export is defined on the mock` on that +// dereference — before any test body runs — if the factory omits them. +vi.mock("./api/testRowQueries", async (importOriginal) => ({ + ...(await importOriginal()), + resetTestRows: vi.fn().mockResolvedValue(100000), +})); + +function renderControls() { + const queryClient = new QueryClient(); + + render( + + + , + ); + + return { queryClient }; +} + +beforeEach(() => { + setMockConfig({ latencyMs: 150, failureRate: 0 }); + vi.clearAllMocks(); +}); + +describe("MockControls", () => { + it("writes the chosen latency to the mock config", async () => { + renderControls(); + + await userEvent.selectOptions(screen.getByLabelText("Latency"), "800"); + + expect(mockConfig.latencyMs).toBe(800); + }); + + it("writes the chosen failure rate to the mock config", async () => { + renderControls(); + + await userEvent.selectOptions(screen.getByLabelText("Failures"), "0.5"); + + expect(mockConfig.failureRate).toBe(0.5); + }); + + it("calls the reset endpoint", async () => { + const { resetTestRows } = await import("./api/testRowQueries"); + renderControls(); + + await userEvent.click(screen.getByRole("button", { name: "Reset data" })); + + expect(resetTestRows).toHaveBeenCalled(); + }); + + // The cache keys are the part of this component that is easy to get wrong, so + // seed both caches and prove the click empties them. Asserting only that the + // spy fired would stay green under a mistyped key. + it("drops the cached row slices and the cached count", async () => { + const { queryClient } = renderControls(); + + // The shape `rowPagesKey` builds: the prefix, the page number, the sort, + // and the collapse set. + const pageKey = ["testGrid", "page", 0, null, []]; + + queryClient.setQueryData(pageKey, [{ id: 1 }]); + queryClient.setQueryData(["testGrid", "count", []], 100000); + + await userEvent.click(screen.getByRole("button", { name: "Reset data" })); + + await waitFor(() => { + expect(queryClient.getQueryData(pageKey)).toBeUndefined(); + }); + expect(queryClient.getQueryData(["testGrid", "count", []])).toBeUndefined(); + }); + + it("shows a message when the reset request fails", async () => { + const { resetTestRows } = await import("./api/testRowQueries"); + vi.mocked(resetTestRows).mockRejectedValueOnce(new Error("Injected mock failure")); + + renderControls(); + + await userEvent.click(screen.getByRole("button", { name: "Reset data" })); + + expect(await screen.findByText("Injected mock failure")).toBeInTheDocument(); + }); +}); diff --git a/src/web/src/features/testGrid/MockControls.tsx b/src/web/src/features/testGrid/MockControls.tsx new file mode 100644 index 0000000..4ea4188 --- /dev/null +++ b/src/web/src/features/testGrid/MockControls.tsx @@ -0,0 +1,105 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { rowsKeyPrefix } from "../dataGrid/hooks/useRowPages"; +import { resetTestRows } from "./api/testRowQueries"; +import { mockConfig, setMockConfig } from "./mocks/mockConfig"; +import { GRID_NAME, testGrid } from "./testGrid"; + +const LATENCIES = [0, 150, 800]; +const FAILURE_RATES = [0, 0.1, 0.5]; + +const barStyle: React.CSSProperties = { + display: "flex", + gap: 16, + alignItems: "center", + padding: "8px 12px", + borderBottom: "1px solid var(--gray-6)", + fontSize: 13, +}; + +/** + * The development control bar for the mock server. + * + * It writes straight to `mockConfig`, which the MSW handlers read per request. + * Local state exists only so the selects re-render; the config object is the + * single source of truth. + */ +export function MockControls() { + const queryClient = useQueryClient(); + + const [latencyMs, setLatencyMs] = useState(mockConfig.latencyMs); + const [failureRate, setFailureRate] = useState(mockConfig.failureRate); + const [resetting, setResetting] = useState(false); + const [error, setError] = useState(null); + + const onLatency = (value: number) => { + setLatencyMs(value); + setMockConfig({ latencyMs: value }); + }; + + const onFailureRate = (value: number) => { + setFailureRate(value); + setMockConfig({ failureRate: value }); + }; + + const onReset = async () => { + setResetting(true); + setError(null); + + try { + await resetTestRows(); + + // Every cached slice describes the old data, so drop them all. The count + // is a separate query and needs the same treatment. Both keys are + // PREFIXES: `rowCountKey` appends the collapse set, and resetQueries + // matches by prefix, so this catches every variant. + await queryClient.resetQueries({ queryKey: rowsKeyPrefix(GRID_NAME) }); + await queryClient.resetQueries({ queryKey: [GRID_NAME, "count"] }); + + // The store is a second copy of those rows and no query touches it. The + // bump routes the reset through the same hold a sort change takes, so the + // old rows stay on screen, dimmed, until the new ones arrive. + testGrid.bumpDataGeneration(); + } catch (cause) { + // The reset request goes through the same mock server the bar can make + // fail on purpose, so this is a path the user reaches deliberately, not a + // theoretical one. Without the catch it is an unhandled rejection and a + // button that silently did nothing. + setError(cause instanceof Error ? cause.message : "Reset failed"); + } finally { + setResetting(false); + } + }; + + return ( +
+ + + + + + + {error && {error}} +
+ ); +} diff --git a/src/web/src/features/testGrid/TestGridPage.tsx b/src/web/src/features/testGrid/TestGridPage.tsx new file mode 100644 index 0000000..6037c02 --- /dev/null +++ b/src/web/src/features/testGrid/TestGridPage.tsx @@ -0,0 +1,23 @@ +import { DataGrid } from "../dataGrid/DataGrid"; +import { MockControls } from "./MockControls"; +import { testGrid } from "./testGrid"; + +/** + * The development test grid page: the mock control bar above a full-height + * grid. + * + * The `flex: 1; min-height: 0` chain matches `BondsPage`, and it only produces a + * real height because index.css makes `.radix-themes` a flex column. See the + * comment on `BondsPage` for the full explanation. + */ +export function TestGridPage() { + return ( +
+ + +
+ +
+
+ ); +} diff --git a/src/web/src/features/testGrid/api/testRowQueries.test.ts b/src/web/src/features/testGrid/api/testRowQueries.test.ts new file mode 100644 index 0000000..a738ae2 --- /dev/null +++ b/src/web/src/features/testGrid/api/testRowQueries.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TestRow } from "./types"; +import { + fetchTestRow, + fetchTestRowCount, + fetchTestRows, + resetTestRows, + updateTestRow, +} from "./testRowQueries"; + +const row: TestRow = { + id: 1, + name: "Northwind Group 1", + sector: "Aerospace", + region: 0, + quantity: 10, + price: 2.5, + value: 25, + contact: "+14150000001", + updatedAt: "2026-01-01", + active: true, +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +/** The URL of the single call the stub saw. */ +function calledUrl(): URL { + const [input] = vi.mocked(fetch).mock.calls[0]; + return new URL(input instanceof Request ? input.url : String(input)); +} + +beforeEach(() => { + vi.stubEnv("VITE_API_BASE_URL", "http://api.test"); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe("fetchTestRows", () => { + it("sends offset, limit, sort, and the collapsed sectors", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse([row]))); + + const rows = await fetchTestRows({ + offset: 100, + limit: 50, + sort: { field: "price", direction: "desc", nulls: "last" }, + collapsedGroups: ["Energy", "Media"], + }); + + const url = calledUrl(); + expect(url.pathname).toBe("/api/test-rows"); + expect(url.searchParams.get("offset")).toBe("100"); + expect(url.searchParams.get("limit")).toBe("50"); + expect(url.searchParams.get("sortField")).toBe("price"); + expect(url.searchParams.get("sortDir")).toBe("desc"); + expect(url.searchParams.get("nulls")).toBe("last"); + expect(url.searchParams.get("collapsed")).toBe("Energy,Media"); + expect(rows).toEqual([row]); + }); + + it("omits the sort parameters when nothing is sorted", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse([]))); + + await fetchTestRows({ offset: 0, limit: 10, sort: null, collapsedGroups: [] }); + + const url = calledUrl(); + expect(url.searchParams.has("sortField")).toBe(false); + expect(url.searchParams.get("collapsed")).toBe(""); + }); +}); + +describe("fetchTestRowCount", () => { + it("unwraps the count", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ count: 42 }))); + + expect(await fetchTestRowCount({ collapsedGroups: [] })).toBe(42); + }); +}); + +describe("fetchTestRow", () => { + it("returns the row", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(row))); + + expect(await fetchTestRow(1)).toEqual(row); + }); + + it("returns null on 404 rather than throwing", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({}, 404))); + + expect(await fetchTestRow(999)).toBeNull(); + }); +}); + +describe("updateTestRow", () => { + it("PATCHes the changes and returns the server row", async () => { + const stub = vi.fn().mockResolvedValue(jsonResponse({ ...row, quantity: 99, value: 247.5 })); + vi.stubGlobal("fetch", stub); + + const result = await updateTestRow({ id: 1, changes: { quantity: 99 } }); + + expect(calledUrl().pathname).toBe("/api/test-rows/1"); + expect(result.ok).toBe(true); + expect(result.row?.value).toBe(247.5); + }); + + it("returns ok:false when the server rejects the edit", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({}, 500))); + + expect(await updateTestRow({ id: 1, changes: { quantity: 99 } })).toEqual({ ok: false }); + }); +}); + +describe("resetTestRows", () => { + it("POSTs and returns the new count", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ count: 100000 }))); + + expect(await resetTestRows()).toBe(100000); + expect(calledUrl().pathname).toBe("/api/test-rows/reset"); + }); +}); diff --git a/src/web/src/features/testGrid/api/testRowQueries.ts b/src/web/src/features/testGrid/api/testRowQueries.ts new file mode 100644 index 0000000..03013e3 --- /dev/null +++ b/src/web/src/features/testGrid/api/testRowQueries.ts @@ -0,0 +1,85 @@ +import { apiClient } from "../../../lib/http/apiClient"; +import { ApiError } from "../../../lib/http/ApiError"; +import type { FetchRowsParams, UpdateRowParams } from "../../dataGrid/types"; +import type { TestRow } from "./types"; + +const PATH = "/api/test-rows"; + +/** + * One ordered slice. + * + * The sort parameters are omitted rather than sent empty when nothing is + * sorted: axios drops an undefined param, and the handler treats a missing + * `sortField` as natural order. + */ +export async function fetchTestRows(p: FetchRowsParams): Promise { + const { data } = await apiClient.get(PATH, { + params: { + offset: p.offset, + limit: p.limit, + sortField: p.sort?.field, + sortDir: p.sort?.direction, + nulls: p.sort?.nulls, + collapsed: p.collapsedGroups.join(","), + }, + signal: p.signal, + }); + + return data; +} + +/** The total under the current collapse state. */ +export async function fetchTestRowCount(p: { + collapsedGroups: string[]; + signal?: AbortSignal; +}): Promise { + const { data } = await apiClient.get<{ count: number }>(`${PATH}/count`, { + params: { collapsed: p.collapsedGroups.join(",") }, + signal: p.signal, + }); + + return data.count; +} + +/** + * One row by id, for an id-only push notification. + * + * A 404 is a legitimate answer here — the row is gone — so it becomes null + * rather than an exception. Every other failure still throws. + */ +export async function fetchTestRow(id: number, signal?: AbortSignal): Promise { + try { + const { data } = await apiClient.get(`${PATH}/${id}`, { signal }); + return data; + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + return null; + } + + throw error; + } +} + +/** + * Persists one cell edit. + * + * A rejected edit becomes `{ ok: false }` rather than an exception, because + * that is the shape `hooks/useCellRenderer.ts` reads to trigger the rollback + * and the error banner. + */ +export async function updateTestRow( + p: UpdateRowParams, +): Promise<{ ok: boolean; row?: TestRow }> { + try { + const { data } = await apiClient.patch(`${PATH}/${p.id}`, p.changes); + return { ok: true, row: data }; + } catch { + return { ok: false }; + } +} + +/** Regenerates the mock data. Returns the new row count. */ +export async function resetTestRows(): Promise { + const { data } = await apiClient.post<{ count: number }>(`${PATH}/reset`); + return data.count; +} diff --git a/src/web/src/features/testGrid/api/types.ts b/src/web/src/features/testGrid/api/types.ts new file mode 100644 index 0000000..14d2924 --- /dev/null +++ b/src/web/src/features/testGrid/api/types.ts @@ -0,0 +1,95 @@ +/** + * One synthetic row of the development test grid. + * + * The column set is chosen to cover every cell primitive in `src/lib/grid`: + * text, integer, currency, enum, phone, and date. + */ +export interface TestRow { + /** + * Stable row key. Ids ascend with `sector`, so the unsorted order is already + * the group order. + */ + id: number; + + /** + * Free text. Editable. + */ + name: string; + + /** + * The group field. Read-only, because moving a row between groups is not what + * this page tests. + */ + sector: string; + + /** + * Region code. Index into `REGIONS`. Editable. + */ + region: number; + + /** + * Whole units held. Editable. One of the two inputs to `value`. + */ + quantity: number; + + /** + * Unit price. Editable. The other input to `value`. + */ + price: number; + + /** + * `quantity * price`, computed by the server. Read-only. + * + * It exists to prove the write-back: an edit to `quantity` or `price` changes + * a column the client never wrote, so a stale `value` means the server row is + * not reaching the grid. + */ + value: number; + + /** + * E.164 phone number. Editable. + */ + contact: string; + + /** + * ISO date, no time part. Editable. + */ + updatedAt: string; + + /** + * Active flag, drawn as a badge. Editable. + */ + active: boolean; +} + +/** + * The 12 groups, in ascending order. + * + * The order is not decoration. `testGrid`'s `grouping.order` returns an index + * into this array, and `GridGrouping.order` must agree with the ascending order + * of the group field — see `GridGrouping` in features/dataGrid/types.ts. + */ +export const SECTORS = [ + "Aerospace", + "Automotive", + "Banking", + "Chemicals", + "Construction", + "Energy", + "Healthcare", + "Insurance", + "Logistics", + "Media", + "Retail", + "Utilities", +] as const; + +/** + * Region codes and their labels. The enum cell reads `value`; `TestRow.region` + * holds it. + */ +export const REGIONS = [ + { value: 0, label: "APAC" }, + { value: 1, label: "EMEA" }, + { value: 2, label: "Americas" }, +] as const; diff --git a/src/web/src/features/testGrid/index.ts b/src/web/src/features/testGrid/index.ts new file mode 100644 index 0000000..37e066e --- /dev/null +++ b/src/web/src/features/testGrid/index.ts @@ -0,0 +1,2 @@ +export type { TestRow } from "./api/types"; +export { TestGridPage } from "./TestGridPage"; diff --git a/src/web/src/features/testGrid/mocks/generateRows.test.ts b/src/web/src/features/testGrid/mocks/generateRows.test.ts new file mode 100644 index 0000000..daa7fc4 --- /dev/null +++ b/src/web/src/features/testGrid/mocks/generateRows.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { SECTORS } from "../api/types"; +import { DEFAULT_SEED, generateRows } from "./generateRows"; + +describe("generateRows", () => { + it("returns the requested number of rows", () => { + expect(generateRows(1_000)).toHaveLength(1_000); + }); + + it("is deterministic: the same seed gives the same rows", () => { + expect(generateRows(200, 42)).toEqual(generateRows(200, 42)); + }); + + it("is seeded: a different seed gives different rows", () => { + const a = generateRows(200, 1); + const b = generateRows(200, 2); + + expect(a.map((r) => r.price)).not.toEqual(b.map((r) => r.price)); + }); + + it("numbers the ids from 1 with no gaps", () => { + const rows = generateRows(50); + + expect(rows.map((r) => r.id)).toEqual(Array.from({ length: 50 }, (_, i) => i + 1)); + }); + + it("uses every sector and assigns ids in sector order", () => { + const rows = generateRows(1_200); + const indexes = rows.map((r) => SECTORS.indexOf(r.sector as (typeof SECTORS)[number])); + + expect(new Set(indexes).size).toBe(SECTORS.length); + expect(indexes).toEqual([...indexes].sort((a, b) => a - b)); + expect(indexes).not.toContain(-1); + }); + + it("spreads the rows over the sectors evenly, within one row", () => { + const rows = generateRows(1_200); + const sizes = SECTORS.map((s) => rows.filter((r) => r.sector === s).length); + + expect(Math.max(...sizes) - Math.min(...sizes)).toBeLessThanOrEqual(1); + }); + + it("computes value from quantity and price", () => { + for (const row of generateRows(100)) { + expect(row.value).toBeCloseTo(row.quantity * row.price, 6); + } + }); + + it("produces an E.164 contact and an ISO date", () => { + for (const row of generateRows(100)) { + expect(row.contact).toMatch(/^\+1\d{10}$/); + expect(row.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/); + } + }); + + it("exposes the default seed it uses when none is given", () => { + expect(generateRows(10)).toEqual(generateRows(10, DEFAULT_SEED)); + }); +}); diff --git a/src/web/src/features/testGrid/mocks/generateRows.ts b/src/web/src/features/testGrid/mocks/generateRows.ts new file mode 100644 index 0000000..028f09c --- /dev/null +++ b/src/web/src/features/testGrid/mocks/generateRows.ts @@ -0,0 +1,92 @@ +import { REGIONS, SECTORS, type TestRow } from "../api/types"; + +/** + * The seed every caller gets unless it names one. Fixed so a page reload shows + * the same 100,000 rows, which is what makes a bug reproducible. + */ +export const DEFAULT_SEED = 1337; + +/** + * How many rows the development page loads. + */ +export const DEFAULT_ROW_COUNT = 100_000; + +const NAME_HEADS = [ + "Northwind", + "Silverpeak", + "Ironwood", + "Blue Harbor", + "Redstone", + "Fairview", + "Copperline", + "Granite", +]; + +const NAME_TAILS = ["Holdings", "Partners", "Group", "Industries", "Labs", "Trading"]; + +/** + * A small, fast, seedable PRNG. `Math.random` cannot be seeded, and a page whose + * data changes on every reload cannot be used to reproduce anything. + */ +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + + return () => { + a = (a + 0x6d2b79f5) >>> 0; + + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Rounds to two decimals, so a currency column holds a currency value. */ +function money(value: number): number { + return Math.round(value * 100) / 100; +} + +/** + * Builds `count` synthetic rows from `seed`. Pure: same arguments, same rows. + * + * The sector is a function of the row's POSITION, not of the PRNG. That is what + * makes the ids ascend with the sector, so the unsorted order — by id — is + * already the group order and the grid needs no extra ordering to look right + * before the user sorts anything. + */ +export function generateRows(count: number, seed: number = DEFAULT_SEED): TestRow[] { + const random = mulberry32(seed); + const rows: TestRow[] = []; + + // A fixed base date keeps the rows deterministic. `Date.now()` would make two + // runs of the same seed differ. + const baseDate = Date.UTC(2026, 0, 1); + const dayMs = 24 * 60 * 60 * 1000; + + for (let i = 0; i < count; i += 1) { + const sectorIndex = Math.floor((i * SECTORS.length) / count); + const quantity = 1 + Math.floor(random() * 5_000); + const price = money(5 + random() * 995); + + const head = NAME_HEADS[Math.floor(random() * NAME_HEADS.length)]; + const tail = NAME_TAILS[Math.floor(random() * NAME_TAILS.length)]; + + const phoneTail = String(Math.floor(random() * 10_000_000)).padStart(7, "0"); + const dayOffset = Math.floor(random() * 365); + + rows.push({ + id: i + 1, + name: `${head} ${tail} ${i + 1}`, + sector: SECTORS[sectorIndex], + region: REGIONS[Math.floor(random() * REGIONS.length)].value, + quantity, + price, + value: money(quantity * price), + contact: `+1415${phoneTail}`, + updatedAt: new Date(baseDate + dayOffset * dayMs).toISOString().slice(0, 10), + active: random() < 0.7, + }); + } + + return rows; +} diff --git a/src/web/src/features/testGrid/mocks/handlers.test.ts b/src/web/src/features/testGrid/mocks/handlers.test.ts new file mode 100644 index 0000000..42c5521 --- /dev/null +++ b/src/web/src/features/testGrid/mocks/handlers.test.ts @@ -0,0 +1,126 @@ +import { setupServer } from "msw/node"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { TestRow } from "../api/types"; +import { testGridHandlers } from "./handlers"; +import { mockConfig, setMockConfig } from "./mockConfig"; +import { testRowStore } from "./testRowStore"; + +const BASE = "http://api.test"; + +const server = setupServer(...testGridHandlers); + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterAll(() => server.close()); + +beforeEach(() => { + // Real delay would make every case wait, and a random failure would make + // every case flaky. Both are opt-in per case. + setMockConfig({ latencyMs: 0, failureRate: 0 }); + testRowStore.reset(); +}); + +afterEach(() => server.resetHandlers()); + +describe("testGrid handlers", () => { + it("serves a slice with the requested offset and limit", async () => { + const res = await fetch(`${BASE}/api/test-rows?offset=10&limit=3`); + const rows = (await res.json()) as TestRow[]; + + expect(res.status).toBe(200); + expect(rows.map((r) => r.id)).toEqual([11, 12, 13]); + }); + + it("reads the sort out of the query string", async () => { + const res = await fetch( + `${BASE}/api/test-rows?offset=0&limit=5&sortField=price&sortDir=desc&nulls=last`, + ); + const rows = (await res.json()) as TestRow[]; + const prices = rows.map((r) => r.price); + + expect(prices).toEqual([...prices].sort((a, b) => b - a)); + }); + + it("reports the total", async () => { + const res = await fetch(`${BASE}/api/test-rows/count`); + + expect(await res.json()).toEqual({ count: 100_000 }); + }); + + it("drops a collapsed sector from the total", async () => { + const res = await fetch(`${BASE}/api/test-rows/count?collapsed=Aerospace`); + const { count } = (await res.json()) as { count: number }; + + expect(count).toBeLessThan(100_000); + }); + + it("serves one row by id", async () => { + const res = await fetch(`${BASE}/api/test-rows/7`); + + expect(((await res.json()) as TestRow).id).toBe(7); + }); + + it("answers 404 for an unknown id", async () => { + expect((await fetch(`${BASE}/api/test-rows/999999`)).status).toBe(404); + }); + + it("applies a PATCH and returns the recomputed row", async () => { + const res = await fetch(`${BASE}/api/test-rows/7`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ quantity: 2, price: 3 }), + }); + const row = (await res.json()) as TestRow; + + expect(res.status).toBe(200); + expect(row.value).toBe(6); + }); + + it("keeps a PATCH, so a later GET sees it", async () => { + await fetch(`${BASE}/api/test-rows/7`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Edited" }), + }); + + const row = (await (await fetch(`${BASE}/api/test-rows/7`)).json()) as TestRow; + expect(row.name).toBe("Edited"); + }); + + it("answers 404 when PATCH names an unknown id", async () => { + const res = await fetch(`${BASE}/api/test-rows/999999`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Edited" }), + }); + + expect(res.status).toBe(404); + }); + + it("restores the data on reset", async () => { + await fetch(`${BASE}/api/test-rows/7`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Edited" }), + }); + + const reset = await fetch(`${BASE}/api/test-rows/reset`, { method: "POST" }); + expect(await reset.json()).toEqual({ count: 100_000 }); + + const row = (await (await fetch(`${BASE}/api/test-rows/7`)).json()) as TestRow; + expect(row.name).not.toBe("Edited"); + }); + + it("fails every request at a failure rate of 1", async () => { + setMockConfig({ failureRate: 1 }); + + expect((await fetch(`${BASE}/api/test-rows?offset=0&limit=1`)).status).toBe(500); + expect((await fetch(`${BASE}/api/test-rows/count`)).status).toBe(500); + }); + + it("patches one field without disturbing the other", () => { + setMockConfig({ latencyMs: 0, failureRate: 0 }); + setMockConfig({ failureRate: 0.25 }); + + expect(mockConfig).toEqual({ latencyMs: 0, failureRate: 0.25 }); + }); +}); diff --git a/src/web/src/features/testGrid/mocks/handlers.ts b/src/web/src/features/testGrid/mocks/handlers.ts new file mode 100644 index 0000000..66d7fcb --- /dev/null +++ b/src/web/src/features/testGrid/mocks/handlers.ts @@ -0,0 +1,133 @@ +import { delay, http, HttpResponse, type RequestHandler } from "msw"; +import type { SortSpec } from "../../dataGrid/data/sortSpec"; +import type { TestRow } from "../api/types"; +import { mockConfig } from "./mockConfig"; +import { testRowStore } from "./testRowStore"; + +/** + * Paths are matched with a leading wildcard rather than against + * `VITE_API_BASE_URL`. + * + * The base URL differs between the dev server and a test run, and reading it + * here would tie the handlers to an environment variable that Vitest does not + * load. A wildcard matches whatever origin `apiClient` resolves. + */ +const ROWS = "*/api/test-rows"; + +/** + * Waits, then decides whether this request fails. + * + * Returns the 500 response to send, or null to carry on. Every handler starts + * with it, so latency and failure injection are described once. + */ +async function gate(): Promise { + await delay(mockConfig.latencyMs); + + if (mockConfig.failureRate > 0 && Math.random() < mockConfig.failureRate) { + return HttpResponse.json({ message: "Injected mock failure" }, { status: 500 }); + } + + return null; +} + +/** The collapsed sectors, as a comma-separated query parameter. */ +function readCollapsed(url: URL): string[] { + return (url.searchParams.get("collapsed") ?? "").split(",").filter(Boolean); +} + +/** The column sort, or null when the request names no sort field. */ +function readSort(url: URL): SortSpec | null { + const field = url.searchParams.get("sortField"); + + if (!field) { + return null; + } + + return { + field, + direction: url.searchParams.get("sortDir") === "desc" ? "desc" : "asc", + nulls: url.searchParams.get("nulls") === "first" ? "first" : "last", + }; +} + +/** + * The development server for the test grid. + * + * ORDER MATTERS. MSW matches handlers in registration order, so the two literal + * paths must precede `:id`, or `:id` swallows "count" and "reset". + */ +export const testGridHandlers: RequestHandler[] = [ + http.get(`${ROWS}/count`, async ({ request }) => { + const failure = await gate(); + + if (failure) { + return failure; + } + + const url = new URL(request.url); + + return HttpResponse.json({ count: testRowStore.count(readCollapsed(url)) }); + }), + + http.post(`${ROWS}/reset`, async () => { + const failure = await gate(); + + if (failure) { + return failure; + } + + return HttpResponse.json({ count: testRowStore.reset() }); + }), + + http.get(`${ROWS}/:id`, async ({ params }) => { + const failure = await gate(); + + if (failure) { + return failure; + } + + const row = testRowStore.find(Number(params.id)); + + if (!row) { + return HttpResponse.json({ message: "No such row" }, { status: 404 }); + } + + return HttpResponse.json(row); + }), + + http.patch(`${ROWS}/:id`, async ({ params, request }) => { + const failure = await gate(); + + if (failure) { + return failure; + } + + const changes = (await request.json()) as Partial; + const row = testRowStore.update(Number(params.id), changes); + + if (!row) { + return HttpResponse.json({ message: "No such row" }, { status: 404 }); + } + + return HttpResponse.json(row); + }), + + http.get(ROWS, async ({ request }) => { + const failure = await gate(); + + if (failure) { + return failure; + } + + const url = new URL(request.url); + + const rows = testRowStore.slice({ + offset: Number(url.searchParams.get("offset") ?? 0), + limit: Number(url.searchParams.get("limit") ?? 100), + sort: readSort(url), + collapsed: readCollapsed(url), + }); + + return HttpResponse.json(rows); + }), +]; diff --git a/src/web/src/features/testGrid/mocks/mockConfig.test.ts b/src/web/src/features/testGrid/mocks/mockConfig.test.ts new file mode 100644 index 0000000..74cb172 --- /dev/null +++ b/src/web/src/features/testGrid/mocks/mockConfig.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { mockConfig } from "./mockConfig"; + +describe("mockConfig", () => { + it("ships a visible latency and NO injected failures", () => { + // The failure rate matters: a grid that fails at random cannot be used to + // test anything else, so the failure path is opt-in. + expect(mockConfig).toEqual({ latencyMs: 150, failureRate: 0 }); + }); +}); diff --git a/src/web/src/features/testGrid/mocks/mockConfig.ts b/src/web/src/features/testGrid/mocks/mockConfig.ts new file mode 100644 index 0000000..78aeb28 --- /dev/null +++ b/src/web/src/features/testGrid/mocks/mockConfig.ts @@ -0,0 +1,38 @@ +/** + * What the mock server does before it answers. Mutable at run time, because the + * page's control bar changes it while the grid is open. + */ +export interface MockConfig { + /** + * Delay before every response, in milliseconds. Zero answers immediately, and + * then the grid never shows a loading state. + */ + latencyMs: number; + + /** + * Chance that a request answers 500 instead of doing its work, from 0 to 1. + * + * It defaults to 0. A grid that fails at random cannot be used to test + * anything else, so the failure path is opt-in: turn it up when the failure + * path IS the subject. + */ + failureRate: number; +} + +/** + * The live config. It is a mutable object rather than a store because the MSW + * handlers run outside React and read it per request. + * + * This module imports nothing on purpose. The page's control bar writes to it, + * so it is reachable from the production bundle, while the generator, the + * store, and the handlers are not. + */ +export const mockConfig: MockConfig = { + latencyMs: 150, + failureRate: 0, +}; + +/** Applies a partial change. */ +export function setMockConfig(patch: Partial): void { + Object.assign(mockConfig, patch); +} diff --git a/src/web/src/features/testGrid/mocks/testRowStore.test.ts b/src/web/src/features/testGrid/mocks/testRowStore.test.ts new file mode 100644 index 0000000..c38d882 --- /dev/null +++ b/src/web/src/features/testGrid/mocks/testRowStore.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { SECTORS } from "../api/types"; +import { createTestRowStore } from "./testRowStore"; + +/** 120 rows over 12 sectors is 10 rows per sector — small and exactly even. */ +const COUNT = 120; +const PER_SECTOR = COUNT / SECTORS.length; + +function store() { + return createTestRowStore(COUNT); +} + +describe("testRowStore", () => { + it("counts every row when nothing is collapsed", () => { + expect(store().count([])).toBe(COUNT); + }); + + it("slices the requested range", () => { + const rows = store().slice({ offset: 10, limit: 5, sort: null, collapsed: [] }); + + expect(rows.map((r) => r.id)).toEqual([11, 12, 13, 14, 15]); + }); + + it("orders by sector before the requested sort", () => { + const rows = store().slice({ + offset: 0, + limit: COUNT, + sort: { field: "price", direction: "asc", nulls: "last" }, + collapsed: [], + }); + + const sectorIndexes = rows.map((r) => SECTORS.indexOf(r.sector as (typeof SECTORS)[number])); + expect(sectorIndexes).toEqual([...sectorIndexes].sort((a, b) => a - b)); + + const firstSector = rows.slice(0, PER_SECTOR); + expect(firstSector.map((r) => r.price)).toEqual( + [...firstSector.map((r) => r.price)].sort((a, b) => a - b), + ); + }); + + it("excludes a collapsed sector from the count", () => { + expect(store().count([SECTORS[0]])).toBe(COUNT - PER_SECTOR); + }); + + it("excludes a collapsed sector from a slice", () => { + const rows = store().slice({ + offset: 0, + limit: COUNT, + sort: null, + collapsed: [SECTORS[0]], + }); + + expect(rows).toHaveLength(COUNT - PER_SECTOR); + expect(rows.some((r) => r.sector === SECTORS[0])).toBe(false); + }); + + it("finds one row by id, and answers undefined for an unknown id", () => { + expect(store().find(3)?.id).toBe(3); + expect(store().find(9_999)).toBeUndefined(); + }); + + it("applies an update and recomputes value", () => { + const s = store(); + const before = s.find(4)!; + + const after = s.update(4, { quantity: before.quantity + 10 })!; + + expect(after.quantity).toBe(before.quantity + 10); + expect(after.value).toBeCloseTo(after.quantity * after.price, 6); + }); + + it("keeps an update, so a later read sees it", () => { + const s = store(); + s.update(4, { name: "Edited" }); + + expect(s.find(4)?.name).toBe("Edited"); + expect(s.slice({ offset: 3, limit: 1, sort: null, collapsed: [] })[0].name).toBe("Edited"); + }); + + it("stamps updatedAt only when the change does not name it", () => { + const s = store(); + + const stamped = s.update(5, { quantity: 1 })!; + expect(stamped.updatedAt).toBe(new Date().toISOString().slice(0, 10)); + + const explicit = s.update(6, { updatedAt: "2020-02-02" })!; + expect(explicit.updatedAt).toBe("2020-02-02"); + }); + + it("answers undefined when the updated row does not exist", () => { + expect(store().update(9_999, { name: "x" })).toBeUndefined(); + }); + + it("re-orders after an edit that moves a row inside its sector", () => { + const s = store(); + const sorted = { offset: 0, limit: PER_SECTOR, sort: null, collapsed: [] }; + + const byPrice = { + ...sorted, + sort: { field: "price", direction: "asc", nulls: "last" } as const, + }; + + const firstBefore = s.slice(byPrice)[0]; + s.update(firstBefore.id, { price: 100_000 }); + + expect(s.slice(byPrice)[0].id).not.toBe(firstBefore.id); + }); + + it("drops every edit on reset", () => { + const s = store(); + const original = s.find(4)!.name; + + s.update(4, { name: "Edited" }); + expect(s.reset()).toBe(COUNT); + + expect(s.find(4)?.name).toBe(original); + }); +}); diff --git a/src/web/src/features/testGrid/mocks/testRowStore.ts b/src/web/src/features/testGrid/mocks/testRowStore.ts new file mode 100644 index 0000000..1cf43c6 --- /dev/null +++ b/src/web/src/features/testGrid/mocks/testRowStore.ts @@ -0,0 +1,215 @@ +import { compareBySpec, type SortSpec } from "../../dataGrid/data/sortSpec"; +import { SECTORS, type TestRow } from "../api/types"; +import { DEFAULT_ROW_COUNT, DEFAULT_SEED, generateRows } from "./generateRows"; + +/** + * One slice request, as the store sees it. It is the HTTP query already parsed, + * so the handlers hold no logic beyond parsing. + */ +export interface SliceQuery { + /** + * Index of the first row, counted over the VISIBLE rows. + */ + offset: number; + + /** + * How many rows to return. + */ + limit: number; + + /** + * The user's column sort, applied inside a sector. Null means natural order. + */ + sort: SortSpec | null; + + /** + * Sectors the user collapsed. Their rows leave both the slice and the count, + * which is what a real grouped server does. + */ + collapsed: string[]; +} + +/** + * The synthetic server's data. Everything a handler needs, and nothing about + * HTTP. + */ +export interface TestRowStore { + /** + * One ordered, filtered slice. + */ + slice(query: SliceQuery): TestRow[]; + + /** + * How many rows a slice request would see in total. + */ + count(collapsed: string[]): number; + + /** + * One row by id, or undefined. + */ + find(id: number): TestRow | undefined; + + /** + * Applies `changes`, recomputes `value`, and returns the whole row. Undefined + * when no row has that id. + */ + update(id: number, changes: Partial): TestRow | undefined; + + /** + * Regenerates the rows from the seed, dropping every edit. Returns the row + * count. + */ + reset(): number; +} + +/** + * How many ordered views the store keeps. A scroll produces one request per + * window, and ordering 100,000 rows costs about 50 ms, so an uncached store + * would be the slow part of every measurement this page exists to make. + * + * Three covers the common pattern: the current sort, the previous one, and the + * unsorted view underneath both. + */ +const MAX_CACHED_ORDERS = 3; + +const SECTOR_INDEX = new Map(SECTORS.map((sector, index) => [sector as string, index])); + +/** Rounds to two decimals, matching the generator's currency values. */ +function money(value: number): number { + return Math.round(value * 100) / 100; +} + +/** Today, as the ISO date the date cell stores. */ +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +/** + * Names one ordered view. Two requests that share this string may share the + * ordered array. + */ +function viewKey(sort: SortSpec | null, collapsed: string[]): string { + const order = sort ? `${sort.field}:${sort.direction}:${sort.nulls}` : "natural"; + return `${order}|${[...collapsed].sort().join(",")}`; +} + +/** + * Builds a store over `count` generated rows. + * + * Exported for tests, which use a small count. The application uses the + * `testRowStore` singleton below. + */ +export function createTestRowStore( + count: number = DEFAULT_ROW_COUNT, + seed: number = DEFAULT_SEED, +): TestRowStore { + let rows: TestRow[] = generateRows(count, seed); + let byId = new Map(rows.map((row) => [row.id, row])); + + const views = new Map(); + + const sectorRank = (row: TestRow): number => SECTOR_INDEX.get(row.sector) ?? SECTORS.length; + + /** + * The one ordering rule: sector first, then the user's sort, then the id. + * `compareBySpec` is the same comparator the client live query mirrors — a + * disagreement between the two shows up as rows that jump under a still + * viewport. + */ + const compare = (a: TestRow, b: TestRow, sort: SortSpec | null): number => { + const bySector = sectorRank(a) - sectorRank(b); + + if (bySector !== 0) { + return bySector; + } + + if (!sort) { + return a.id - b.id; + } + + return compareBySpec(a, b, sort, (row) => row.id); + }; + + const view = (sort: SortSpec | null, collapsed: string[]): TestRow[] => { + const key = viewKey(sort, collapsed); + const cached = views.get(key); + + if (cached) { + return cached; + } + + const hidden = new Set(collapsed); + const visible = hidden.size === 0 ? [...rows] : rows.filter((r) => !hidden.has(r.sector)); + + visible.sort((a, b) => compare(a, b, sort)); + views.set(key, visible); + + if (views.size > MAX_CACHED_ORDERS) { + // Map iterates in insertion order, so the first key is the oldest. + const oldest = views.keys().next().value; + + if (oldest !== undefined) { + views.delete(oldest); + } + } + + return visible; + }; + + return { + slice(query) { + const ordered = view(query.sort, query.collapsed); + return ordered.slice(query.offset, query.offset + query.limit); + }, + + count(collapsed) { + return view(null, collapsed).length; + }, + + find(id) { + return byId.get(id); + }, + + update(id, changes) { + const current = byId.get(id); + + if (!current) { + return undefined; + } + + const next: TestRow = { ...current, ...changes, id: current.id, sector: current.sector }; + next.value = money(next.quantity * next.price); + + if (changes.updatedAt === undefined) { + next.updatedAt = today(); + } + + const index = rows.indexOf(current); + rows[index] = next; + byId.set(id, next); + + // Every cached view holds the OLD row object, and an edit can also move + // the row inside its sector. Rebuilding is cheaper to reason about than + // patching each view in place, and an edit is rare next to a scroll. + views.clear(); + + return next; + }, + + reset() { + rows = generateRows(count, seed); + byId = new Map(rows.map((row) => [row.id, row])); + views.clear(); + + return rows.length; + }, + }; +} + +/** + * The store the MSW handlers use. Built at import, but the rows are generated + * inside `createTestRowStore`, and `src/mocks/browser.ts` — the only module that + * reaches this file — is itself behind a dynamic import in a DEV branch. So no + * production bundle pays for it. + */ +export const testRowStore = createTestRowStore(); diff --git a/src/web/src/features/testGrid/testGrid.test.ts b/src/web/src/features/testGrid/testGrid.test.ts new file mode 100644 index 0000000..1472d35 --- /dev/null +++ b/src/web/src/features/testGrid/testGrid.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { rootReducer } from "../../app/rootReducer"; +import { SECTORS } from "./api/types"; +import { GRID_NAME, testGrid, testGridDescriptor } from "./testGrid"; + +describe("testGridDescriptor", () => { + it("keys a row by its id", () => { + expect(testGridDescriptor.rowKey({ id: 7 } as never)).toBe(7); + }); + + it("groups by sector, in the order SECTORS lists", () => { + const grouping = testGridDescriptor.grouping!; + + expect(grouping.field).toBe("sector"); + expect(grouping.of({ sector: "Banking" } as never)).toBe("Banking"); + expect(grouping.label("Banking")).toBe("Banking"); + expect(grouping.order("Aerospace")).toBe(0); + expect(grouping.order("Utilities")).toBe(SECTORS.length - 1); + }); + + it("keeps SECTORS ascending, which grouping.order depends on", () => { + expect([...SECTORS]).toEqual([...SECTORS].sort()); + }); + + it("lists every column def in the default order, and nothing else", () => { + expect([...testGridDescriptor.columns.defaultOrder].sort()).toEqual( + Object.keys(testGridDescriptor.columns.defs).sort(), + ); + }); + + it("marks the derived and identity columns read-only", () => { + const defs = testGridDescriptor.columns.defs; + + expect(defs.id.editable).toBe(false); + expect(defs.sector.editable).toBe(false); + expect(defs.value.editable).toBe(false); + expect(defs.quantity.editable).toBe(true); + expect(defs.price.editable).toBe(true); + }); + + it("lets every column sort, including the read-only ones", () => { + expect(testGridDescriptor.columns.sortable!("value")).toBe(true); + }); + + it("names a registered cell type on every column", () => { + for (const def of Object.values(testGridDescriptor.columns.defs)) { + expect(() => + testGridDescriptor.cells.makeCell(def.type, null, { editable: false, withTime: false }), + ).not.toThrow(); + } + }); +}); + +describe("testGrid instance", () => { + it("injects itself into the root reducer on import", () => { + const state = rootReducer(undefined, { type: "@@init" }); + + expect(testGrid.selectRoot(state).columns.order).toEqual( + testGridDescriptor.columns.defaultOrder, + ); + expect(GRID_NAME).toBe("testGrid"); + }); + + it("starts with no collapsed sectors", () => { + const state = rootReducer(undefined, { type: "@@init" }); + + expect(testGrid.selectRoot(state).groups.collapsedGroups).toEqual([]); + }); +}); diff --git a/src/web/src/features/testGrid/testGrid.ts b/src/web/src/features/testGrid/testGrid.ts new file mode 100644 index 0000000..b06aaef --- /dev/null +++ b/src/web/src/features/testGrid/testGrid.ts @@ -0,0 +1,103 @@ +import { injectSlice } from "../../app/rootReducer"; +import { createGridInstance } from "../dataGrid/store/createGridInstance"; +import { localStorageColumnsAdapter } from "../dataGrid/store/localStorageColumnsAdapter"; +import type { ColumnDef, GridDescriptor, GridSliceState } from "../dataGrid/types"; +import { SECTORS, type TestRow } from "./api/types"; +import { + fetchTestRow, + fetchTestRowCount, + fetchTestRows, + updateTestRow, +} from "./api/testRowQueries"; +import { testGridCells } from "./testGridCells"; + +export const GRID_NAME = "testGrid"; + +const COLUMN_DEFS: Record = { + id: { field: "id", title: "ID", defaultWidth: 80, editable: false, type: "int" }, + name: { field: "name", title: "Name", defaultWidth: 220, editable: true, type: "text" }, + sector: { field: "sector", title: "Sector", defaultWidth: 130, editable: false, type: "text" }, + region: { field: "region", title: "Region", defaultWidth: 120, editable: true, type: "region" }, + quantity: { + field: "quantity", + title: "Quantity", + defaultWidth: 110, + editable: true, + type: "int", + }, + price: { field: "price", title: "Price", defaultWidth: 120, editable: true, type: "currency" }, + value: { field: "value", title: "Value", defaultWidth: 140, editable: false, type: "currency" }, + contact: { field: "contact", title: "Contact", defaultWidth: 160, editable: true, type: "phone" }, + updatedAt: { + field: "updatedAt", + title: "Updated", + defaultWidth: 120, + editable: true, + type: "date", + }, + active: { field: "active", title: "Active", defaultWidth: 110, editable: true, type: "active" }, +}; + +const columnsAdapter = localStorageColumnsAdapter(`${GRID_NAME}:columns`); + +/** + * The development test grid: 100,000 synthetic rows, grouped by sector, with + * every editable cell type the app has. + * + * Its rows come from MSW handlers, not from the API, and the worker that serves + * them starts only in a development build — see src/mocks/browser.ts. In a + * production build these requests reach the real API and 404, which is why the + * route renders a notice instead of the grid there. + */ +export const testGridDescriptor: GridDescriptor = { + name: GRID_NAME, + rowKey: (row) => row.id, + columns: { + defs: COLUMN_DEFS, + defaultOrder: [ + "id", + "name", + "sector", + "region", + "quantity", + "price", + "value", + "contact", + "updatedAt", + "active", + ], + // The default is "editable columns only", which would leave `id`, `sector`, + // and the derived `value` unsortable. Sorting a read-only column is a thing + // this page exists to exercise. + sortable: () => true, + }, + grouping: { + field: "sector", + of: (row) => row.sector, + // `SECTORS` is ascending, so this agrees with the ascending order of the + // `sector` field — the contract on `GridGrouping.order`. + order: (sector) => SECTORS.indexOf(sector as (typeof SECTORS)[number]), + label: (sector) => sector, + }, + api: { + fetchRows: fetchTestRows, + fetchCount: fetchTestRowCount, + fetchRow: fetchTestRow, + updateRow: updateTestRow, + loadColumns: columnsAdapter.loadColumns, + saveColumns: columnsAdapter.saveColumns, + }, + cells: testGridCells, +}; + +export const testGrid = createGridInstance(testGridDescriptor); + +// Claim this grid's slot in RootState, next to the grid it belongs to, the same +// way bondGrid does. +declare module "../../app/rootReducer" { + interface LazyLoadedSlices { + [GRID_NAME]: GridSliceState; + } +} + +injectSlice({ reducerPath: GRID_NAME, reducer: testGrid.reducer }); diff --git a/src/web/src/features/testGrid/testGridCells.test.ts b/src/web/src/features/testGrid/testGridCells.test.ts new file mode 100644 index 0000000..8412f93 --- /dev/null +++ b/src/web/src/features/testGrid/testGridCells.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { testGridCells } from "./testGridCells"; + +const ctx = { editable: true, withTime: false }; +const readOnlyCtx = { editable: false, withTime: false }; + +describe("testGridCells", () => { + it("registers every type the column defs use", () => { + for (const type of ["int", "text", "currency", "region", "phone", "date", "active"]) { + expect(() => testGridCells.makeCell(type, null, readOnlyCtx)).not.toThrow(); + } + }); + + it("opens an overlay for an editable cell and not for a read-only one", () => { + expect(testGridCells.makeCell("currency", 12.5, ctx).allowOverlay).toBe(true); + expect(testGridCells.makeCell("currency", 12.5, readOnlyCtx).allowOverlay).toBe(false); + }); + + it("maps a boolean to the active enum", () => { + const cell = testGridCells.makeCell("active", true, ctx) as unknown as { + data: { value: number }; + }; + expect(cell.data.value).toBe(1); + + const off = testGridCells.makeCell("active", false, ctx) as unknown as { + data: { value: number }; + }; + expect(off.data.value).toBe(0); + }); + + it("carries one renderer per cell kind", () => { + expect(testGridCells.customRenderers.length).toBeGreaterThanOrEqual(6); + }); +}); diff --git a/src/web/src/features/testGrid/testGridCells.ts b/src/web/src/features/testGrid/testGridCells.ts new file mode 100644 index 0000000..c7478c6 --- /dev/null +++ b/src/web/src/features/testGrid/testGridCells.ts @@ -0,0 +1,135 @@ +import type { CustomRenderer } from "@glideapps/glide-data-grid"; +import { + createCellRegistry, + type CellContext, + type CellRegistry, + type CellTypeDef, +} from "../../lib/grid/cellRegistry"; +import { createDateCell } from "../../lib/grid/createDateCell"; +import { createEnumCell } from "../../lib/grid/createEnumCell"; +import { createNumberCell } from "../../lib/grid/createNumberCell"; +import { createPhoneCell } from "../../lib/grid/createPhoneCell"; +import { createTextCell } from "../../lib/grid/createTextCell"; +import { REGIONS } from "./api/types"; + +// Shared cell primitives, built once at module scope. Renderer and editor +// identity is tied to the create* call, so building these inside a React render +// would remount the editor on every keystroke. +const textCell = createTextCell({ kind: "test-text" }); + +const intCell = createNumberCell({ + kind: "test-int", + format: "integer", + thousandSeparator: true, + min: 0, +}); + +const currencyCell = createNumberCell({ + kind: "test-currency", + format: "currency", + currency: "USD", + decimalScale: 2, + min: 0, +}); + +const phoneCell = createPhoneCell({ kind: "test-phone", nullable: true, defaultCountry: "US" }); + +const dateCell = createDateCell({ kind: "test-date", nullable: true }); + +// Colors come from RADIX_BADGE_SCALES in lib/grid/radixBadgePalette.ts. That +// list is the whole `RadixColor` union — "blue", "purple", and "gray" are NOT +// in it, and each scale must also be imported in theme/radixStyles.ts. +const regionCell = createEnumCell({ + kind: "test-region", + nullable: false, + options: [ + { value: REGIONS[0].value, label: REGIONS[0].label, color: "cyan" }, + { value: REGIONS[1].value, label: REGIONS[1].label, color: "amber" }, + { value: REGIONS[2].value, label: REGIONS[2].label, color: "plum" }, + ], +}); + +const activeCell = createEnumCell({ + kind: "test-active", + nullable: true, + options: [ + { value: 0, label: "Inactive", color: "tomato" }, + { value: 1, label: "Active", color: "green" }, + ], +}); + +const asNum = (raw: unknown): number | null => (raw == null ? null : Number(raw)); +const asStr = (raw: unknown): string | null => (raw == null ? null : String(raw)); + +/** + * Left unannotated (not `CellTypeDef[]`) so each `renderer` keeps its precise + * type instead of widening to the registry's unparameterized `CustomRenderer`. + * See the same pattern, and the reason for the cast below, in + * `features/bonds/bondCells.ts`. + */ +const testCellDefs = [ + { + type: "text", + kind: "test-text", + renderer: textCell.renderer, + make: (raw: unknown, ctx: CellContext) => textCell.makeCell(asStr(raw), ctx.editable), + }, + { + type: "int", + kind: "test-int", + renderer: intCell.renderer, + make: (raw: unknown, ctx: CellContext) => intCell.makeCell(asNum(raw), ctx.editable), + }, + { + type: "currency", + kind: "test-currency", + renderer: currencyCell.renderer, + make: (raw: unknown, ctx: CellContext) => currencyCell.makeCell(asNum(raw), ctx.editable), + }, + { + type: "region", + kind: "test-region", + renderer: regionCell.renderer, + make: (raw: unknown, ctx: CellContext) => regionCell.makeCell(asNum(raw), ctx.editable), + }, + { + type: "phone", + kind: "test-phone", + renderer: phoneCell.renderer, + make: (raw: unknown, ctx: CellContext) => phoneCell.makeCell(asStr(raw), ctx.editable), + }, + { + type: "date", + kind: "test-date", + renderer: dateCell.renderer, + make: (raw: unknown, ctx: CellContext) => + dateCell.makeCell(asStr(raw), ctx.withTime, ctx.editable), + }, + { + type: "active", + kind: "test-active", + renderer: activeCell.renderer, + make: (raw: unknown, ctx: CellContext) => + activeCell.makeCell(raw == null ? null : raw ? 1 : 0, ctx.editable), + }, +]; + +/** + * Cell registry for the development test grid. Column `type` keys: "text", + * "int", "currency", "region", "phone", "date", "active". + * + * Unlike `bondCells`, every `make` passes `ctx.editable` through as it comes, so + * a column marked editable in the column def opens its editor. + */ +// The `renderer` cast is narrowed to that one field for the reason spelled out +// at the foot of `features/bonds/bondCells.ts`: `strictFunctionTypes` makes the +// per-cell renderer types and the registry's unparameterized `CustomRenderer` +// incomparable in either direction, and routing the whole def through `unknown` +// would drop the structural check on every other field. Runtime routing is by +// `data.kind` through each renderer's `isMatch`, which is unaffected. +export const testGridCells: CellRegistry = createCellRegistry( + testCellDefs.map((d): CellTypeDef => ({ + ...d, + renderer: d.renderer as unknown as CustomRenderer, + })), +); diff --git a/src/web/src/index.css b/src/web/src/index.css index f6633a8..550f8fc 100644 --- a/src/web/src/index.css +++ b/src/web/src/index.css @@ -1,110 +1,52 @@ -:root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } -} - -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } +/* Radix Themes owns colour, typography, and appearance (see RadixThemeProvider). + This file carries only what Radix cannot: the document layout chain and the + token bridges below. */ + +/* The canvas grid sizes itself to its parent, so the layout chain from html + down to the grid container must have a real height. Pages follow the + convention: outer page div is `flex: 1; min-height: 0`. */ +html, +body, +#root { + height: 100%; + margin: 0; } #root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; display: flex; flex-direction: column; - box-sizing: border-box; -} - -body { - margin: 0; } -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); -} - -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; -} - -code { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); -} - -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); +/* RadixThemeProvider renders Radix's , which emits a real DOM node + (`div.radix-themes`) between #root and the page. Without this rule that + div is a non-flex, auto-height block, which breaks the #root flex chain + above: a page's `flex: 1` has no flex parent to size against, so the page + collapses to its content height and the grid canvas (which sizes to 100% + of its parent) renders as a sliver. Mirroring #root's flex layout here + onto .radix-themes keeps the chain intact. */ +#root > .radix-themes { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +/* shadcn -> Radix token bridge. Read via tailwind.css's `@theme inline` block. */ +.radix-themes { + --background: var(--color-panel-solid); + --foreground: var(--gray-12); + --border: var(--gray-6); + --input: var(--gray-7); + --ring: var(--accent-8); +} + +/* react-day-picker accent binding for the grid's date-cell editor. The editor + renders as a DESCENDANT of this class so these vars cascade into + the react-day-picker root, binding its accent to Radix's accent so the + calendar follows radixTheme.ts at runtime (e.g. flipping accentColor reflows + the calendar with no code change). Kept scoped because they only apply to + react-day-picker, not to shadcn components generally. */ +.date-cell-editor { + --rdp-accent-color: var(--accent-9); + --rdp-accent-background-color: var(--accent-3); } diff --git a/src/web/src/lib/date/dateUtils.test.ts b/src/web/src/lib/date/dateUtils.test.ts new file mode 100644 index 0000000..1c302bc --- /dev/null +++ b/src/web/src/lib/date/dateUtils.test.ts @@ -0,0 +1,151 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { + composeIso, + formatDateDisplay, + isoToCalendarDate, + isoToTimeInput, + isValidDateValue, + parseDatePaste, +} from "./dateUtils"; + +describe("formatDateDisplay", () => { + it("formats date-only in UTC (not a day early)", () => { + expect(formatDateDisplay("2026-06-20T00:00:00Z", false)).toBe("6/20/2026"); + }); + + it("formats date+time in the local zone (round-trips a locally-composed instant)", () => { + const iso = composeIso(new Date(2026, 5, 20), "14:30", true); + expect(formatDateDisplay(iso, true)).toBe("6/20/2026 2:30 PM"); + }); + + it("falls back to the raw string when unparseable", () => { + expect(formatDateDisplay("not-a-date", false)).toBe("not-a-date"); + }); +}); + +describe("isValidDateValue", () => { + it("accepts a valid ISO regardless of nullable", () => { + expect(isValidDateValue("2026-06-20T00:00:00Z", false)).toBe(true); + expect(isValidDateValue("2026-06-20T00:00:00Z", true)).toBe(true); + }); + + it("rejects a malformed non-empty value regardless of nullable", () => { + expect(isValidDateValue("garbage", false)).toBe(false); + expect(isValidDateValue("garbage", true)).toBe(false); + }); + + it("treats empty per the nullable flag", () => { + expect(isValidDateValue(null, true)).toBe(true); + expect(isValidDateValue("", true)).toBe(true); + expect(isValidDateValue(null, false)).toBe(false); + }); +}); + +describe("parseDatePaste", () => { + it("parses a bare ISO date as UTC midnight (date-only)", () => { + expect(parseDatePaste("2026-06-20", false)).toBe("2026-06-20T00:00:00.000Z"); + }); + + it("pins a locale-format date to UTC midnight of the intended day (date-only)", () => { + expect(parseDatePaste("6/20/2026", false)).toBe("2026-06-20T00:00:00.000Z"); + }); + + it("preserves the time component when withTime", () => { + expect(parseDatePaste("2026-06-20T14:30:00Z", true)).toBe("2026-06-20T14:30:00.000Z"); + }); + + it("returns null for an empty/whitespace paste", () => { + expect(parseDatePaste(" ", false)).toBe(null); + }); + + it("returns undefined for an unparseable paste", () => { + expect(parseDatePaste("garbage", false)).toBe(undefined); + }); +}); + +describe("parseDatePaste — date-only paste in a negative-UTC-offset timezone", () => { + // Pinned to a negative-offset zone so this suite is deterministic regardless of + // the machine running it. This is exactly the class of zone the reported bug + // shifts a day in: a UTC-designated ISO instant (the module's own canonical + // output, e.g. from copying a date-only cell) fell through to the LOCAL + // components branch and read one calendar day early. + beforeAll(() => { + vi.stubEnv("TZ", "America/New_York"); + }); + + afterAll(() => { + vi.unstubAllEnvs(); + }); + + it("parses a full UTC-designated ISO instant as UTC midnight, not a day early", () => { + expect(parseDatePaste("2026-06-20T00:00:00.000Z", false)).toBe("2026-06-20T00:00:00.000Z"); + }); + + it("still parses a bare ISO date as UTC midnight", () => { + expect(parseDatePaste("2026-06-20", false)).toBe("2026-06-20T00:00:00.000Z"); + }); + + it("still pins a locale-format date to UTC midnight of the intended local day", () => { + expect(parseDatePaste("6/20/2026", false)).toBe("2026-06-20T00:00:00.000Z"); + }); +}); + +describe("isoToCalendarDate / isoToTimeInput", () => { + it("reads the UTC calendar day for a date-only value", () => { + const d = isoToCalendarDate("2026-06-20T23:00:00Z", false); + expect(d?.getFullYear()).toBe(2026); + expect(d?.getMonth()).toBe(5); // June (0-based) + expect(d?.getDate()).toBe(20); + }); + + it("reads the local calendar day for a date+time instant", () => { + const iso = composeIso(new Date(2026, 5, 20), "23:30", true); + const d = isoToCalendarDate(iso, true); + expect(d?.getFullYear()).toBe(2026); + expect(d?.getMonth()).toBe(5); + expect(d?.getDate()).toBe(20); + }); + + it("returns undefined for null/invalid", () => { + expect(isoToCalendarDate(null, false)).toBeUndefined(); + expect(isoToCalendarDate("garbage", true)).toBeUndefined(); + }); + + it("reads the local time as HH:mm", () => { + const iso = composeIso(new Date(2026, 5, 20), "09:05", true); + expect(isoToTimeInput(iso)).toBe("09:05"); + }); + + it("returns empty string for null or invalid time input", () => { + expect(isoToTimeInput(null)).toBe(""); + expect(isoToTimeInput("garbage")).toBe(""); + }); +}); + +describe("composeIso", () => { + it("date-only composes to UTC midnight of the picked day", () => { + const picked = new Date(2026, 5, 20); // local June 20 + expect(composeIso(picked, "", false)).toBe("2026-06-20T00:00:00.000Z"); + }); + + it("withTime interprets the picked day + HH:mm as local and round-trips locally", () => { + const picked = new Date(2026, 5, 20); + const iso = composeIso(picked, "14:30", true); + expect(isoToTimeInput(iso)).toBe("14:30"); + const back = isoToCalendarDate(iso, true); + expect(back?.getDate()).toBe(20); + expect(back?.getMonth()).toBe(5); + }); + + it("withTime applies the local→UTC offset (not a UTC passthrough)", () => { + // composeIso interprets the picked day + time as LOCAL wall-clock, so the + // stored UTC time is local + getTimezoneOffset(). This assertion holds in any + // zone (trivially in UTC), and actively fails if compose ever reverts to a + // UTC passthrough while running in a non-UTC zone. + const offsetMin = new Date(2026, 5, 20, 14, 30).getTimezoneOffset(); + const iso = composeIso(new Date(2026, 5, 20), "14:30", true); + const d = new Date(iso); + const utcMinutes = d.getUTCHours() * 60 + d.getUTCMinutes(); + expect(utcMinutes).toBe((14 * 60 + 30 + offsetMin + 1440) % 1440); + }); +}); diff --git a/src/web/src/lib/date/dateUtils.ts b/src/web/src/lib/date/dateUtils.ts new file mode 100644 index 0000000..4fef6c6 --- /dev/null +++ b/src/web/src/lib/date/dateUtils.ts @@ -0,0 +1,151 @@ +/** + * Pure, grid-agnostic helpers for the canonical date representation used by the + * grid's date cells: an ISO string stored in UTC. Date-only values are pinned to + * UTC midnight so display (which formats in UTC) never shifts a day. + */ + +/** + * Display string for a cell value. + * - Date-only (`withTime` false): a floating calendar date, formatted in UTC so a + * UTC-midnight value never shifts a day. + * - Date+time (`withTime` true): a real instant, formatted in the VIEWER'S LOCAL + * timezone (both date and time) — the local day can differ from the UTC day. + * Unparseable input → raw string. + */ +export function formatDateDisplay(iso: string, withTime: boolean): string { + const d = new Date(iso); + + if (isNaN(d.getTime())) { + return iso; + } + + if (!withTime) { + return d.toLocaleDateString("en-US", { timeZone: "UTC" }); + } + + const date = d.toLocaleDateString("en-US"); + const time = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); + + return `${date} ${time}`; +} + +/** + * Empty is governed by `nullable`. Any non-empty value must be parseable by + * `new Date()` (intentionally lenient — accepts ISO and locale date strings). + */ +export function isValidDateValue(value: string | null, nullable: boolean): boolean { + if (value == null || value === "") { + return nullable; + } + + return !isNaN(new Date(value).getTime()); +} + +/** + * Parse a pasted string into a canonical ISO string for a cell. + * + * Trimmed first (whitespace-only = empty → null). Returns `undefined` when the + * value can't be parsed. When `withTime` the parsed instant is kept as-is. + * Otherwise the result is pinned to UTC midnight of the intended calendar day, + * upholding the module's UTC-midnight invariant regardless of paste format or + * the user's timezone: a bare ISO date (`2026-06-20`) or a UTC-designated ISO + * instant (`2026-06-20T00:00:00.000Z` — this module's own canonical output, as + * seen when a date-only cell is copied and pasted) parses as UTC midnight, so + * we read its UTC parts; any other format (e.g. `6/20/2026`) parses as local + * midnight, so we read its local parts (an offset-designated instant such as + * `2026-06-20T00:00:00+08:00` is knowingly left on this branch too — its + * intended calendar day is ambiguous, so we don't special-case it here). + */ +export function parseDatePaste(raw: string, withTime: boolean): string | null | undefined { + const trimmed = raw.trim(); + + if (trimmed === "") { + return null; + } + + const d = new Date(trimmed); + + if (isNaN(d.getTime())) { + return undefined; + } + + if (withTime) { + return d.toISOString(); + } + + const isUtcSourced = /^\d{4}-\d{2}-\d{2}(T[\d:.]*Z)?$/.test(trimmed); + const y = isUtcSourced ? d.getUTCFullYear() : d.getFullYear(); + const m = isUtcSourced ? d.getUTCMonth() : d.getMonth(); + const day = isUtcSourced ? d.getUTCDate() : d.getDate(); + + return new Date(Date.UTC(y, m, day)).toISOString(); +} + +/** + * The calendar day to highlight in the picker, as a local-midnight Date. + * - Date-only (`withTime` false): the UTC calendar day (floating date). + * - Date+time (`withTime` true): the LOCAL calendar day of the instant. + */ +export function isoToCalendarDate(iso: string | null, withTime: boolean): Date | undefined { + if (!iso) { + return undefined; + } + + const d = new Date(iso); + + if (isNaN(d.getTime())) { + return undefined; + } + + if (withTime) { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); + } + + return new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); +} + +/** The instant's LOCAL time-of-day as an `HH:mm` string for ``. */ +export function isoToTimeInput(iso: string | null): string { + if (!iso) { + return ""; + } + + const d = new Date(iso); + + if (isNaN(d.getTime())) { + return ""; + } + + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + + return `${hh}:${mm}`; +} + +/** + * Combine a picked calendar day (read via its local Y/M/D) and an optional + * `HH:mm` time into the canonical UTC ISO string. + * - Date-only (`withTime` false): pin to UTC midnight (floating date). + * - Date+time (`withTime` true): interpret the picked day + time as the viewer's + * LOCAL wall-clock and store the equivalent UTC instant. + */ +export function composeIso(calendarDate: Date, timeInput: string, withTime: boolean): string { + const y = calendarDate.getFullYear(); + const m = calendarDate.getMonth(); + const d = calendarDate.getDate(); + + let hh = 0; + let mm = 0; + + if (withTime && timeInput) { + const [h, min] = timeInput.split(":").map(Number); + hh = Number.isFinite(h) ? h : 0; + mm = Number.isFinite(min) ? min : 0; + } + + if (withTime) { + return new Date(y, m, d, hh, mm).toISOString(); + } + + return new Date(Date.UTC(y, m, d)).toISOString(); +} diff --git a/src/web/src/lib/grid/cellRegistry.test.ts b/src/web/src/lib/grid/cellRegistry.test.ts new file mode 100644 index 0000000..ed381ac --- /dev/null +++ b/src/web/src/lib/grid/cellRegistry.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { GridCellKind, type CustomCell, type GridCell } from "@glideapps/glide-data-grid"; +import { createCellRegistry, type CellTypeDef } from "./cellRegistry"; + +const textCell = (s: string): GridCell => ({ + kind: GridCellKind.Text, + data: s, + displayData: s, + allowOverlay: false, +}); + +const defs: CellTypeDef[] = [ + { type: "text", make: (raw) => textCell(String(raw ?? "")) }, + { + type: "phone", + kind: "phone", + make: (raw) => ({ + kind: GridCellKind.Custom, + allowOverlay: true, + copyData: String(raw ?? ""), + data: { kind: "phone", value: raw ?? null }, + }), + renderer: { + kind: GridCellKind.Custom, + isMatch: () => true, + draw: () => {}, + provideEditor: () => undefined, + } as never, + validate: (cell) => (cell.data as unknown as { value: unknown }).value !== "bad", + }, +]; + +describe("createCellRegistry", () => { + it("makeCell dispatches by column type", () => { + const r = createCellRegistry(defs); + expect(r.makeCell("text", "hi", { editable: false, withTime: false })).toMatchObject({ + data: "hi", + }); + }); + + it("collects only defined renderers", () => { + const r = createCellRegistry(defs); + expect(r.customRenderers).toHaveLength(1); + }); + + it("validateCell routes custom cells by kind and passes everything else", () => { + const r = createCellRegistry(defs); + const good = { + kind: GridCellKind.Custom, + data: { kind: "phone", value: "ok" }, + copyData: "", + allowOverlay: true, + } as unknown as CustomCell<{ kind: string }>; + const bad = { + kind: GridCellKind.Custom, + data: { kind: "phone", value: "bad" }, + copyData: "", + allowOverlay: true, + } as unknown as CustomCell<{ kind: string }>; + const unknown = { + kind: GridCellKind.Custom, + data: { kind: "mystery" }, + copyData: "", + allowOverlay: true, + } as unknown as CustomCell<{ kind: string }>; + expect(r.validateCell(good)).toBe(true); + expect(r.validateCell(bad)).toBe(false); + expect(r.validateCell(unknown)).toBe(true); // no validator → valid + expect(r.validateCell(textCell("x"))).toBe(true); // non-custom → valid + }); + + it("throws on an unknown column type (fail fast, not a blank cell)", () => { + const r = createCellRegistry(defs); + expect(() => r.makeCell("nope", 1, { editable: false, withTime: false })).toThrow( + /unknown cell type/i, + ); + }); +}); diff --git a/src/web/src/lib/grid/cellRegistry.ts b/src/web/src/lib/grid/cellRegistry.ts new file mode 100644 index 0000000..ed690da --- /dev/null +++ b/src/web/src/lib/grid/cellRegistry.ts @@ -0,0 +1,75 @@ +import { + GridCellKind, + type CustomCell, + type CustomRenderer, + type GridCell, +} from "@glideapps/glide-data-grid"; + +/** Per-column-cell context the maker needs beyond the raw value. */ +export interface CellContext { + editable: boolean; + withTime: boolean; +} + +/** + * One cell type. `type` keys the column→cell dispatch; `kind` (for custom cells) + * is the runtime `cell.data.kind` used to route validation. Built-in cells omit + * `kind`, `renderer`, and `validate`. + */ +export interface CellTypeDef { + type: string; + kind?: string; + make: (raw: unknown, ctx: CellContext) => GridCell; + renderer?: CustomRenderer; + validate?: (cell: CustomCell<{ kind: string }>) => boolean; +} + +export interface CellRegistry { + makeCell: (type: string, raw: unknown, ctx: CellContext) => GridCell; + customRenderers: CustomRenderer[]; + validateCell: (cell: GridCell) => boolean; +} + +/** + * Build a registry from cell-type defs. Derives the three things a grid needs + * from one source of truth: a type→maker dispatch, the custom-renderer list, and + * a kind→validator router. Adding a cell type means adding one def — no edits to + * the column dispatch, the renderer array, or a validation if-chain. + */ +export function createCellRegistry(defs: CellTypeDef[]): CellRegistry { + const byType = new Map(defs.map((d) => [d.type, d])); + const validators = new Map>(); + + for (const d of defs) { + if (d.kind && d.validate) { + validators.set(d.kind, d.validate); + } + } + + return { + makeCell(type, raw, ctx) { + const def = byType.get(type); + + if (!def) { + throw new Error(`createCellRegistry: unknown cell type "${type}"`); + } + + return def.make(raw, ctx); + }, + + customRenderers: defs + .map((d) => d.renderer) + .filter((r): r is CustomRenderer => r !== undefined), + + validateCell(cell) { + if (cell.kind !== GridCellKind.Custom) { + return true; + } + + const data = (cell as CustomCell<{ kind: string }>).data; + const v = validators.get(data.kind); + + return v ? v(cell as CustomCell<{ kind: string }>) : true; + }, + }; +} diff --git a/src/web/src/lib/grid/createCustomCell.test.ts b/src/web/src/lib/grid/createCustomCell.test.ts new file mode 100644 index 0000000..24da759 --- /dev/null +++ b/src/web/src/lib/grid/createCustomCell.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { drawEmptyDash, type CellDrawArgs } from "./createCustomCell"; + +function fakeCtx() { + const calls: string[] = []; + const ctx = { + font: "", + fillStyle: "" as string, + textBaseline: "alphabetic" as CanvasTextBaseline, + fillText: (t: string, x: number, y: number) => { + calls.push(`fillText:${t}@${x},${y}`); + }, + }; + return { ctx: ctx as unknown as CanvasRenderingContext2D, calls, raw: ctx }; +} + +const theme = { + textDark: "#111", + textLight: "#999", + baseFontFull: "13px sans-serif", + cellHorizontalPadding: 8, +}; + +const rect = { x: 10, y: 0, width: 100, height: 34 }; + +function drawArgs(ctx: CanvasRenderingContext2D) { + return { ctx, rect, theme } as unknown as CellDrawArgs<{ kind: string }>; +} + +describe("drawEmptyDash", () => { + it("paints an em dash in the muted text color at the padded left baseline", () => { + const { ctx, calls, raw } = fakeCtx(); + + drawEmptyDash(drawArgs(ctx)); + + expect(raw.fillStyle).toBe("#999"); + expect(raw.font).toBe("13px sans-serif"); + expect(raw.textBaseline).toBe("middle"); + // x = rect.x + cellHorizontalPadding (10 + 8), y = rect.y + rect.height / 2 (0 + 17) + expect(calls).toEqual(["fillText:—@18,17"]); + }); +}); diff --git a/src/web/src/lib/grid/createCustomCell.tsx b/src/web/src/lib/grid/createCustomCell.tsx new file mode 100644 index 0000000..034d1cd --- /dev/null +++ b/src/web/src/lib/grid/createCustomCell.tsx @@ -0,0 +1,157 @@ +import { + type CustomCell, + type CustomRenderer, + type DrawArgs, + GridCellKind, +} from "@glideapps/glide-data-grid"; +import type * as React from "react"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +/** The canvas draw args passed to your draw function. */ +export type CellDrawArgs = DrawArgs>; + +/** Props your editor component receives. */ +export interface EditorProps { + value: TData; + onChange: (updated: TData) => void; + onFinishedEditing: (updated?: TData) => void; + isValid?: boolean; +} + +/** Config you pass to createCustomCell. */ +export interface CustomCellConfig { + /** Unique string identifier for this cell type. Must match `cell.data.kind`. */ + kind: TData["kind"]; + + /** + * Canvas draw function. Receives the standard GDG draw args plus your + * typed cell data for convenience. + * + * @example + * draw: (args, data) => { + * const { ctx, rect, theme } = args; + * ctx.fillStyle = theme.textDark; + * ctx.fillText(data.label, rect.x + 8, rect.y + rect.height / 2); + * } + */ + draw: (args: CellDrawArgs, data: TData) => void; + + /** + * React component rendered in the edit overlay. Receives the cell's typed + * data, a live `onChange` to update the in-progress value, and + * `onFinishedEditing` to commit (with a value) or cancel (undefined). + * + * @example + * editor: ({ value, onChange }) => ( + * onChange({ ...value, text: e.target.value })} + * /> + * ) + */ + editor: React.FC>; + + /** + * Optional. Handle a clipboard paste into this cell. `val` is the pasted + * string; return the new typed data, or `undefined` to reject the paste. + * Omit if the cell is not pasteable. + */ + onPaste?: (val: string, data: TData) => TData | undefined; +} + +// ─── Factory ───────────────────────────────────────────────────────────────── + +/** + * Creates a GDG CustomRenderer from a draw function and a React editor component. + * + * @example + * const myRenderer = createCustomCell({ + * kind: "my-cell", + * draw: (args, data) => { ... }, + * editor: MyEditorComponent, + * }); + * + * // Then pass to DataEditor: + * + */ +export function createCustomCell( + config: CustomCellConfig, +): CustomRenderer> { + const { kind, draw, editor: EditorComponent, onPaste } = config; + + return { + kind: GridCellKind.Custom, + + isMatch: (cell): cell is CustomCell => (cell.data as TData).kind === kind, + + draw: (args, cell) => draw(args, cell.data as TData), + + provideEditor: + () => + ({ value: cell, onChange, onFinishedEditing, isValid }) => { + const handleChange = (updated: TData) => { + onChange({ ...cell, data: updated } as CustomCell); + }; + + const handleFinish = (updated?: TData) => { + onFinishedEditing( + updated === undefined ? undefined : ({ ...cell, data: updated } as CustomCell), + ); + }; + + return ( + + ); + }, + + ...(onPaste && { + onPaste: (val: string, data: TData) => onPaste(val, data), + }), + }; +} + +// ─── Helper: read-only placeholder ─────────────────────────────────────────── + +/** + * Paint an em dash ("—") in the muted text color at the cell's padded left edge — + * the placeholder a read-only cell draws for a null/empty value (an editable cell + * draws blank instead, inviting entry). Centralizes the look every value cell + * uses for a missing read-only value. + */ +export function drawEmptyDash(args: CellDrawArgs): void { + const { ctx, rect, theme } = args; + ctx.fillStyle = theme.textLight; + ctx.font = theme.baseFontFull; + ctx.textBaseline = "middle"; + ctx.fillText("—", rect.x + theme.cellHorizontalPadding, rect.y + rect.height / 2); +} + +// ─── Helper: build a cell payload ──────────────────────────────────────────── + +/** + * Convenience function to build the full GDG cell object for a custom cell. + * + * `copyData` is the plain-text representation used when the cell is copied to + * the clipboard. Provide a meaningful value so copy/paste and CSV export work. + * + * @example + * const cell = makeCustomCell({ kind: "star-cell", rating: 4 }, "4"); + */ +export function makeCustomCell( + data: TData, + copyData = "", + allowOverlay = true, +): CustomCell { + return { + kind: GridCellKind.Custom, + allowOverlay, + copyData, + data, + }; +} diff --git a/src/web/src/lib/grid/createDateCell.test.ts b/src/web/src/lib/grid/createDateCell.test.ts new file mode 100644 index 0000000..f7c32bf --- /dev/null +++ b/src/web/src/lib/grid/createDateCell.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import type { CustomCell } from "@glideapps/glide-data-grid"; +import { createDateCell, type DateCellData } from "./createDateCell"; + +const date = createDateCell({ kind: "date", nullable: true }); + +describe("createDateCell.makeCell", () => { + it("stores the ISO value, withTime flag, and ISO copyData", () => { + const cell = date.makeCell("2026-06-20T00:00:00Z", true); + expect(cell.data.kind).toBe("date"); + expect(cell.data.value).toBe("2026-06-20T00:00:00Z"); + expect(cell.data.withTime).toBe(true); + expect(cell.copyData).toBe("2026-06-20T00:00:00Z"); + }); + + it("renders a null value as empty copyData", () => { + const cell = date.makeCell(null, false); + expect(cell.data.value).toBeNull(); + expect(cell.data.withTime).toBe(false); + expect(cell.copyData).toBe(""); + }); + + it("disables the overlay when allowOverlay is false", () => { + const cell = date.makeCell("2026-06-20T00:00:00Z", false, false); + expect(cell.allowOverlay).toBe(false); + }); + + it("allows the overlay by default", () => { + const cell = date.makeCell("2026-06-20T00:00:00Z", false); + expect(cell.allowOverlay).toBe(true); + }); + + it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => { + expect(date.makeCell(null, false, false).data.readOnly).toBe(true); + }); + + it("leaves an editable cell unflagged so an empty value stays blank", () => { + expect(date.makeCell(null, false).data.readOnly).toBeUndefined(); + }); +}); + +describe("createDateCell.validate", () => { + const mk = (value: string | null): CustomCell => date.makeCell(value, false); + + it("accepts a valid ISO value", () => { + expect(date.validate(mk("2026-06-20T00:00:00Z"))).toBe(true); + }); + + it("accepts empty because the cell is nullable", () => { + expect(date.validate(mk(null))).toBe(true); + }); + + it("rejects a malformed non-empty value", () => { + expect(date.validate(mk("garbage"))).toBe(false); + }); +}); + +describe("createDateCell.renderer", () => { + it("matches cells of its kind", () => { + const cell = date.makeCell("2026-06-20T00:00:00Z", false); + expect(date.renderer.isMatch(cell)).toBe(true); + }); +}); + +describe("createDateCell.onPaste", () => { + const baseData: DateCellData = { kind: "date", value: null, withTime: false }; + + it("pins a pasted ISO date to UTC midnight for a date-only cell", () => { + expect(date.renderer.onPaste?.("2026-06-20", baseData)).toEqual({ + kind: "date", + value: "2026-06-20T00:00:00.000Z", + withTime: false, + }); + }); + + it("rejects an unparseable paste (returns undefined)", () => { + expect(date.renderer.onPaste?.("garbage", baseData)).toBeUndefined(); + }); + + it("accepts an empty paste as null because the cell is nullable", () => { + expect(date.renderer.onPaste?.(" ", baseData)).toEqual({ + kind: "date", + value: null, + withTime: false, + }); + }); + + it("preserves the time component when the cell is withTime", () => { + expect( + date.renderer.onPaste?.("2026-06-20T14:30:00Z", { ...baseData, withTime: true }), + ).toEqual({ + kind: "date", + value: "2026-06-20T14:30:00.000Z", + withTime: true, + }); + }); +}); + +function fakeCtx() { + const calls: string[] = []; + const ctx = { + font: "", + fillStyle: "" as string, + textBaseline: "alphabetic" as CanvasTextBaseline, + fillText: (t: string) => { + calls.push(t); + }, + }; + return { ctx: ctx as unknown as CanvasRenderingContext2D, calls }; +} + +const theme = { + textDark: "#111", + textLight: "#999", + baseFontFull: "13px sans-serif", + cellHorizontalPadding: 8, +}; +const rect = { x: 0, y: 0, width: 120, height: 34 }; +const drawArgs = (ctx: CanvasRenderingContext2D) => + ({ ctx, rect, theme }) as unknown as Parameters[0]; + +describe("createDateCell draw", () => { + it("draws an em dash for a read-only empty cell", () => { + const { ctx, calls } = fakeCtx(); + date.renderer.draw(drawArgs(ctx), date.makeCell(null, false, false)); + expect(calls).toEqual(["—"]); + }); + + it("draws nothing for an editable empty cell", () => { + const { ctx, calls } = fakeCtx(); + date.renderer.draw(drawArgs(ctx), date.makeCell(null, false)); + expect(calls).toEqual([]); + }); + + it("draws the localized date for a non-empty cell", () => { + const { ctx, calls } = fakeCtx(); + date.renderer.draw(drawArgs(ctx), date.makeCell("2026-06-20T00:00:00Z", false, false)); + expect(calls).toEqual(["6/20/2026"]); + }); +}); diff --git a/src/web/src/lib/grid/createDateCell.tsx b/src/web/src/lib/grid/createDateCell.tsx new file mode 100644 index 0000000..d4f9d8e --- /dev/null +++ b/src/web/src/lib/grid/createDateCell.tsx @@ -0,0 +1,161 @@ +import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid"; +import { Text, Theme } from "@radix-ui/themes"; +import { useState, type ChangeEvent } from "react"; +import { Calendar } from "../../components/ui/calendar"; +import { Input } from "../../components/ui/input"; +import { radixThemeConfig } from "../../theme/radixTheme"; +import { + composeIso, + formatDateDisplay, + isoToCalendarDate, + isoToTimeInput, + isValidDateValue, + parseDatePaste, +} from "../date/dateUtils"; +import { + createCustomCell, + drawEmptyDash, + makeCustomCell, + type EditorProps, +} from "./createCustomCell"; + +/** How many years before/after the current year the calendar's year dropdown spans. */ +const CALENDAR_YEARS_BACK = 500; +const CALENDAR_YEARS_FORWARD = 500; + +/** Cell payload. `value` is a canonical UTC ISO string, or null for an unset cell. */ +export interface DateCellData { + kind: string; + value: string | null; + withTime: boolean; + readOnly?: boolean; +} + +export interface DateCell { + renderer: CustomRenderer>; + makeCell: ( + value: string | null, + withTime: boolean, + allowOverlay?: boolean, + ) => CustomCell; + validate: (cell: CustomCell) => boolean; +} + +/** + * Build a reusable date cell: a canvas text `draw` (localized date, or date+time + * when the cell's `withTime` is set) plus a shadcn `Calendar` editor with an + * optional native time input. Stores canonical UTC ISO strings. + * + * Call once at module scope (not inside a React render): the editor component + * identity is tied to this call, so re-creating it per render would remount it. + */ +export function createDateCell({ + kind, + nullable = false, +}: { + kind: string; + nullable?: boolean; +}): DateCell { + const Editor = ({ value, onChange }: EditorProps) => { + const selected = isoToCalendarDate(value.value, value.withTime); + const currentYear = new Date().getFullYear(); + + const [time, setTime] = useState(isoToTimeInput(value.value)); + + const handleDay = (day?: Date) => { + if (!day) { + // Deselect clears the cell only when empty is allowed (mirrors onPaste). + if (nullable) { + onChange({ ...value, value: null }); + } + return; + } + onChange({ ...value, value: composeIso(day, time, value.withTime) }); + }; + + const handleTime = (e: ChangeEvent) => { + const next = e.target.value; + setTime(next); + + if (selected) { + onChange({ ...value, value: composeIso(selected, next, value.withTime) }); + } + }; + + return ( + +
+ + {value.withTime && ( + + )} +
+
+ ); + }; + + const renderer = createCustomCell({ + kind, + draw: (args, data) => { + if (data.value == null || data.value === "") { + if (data.readOnly) drawEmptyDash(args); + return; + } + + const { ctx, rect, theme } = args; + ctx.fillStyle = theme.textDark; + ctx.font = theme.baseFontFull; + ctx.textBaseline = "middle"; + ctx.fillText( + formatDateDisplay(data.value, data.withTime), + rect.x + theme.cellHorizontalPadding, + rect.y + rect.height / 2, + ); + }, + editor: Editor, + onPaste: (val, data) => { + const parsed = parseDatePaste(val, data.withTime); + + if (parsed === undefined) return undefined; + if (parsed === null && !nullable) return undefined; + + return { ...data, value: parsed }; + }, + }); + + const makeCell = ( + value: string | null, + withTime: boolean, + allowOverlay = true, + ): CustomCell => + makeCustomCell( + { kind, value, withTime, ...(allowOverlay ? {} : { readOnly: true }) }, + value ?? "", + allowOverlay, + ); + + const validate = (cell: CustomCell): boolean => + isValidDateValue(cell.data.value, nullable); + + return { renderer, makeCell, validate }; +} diff --git a/src/web/src/lib/grid/createEnumCell.test.ts b/src/web/src/lib/grid/createEnumCell.test.ts new file mode 100644 index 0000000..6eb9d93 --- /dev/null +++ b/src/web/src/lib/grid/createEnumCell.test.ts @@ -0,0 +1,104 @@ +import { GridCellKind } from "@glideapps/glide-data-grid"; +import { describe, expect, it } from "vitest"; +import { createEnumCell } from "./createEnumCell"; +import { radixColorByIndex } from "./radixBadgePalette"; + +const cell = createEnumCell({ + kind: "enum-test", + options: [ + { value: 1, label: "One" }, + { value: 2, label: "Two", color: "red" }, + ], +}); + +const nullableCell = createEnumCell({ + kind: "enum-nullable-test", + nullable: true, + options: [ + { value: 1, label: "One" }, + { value: 2, label: "Two", color: "red" }, + ], +}); + +describe("createEnumCell", () => { + it("makeCell builds a custom cell with kind, value, and label copyData", () => { + const c = cell.makeCell(1); + expect(c.kind).toBe(GridCellKind.Custom); + expect(c.data).toEqual({ kind: "enum-test", value: 1 }); + expect(c.copyData).toBe("One"); + }); + + it("colorOf uses the index default when no override is given", () => { + expect(cell.colorOf(1)).toBe(radixColorByIndex(1)); + }); + + it("colorOf prefers a per-value override over the index default", () => { + expect(cell.colorOf(2)).toBe("red"); + }); + + it("the renderer matches its own cell kind and not others", () => { + const match = cell.renderer.isMatch(cell.makeCell(1)); + expect(match).toBe(true); + }); + + it("makeCell(null) builds a cleared cell with null value and empty copyData", () => { + const c = nullableCell.makeCell(null); + expect(c.data).toEqual({ kind: "enum-nullable-test", value: null }); + expect(c.copyData).toBe(""); + }); +}); + +describe("createEnumCell.makeCell allowOverlay", () => { + it("disables the overlay (no Select) when allowOverlay is false", () => { + expect(cell.makeCell(1, false).allowOverlay).toBe(false); + }); + + it("allows the overlay by default", () => { + expect(cell.makeCell(1).allowOverlay).toBe(true); + }); + + it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => { + expect(nullableCell.makeCell(null, false).data.readOnly).toBe(true); + }); + + it("leaves an editable cell unflagged so an empty value stays blank", () => { + expect(cell.makeCell(1).data.readOnly).toBeUndefined(); + }); +}); + +function fakeCtx() { + const calls: string[] = []; + const ctx = { + font: "", + fillStyle: "" as string, + textBaseline: "alphabetic" as CanvasTextBaseline, + fillText: (t: string) => { + calls.push(t); + }, + }; + return { ctx: ctx as unknown as CanvasRenderingContext2D, calls }; +} + +const theme = { + textDark: "#111", + textLight: "#999", + baseFontFull: "13px sans-serif", + cellHorizontalPadding: 8, +}; +const rect = { x: 0, y: 0, width: 160, height: 34 }; +const drawArgs = (ctx: CanvasRenderingContext2D) => + ({ ctx, rect, theme }) as unknown as Parameters[0]; + +describe("createEnumCell draw", () => { + it("draws an em dash for a read-only empty cell", () => { + const { ctx, calls } = fakeCtx(); + nullableCell.renderer.draw(drawArgs(ctx), nullableCell.makeCell(null, false)); + expect(calls).toEqual(["—"]); + }); + + it("draws nothing for an editable empty cell", () => { + const { ctx, calls } = fakeCtx(); + nullableCell.renderer.draw(drawArgs(ctx), nullableCell.makeCell(null)); + expect(calls).toEqual([]); + }); +}); diff --git a/src/web/src/lib/grid/createEnumCell.tsx b/src/web/src/lib/grid/createEnumCell.tsx new file mode 100644 index 0000000..7fc4ff5 --- /dev/null +++ b/src/web/src/lib/grid/createEnumCell.tsx @@ -0,0 +1,132 @@ +import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid"; +import { Select, Text, Theme } from "@radix-ui/themes"; +import { radixThemeConfig } from "../../theme/radixTheme"; +import { + createCustomCell, + drawEmptyDash, + makeCustomCell, + type EditorProps, +} from "./createCustomCell"; +import { radixColorByIndex, type RadixColor } from "./radixBadgePalette"; +import { drawSoftBadge } from "./softBadge"; +import { SoftBadge } from "./softBadgeView"; + +/** One selectable enum value: numeric value, label, and an optional color override. */ +export interface EnumOption { + value: number; + label: string; + color?: RadixColor; +} + +/** Cell payload stored in the grid. `kind` routes the renderer; `value` is the + * enum, or `null` for an unset (cleared) cell on a nullable column. */ +export interface EnumCellData { + kind: string; + value: number | null; + readOnly?: boolean; +} + +interface ResolvedOption { + label: string; + color: RadixColor; +} + +export interface EnumCell { + renderer: CustomRenderer>; + /** + * Build a cell for `value`. Pass `allowOverlay: false` for a read-only column: + * the badge still renders, the `Select` editor never opens, and an empty value + * draws as "—" instead of blank. + */ + makeCell: (value: number | null, allowOverlay?: boolean) => CustomCell; + /** Resolved color for a value (override or index default). Exposed for tests/reuse. */ + colorOf: (value: number) => RadixColor; +} + +/** Sentinel item value for the "clear" option in a nullable Select. Radix + * reserves the empty string for items, so a non-empty sentinel is required. */ +const NONE_VALUE = "__none__"; + +/** + * Build a reusable enum cell: a canvas soft-badge `draw` plus a Radix `Select` + * editor. Colors default to radixColorByIndex(value), overridable per option. + * Each enum column must pass a unique `kind` so glide's isMatch routes correctly. + * + * Call this once at module scope (not inside a React render): the editor component + * identity is tied to this call, so re-creating it per render would remount editors. + */ +export function createEnumCell({ + kind, + options, + nullable = false, +}: { + kind: string; + options: EnumOption[]; + nullable?: boolean; +}): EnumCell { + const byValue = new Map(); + + for (const opt of options) { + byValue.set(opt.value, { label: opt.label, color: opt.color ?? radixColorByIndex(opt.value) }); + } + + const lookup = (value: number): ResolvedOption => + byValue.get(value) ?? { label: String(value), color: radixColorByIndex(value) }; + + const Editor = ({ value, onFinishedEditing }: EditorProps) => { + const handleValueChange = (next: string) => { + onFinishedEditing({ ...value, value: next === NONE_VALUE ? null : Number(next) }); + }; + + return ( + + + + + {nullable && ( + + None + + )} + {options.map((opt) => ( + + + + ))} + + + + ); + }; + + const renderer = createCustomCell({ + kind, + draw: (args, data) => { + if (data.value == null) { + if (data.readOnly) drawEmptyDash(args); + return; + } + + const { label, color } = lookup(data.value); + // Pass the grid's base font so drawSoftBadge measures with the same font it + // renders (glide's FullTheme exposes the composed font as `baseFontFull`). + drawSoftBadge(args.ctx, args.rect, color, label, args.theme.baseFontFull); + }, + editor: Editor, + }); + + const makeCell = (value: number | null, allowOverlay = true): CustomCell => + makeCustomCell( + { kind, value, ...(allowOverlay ? {} : { readOnly: true }) }, + value == null ? "" : lookup(value).label, + allowOverlay, + ); + + return { renderer, makeCell, colorOf: (value) => lookup(value).color }; +} diff --git a/src/web/src/lib/grid/createNumberCell.test.ts b/src/web/src/lib/grid/createNumberCell.test.ts new file mode 100644 index 0000000..c52f73b --- /dev/null +++ b/src/web/src/lib/grid/createNumberCell.test.ts @@ -0,0 +1,120 @@ +import { GridCellKind } from "@glideapps/glide-data-grid"; +import { describe, expect, it } from "vitest"; +import { createNumberCell } from "./createNumberCell"; + +const cell = createNumberCell({ kind: "num-test", min: 0, max: 100 }); +const nullableCell = createNumberCell({ + kind: "num-nullable-test", + nullable: true, + min: 0, + max: 100, +}); + +describe("createNumberCell", () => { + it("makeCell builds a custom cell with kind, numeric value, and string copyData", () => { + const c = cell.makeCell(42.5); + expect(c.kind).toBe(GridCellKind.Custom); + expect(c.data).toEqual({ kind: "num-test", value: 42.5 }); + expect(c.copyData).toBe("42.5"); + }); + + it("makeCell(null) builds a cleared cell with null value and empty copyData", () => { + const c = cell.makeCell(null); + expect(c.data).toEqual({ kind: "num-test", value: null }); + expect(c.copyData).toBe(""); + }); + + it("the renderer matches its own cell kind and not a foreign one", () => { + expect(cell.renderer.isMatch(cell.makeCell(1))).toBe(true); + expect(cell.renderer.isMatch(nullableCell.makeCell(1))).toBe(false); + }); + + it("validate blocks empty when not nullable, allows it when nullable", () => { + expect(cell.validate(cell.makeCell(null))).toBe(false); + expect(nullableCell.validate(nullableCell.makeCell(null))).toBe(true); + }); + + it("validate accepts in-range and rejects out-of-range values", () => { + expect(cell.validate(cell.makeCell(50))).toBe(true); + expect(cell.validate(cell.makeCell(500))).toBe(false); + }); + + it("onPaste parses a separated string and rejects junk / out-of-range", () => { + const onPaste = cell.renderer.onPaste!; + const data = cell.makeCell(null).data; + expect(onPaste("50", data)).toEqual({ kind: "num-test", value: 50 }); + expect(onPaste("garbage", data)).toBeUndefined(); + expect(onPaste("500", data)).toBeUndefined(); + }); + + it("onPaste rejects empty when not nullable but clears when nullable", () => { + expect(cell.renderer.onPaste!(" ", cell.makeCell(1).data)).toBeUndefined(); + expect(nullableCell.renderer.onPaste!(" ", nullableCell.makeCell(1).data)).toEqual({ + kind: "num-nullable-test", + value: null, + }); + }); +}); + +describe("createNumberCell.makeCell allowOverlay", () => { + it("disables the overlay when allowOverlay is false", () => { + expect(cell.makeCell(1, false).allowOverlay).toBe(false); + }); + + it("allows the overlay by default", () => { + expect(cell.makeCell(1).allowOverlay).toBe(true); + }); + + it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => { + expect(cell.makeCell(1, false).data.readOnly).toBe(true); + }); + + it("leaves an editable cell unflagged so an empty value stays blank", () => { + expect(cell.makeCell(1).data.readOnly).toBeUndefined(); + }); +}); + +function fakeCtx() { + const calls: string[] = []; + const ctx = { + font: "", + fillStyle: "" as string, + textBaseline: "alphabetic" as CanvasTextBaseline, + fillText: (t: string) => { + calls.push(t); + }, + }; + return { ctx: ctx as unknown as CanvasRenderingContext2D, calls, raw: ctx }; +} + +const theme = { + textDark: "#111", + textLight: "#999", + baseFontFull: "13px sans-serif", + cellHorizontalPadding: 8, +}; +const rect = { x: 0, y: 0, width: 120, height: 34 }; +const drawArgs = (ctx: CanvasRenderingContext2D) => + ({ ctx, rect, theme }) as unknown as Parameters[0]; + +describe("createNumberCell draw", () => { + it("draws an em dash for a read-only empty cell", () => { + const { ctx, calls, raw } = fakeCtx(); + cell.renderer.draw(drawArgs(ctx), cell.makeCell(null, false)); + expect(calls).toEqual(["—"]); + expect(raw.fillStyle).toBe("#999"); + }); + + it("draws nothing for an editable empty cell", () => { + const { ctx, calls } = fakeCtx(); + cell.renderer.draw(drawArgs(ctx), cell.makeCell(null)); + expect(calls).toEqual([]); + }); + + it("draws the formatted value for a non-empty cell", () => { + const { ctx, calls, raw } = fakeCtx(); + cell.renderer.draw(drawArgs(ctx), cell.makeCell(42.5, false)); + expect(calls).toEqual(["42.5"]); + expect(raw.fillStyle).toBe("#111"); + }); +}); diff --git a/src/web/src/lib/grid/createNumberCell.tsx b/src/web/src/lib/grid/createNumberCell.tsx new file mode 100644 index 0000000..20edd95 --- /dev/null +++ b/src/web/src/lib/grid/createNumberCell.tsx @@ -0,0 +1,122 @@ +import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid"; +import { Theme } from "@radix-ui/themes"; +import { NumericInput } from "../../components/ui/numericInput"; +import { radixThemeConfig } from "../../theme/radixTheme"; +import { + formatNumberDisplay, + isValueInRange, + parseNumberPaste, + type NumberFormat, +} from "../number/numberUtils"; +import { + createCustomCell, + drawEmptyDash, + makeCustomCell, + type EditorProps, +} from "./createCustomCell"; + +/** Cell payload. `value` is a JS number, or null for an unset cell. */ +export interface NumberCellData { + kind: string; + value: number | null; + readOnly?: boolean; +} + +export interface NumberCell { + renderer: CustomRenderer>; + makeCell: (value: number | null, allowOverlay?: boolean) => CustomCell; + validate: (cell: CustomCell) => boolean; +} + +/** + * Build a reusable numeric cell: a canvas text `draw` (formatted per options) + * plus a `` editor. Out-of-range entry is blocked in the editor; + * `validate` and `onPaste` gate commits and pastes. Empty is allowed only when + * `nullable`. + * + * Call once at module scope (not inside a React render): the editor component + * identity is tied to this call, so re-creating it per render would remount it. + */ +export function createNumberCell({ + kind, + nullable = false, + format, + decimalScale, + currency, + min, + max, + thousandSeparator, + prefix, + suffix, +}: { + kind: string; + nullable?: boolean; + format?: NumberFormat; + decimalScale?: number; + currency?: string; + min?: number; + max?: number; + thousandSeparator?: boolean; + prefix?: string; + suffix?: string; +}): NumberCell { + const fmt = { format, decimalScale, currency, min, max, thousandSeparator, prefix, suffix }; + + const Editor = ({ value, onChange }: EditorProps) => ( + +
+ onChange({ ...value, value: next })} + autoFocus + /> +
+
+ ); + + const renderer = createCustomCell({ + kind, + draw: (args, data) => { + if (data.value == null) { + if (data.readOnly) drawEmptyDash(args); + return; + } + + const { ctx, rect, theme } = args; + ctx.fillStyle = theme.textDark; + ctx.font = theme.baseFontFull; + ctx.textBaseline = "middle"; + ctx.fillText( + formatNumberDisplay(data.value, fmt), + rect.x + theme.cellHorizontalPadding, + rect.y + rect.height / 2, + ); + }, + editor: Editor, + onPaste: (val, data) => { + const parsed = parseNumberPaste(val, { min, max, nullable }); + + if (parsed === undefined) return undefined; + + return { ...data, value: parsed }; + }, + }); + + const makeCell = (value: number | null, allowOverlay = true): CustomCell => + makeCustomCell( + { kind, value, ...(allowOverlay ? {} : { readOnly: true }) }, + value == null ? "" : String(value), + allowOverlay, + ); + + const validate = (cell: CustomCell): boolean => { + const v = cell.data.value; + + if (v == null) return nullable; + + return isValueInRange(v, min, max); + }; + + return { renderer, makeCell, validate }; +} diff --git a/src/web/src/lib/grid/createPhoneCell.test.ts b/src/web/src/lib/grid/createPhoneCell.test.ts new file mode 100644 index 0000000..f85e43a --- /dev/null +++ b/src/web/src/lib/grid/createPhoneCell.test.ts @@ -0,0 +1,105 @@ +import { GridCellKind } from "@glideapps/glide-data-grid"; +import { describe, expect, it } from "vitest"; +import { createPhoneCell } from "./createPhoneCell"; + +const cell = createPhoneCell({ kind: "phone-test" }); +const nullableCell = createPhoneCell({ kind: "phone-nullable-test", nullable: true }); + +describe("createPhoneCell", () => { + it("makeCell builds a custom cell with kind, E.164 value, and E.164 copyData", () => { + const c = cell.makeCell("+14155552671"); + expect(c.kind).toBe(GridCellKind.Custom); + expect(c.data).toEqual({ kind: "phone-test", value: "+14155552671" }); + expect(c.copyData).toBe("+14155552671"); + }); + + it("makeCell(null) builds a cleared cell with null value and empty copyData", () => { + const c = cell.makeCell(null); + expect(c.data).toEqual({ kind: "phone-test", value: null }); + expect(c.copyData).toBe(""); + }); + + it("the renderer matches its own cell kind and not a foreign one", () => { + expect(cell.renderer.isMatch(cell.makeCell("+14155552671"))).toBe(true); + expect(cell.renderer.isMatch(nullableCell.makeCell("+14155552671"))).toBe(false); + }); + + it("validate blocks empty when not nullable, allows it when nullable", () => { + expect(cell.validate(cell.makeCell(null))).toBe(false); + expect(nullableCell.validate(nullableCell.makeCell(null))).toBe(true); + }); + + it("validate accepts a valid number and rejects a malformed one", () => { + expect(cell.validate(cell.makeCell("+14155552671"))).toBe(true); + expect(cell.validate(cell.makeCell("+1234"))).toBe(false); + }); + + it("onPaste parses a national string to E.164 and rejects junk", () => { + const onPaste = cell.renderer.onPaste!; + const data = cell.makeCell(null).data; + expect(onPaste("(415) 555-2671", data)).toEqual({ kind: "phone-test", value: "+14155552671" }); + expect(onPaste("garbage", data)).toBeUndefined(); + }); + + it("onPaste rejects an empty paste when not nullable but clears when nullable", () => { + expect(cell.renderer.onPaste!(" ", cell.makeCell("+14155552671").data)).toBeUndefined(); + expect( + nullableCell.renderer.onPaste!(" ", nullableCell.makeCell("+14155552671").data), + ).toEqual({ kind: "phone-nullable-test", value: null }); + }); +}); + +describe("createPhoneCell.makeCell allowOverlay", () => { + it("disables the overlay when allowOverlay is false", () => { + expect(cell.makeCell("+14155552671", false).allowOverlay).toBe(false); + }); + + it("allows the overlay by default", () => { + expect(cell.makeCell("+14155552671").allowOverlay).toBe(true); + }); + + it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => { + expect(cell.makeCell(null, false).data.readOnly).toBe(true); + }); + + it("leaves an editable cell unflagged so an empty value stays blank", () => { + expect(cell.makeCell("+14155552671").data.readOnly).toBeUndefined(); + }); +}); + +function fakeCtx() { + const calls: string[] = []; + const ctx = { + font: "", + fillStyle: "" as string, + textBaseline: "alphabetic" as CanvasTextBaseline, + fillText: (t: string) => { + calls.push(t); + }, + }; + return { ctx: ctx as unknown as CanvasRenderingContext2D, calls }; +} + +const theme = { + textDark: "#111", + textLight: "#999", + baseFontFull: "13px sans-serif", + cellHorizontalPadding: 8, +}; +const rect = { x: 0, y: 0, width: 120, height: 34 }; +const drawArgs = (ctx: CanvasRenderingContext2D) => + ({ ctx, rect, theme }) as unknown as Parameters[0]; + +describe("createPhoneCell draw", () => { + it("draws an em dash for a read-only empty cell", () => { + const { ctx, calls } = fakeCtx(); + cell.renderer.draw(drawArgs(ctx), cell.makeCell(null, false)); + expect(calls).toEqual(["—"]); + }); + + it("draws nothing for an editable empty cell", () => { + const { ctx, calls } = fakeCtx(); + cell.renderer.draw(drawArgs(ctx), cell.makeCell(null)); + expect(calls).toEqual([]); + }); +}); diff --git a/src/web/src/lib/grid/createPhoneCell.tsx b/src/web/src/lib/grid/createPhoneCell.tsx new file mode 100644 index 0000000..e45ad79 --- /dev/null +++ b/src/web/src/lib/grid/createPhoneCell.tsx @@ -0,0 +1,121 @@ +import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid"; +import { Text, Theme } from "@radix-ui/themes"; +import PhoneInput, { type Country } from "react-phone-number-input"; +import "react-phone-number-input/style.css"; +import { radixThemeConfig } from "../../theme/radixTheme"; +import { formatPhoneDisplay, isValidPhoneValue, parsePhonePaste } from "../phone/phoneUtils"; +import { + createCustomCell, + drawEmptyDash, + makeCustomCell, + type EditorProps, +} from "./createCustomCell"; + +/** Cell payload. `value` is a canonical E.164 string, or null for an unset cell. */ +export interface PhoneCellData { + kind: string; + value: string | null; + readOnly?: boolean; +} + +export interface PhoneCell { + renderer: CustomRenderer>; + makeCell: (value: string | null, allowOverlay?: boolean) => CustomCell; + validate: (cell: CustomCell) => boolean; +} + +/** + * Build a reusable phone-number cell: a canvas text `draw` (international format) + * plus a `react-phone-number-input` editor. Stores canonical E.164 strings. + * Empty is allowed only when `nullable`; any non-empty value must be valid. + * + * Call once at module scope (not inside a React render): the editor component + * identity is tied to this call, so re-creating it per render would remount it. + */ +export function createPhoneCell({ + kind, + nullable = false, + defaultCountry = "US", +}: { + kind: string; + nullable?: boolean; + defaultCountry?: Country; +}): PhoneCell { + const Editor = ({ value, onChange, isValid }: EditorProps) => { + const handleChange = (next?: string) => { + // PhoneInput emits E.164 (or undefined when cleared). Live-sync to glide; + // glide commits this value on overlay close, gated by validateCell. + onChange({ ...value, value: next ?? null }); + }; + + const invalid = isValid === false; + + return ( + +
+
+ +
+ {invalid && ( + + Invalid phone number + + )} +
+
+ ); + }; + + const renderer = createCustomCell({ + kind, + draw: (args, data) => { + if (data.value == null || data.value === "") { + if (data.readOnly) drawEmptyDash(args); + return; + } + + const { ctx, rect, theme } = args; + ctx.fillStyle = theme.textDark; + ctx.font = theme.baseFontFull; + ctx.textBaseline = "middle"; + ctx.fillText( + formatPhoneDisplay(data.value, defaultCountry), + rect.x + theme.cellHorizontalPadding, + rect.y + rect.height / 2, + ); + }, + editor: Editor, + onPaste: (val, data) => { + const parsed = parsePhonePaste(val, defaultCountry); + + if (parsed === undefined) return undefined; + if (parsed === null && !nullable) return undefined; + + return { ...data, value: parsed }; + }, + }); + + const makeCell = (value: string | null, allowOverlay = true): CustomCell => + makeCustomCell( + { kind, value, ...(allowOverlay ? {} : { readOnly: true }) }, + value ?? "", + allowOverlay, + ); + + const validate = (cell: CustomCell): boolean => + isValidPhoneValue(cell.data.value, nullable); + + return { renderer, makeCell, validate }; +} diff --git a/src/web/src/lib/grid/createTextCell.test.ts b/src/web/src/lib/grid/createTextCell.test.ts new file mode 100644 index 0000000..c976c03 --- /dev/null +++ b/src/web/src/lib/grid/createTextCell.test.ts @@ -0,0 +1,103 @@ +import { GridCellKind } from "@glideapps/glide-data-grid"; +import { describe, expect, it } from "vitest"; +import { createTextCell } from "./createTextCell"; + +const cell = createTextCell({ kind: "text-test", validation: { required: true, maxLength: 5 } }); +const optional = createTextCell({ kind: "text-optional", validation: { email: true } }); + +describe("createTextCell", () => { + it("makeCell builds a custom cell with kind, value, and string copyData", () => { + const c = cell.makeCell("hi"); + expect(c.kind).toBe(GridCellKind.Custom); + expect(c.data).toEqual({ kind: "text-test", value: "hi" }); + expect(c.copyData).toBe("hi"); + }); + + it("makeCell(null) builds a cleared cell with null value and empty copyData", () => { + const c = cell.makeCell(null); + expect(c.data).toEqual({ kind: "text-test", value: null }); + expect(c.copyData).toBe(""); + }); + + it("the renderer matches its own cell kind and not a foreign one", () => { + expect(cell.renderer.isMatch(cell.makeCell("a"))).toBe(true); + expect(cell.renderer.isMatch(optional.makeCell("a"))).toBe(false); + }); + + it("validate blocks empty when required, allows it when optional", () => { + expect(cell.validate(cell.makeCell(null))).toBe(false); + expect(optional.validate(optional.makeCell(null))).toBe(true); + }); + + it("validate enforces the configured rules (maxLength / email)", () => { + expect(cell.validate(cell.makeCell("hello"))).toBe(true); + expect(cell.validate(cell.makeCell("toolong"))).toBe(false); + expect(optional.validate(optional.makeCell("a@b.co"))).toBe(true); + expect(optional.validate(optional.makeCell("nope"))).toBe(false); + }); + + it("onPaste trims and stores the value, clearing to null when empty", () => { + const onPaste = cell.renderer.onPaste!; + const data = cell.makeCell(null).data; + expect(onPaste(" hey ", data)).toEqual({ kind: "text-test", value: "hey" }); + expect(onPaste(" ", data)).toEqual({ kind: "text-test", value: null }); + }); +}); + +describe("createTextCell.makeCell allowOverlay", () => { + it("disables the overlay when allowOverlay is false", () => { + expect(cell.makeCell("a", false).allowOverlay).toBe(false); + }); + it("allows the overlay by default", () => { + expect(cell.makeCell("a").allowOverlay).toBe(true); + }); + it("flags the cell read-only (draws — for empty) when the overlay is disabled", () => { + expect(cell.makeCell(null, false).data.readOnly).toBe(true); + }); + it("leaves an editable cell unflagged so an empty value stays blank", () => { + expect(cell.makeCell("a").data.readOnly).toBeUndefined(); + }); +}); + +function fakeCtx() { + const calls: string[] = []; + const ctx = { + font: "", + fillStyle: "" as string, + textBaseline: "alphabetic" as CanvasTextBaseline, + fillText: (t: string) => { + calls.push(t); + }, + }; + return { ctx: ctx as unknown as CanvasRenderingContext2D, calls }; +} + +const theme = { + textDark: "#111", + textLight: "#999", + baseFontFull: "13px sans-serif", + cellHorizontalPadding: 8, +}; +const rect = { x: 0, y: 0, width: 120, height: 34 }; +const drawArgs = (ctx: CanvasRenderingContext2D) => + ({ ctx, rect, theme }) as unknown as Parameters[0]; + +describe("createTextCell draw", () => { + it("draws an em dash for a read-only empty cell", () => { + const { ctx, calls } = fakeCtx(); + cell.renderer.draw(drawArgs(ctx), cell.makeCell(null, false)); + expect(calls).toEqual(["—"]); + }); + + it("draws nothing for an editable empty cell", () => { + const { ctx, calls } = fakeCtx(); + cell.renderer.draw(drawArgs(ctx), cell.makeCell(null)); + expect(calls).toEqual([]); + }); + + it("draws the text for a non-empty cell", () => { + const { ctx, calls } = fakeCtx(); + cell.renderer.draw(drawArgs(ctx), cell.makeCell("hi", false)); + expect(calls).toEqual(["hi"]); + }); +}); diff --git a/src/web/src/lib/grid/createTextCell.tsx b/src/web/src/lib/grid/createTextCell.tsx new file mode 100644 index 0000000..0333a66 --- /dev/null +++ b/src/web/src/lib/grid/createTextCell.tsx @@ -0,0 +1,104 @@ +import type { CustomCell, CustomRenderer } from "@glideapps/glide-data-grid"; +import { Text, Theme } from "@radix-ui/themes"; +import { Input } from "../../components/ui/input"; +import { radixThemeConfig } from "../../theme/radixTheme"; +import { validateText, type TextValidationOptions } from "../text/textValidation"; +import { + createCustomCell, + drawEmptyDash, + makeCustomCell, + type EditorProps, +} from "./createCustomCell"; + +/** Cell payload. `value` is the text string, or null for an unset cell. */ +export interface TextCellData { + kind: string; + value: string | null; + readOnly?: boolean; +} + +export interface TextCell { + renderer: CustomRenderer>; + makeCell: (value: string | null, allowOverlay?: boolean) => CustomCell; + validate: (cell: CustomCell) => boolean; +} + +/** + * Build a reusable text cell: a canvas text `draw` plus an `` editor with + * configurable validation. The editor shows the first failing rule's message; + * `validate` gates commit; `onPaste` accepts the trimmed string (commit-gated). + * `validation.required` plays the role of the number/phone cells' `nullable`. + * + * Call once at module scope (not inside a React render): the editor component + * identity is tied to this call, so re-creating it per render would remount it. + */ +export function createTextCell({ + kind, + validation = {}, +}: { + kind: string; + validation?: TextValidationOptions; +}): TextCell { + const Editor = ({ value, onChange, isValid }: EditorProps) => { + const text = value.value ?? ""; + + const result = validateText(text, validation); + const error = result.valid ? null : result.error; + + const invalid = isValid === false; + + return ( + +
+ + onChange({ ...value, value: e.target.value === "" ? null : e.target.value }) + } + autoFocus + style={invalid ? { borderColor: "var(--red-9)" } : undefined} + /> + {invalid && error && ( + + {error} + + )} +
+
+ ); + }; + + const renderer = createCustomCell({ + kind, + draw: (args, data) => { + if (data.value == null || data.value === "") { + if (data.readOnly) drawEmptyDash(args); + return; + } + + const { ctx, rect, theme } = args; + ctx.fillStyle = theme.textDark; + ctx.font = theme.baseFontFull; + ctx.textBaseline = "middle"; + ctx.fillText(data.value, rect.x + theme.cellHorizontalPadding, rect.y + rect.height / 2); + }, + editor: Editor, + onPaste: (val, data) => { + const trimmed = val.trim(); + return { ...data, value: trimmed === "" ? null : trimmed }; + }, + }); + + const makeCell = (value: string | null, allowOverlay = true): CustomCell => + makeCustomCell( + { kind, value, ...(allowOverlay ? {} : { readOnly: true }) }, + value ?? "", + allowOverlay, + ); + + const validate = (cell: CustomCell): boolean => + validateText(cell.data.value ?? "", validation).valid; + + return { renderer, makeCell, validate }; +} diff --git a/src/web/src/lib/grid/radixBadgePalette.test.ts b/src/web/src/lib/grid/radixBadgePalette.test.ts new file mode 100644 index 0000000..0f0044d --- /dev/null +++ b/src/web/src/lib/grid/radixBadgePalette.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { BADGE_SEQUENCE, RADIX_BADGE_SCALES, radixColorByIndex } from "./radixBadgePalette"; + +describe("radixColorByIndex", () => { + it("maps an index to the matching sequence entry", () => { + expect(radixColorByIndex(0)).toBe(BADGE_SEQUENCE[0]); + expect(radixColorByIndex(3)).toBe(BADGE_SEQUENCE[3]); + }); + + it("wraps around past the end of the sequence", () => { + expect(radixColorByIndex(BADGE_SEQUENCE.length)).toBe(BADGE_SEQUENCE[0]); + expect(radixColorByIndex(BADGE_SEQUENCE.length + 2)).toBe(BADGE_SEQUENCE[2]); + }); + + it("handles negative indices", () => { + expect(radixColorByIndex(-1)).toBe(BADGE_SEQUENCE[BADGE_SEQUENCE.length - 1]); + }); + + it("truncates fractional indices toward zero", () => { + expect(radixColorByIndex(2.9)).toBe(BADGE_SEQUENCE[2]); + }); + + it("every sequence color is in the importable scale list", () => { + for (const c of BADGE_SEQUENCE) { + expect(RADIX_BADGE_SCALES).toContain(c); + } + }); +}); diff --git a/src/web/src/lib/grid/radixBadgePalette.ts b/src/web/src/lib/grid/radixBadgePalette.ts new file mode 100644 index 0000000..97d082a --- /dev/null +++ b/src/web/src/lib/grid/radixBadgePalette.ts @@ -0,0 +1,50 @@ +// Curated, domain-agnostic set of Radix Themes color scales used for enum badges. +// +// IMPORTANT: every scale listed in RADIX_BADGE_SCALES MUST have its +// `tokens/colors/.css` imported in `src/theme/radixStyles.ts`. Otherwise +// getComputedStyle resolves its CSS vars to '' and `softBadge.tsx` warns. Keep +// this list and radixStyles.ts in sync. + +/** All scales an enum badge may use (default sequence + semantic overrides). */ +export const RADIX_BADGE_SCALES = [ + "iris", + "tomato", + "amber", + "grass", + "cyan", + "plum", + "orange", + "jade", + "crimson", + "indigo", + // Extra scales reserved for semantic overrides (also imported in radixStyles.ts): + "red", + "green", +] as const; + +export type RadixColor = (typeof RADIX_BADGE_SCALES)[number]; + +/** Ordered subset used for deterministic, value-driven default coloring. */ +export const BADGE_SEQUENCE = [ + "iris", + "tomato", + "amber", + "grass", + "cyan", + "plum", + "orange", + "jade", + "crimson", + "indigo", +] as const satisfies readonly RadixColor[]; + +/** + * Deterministic default color for an enum value (the "index parity" rule): + * value -> BADGE_SEQUENCE[value mod length]. Guards negatives / non-integers. + */ +export function radixColorByIndex(index: number): RadixColor { + const n = BADGE_SEQUENCE.length; + const i = ((Math.trunc(index) % n) + n) % n; + + return BADGE_SEQUENCE[i]; +} diff --git a/src/web/src/lib/grid/softBadge.test.ts b/src/web/src/lib/grid/softBadge.test.ts new file mode 100644 index 0000000..dd79e2e --- /dev/null +++ b/src/web/src/lib/grid/softBadge.test.ts @@ -0,0 +1,133 @@ +// @vitest-environment node +// This suite hand-stubs document/getComputedStyle rather than using jsdom's, so +// it needs the node environment the stubs were written against. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { drawSoftBadge, invalidateBadgeColorCache, resolveRadixSoft } from "./softBadge"; + +// --- DOM boundary stub (node env has no document / getComputedStyle) --- +// Mirrors the manual-stub approach used by colorSchemeWatcher.test.ts. Returns the +// getComputedStyle spy so tests can assert how often it is read. +function stubComputedStyle(getProp: (prop: string) => string) { + globalThis.document = { + querySelector: () => null, + documentElement: {}, + } as unknown as Document; + const gcs = vi.fn(() => ({ getPropertyValue: getProp }) as unknown as CSSStyleDeclaration); + globalThis.getComputedStyle = gcs as unknown as typeof getComputedStyle; + return gcs; +} + +afterEach(() => { + invalidateBadgeColorCache(); + vi.restoreAllMocks(); +}); + +describe("resolveRadixSoft", () => { + it("reads --{color}-a3 / -11 / -9 from the theme root", () => { + stubComputedStyle((prop) => { + if (prop === "--grass-a3") return "#11ff0040"; + if (prop === "--grass-11") return "#1a7a3a"; + if (prop === "--grass-9") return "#46a758"; + return ""; + }); + + expect(resolveRadixSoft("grass")).toEqual({ + bg: "#11ff0040", + text: "#1a7a3a", + dot: "#46a758", + }); + }); + + it("caches a resolved scale (reads getComputedStyle once)", () => { + const gcs = stubComputedStyle(() => "#46a758"); + + resolveRadixSoft("grass"); + resolveRadixSoft("grass"); + + expect(gcs).toHaveBeenCalledTimes(1); + }); + + it("warns once for an unresolved scale (empty vars) and does not cache it", () => { + stubComputedStyle(() => ""); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const first = resolveRadixSoft("tomato"); + resolveRadixSoft("tomato"); + + expect(first).toEqual({ bg: "", text: "", dot: "" }); + expect(warn).toHaveBeenCalledTimes(1); + }); +}); + +describe("drawSoftBadge", () => { + function fakeCtx() { + const calls: string[] = []; + const ctx = { + font: "", + fillStyle: "" as string | CanvasGradient | CanvasPattern, + textBaseline: "alphabetic" as CanvasTextBaseline, + measureText: (t: string) => ({ width: t.length * 6 }) as TextMetrics, + beginPath: () => { + calls.push("beginPath"); + }, + roundRect: () => { + calls.push("roundRect"); + }, + arc: () => { + calls.push("arc"); + }, + rect: () => { + calls.push("rect"); + }, + clip: () => { + calls.push("clip"); + }, + fill: () => { + calls.push("fill"); + }, + save: () => { + calls.push("save"); + }, + restore: () => { + calls.push("restore"); + }, + fillText: (t: string) => { + calls.push(`fillText:${t}`); + }, + }; + return { ctx: ctx as unknown as CanvasRenderingContext2D, calls, raw: ctx }; + } + + const rect = { x: 0, y: 0, width: 200, height: 34 }; + + it("sets the font and paints pill, dot, and clipped label for a resolved scale", () => { + stubComputedStyle(() => "#46a758"); + const { ctx, calls, raw } = fakeCtx(); + + drawSoftBadge(ctx, rect, "grass", "Accepted", "13px sans-serif"); + + expect(raw.font).toBe("13px sans-serif"); + expect(calls).toContain("roundRect"); + expect(calls).toContain("arc"); + expect(calls).toContain("fillText:Accepted"); + }); + + it("draws nothing when the scale is unresolved", () => { + stubComputedStyle(() => ""); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const { ctx, calls } = fakeCtx(); + + drawSoftBadge(ctx, rect, "plum", "X", "13px sans-serif"); + + expect(calls).toEqual([]); + }); + + it("draws nothing when the cell is too narrow", () => { + stubComputedStyle(() => "#46a758"); + const { ctx, calls } = fakeCtx(); + + drawSoftBadge(ctx, { x: 0, y: 0, width: 10, height: 34 }, "grass", "X", "13px sans-serif"); + + expect(calls).toEqual([]); + }); +}); diff --git a/src/web/src/lib/grid/softBadge.tsx b/src/web/src/lib/grid/softBadge.tsx new file mode 100644 index 0000000..7269c45 --- /dev/null +++ b/src/web/src/lib/grid/softBadge.tsx @@ -0,0 +1,124 @@ +import type { RadixColor } from "./radixBadgePalette"; + +export interface ResolvedBadgeColors { + bg: string; // soft background (--{color}-a3) + text: string; // label text (--{color}-11) + dot: string; // accent dot (--{color}-9) +} + +// Resolved-color cache. Keyed by scale only; cleared by invalidateBadgeColorCache +// when the light/dark appearance flips (the CSS vars themselves auto-reflect it). +const cache = new Map(); +const warned = new Set(); +let root: Element | null = null; + +function themeRoot(): Element { + if (root && root.isConnected) { + return root; + } + + root = document.querySelector(".radix-themes") ?? document.documentElement; + + return root; +} + +/** + * Clear the resolved-color cache (call when appearance flips light/dark) and drop + * the cached theme-root element so it is re-queried on next use. + */ +export function invalidateBadgeColorCache(): void { + cache.clear(); + root = null; +} + +/** + * Resolve a Radix scale to concrete soft-badge colors the canvas can use. + * Reads --{color}-a3/-11/-9 from the Themes root (auto-reflects appearance). + * Unresolved scales (CSS not imported) return empties and warn once. + */ +export function resolveRadixSoft(color: RadixColor): ResolvedBadgeColors { + const hit = cache.get(color); + + if (hit) { + return hit; + } + + const cs = getComputedStyle(themeRoot()); + + const read = (step: string) => cs.getPropertyValue(`--${color}-${step}`).trim(); + + const resolved: ResolvedBadgeColors = { bg: read("a3"), text: read("11"), dot: read("9") }; + + if (!resolved.dot) { + if (!warned.has(color)) { + warned.add(color); + + console.warn( + `[softBadge] Radix scale "${color}" resolved to empty — is tokens/colors/${color}.css imported in src/theme/radixStyles.ts?`, + ); + } + + return resolved; // do not cache failures (allows recovery after HMR/import fix) + } + + cache.set(color, resolved); + return resolved; +} + +/** + * Draw a Radix-soft-Badge-style pill on the grid canvas: soft bg fill, a small + * solid dot, then the label — full-pill radius, left-aligned, clipped to the cell. + * `font` must be the caller's canvas font (e.g. glide's theme.baseFontFull) so the + * measured text width matches what gets rendered. + */ +export function drawSoftBadge( + ctx: CanvasRenderingContext2D, + rect: { x: number; y: number; width: number; height: number }, + color: RadixColor, + label: string, + font: string, +): void { + const { bg, text, dot } = resolveRadixSoft(color); + + if (!dot) { + return; // unresolved scale; already warned + } + + // Layout across the pill interior: innerPadX | dot(2*dotR) | gap | text | innerPadX + const PAD = 8; + const innerPadX = 10; + const dotR = 3; + const gap = 6; + const h = Math.min(22, rect.height - 8); + const y = rect.y + (rect.height - h) / 2; + const x = rect.x + PAD; + const radius = h / 2; + const cy = y + h / 2; + + const maxW = rect.width - PAD * 2; + if (maxW <= 0) return; + + ctx.font = font; // set before measuring so width matches what we render + const textW = ctx.measureText(label).width; + const badgeW = Math.min(innerPadX + dotR * 2 + gap + textW + innerPadX, maxW); + + ctx.beginPath(); + ctx.roundRect(x, y, badgeW, h, radius); + ctx.fillStyle = bg; + ctx.fill(); + + const dotX = x + innerPadX + dotR; + ctx.beginPath(); + ctx.arc(dotX, cy, dotR, 0, Math.PI * 2); + ctx.fillStyle = dot; + ctx.fill(); + + ctx.save(); + ctx.beginPath(); + ctx.rect(x, y, badgeW, h); + ctx.clip(); + ctx.fillStyle = text; + ctx.textBaseline = "middle"; + ctx.fillText(label, dotX + dotR + gap, cy + 0.5); + ctx.restore(); +} diff --git a/src/web/src/lib/grid/softBadgeView.tsx b/src/web/src/lib/grid/softBadgeView.tsx new file mode 100644 index 0000000..2191f0c --- /dev/null +++ b/src/web/src/lib/grid/softBadgeView.tsx @@ -0,0 +1,11 @@ +import { Badge } from "@radix-ui/themes"; +import type { RadixColor } from "./radixBadgePalette"; + +/** React soft badge (full-pill) — used in the edit dropdown trigger and items. */ +export function SoftBadge({ color, label }: { color: RadixColor; label: string }) { + return ( + + {label} + + ); +} diff --git a/src/web/src/lib/grid/useEditHighlight.test.ts b/src/web/src/lib/grid/useEditHighlight.test.ts new file mode 100644 index 0000000..802e0f7 --- /dev/null +++ b/src/web/src/lib/grid/useEditHighlight.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { GridCellKind, type GridCell } from "@glideapps/glide-data-grid"; +import { createEditHighlight } from "./useEditHighlight"; + +const textCell = (data: string): GridCell => ({ + kind: GridCellKind.Text, + data, + displayData: data, + allowOverlay: true, +}); + +describe("createEditHighlight", () => { + it("leaves a cell untouched when its key was never edited", () => { + const h = createEditHighlight(() => 100); + const cell = textCell("a"); + + const result = h.withHighlight("1:name", cell); + + expect(result).toBe(cell); + expect(result).not.toHaveProperty("lastUpdated"); + }); + + it("stamps lastUpdated with the edit time after markEdited", () => { + const h = createEditHighlight(() => 1234); + + h.markEdited("1:name"); + const result = h.withHighlight("1:name", textCell("a")); + + expect(result.lastUpdated).toBe(1234); + }); + + it("only highlights the edited key, not its neighbours", () => { + const h = createEditHighlight(() => 50); + + h.markEdited("1:name"); + + expect(h.withHighlight("1:name", textCell("a")).lastUpdated).toBe(50); + expect(h.withHighlight("2:name", textCell("b")).lastUpdated).toBeUndefined(); + }); + + it("advances lastUpdated to the latest edit time when a key is re-edited", () => { + let clock = 10; + const h = createEditHighlight(() => clock); + + h.markEdited("1:name"); + clock = 999; + h.markEdited("1:name"); + + expect(h.withHighlight("1:name", textCell("a")).lastUpdated).toBe(999); + }); +}); diff --git a/src/web/src/lib/grid/useEditHighlight.ts b/src/web/src/lib/grid/useEditHighlight.ts new file mode 100644 index 0000000..7fb2e7f --- /dev/null +++ b/src/web/src/lib/grid/useEditHighlight.ts @@ -0,0 +1,49 @@ +import { useState } from "react"; +import type { GridCell } from "@glideapps/glide-data-grid"; + +export interface EditHighlight { + /** Record that the cell identified by `key` was just edited. */ + markEdited: (key: string) => void; + /** Stamp `cell` with `lastUpdated` if `key` was recently edited, else return it unchanged. */ + withHighlight: (key: string, cell: GridCell) => GridCell; +} + +/** + * Pure (React-free) core: tracks edit timestamps per stable cell key and stamps + * matching cells with Glide's `lastUpdated`, which the grid renders as a + * highlighted background that fades out on its own. + * + * `now` is injectable for tests; in the browser it defaults to performance.now(), + * which is what Glide compares `lastUpdated` against (not Date.now()). + */ +export function createEditHighlight(now: () => number = () => performance.now()): EditHighlight { + const editedAt = new Map(); + + return { + markEdited(key: string): void { + editedAt.set(key, now()); + }, + withHighlight(key: string, cell: GridCell): GridCell { + const lastUpdated = editedAt.get(key); + return lastUpdated === undefined ? cell : { ...cell, lastUpdated }; + }, + }; +} + +/** + * React hook wrapper. Keys edits by a caller-supplied stable identity + * (e.g. `${dataIndex}:${field}`) rather than grid coordinates, so the highlight + * follows the data through sorting, grouping, and windowing instead of staying + * pinned to a screen position. + * + * Usage: + * const { markEdited, withHighlight } = useEditHighlight() + * // in getCellContent: return withHighlight(key, dataCell(row, field)) + * // in onCellEdited: markEdited(key) + */ +export function useEditHighlight(): EditHighlight { + // Lazy initializer runs once; the instance is stable for the component's + // lifetime and its methods are stable references. + const [highlight] = useState(createEditHighlight); + return highlight; +} diff --git a/src/web/src/lib/http/ApiError.ts b/src/web/src/lib/http/ApiError.ts new file mode 100644 index 0000000..304cdfa --- /dev/null +++ b/src/web/src/lib/http/ApiError.ts @@ -0,0 +1,24 @@ +/** + * The single failure shape for every API call, whatever the cause — network + * failure, non-2xx response, or an unparseable body. Consumers branch on + * `status`/`code` and never inspect a `Response`. + * + * Fields are declared and assigned explicitly rather than via constructor + * parameter properties: `erasableSyntaxOnly` in tsconfig.app.json forbids them. + */ +export class ApiError extends Error { + /** HTTP status, or 0 when the request never reached the server. */ + readonly status: number; + /** Stable machine-readable cause, e.g. "network", "http", "unauthorized". */ + readonly code: string; + /** Parsed response body or underlying error, when available. */ + readonly detail: unknown; + + constructor(status: number, code: string, message: string, detail?: unknown) { + super(message); + this.name = "ApiError"; + this.status = status; + this.code = code; + this.detail = detail; + } +} diff --git a/src/web/src/lib/http/apiClient.test.ts b/src/web/src/lib/http/apiClient.test.ts new file mode 100644 index 0000000..1202a04 --- /dev/null +++ b/src/web/src/lib/http/apiClient.test.ts @@ -0,0 +1,287 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import axios from "axios"; +import { apiClient } from "./apiClient"; +import { ApiError } from "./ApiError"; +import { setAccessToken, setRefreshHandler, resetAuthTokensForTest } from "./authTokens"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +/** The Request the fetch adapter handed to the stubbed global fetch. */ +function requestAt(index: number): Request { + return vi.mocked(fetch).mock.calls[index][0] as Request; +} + +describe("apiClient", () => { + beforeEach(() => { + resetAuthTokensForTest(); + vi.stubEnv("VITE_API_BASE_URL", "http://api.test"); + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it("resolves the parsed body against the configured base URL", async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse([{ symbol: "12345" }])); + + const response = await apiClient.get<{ symbol: string }[]>("/api/valuations"); + + expect(response.data).toEqual([{ symbol: "12345" }]); + expect(requestAt(0).url).toBe("http://api.test/api/valuations"); + }); + + it("appends defined query parameters and drops undefined ones", async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse([])); + + await apiClient.get("/api/valuations", { + params: { offset: 0, limit: 100, filter: undefined }, + }); + + expect(requestAt(0).url).toBe("http://api.test/api/valuations?offset=0&limit=100"); + }); + + it("attaches the bearer token when one is present", async () => { + setAccessToken("token-1"); + vi.mocked(fetch).mockResolvedValue(jsonResponse([])); + + await apiClient.get("/api/valuations"); + + expect(requestAt(0).headers.get("Authorization")).toBe("Bearer token-1"); + }); + + it("omits the Authorization header when unauthenticated", async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse([])); + + await apiClient.get("/api/valuations"); + + expect(requestAt(0).headers.has("Authorization")).toBe(false); + }); + + it("wraps a non-2xx response in ApiError with the parsed body as detail", async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse({ title: "Server exploded" }, 500)); + + const error = await apiClient.get("/api/valuations").catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ + status: 500, + code: "http", + detail: { title: "Server exploded" }, + }); + }); + + it("wraps a network failure in ApiError with status 0", async () => { + vi.mocked(fetch).mockRejectedValue(new TypeError("Failed to fetch")); + + const error = await apiClient.get("/api/valuations").catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ status: 0, code: "network" }); + }); + + it("tolerates a non-JSON error body, keeping the raw text as detail", async () => { + vi.mocked(fetch).mockResolvedValue( + new Response("502 Bad Gateway", { status: 502 }), + ); + + const error = await apiClient.get("/api/valuations").catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ status: 502, code: "http" }); + expect((error as ApiError).detail).toContain("502 Bad Gateway"); + }); + + it("returns a 204 with an empty body rather than failing", async () => { + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 204 })); + + const response = await apiClient.post("/api/valuations/refresh"); + + expect(response.status).toBe(204); + expect(response.data).toBe(""); + }); + + it("throws a configuration error, not an ApiError, when the base URL is unset", async () => { + vi.stubEnv("VITE_API_BASE_URL", ""); + + const error = await apiClient.get("/api/valuations").catch((e: unknown) => e); + + expect(error).not.toBeInstanceOf(ApiError); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/VITE_API_BASE_URL/); + }); +}); + +describe("apiClient auth recovery", () => { + beforeEach(() => { + resetAuthTokensForTest(); + vi.stubEnv("VITE_API_BASE_URL", "http://api.test"); + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it("refreshes once and retries after a 401, carrying the fresh token", async () => { + setAccessToken("stale"); + setRefreshHandler(async () => "fresh"); + + vi.mocked(fetch) + .mockResolvedValueOnce(jsonResponse({ error: "expired" }, 401)) + .mockResolvedValueOnce(jsonResponse([{ symbol: "12345" }])); + + const response = await apiClient.get<{ symbol: string }[]>("/api/valuations"); + + expect(response.data).toEqual([{ symbol: "12345" }]); + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2); + expect(requestAt(1).headers.get("Authorization")).toBe("Bearer fresh"); + }); + + it("does not loop when the retry also returns 401", async () => { + setAccessToken("stale"); + setRefreshHandler(async () => "fresh"); + // A fresh Response per call: a native Response body is a single-read + // stream, so reusing one instance makes the retry's read throw and + // surface as a network error instead of the 401 under test. + vi.mocked(fetch).mockImplementation(async () => jsonResponse({ error: "nope" }, 401)); + + const error = await apiClient.get("/api/valuations").catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ status: 401, code: "unauthorized" }); + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2); + }); + + it("surfaces a failed refresh as an unauthorized ApiError", async () => { + setAccessToken("stale"); + setRefreshHandler(async () => { + throw new Error("refresh endpoint unreachable"); + }); + vi.mocked(fetch).mockResolvedValue(jsonResponse({ error: "expired" }, 401)); + + const error = await apiClient.get("/api/valuations").catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ status: 401, code: "unauthorized" }); + expect((error as ApiError).detail).toBeInstanceOf(Error); + }); + + it("passes a transport-level refresh failure through rather than relabelling it 401", async () => { + setAccessToken("stale"); + setRefreshHandler(async () => { + throw new ApiError(0, "network", "Network request failed"); + }); + vi.mocked(fetch).mockResolvedValue(jsonResponse({ error: "expired" }, 401)); + + const error = await apiClient.get("/api/valuations").catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ status: 0, code: "network" }); + }); + + it("preserves the request body when retrying after a refresh", async () => { + setAccessToken("stale"); + setRefreshHandler(async () => "fresh"); + + vi.mocked(fetch).mockImplementation(async (...args: unknown[]) => { + const request = args[0] as Request; + return request.headers.get("Authorization") === "Bearer fresh" + ? jsonResponse({ ok: true }) + : jsonResponse({ error: "expired" }, 401); + }); + + await apiClient.post("/api/valuations/refresh", { symbol: "12345" }); + + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2); + expect(await requestAt(1).clone().text()).toBe(JSON.stringify({ symbol: "12345" })); + }); + + it("collapses concurrent 401s onto a single refresh", async () => { + setAccessToken("stale"); + const refresh = vi.fn(async () => "fresh"); + setRefreshHandler(refresh); + + vi.mocked(fetch).mockImplementation(async (...args: unknown[]) => { + const request = args[0] as Request; + return request.headers.get("Authorization") === "Bearer fresh" + ? jsonResponse([{ symbol: "12345" }]) + : jsonResponse({ error: "expired" }, 401); + }); + + const responses = await Promise.all([ + apiClient.get("/api/valuations"), + apiClient.get("/api/valuations"), + apiClient.get("/api/valuations"), + ]); + + expect(refresh).toHaveBeenCalledTimes(1); + expect(responses.map((r) => r.status)).toEqual([200, 200, 200]); + }); + + it("does not attempt a refresh for a request marked skipAuthRefresh", async () => { + setAccessToken("stale"); + const refresh = vi.fn(async () => "fresh"); + setRefreshHandler(refresh); + vi.mocked(fetch).mockResolvedValue(jsonResponse({ error: "nope" }, 401)); + + const error = await apiClient + .post("/api/auth/token", undefined, { skipAuthRefresh: true }) + .catch((e: unknown) => e); + + expect(refresh).not.toHaveBeenCalled(); + expect(error).toMatchObject({ status: 401, code: "unauthorized" }); + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1); + }); +}); + +describe("apiClient cancellation and timeout", () => { + /** A fetch that never resolves on its own — it settles only when aborted. */ + function neverResolvingFetch() { + return vi.fn((...args: unknown[]) => { + const request = args[0] as Request; + return new Promise((_, reject) => { + request.signal.addEventListener("abort", () => { + reject(request.signal.reason as Error); + }); + }); + }); + } + + beforeEach(() => { + resetAuthTokensForTest(); + vi.stubEnv("VITE_API_BASE_URL", "http://api.test"); + vi.stubGlobal("fetch", neverResolvingFetch()); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it("maps a timeout to ApiError with code 'timeout'", async () => { + const error = await apiClient.get("/api/valuations", { timeout: 20 }).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ status: 0, code: "timeout" }); + }); + + it("rethrows a cancellation rather than wrapping it", async () => { + const controller = new AbortController(); + const promise = apiClient.get("/api/valuations", { signal: controller.signal }); + + controller.abort(); + + const error = await promise.catch((e: unknown) => e); + + expect(axios.isCancel(error)).toBe(true); + expect(error).not.toBeInstanceOf(ApiError); + }); +}); diff --git a/src/web/src/lib/http/apiClient.ts b/src/web/src/lib/http/apiClient.ts new file mode 100644 index 0000000..18de94d --- /dev/null +++ b/src/web/src/lib/http/apiClient.ts @@ -0,0 +1,131 @@ +import axios, { AxiosError, type InternalAxiosRequestConfig } from "axios"; +import { ApiError } from "./ApiError"; +import { getAccessToken, refreshAccessToken } from "./authTokens"; + +// Program-global: these flags appear on every axios config in the app, but only +// apiClient's interceptors honour them. +declare module "axios" { + interface AxiosRequestConfig { + /** + * Suppresses the 401 refresh-and-retry for this request. Set it on the + * token request itself: refreshing in response to a failed refresh awaits + * the in-flight refresh from inside it, which never settles. + */ + skipAuthRefresh?: boolean; + /** + * Internal. Marks a request already retried once after a refresh, so a + * second 401 is terminal instead of looping. + */ + retriedAfterRefresh?: boolean; + } +} + +/** How long a request may run before it is aborted. */ +const REQUEST_TIMEOUT_MS = 15_000; + +/** + * Reads the API base URL at call time rather than at instance creation, so + * tests can stub it and so a misconfiguration surfaces as a clear error at the + * first request instead of a blank screen at import. + */ +function resolveBaseUrl(): string { + const configured = import.meta.env.VITE_API_BASE_URL; + + if (!configured) { + throw new Error( + "VITE_API_BASE_URL is not set. Add it to src/web/.env.development (local) " + + "or the build environment (deployed). There is deliberately no default: " + + "a silent fallback fails in production as an opaque CORS error.", + ); + } + + return configured.replace(/\/$/, ""); +} + +/** + * The app's HTTP surface. Every request carries the base URL, the bearer token, + * a timeout, and a single failure shape — see the interceptors below. + * + * The fetch adapter is pinned rather than left to axios's environment + * detection: the MAUI HybridWebView origin is exercised on fetch, and it lets + * tests stub `globalThis.fetch` instead of jsdom's XHR. + */ +export const apiClient = axios.create({ + adapter: "fetch", + timeout: REQUEST_TIMEOUT_MS, + headers: { Accept: "application/json" }, +}); + +apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => { + config.baseURL = resolveBaseUrl(); + + const token = getAccessToken(); + + // The delete branch matters on the 401 retry path: the config object is + // reused, so a stale header would otherwise survive the refresh. + if (token) config.headers.set("Authorization", `Bearer ${token}`); + else config.headers.delete("Authorization"); + + return config; +}); + +/** Collapses every axios failure mode into the app's single error shape. */ +function toApiError(error: AxiosError): ApiError { + if (error.code === AxiosError.ETIMEDOUT || error.code === AxiosError.ECONNABORTED) + return new ApiError(0, "timeout", "Request timed out", error); + + const response = error.response; + + if (!response) return new ApiError(0, "network", "Network request failed", error); + + return new ApiError( + response.status, + response.status === 401 ? "unauthorized" : "http", + `${response.status} ${response.statusText}`, + response.data, + ); +} + +apiClient.interceptors.response.use(undefined, async (error: unknown) => { + // A cancellation is not a failure. Wrapping it in ApiError — "the single + // failure shape for every API call" — would make that type lie, and would + // break every generic cancellation check. Must precede the isAxiosError + // guard: CanceledError is an AxiosError. + if (axios.isCancel(error)) throw error; + + // Axios runs request interceptors, the dispatch, and response interceptors as + // one flat promise chain, so a thrown configuration error arrives here too. + // Rethrowing anything that is not an AxiosError is what keeps it a plain + // Error rather than a mislabelled ApiError(0, "network"). + if (!axios.isAxiosError(error)) throw error; + + const config = error.config; + + if ( + error.response?.status === 401 && + config && + !config.skipAuthRefresh && + !config.retriedAfterRefresh + ) { + config.retriedAfterRefresh = true; + + try { + // Single-flight: N concurrent 401s from a scrolling grid share one + // refresh. That guarantee lives in authTokens, not here. + await refreshAccessToken(); + } catch (cause) { + // A transport-level refresh failure is retryable; only an authentication + // failure is terminal. Relabelling the former as 401 makes queryClient's + // no-retry-on-4xx rule swallow a network blip that the previous client + // retried three times. + if (cause instanceof ApiError && cause.status === 0) throw cause; + throw new ApiError(401, "unauthorized", "Access token refresh failed", cause); + } + + // Re-entering request() re-runs the request interceptor, which attaches the + // new token. A second 401 finds retriedAfterRefresh set and falls through. + return apiClient.request(config); + } + + throw toApiError(error); +}); diff --git a/src/web/src/lib/http/authTokens.test.ts b/src/web/src/lib/http/authTokens.test.ts new file mode 100644 index 0000000..ff2cefb --- /dev/null +++ b/src/web/src/lib/http/authTokens.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + getAccessToken, + setAccessToken, + refreshAccessToken, + setRefreshHandler, + resetAuthTokensForTest, +} from "./authTokens"; + +describe("authTokens", () => { + beforeEach(() => { + resetAuthTokensForTest(); + }); + + it("starts with no token", () => { + expect(getAccessToken()).toBeNull(); + }); + + it("stores and clears the token", () => { + setAccessToken("abc"); + expect(getAccessToken()).toBe("abc"); + setAccessToken(null); + expect(getAccessToken()).toBeNull(); + }); + + it("stores the refreshed token", async () => { + setRefreshHandler(async () => "fresh"); + const token = await refreshAccessToken(); + expect(token).toBe("fresh"); + expect(getAccessToken()).toBe("fresh"); + }); + + it("runs one refresh for concurrent callers", async () => { + const handler = vi.fn(async () => "fresh"); + setRefreshHandler(handler); + + const results = await Promise.all([ + refreshAccessToken(), + refreshAccessToken(), + refreshAccessToken(), + ]); + + expect(handler).toHaveBeenCalledTimes(1); + expect(results).toEqual(["fresh", "fresh", "fresh"]); + }); + + it("allows a later refresh after one fails", async () => { + const handler = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce("fresh"); + setRefreshHandler(handler); + + await expect(refreshAccessToken()).rejects.toThrow("boom"); + await expect(refreshAccessToken()).resolves.toBe("fresh"); + expect(handler).toHaveBeenCalledTimes(2); + }); + + it("clears the token when refresh fails", async () => { + setAccessToken("stale"); + setRefreshHandler(async () => { + throw new Error("boom"); + }); + + await expect(refreshAccessToken()).rejects.toThrow("boom"); + expect(getAccessToken()).toBeNull(); + }); +}); diff --git a/src/web/src/lib/http/authTokens.ts b/src/web/src/lib/http/authTokens.ts new file mode 100644 index 0000000..b789f57 --- /dev/null +++ b/src/web/src/lib/http/authTokens.ts @@ -0,0 +1,74 @@ +/** + * In-memory bearer-token store. + * + * The token is deliberately never persisted to localStorage or sessionStorage — + * both are readable by any injected script, and a bearer token there is an XSS + * escalation into full API access. Losing the token on reload is the intended + * trade-off; the refresh handler re-obtains one. + */ + +/** Obtains a new access token. Replaced wholesale when a real IdP lands. */ +export type RefreshHandler = () => Promise; + +let accessToken: string | null = null; +let inFlight: Promise | null = null; + +const notConfigured: RefreshHandler = () => { + throw new Error("No refresh handler configured — call setRefreshHandler() during startup."); +}; + +let refreshHandler: RefreshHandler = notConfigured; + +/** The current access token, or null when unauthenticated. */ +export function getAccessToken(): string | null { + return accessToken; +} + +/** Sets or clears the access token. */ +export function setAccessToken(token: string | null): void { + accessToken = token; +} + +/** + * Installs the function that obtains a new access token. This is the single + * seam a real identity provider replaces; nothing else in the transport knows + * how tokens are minted. + */ +export function setRefreshHandler(fn: RefreshHandler): void { + refreshHandler = fn; +} + +/** + * Obtains a fresh access token, collapsing concurrent callers onto one request. + * + * A grid fires many requests at once, so an expiring token produces N + * simultaneous 401s. Without this, each would trigger its own refresh — + * hammering the token endpoint and, with rotating refresh tokens, invalidating + * each other's results. + */ +export function refreshAccessToken(): Promise { + if (inFlight) return inFlight; + + inFlight = Promise.resolve() + .then(refreshHandler) + .then((token) => { + accessToken = token; + return token; + }) + .catch((error: unknown) => { + accessToken = null; + throw error; + }) + .finally(() => { + inFlight = null; + }); + + return inFlight; +} + +/** Test-only: restores module state between cases. */ +export function resetAuthTokensForTest(): void { + accessToken = null; + inFlight = null; + refreshHandler = notConfigured; +} diff --git a/src/web/src/lib/number/numberUtils.test.ts b/src/web/src/lib/number/numberUtils.test.ts new file mode 100644 index 0000000..2c97ccd --- /dev/null +++ b/src/web/src/lib/number/numberUtils.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { + currencySymbol, + formatNumberDisplay, + isValueInRange, + parseNumberPaste, + resolveNumberFormat, +} from "./numberUtils"; + +describe("resolveNumberFormat", () => { + it("currency: $ prefix, 2 fixed decimals, grouped", () => { + const r = resolveNumberFormat({ format: "currency", currency: "USD" }); + expect(r.prefix).toBe("$"); + expect(r.decimalScale).toBe(2); + expect(r.fixedDecimalScale).toBe(true); + expect(r.thousandSeparator).toBe(true); + }); + + it("integer: 0 decimals, no grouping by default", () => { + const r = resolveNumberFormat({ format: "integer" }); + expect(r.decimalScale).toBe(0); + expect(r.thousandSeparator).toBe(false); + expect(r.prefix).toBeUndefined(); + }); + + it("decimal (default): free decimals, no grouping", () => { + const r = resolveNumberFormat({}); + expect(r.decimalScale).toBeUndefined(); + expect(r.thousandSeparator).toBe(false); + }); + + it("explicit options override format defaults", () => { + const r = resolveNumberFormat({ + format: "currency", + decimalScale: 0, + thousandSeparator: false, + prefix: "€", + }); + expect(r.decimalScale).toBe(0); + expect(r.thousandSeparator).toBe(false); + expect(r.prefix).toBe("€"); + }); + + it("allowNegative follows min", () => { + expect(resolveNumberFormat({}).allowNegative).toBe(true); + expect(resolveNumberFormat({ min: 0 }).allowNegative).toBe(false); + expect(resolveNumberFormat({ min: -5 }).allowNegative).toBe(true); + }); + + it("isAllowed enforces [min, max] and permits empty", () => { + const { isAllowed } = resolveNumberFormat({ min: 0, max: 100 }); + expect(isAllowed({ floatValue: 50 })).toBe(true); + expect(isAllowed({ floatValue: -1 })).toBe(false); + expect(isAllowed({ floatValue: 101 })).toBe(false); + expect(isAllowed({ floatValue: undefined })).toBe(true); + }); +}); + +describe("currencySymbol", () => { + it("returns the symbol for known currencies", () => { + expect(currencySymbol("USD")).toBe("$"); + expect(currencySymbol("EUR")).toBe("€"); + }); + + it("falls back to $ for junk", () => { + expect(currencySymbol("NOPE")).toBe("$"); + }); +}); + +describe("isValueInRange", () => { + it("checks inclusive bounds; null is in range", () => { + expect(isValueInRange(5, 0, 10)).toBe(true); + expect(isValueInRange(0, 0, 10)).toBe(true); + expect(isValueInRange(-1, 0, 10)).toBe(false); + expect(isValueInRange(11, 0, 10)).toBe(false); + expect(isValueInRange(null, 0, 10)).toBe(true); + }); +}); + +describe("parseNumberPaste", () => { + it("strips separators and currency symbols", () => { + expect(parseNumberPaste("1,234.50", { nullable: false })).toBe(1234.5); + expect(parseNumberPaste("$50", { nullable: false })).toBe(50); + }); + + it("rejects non-numbers", () => { + expect(parseNumberPaste("abc", { nullable: false })).toBeUndefined(); + }); + + it("empty is null when nullable, undefined otherwise", () => { + expect(parseNumberPaste(" ", { nullable: true })).toBeNull(); + expect(parseNumberPaste(" ", { nullable: false })).toBeUndefined(); + }); + + it("rejects out-of-range values", () => { + expect(parseNumberPaste("500", { min: 0, max: 100, nullable: false })).toBeUndefined(); + }); +}); + +describe("formatNumberDisplay", () => { + it("formats currency, integer, and decimal", () => { + expect(formatNumberDisplay(1234.5, { format: "currency", currency: "USD" })).toBe("$1,234.50"); + expect(formatNumberDisplay(1000, { format: "integer" })).toBe("1000"); + expect(formatNumberDisplay(1234.567, { format: "decimal" })).toBe("1234.567"); + }); +}); diff --git a/src/web/src/lib/number/numberUtils.ts b/src/web/src/lib/number/numberUtils.ts new file mode 100644 index 0000000..f16e1dc --- /dev/null +++ b/src/web/src/lib/number/numberUtils.ts @@ -0,0 +1,119 @@ +/** + * Numeric formatting/parsing core shared by the standalone NumericInput and the + * grid number cell. Framework-free: depends only on Intl. + */ + +/** Format presets. Const-object + union (no TS enums — erasableSyntaxOnly). */ +export const NUMBER_FORMAT = { + integer: "integer", + decimal: "decimal", + currency: "currency", +} as const; + +export type NumberFormat = (typeof NUMBER_FORMAT)[keyof typeof NUMBER_FORMAT]; + +/** Friendly formatting options accepted by callers. */ +export interface NumberFormatOptions { + format?: NumberFormat; + decimalScale?: number; + currency?: string; + min?: number; + max?: number; + thousandSeparator?: boolean; + prefix?: string; + suffix?: string; +} + +/** Low-level props derived for react-number-format / Intl. */ +export interface ResolvedNumberFormat { + thousandSeparator: boolean; + decimalScale: number | undefined; + fixedDecimalScale: boolean; + prefix: string | undefined; + suffix: string | undefined; + allowNegative: boolean; + isAllowed: (values: { floatValue?: number }) => boolean; +} + +/** The currency symbol for an ISO code (en-US locale), or '$' if unknown. */ +export function currencySymbol(currency: string): string { + try { + const parts = new Intl.NumberFormat("en-US", { style: "currency", currency }).formatToParts(0); + return parts.find((p) => p.type === "currency")?.value ?? "$"; + } catch { + return "$"; + } +} + +/** Inclusive bounds check. `null` (empty) is always in range; emptiness is governed elsewhere. */ +export function isValueInRange(value: number | null, min?: number, max?: number): boolean { + if (value === null) return true; + if (min !== undefined && value < min) return false; + if (max !== undefined && value > max) return false; + return true; +} + +/** Resolve friendly options into react-number-format props. Explicit options always win. */ +export function resolveNumberFormat(opts: NumberFormatOptions): ResolvedNumberFormat { + const format = opts.format ?? "decimal"; + const isCurrency = format === "currency"; + const isInteger = format === "integer"; + + const decimalScale = opts.decimalScale ?? (isInteger ? 0 : isCurrency ? 2 : undefined); + const thousandSeparator = opts.thousandSeparator ?? isCurrency; + const prefix = opts.prefix ?? (isCurrency ? currencySymbol(opts.currency ?? "USD") : undefined); + const allowNegative = opts.min === undefined || opts.min < 0; + + const { min, max } = opts; + + const isAllowed = ({ floatValue }: { floatValue?: number }): boolean => { + if (floatValue === undefined) return true; + return isValueInRange(floatValue, min, max); + }; + + return { + thousandSeparator, + decimalScale, + fixedDecimalScale: isCurrency, + prefix, + suffix: opts.suffix, + allowNegative, + isAllowed, + }; +} + +/** + * Parse a pasted string to a number. Strips grouping separators and currency + * symbols. Returns `null` for empty (only when nullable), `undefined` for + * non-numbers and out-of-range values. + */ +export function parseNumberPaste( + raw: string, + opts: { min?: number; max?: number; nullable: boolean }, +): number | null | undefined { + const trimmed = raw.trim(); + if (trimmed === "") return opts.nullable ? null : undefined; + + const cleaned = trimmed.replace(/[^0-9.-]/g, ""); + if (cleaned === "" || cleaned === "-" || cleaned === ".") return undefined; + + const n = Number(cleaned); + + if (!Number.isFinite(n)) return undefined; + if (!isValueInRange(n, opts.min, opts.max)) return undefined; + + return n; +} + +/** Format a number for static display (grid canvas), consistent with the editor. */ +export function formatNumberDisplay(value: number, opts: NumberFormatOptions): string { + const r = resolveNumberFormat(opts); + + const formatted = new Intl.NumberFormat("en-US", { + useGrouping: r.thousandSeparator, + minimumFractionDigits: r.fixedDecimalScale ? r.decimalScale : 0, + maximumFractionDigits: r.decimalScale ?? 20, + }).format(value); + + return (r.prefix ?? "") + formatted + (r.suffix ?? ""); +} diff --git a/src/web/src/lib/phone/phoneUtils.test.ts b/src/web/src/lib/phone/phoneUtils.test.ts new file mode 100644 index 0000000..7da7a08 --- /dev/null +++ b/src/web/src/lib/phone/phoneUtils.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { formatPhoneDisplay, isValidPhoneValue, parsePhonePaste } from "./phoneUtils"; + +describe("formatPhoneDisplay", () => { + it("formats a valid E.164 number in international form", () => { + expect(formatPhoneDisplay("+14155552671")).toBe("+1 415 555 2671"); + }); + + it("falls back to the raw string when not parseable", () => { + expect(formatPhoneDisplay("not-a-number")).toBe("not-a-number"); + }); + + it("returns an empty string for empty input", () => { + expect(formatPhoneDisplay("")).toBe(""); + }); + + it("normalizes a non-E.164 value to international form when given a default country", () => { + expect(formatPhoneDisplay("8088888888", "US")).toBe("+1 808 888 8888"); + expect(formatPhoneDisplay("(808) 888-8888", "US")).toBe("+1 808 888 8888"); + }); + + it("without a default country, leaves a non-E.164 value as the raw string", () => { + expect(formatPhoneDisplay("8088888888")).toBe("8088888888"); + }); + + it("falls back to the raw string when a value cannot be parsed even with a default country", () => { + expect(formatPhoneDisplay("not-a-number", "US")).toBe("not-a-number"); + }); +}); + +describe("isValidPhoneValue", () => { + it("accepts a valid number regardless of nullable", () => { + expect(isValidPhoneValue("+14155552671", false)).toBe(true); + expect(isValidPhoneValue("+14155552671", true)).toBe(true); + }); + + it("rejects a malformed non-empty value regardless of nullable", () => { + expect(isValidPhoneValue("+1234", false)).toBe(false); + expect(isValidPhoneValue("+1234", true)).toBe(false); + }); + + it("treats empty per the nullable flag", () => { + expect(isValidPhoneValue(null, true)).toBe(true); + expect(isValidPhoneValue("", true)).toBe(true); + expect(isValidPhoneValue(null, false)).toBe(false); + expect(isValidPhoneValue("", false)).toBe(false); + }); +}); + +describe("parsePhonePaste", () => { + it("parses a national-format US string to E.164 (default country US)", () => { + expect(parsePhonePaste("(415) 555-2671", "US")).toBe("+14155552671"); + }); + + it("round-trips an already-E.164 string", () => { + expect(parsePhonePaste("+14155552671", "US")).toBe("+14155552671"); + }); + + it("returns null for an empty/whitespace paste", () => { + expect(parsePhonePaste(" ", "US")).toBe(null); + }); + + it("returns undefined for an unparseable paste", () => { + expect(parsePhonePaste("garbage", "US")).toBe(undefined); + }); +}); diff --git a/src/web/src/lib/phone/phoneUtils.ts b/src/web/src/lib/phone/phoneUtils.ts new file mode 100644 index 0000000..e6cd8a1 --- /dev/null +++ b/src/web/src/lib/phone/phoneUtils.ts @@ -0,0 +1,73 @@ +import { + formatPhoneNumberIntl, + isValidPhoneNumber, + parsePhoneNumber, + type Country, +} from "react-phone-number-input"; + +/** + * Format a phone value for canvas display, in international form. + * + * Already-E.164 values format directly. For legacy values stored in another + * format (bare digits, national format), pass `defaultCountry` so the value can + * be parsed and normalized (e.g. "8088888888" → "+1 808 888 8888"). Falls back + * to the raw string when it can't be parsed. + */ +export function formatPhoneDisplay(value: string, defaultCountry?: Country): string { + const intl = formatPhoneNumberIntl(value); + + if (intl) { + return intl; + } + + if (defaultCountry) { + try { + const parsed = parsePhoneNumber(value, defaultCountry); + + if (parsed) { + return parsed.formatInternational(); + } + } catch { + // fall through to the raw value + } + } + + return value; +} + +/** + * True when a stored value is acceptable to commit. Empty (null/"") is governed + * by `nullable`; any non-empty value must be a valid phone number. + */ +export function isValidPhoneValue(value: string | null, nullable: boolean): boolean { + if (value == null || value === "") { + return nullable; + } + + return isValidPhoneNumber(value); +} + +/** + * Parse a pasted string. The input is trimmed first, so whitespace-only counts + * as empty. Returns the E.164 string on success, `null` for an empty paste, or + * `undefined` when the value cannot be parsed to a valid number. + */ +export function parsePhonePaste(raw: string, defaultCountry: Country): string | null | undefined { + const trimmed = raw.trim(); + + if (trimmed === "") { + return null; + } + + try { + const parsed = parsePhoneNumber(trimmed, defaultCountry); + + if (parsed && parsed.isValid()) { + return parsed.number; + } + } catch { + // fall through to undefined + } + + return undefined; +} diff --git a/src/web/src/lib/text/textValidation.test.ts b/src/web/src/lib/text/textValidation.test.ts new file mode 100644 index 0000000..67e61ec --- /dev/null +++ b/src/web/src/lib/text/textValidation.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { validateText } from "./textValidation"; + +describe("validateText — required & emptiness", () => { + it("empty + optional is valid and skips other rules", () => { + expect(validateText("", { minLength: 5, email: true })).toEqual({ valid: true }); + }); + it("empty + required fails with the required message", () => { + expect(validateText("", { required: true })).toEqual({ + valid: false, + error: "This field is required", + }); + }); + it("non-empty passes required", () => { + expect(validateText("x", { required: true }).valid).toBe(true); + }); +}); + +describe("validateText — length", () => { + it("rejects below minLength", () => { + expect(validateText("ab", { minLength: 3 })).toEqual({ + valid: false, + error: "Must be at least 3 characters", + }); + }); + it("accepts at the minLength boundary", () => { + expect(validateText("abc", { minLength: 3 }).valid).toBe(true); + }); + it("rejects above maxLength", () => { + expect(validateText("abcd", { maxLength: 3 })).toEqual({ + valid: false, + error: "Must be at most 3 characters", + }); + }); + it("accepts at the maxLength boundary", () => { + expect(validateText("abc", { maxLength: 3 }).valid).toBe(true); + }); +}); + +describe("validateText — email", () => { + it("accepts a well-formed address", () => { + expect(validateText("a@b.co", { email: true }).valid).toBe(true); + }); + it("rejects a malformed address", () => { + expect(validateText("a@b", { email: true })).toEqual({ + valid: false, + error: "Enter a valid email address", + }); + expect(validateText("a b@c.com", { email: true }).valid).toBe(false); + }); +}); + +describe("validateText — pattern", () => { + it("RegExp form uses the default message", () => { + expect(validateText("abc", { pattern: /^\d+$/ })).toEqual({ + valid: false, + error: "Invalid format", + }); + }); + it("object form uses the supplied message", () => { + expect(validateText("abc", { pattern: { value: /^\d+$/, message: "Digits only" } })).toEqual({ + valid: false, + error: "Digits only", + }); + }); + it("accepts a matching value", () => { + expect(validateText("123", { pattern: /^\d+$/ }).valid).toBe(true); + }); +}); + +describe("validateText — custom validate", () => { + it("uses the returned message as the error", () => { + expect(validateText("foo", { validate: (v) => (v === "bar" ? null : "Must be bar") })).toEqual({ + valid: false, + error: "Must be bar", + }); + }); + it("passes when the custom rule returns null", () => { + expect( + validateText("bar", { validate: (v) => (v === "bar" ? null : "Must be bar") }).valid, + ).toBe(true); + }); +}); + +describe("validateText — priority (first failing rule wins)", () => { + it("required is reported before email", () => { + expect(validateText("", { required: true, email: true })).toEqual({ + valid: false, + error: "This field is required", + }); + }); + it("minLength is reported before email", () => { + expect(validateText("a@", { minLength: 5, email: true })).toEqual({ + valid: false, + error: "Must be at least 5 characters", + }); + }); +}); diff --git a/src/web/src/lib/text/textValidation.ts b/src/web/src/lib/text/textValidation.ts new file mode 100644 index 0000000..382bceb --- /dev/null +++ b/src/web/src/lib/text/textValidation.ts @@ -0,0 +1,72 @@ +/** + * Text validation core shared by the standalone and the grid + * text cell. Framework-free: no React, no DOM. Mirrors numberUtils in spirit. + */ + +/** Flat, declarative validation rules. */ +export interface TextValidationOptions { + required?: boolean; + minLength?: number; + maxLength?: number; + email?: boolean; + pattern?: RegExp | { value: RegExp; message: string }; + /** Custom rule escape hatch: return an error message, or null when valid. */ + validate?: (value: string) => string | null; +} + +/** + * Validity, plus the first failing rule's message when invalid. The union makes + * "invalid with no message" unrepresentable: `valid: false` always carries an + * `error`, and `valid: true` never does — enforced by the type, not discipline. + */ +export type TextValidationResult = { valid: true } | { valid: false; error: string }; + +/** Pragmatic email check (intentionally not full RFC 5322). */ +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +const ok: TextValidationResult = { valid: true }; +const fail = (error: string): TextValidationResult => ({ valid: false, error }); + +/** + * Validate a string against the configured rules, returning the first failing + * rule's message. Empty ('') is valid unless `required`; when empty-and-optional + * the remaining rules are skipped (mirrors how a null number is always in range). + * + * Priority: required -> minLength -> maxLength -> email -> pattern -> validate. + */ +export function validateText(value: string, opts: TextValidationOptions): TextValidationResult { + if (value === "") { + return opts.required ? fail("This field is required") : ok; + } + + if (opts.minLength !== undefined && value.length < opts.minLength) { + return fail(`Must be at least ${opts.minLength} characters`); + } + + if (opts.maxLength !== undefined && value.length > opts.maxLength) { + return fail(`Must be at most ${opts.maxLength} characters`); + } + + if (opts.email && !EMAIL_PATTERN.test(value)) { + return fail("Enter a valid email address"); + } + + if (opts.pattern) { + const regex = opts.pattern instanceof RegExp ? opts.pattern : opts.pattern.value; + const message = opts.pattern instanceof RegExp ? "Invalid format" : opts.pattern.message; + + if (!regex.test(value)) { + return fail(message); + } + } + + if (opts.validate) { + const message = opts.validate(value); + + if (message !== null) { + return fail(message); + } + } + + return ok; +} diff --git a/src/web/src/lib/utils.ts b/src/web/src/lib/utils.ts new file mode 100644 index 0000000..f2ad2e7 --- /dev/null +++ b/src/web/src/lib/utils.ts @@ -0,0 +1,7 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +/** Merge conditional + conflicting Tailwind class names (shadcn convention). */ +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} diff --git a/src/web/src/main.tsx b/src/web/src/main.tsx index bef5202..d65db39 100644 --- a/src/web/src/main.tsx +++ b/src/web/src/main.tsx @@ -1,10 +1,65 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' - -createRoot(document.getElementById('root')!).render( - - - , -) +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { Provider } from "react-redux"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { RouterProvider } from "@tanstack/react-router"; +import { store } from "./app/store"; +import { router } from "./app/router"; +import { queryClient } from "./app/queryClient"; +import "./index.css"; +import "./tailwind.css"; +import "./theme/radixStyles"; +import { RadixThemeProvider } from "./theme/RadixThemeProvider"; +import { startColorSchemeWatcher } from "./theme/colorSchemeWatcher"; +import { setRefreshHandler } from "./lib/http/authTokens"; +import { apiClient } from "./lib/http/apiClient"; + +startColorSchemeWatcher(store.dispatch); + +// DEV STUB: mints a token from the API's development endpoint, which performs +// no credential check. This is the single seam a real identity provider +// replaces — see src/AppName.Api/Endpoints/AuthEndpoints.cs. +// +// skipAuthRefresh is mandatory here: without it a 401 from this very request +// would trigger a refresh that awaits the in-flight refresh it is inside. +setRefreshHandler(async () => { + const { data } = await apiClient.post<{ access_token: string }>("/api/auth/token", undefined, { + skipAuthRefresh: true, + }); + return data.access_token; +}); + +/** + * The worker must be running before the first request leaves the page, so the + * render waits for it. The dynamic import keeps the mock modules out of the + * production bundle — see src/mocks/browser.ts. + */ +async function bootstrap() { + if (import.meta.env.DEV) { + const { startMocks } = await import("./mocks/browser"); + + // Render anyway on a mock-startup failure. Letting this rejection go + // unhandled would leave a blank page for EVERY route, not just the + // dev-only pages that depend on the mocks. A page that 404s against the + // real API is a far clearer symptom than an app that never paints. + try { + await startMocks(); + } catch (error) { + console.error("Failed to start mock service worker.", { cause: error }); + } + } + + createRoot(document.getElementById("root")!).render( + + + + + + + + + , + ); +} + +void bootstrap(); diff --git a/src/web/src/mocks/browser.ts b/src/web/src/mocks/browser.ts new file mode 100644 index 0000000..20e77c8 --- /dev/null +++ b/src/web/src/mocks/browser.ts @@ -0,0 +1,20 @@ +import { setupWorker } from "msw/browser"; +import { testGridHandlers } from "../features/testGrid/mocks/handlers"; + +/** + * Starts the development mock server. + * + * Only `src/main.tsx` calls this, from inside an `import.meta.env.DEV` branch + * and through a dynamic `import()`. That branch is what keeps the 100,000-row + * generator, the store, and MSW itself out of the production MAUI bundle: + * nothing the page imports directly reaches this module. + * + * `onUnhandledRequest: "bypass"` is mandatory. The worker sees EVERY request the + * page makes, and the app's own dev token request in `main.tsx` goes to the real + * API. Anything but "bypass" breaks it. + */ +export async function startMocks(): Promise { + const worker = setupWorker(...testGridHandlers); + + await worker.start({ onUnhandledRequest: "bypass", quiet: true }); +} diff --git a/src/web/src/routeTree.gen.ts b/src/web/src/routeTree.gen.ts new file mode 100644 index 0000000..fdbee5a --- /dev/null +++ b/src/web/src/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 TestGridRouteImport } from './routes/test-grid' +import { Route as BondsRouteImport } from './routes/bonds' +import { Route as IndexRouteImport } from './routes/index' + +const TestGridRoute = TestGridRouteImport.update({ + id: '/test-grid', + path: '/test-grid', + getParentRoute: () => rootRouteImport, +} as any) +const BondsRoute = BondsRouteImport.update({ + id: '/bonds', + path: '/bonds', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/bonds': typeof BondsRoute + '/test-grid': typeof TestGridRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/bonds': typeof BondsRoute + '/test-grid': typeof TestGridRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/bonds': typeof BondsRoute + '/test-grid': typeof TestGridRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/bonds' | '/test-grid' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/bonds' | '/test-grid' + id: '__root__' | '/' | '/bonds' | '/test-grid' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + BondsRoute: typeof BondsRoute + TestGridRoute: typeof TestGridRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/test-grid': { + id: '/test-grid' + path: '/test-grid' + fullPath: '/test-grid' + preLoaderRoute: typeof TestGridRouteImport + parentRoute: typeof rootRouteImport + } + '/bonds': { + id: '/bonds' + path: '/bonds' + fullPath: '/bonds' + preLoaderRoute: typeof BondsRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + BondsRoute: BondsRoute, + TestGridRoute: TestGridRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/src/web/src/routes/__root.tsx b/src/web/src/routes/__root.tsx new file mode 100644 index 0000000..d1309a1 --- /dev/null +++ b/src/web/src/routes/__root.tsx @@ -0,0 +1,23 @@ +import { createRootRoute, Navigate, Outlet } from "@tanstack/react-router"; +import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; + +/** + * The app shell. Every route renders inside this ``. Unknown URLs + * fall back to the bonds grid, replacing the bad history entry so Back does + * not return to it — this is the successor to the old `path="*"` route. + */ +export const Route = createRootRoute({ + component: RootLayout, + notFoundComponent: () => , +}); + +function RootLayout() { + return ( + <> + + {/* The devtools package is sideEffects-free, so this branch and the + import are dropped from the production MAUI bundle. */} + {import.meta.env.DEV && } + + ); +} diff --git a/src/web/src/routes/bonds.tsx b/src/web/src/routes/bonds.tsx new file mode 100644 index 0000000..a8b4613 --- /dev/null +++ b/src/web/src/routes/bonds.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { BondsPage } from "../features/bonds"; + +export const Route = createFileRoute("/bonds")({ + component: BondsPage, +}); diff --git a/src/web/src/routes/index.tsx b/src/web/src/routes/index.tsx new file mode 100644 index 0000000..1d1e53e --- /dev/null +++ b/src/web/src/routes/index.tsx @@ -0,0 +1,8 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +/** There is no landing page; "/" is an alias for the bonds grid. */ +export const Route = createFileRoute("/")({ + beforeLoad: () => { + throw redirect({ to: "/bonds", replace: true }); + }, +}); diff --git a/src/web/src/routes/test-grid.tsx b/src/web/src/routes/test-grid.tsx new file mode 100644 index 0000000..5103a4a --- /dev/null +++ b/src/web/src/routes/test-grid.tsx @@ -0,0 +1,21 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { TestGridPage } from "../features/testGrid"; + +/** + * The synthetic test grid, at /test-grid. + * + * Its data comes from the MSW mock server, which starts only in a development + * build (src/mocks/browser.ts). A production build has no server behind these + * requests, so the route says so rather than rendering an empty grid. + */ +export const Route = createFileRoute("/test-grid")({ + component: TestGridRoute, +}); + +function TestGridRoute() { + if (!import.meta.env.DEV) { + return

The test grid runs in development builds only.

; + } + + return ; +} diff --git a/src/web/src/tailwind.css b/src/web/src/tailwind.css new file mode 100644 index 0000000..f3dc5d3 --- /dev/null +++ b/src/web/src/tailwind.css @@ -0,0 +1,13 @@ +/* Tailwind v4. Full import including preflight — this app owns the whole + document, so a global reset is safe. */ +@import "tailwindcss"; + +@source "./**/*.{ts,tsx}"; + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); +} diff --git a/src/web/src/theme/RadixThemeProvider.tsx b/src/web/src/theme/RadixThemeProvider.tsx new file mode 100644 index 0000000..f50906c --- /dev/null +++ b/src/web/src/theme/RadixThemeProvider.tsx @@ -0,0 +1,21 @@ +import { Theme } from "@radix-ui/themes"; +import type { ReactNode } from "react"; +import { useAppSelector } from "../app/store"; +import { radixThemeConfig } from "./radixTheme"; + +/** + * Radix Themes root for the app. Reads the shared `appearance` signal (kept in + * sync with the OS colour scheme by startColorSchemeWatcher) and drives Radix's + * light/dark mode from it — the same source the Glide grid theme uses. Wrap the + * app once; portalled Radix components (dialogs, menus) re-apply the theme + * automatically. + */ +export function RadixThemeProvider({ children }: { children: ReactNode }) { + const appearance = useAppSelector((s) => s.appearance.appearance); + + return ( + + {children} + + ); +} diff --git a/src/web/src/theme/appearanceSlice.test.ts b/src/web/src/theme/appearanceSlice.test.ts new file mode 100644 index 0000000..b4c5fd6 --- /dev/null +++ b/src/web/src/theme/appearanceSlice.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import reducer, { setAppearance, type AppearanceState } from "./appearanceSlice"; + +const initial: AppearanceState = { appearance: "light" }; + +describe("appearanceSlice", () => { + it("defaults to light", () => { + expect(reducer(undefined, { type: "@@INIT" })).toEqual({ appearance: "light" }); + }); + + it('setAppearance("dark") switches to dark', () => { + expect(reducer(initial, setAppearance("dark"))).toEqual({ appearance: "dark" }); + }); + + it('setAppearance("light") switches back to light', () => { + expect(reducer({ appearance: "dark" }, setAppearance("light"))).toEqual({ + appearance: "light", + }); + }); +}); diff --git a/src/web/src/theme/appearanceSlice.ts b/src/web/src/theme/appearanceSlice.ts new file mode 100644 index 0000000..89250be --- /dev/null +++ b/src/web/src/theme/appearanceSlice.ts @@ -0,0 +1,23 @@ +import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; + +export type Appearance = "light" | "dark"; + +export interface AppearanceState { + appearance: Appearance; +} + +const initialState: AppearanceState = { appearance: "light" }; + +const appearanceSlice = createSlice({ + name: "appearance", + initialState, + reducers: { + setAppearance(state, action: PayloadAction) { + state.appearance = action.payload; + }, + }, +}); + +export const { setAppearance } = appearanceSlice.actions; + +export default appearanceSlice.reducer; diff --git a/src/web/src/theme/colorSchemeWatcher.test.ts b/src/web/src/theme/colorSchemeWatcher.test.ts new file mode 100644 index 0000000..f8e40ab --- /dev/null +++ b/src/web/src/theme/colorSchemeWatcher.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { appearanceFromMatches, startColorSchemeWatcher } from "./colorSchemeWatcher"; + +describe("appearanceFromMatches", () => { + it("maps a dark match to dark", () => { + expect(appearanceFromMatches(true)).toBe("dark"); + }); + + it("maps no match to light", () => { + expect(appearanceFromMatches(false)).toBe("light"); + }); +}); + +// --- matchMedia stub (jsdom does not implement matchMedia) --- +// The stub honours listener identity: removeEventListener drops the exact +// callback it was given, and emitChange() invokes only the still-attached ones. +// That is what lets stop() be tested behaviourally (a change after stop must not +// dispatch), rather than by trusting a "removeEventListener was called" flag — +// which would pass even if stop() detached the wrong function. +type Listener = () => void; + +function stubMatchMedia(initialMatches: boolean) { + const listeners = new Set(); + const mql = { + matches: initialMatches, + addEventListener: (event: string, cb: Listener) => { + if (event === "change") listeners.add(cb); + }, + removeEventListener: (event: string, cb: Listener) => { + if (event === "change") listeners.delete(cb); + }, + /** Fire a change to every currently-attached listener (test helper). */ + emitChange: () => listeners.forEach((cb) => cb()), + /** How many listeners are currently attached (test helper). */ + listenerCount: () => listeners.size, + }; + globalThis.matchMedia = vi.fn(() => mql) as unknown as typeof matchMedia; + return mql; +} + +afterEach(() => { + globalThis.matchMedia = undefined as unknown as typeof matchMedia; + vi.restoreAllMocks(); +}); + +describe("startColorSchemeWatcher", () => { + it("dispatches the initial appearance from the current colour scheme", () => { + stubMatchMedia(true); + const dispatch = vi.fn(); + + startColorSchemeWatcher(dispatch); + + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch.mock.calls[0][0].payload).toBe("dark"); + }); + + it("dispatches the new appearance when the colour scheme changes", () => { + const mql = stubMatchMedia(false); + const dispatch = vi.fn(); + + startColorSchemeWatcher(dispatch); + expect(dispatch.mock.calls[0][0].payload).toBe("light"); + + mql.matches = true; + mql.emitChange(); + + expect(dispatch.mock.calls[1][0].payload).toBe("dark"); + }); + + it("stop() detaches the listener so later changes no longer dispatch", () => { + const mql = stubMatchMedia(false); + const dispatch = vi.fn(); + + const stop = startColorSchemeWatcher(dispatch); + expect(mql.listenerCount()).toBe(1); + dispatch.mockClear(); + + stop(); + expect(mql.listenerCount()).toBe(0); + + // A change after stop() must not reach the (now-detached) listener. + mql.matches = true; + mql.emitChange(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("defaults to light and is a no-op stop when matchMedia is absent", () => { + globalThis.matchMedia = undefined as unknown as typeof matchMedia; + const dispatch = vi.fn(); + + const stop = startColorSchemeWatcher(dispatch); + + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch.mock.calls[0][0].payload).toBe("light"); + expect(() => stop()).not.toThrow(); + }); +}); diff --git a/src/web/src/theme/colorSchemeWatcher.ts b/src/web/src/theme/colorSchemeWatcher.ts new file mode 100644 index 0000000..2caa6c1 --- /dev/null +++ b/src/web/src/theme/colorSchemeWatcher.ts @@ -0,0 +1,37 @@ +import { setAppearance, type Appearance } from "./appearanceSlice"; + +// The WebView inherits the OS/app colour scheme, so the CSS media query is our +// appearance signal. +const DARK_QUERY = "(prefers-color-scheme: dark)"; + +/** + * Maps a `prefers-color-scheme: dark` match to an appearance. + */ +export function appearanceFromMatches(matches: boolean): Appearance { + return matches ? "dark" : "light"; +} + +type Dispatch = (action: ReturnType) => unknown; + +/** + * Watches the OS colour scheme and pushes the current appearance into the store, + * once at start and again on every change. Returns a stop function. + * + * Environments without matchMedia (jsdom, older WebViews) default to light and + * get a no-op stop. + */ +export function startColorSchemeWatcher(dispatch: Dispatch): () => void { + const mql = globalThis.matchMedia?.(DARK_QUERY) ?? null; + + const sync = () => dispatch(setAppearance(appearanceFromMatches(mql?.matches ?? false))); + + sync(); + + if (!mql) { + return () => {}; + } + + mql.addEventListener("change", sync); + + return () => mql.removeEventListener("change", sync); +} diff --git a/src/web/src/theme/gridThemes.test.ts b/src/web/src/theme/gridThemes.test.ts new file mode 100644 index 0000000..98ead3f --- /dev/null +++ b/src/web/src/theme/gridThemes.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { darkGridTheme, lightGridTheme, themeForAppearance } from "./gridThemes"; + +describe("themeForAppearance", () => { + it('returns the dark theme for "dark"', () => { + expect(themeForAppearance("dark")).toBe(darkGridTheme); + }); + + it('returns the light theme for "light"', () => { + expect(themeForAppearance("light")).toBe(lightGridTheme); + }); +}); + +describe("grid themes", () => { + it("light and dark use distinct cell backgrounds", () => { + expect(lightGridTheme.bgCell).not.toBe(darkGridTheme.bgCell); + }); +}); diff --git a/src/web/src/theme/gridThemes.ts b/src/web/src/theme/gridThemes.ts new file mode 100644 index 0000000..5af5726 --- /dev/null +++ b/src/web/src/theme/gridThemes.ts @@ -0,0 +1,102 @@ +import type { Theme } from "@glideapps/glide-data-grid"; +import type { Appearance } from "./appearanceSlice"; + +export const lightGridTheme: Partial = { + // Base Grid Layout Fills + bgCell: "#FFFFFF", + bgCellMedium: "#FAFAFB", + bgHeader: "#F7F9FA", + bgHeaderHovered: "#EAEFF2", + bgHeaderHasFocus: "#E1E7EC", + borderColor: "#E1E6EB", + drilldownBorder: "#E1E6EB", + horizontalBorderColor: "#E1E6EB", + + // Icons + bgIconHeader: "#EAEFF2", + fgIconHeader: "#4A4A52", + + // High-Contrast Light Typography + textDark: "#313139", + textMedium: "#737380", + textLight: "#B2B2BC", + textBubble: "#313139", + textHeader: "#4A4A52", + textHeaderSelected: "#000000", + linkColor: "#4F46E5", + + // Selection Systems + accentColor: "#4F46E5", + accentLight: "rgba(79, 70, 229, 0.1)", + accentFg: "#FFFFFF", + + // Interface Popups & Results + bgBubble: "#EDF2F7", + bgBubbleSelected: "#FFFFFF", + bgSearchResult: "#FFF7ED", + + // Font Styles + baseFontStyle: "13px", + headerFontStyle: "bold 12px", + markerFontStyle: "9px", + fontFamily: "Inter, Roboto, -apple-system, sans-serif", + editorFontSize: "13px", + lineHeight: 1.2, + + // Sizing and Layout + cellHorizontalPadding: 12, + cellVerticalPadding: 8, + headerIconSize: 16, +}; + +export const darkGridTheme: Partial = { + // Base App & Grid Architecture Colors + bgCell: "#161719", + bgCellMedium: "#0B0C0E", + bgHeader: "#161719", + bgHeaderHovered: "#22242A", + bgHeaderHasFocus: "#2C2E35", + borderColor: "#2C2E35", + drilldownBorder: "#2C2E35", + horizontalBorderColor: "#2C2E35", + + // Icons + bgIconHeader: "#22242A", + fgIconHeader: "#A0A5AD", + + // System Text & Typography + textDark: "#F0F1F2", + textMedium: "#A0A5AD", + textLight: "#6B7280", + textBubble: "#F0F1F2", + textHeader: "#A0A5AD", + textHeaderSelected: "#FFFFFF", + linkColor: "#60A5FA", + + // Font Styles + baseFontStyle: "13px", + headerFontStyle: "bold 12px", + markerFontStyle: "9px", + fontFamily: "Inter, Roboto, -apple-system, sans-serif", + editorFontSize: "13px", + lineHeight: 1.2, + + // Layout + cellHorizontalPadding: 12, + cellVerticalPadding: 8, + headerIconSize: 16, + + // Selection Highlight Systems + accentColor: "#3B82F6", + accentLight: "rgba(59, 130, 246, 0.15)", + accentFg: "#FFFFFF", + + // Interactive Overlays + bgBubble: "#22242A", + bgBubbleSelected: "#3B82F6", + bgSearchResult: "#3E3214", +}; + +export function themeForAppearance(appearance: Appearance): Partial { + return appearance === "dark" ? darkGridTheme : lightGridTheme; +} diff --git a/src/web/src/theme/radixStyles.ts b/src/web/src/theme/radixStyles.ts new file mode 100644 index 0000000..f9d7e3c --- /dev/null +++ b/src/web/src/theme/radixStyles.ts @@ -0,0 +1,22 @@ +// Single barrel for all @radix-ui/themes CSS so main.tsx stays lean. Trimmed +// per-scale (not the all-in-one styles.css). The color scales below MUST stay in +// sync with RADIX_BADGE_SCALES in src/lib/grid/radixBadgePalette.ts — every scale +// an enum badge can use needs its token CSS here, or softBadge.tsx warns at runtime. +import "@radix-ui/themes/components.css"; +import "@radix-ui/themes/tokens/base.css"; +import "@radix-ui/themes/utilities.css"; +// Accent + gray scales (pre-existing): +import "@radix-ui/themes/tokens/colors/iris.css"; +import "@radix-ui/themes/tokens/colors/slate.css"; +// Enum badge scales (must match RADIX_BADGE_SCALES): +import "@radix-ui/themes/tokens/colors/tomato.css"; +import "@radix-ui/themes/tokens/colors/amber.css"; +import "@radix-ui/themes/tokens/colors/grass.css"; +import "@radix-ui/themes/tokens/colors/cyan.css"; +import "@radix-ui/themes/tokens/colors/plum.css"; +import "@radix-ui/themes/tokens/colors/orange.css"; +import "@radix-ui/themes/tokens/colors/jade.css"; +import "@radix-ui/themes/tokens/colors/crimson.css"; +import "@radix-ui/themes/tokens/colors/indigo.css"; +import "@radix-ui/themes/tokens/colors/red.css"; +import "@radix-ui/themes/tokens/colors/green.css"; diff --git a/src/web/src/theme/radixTheme.test.ts b/src/web/src/theme/radixTheme.test.ts new file mode 100644 index 0000000..1521693 --- /dev/null +++ b/src/web/src/theme/radixTheme.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { radixThemeConfig } from "./radixTheme"; + +describe("radixThemeConfig", () => { + it("uses the iris accent (nearest hue to the grid accent #4F46E5)", () => { + expect(radixThemeConfig.accentColor).toBe("iris"); + }); + + it("uses the slate gray (matches the grid cool-gray tints)", () => { + expect(radixThemeConfig.grayColor).toBe("slate"); + }); + + it("pins radius and scaling so the look is stable", () => { + expect(radixThemeConfig.radius).toBe("medium"); + expect(radixThemeConfig.scaling).toBe("100%"); + }); +}); diff --git a/src/web/src/theme/radixTheme.ts b/src/web/src/theme/radixTheme.ts new file mode 100644 index 0000000..71084a5 --- /dev/null +++ b/src/web/src/theme/radixTheme.ts @@ -0,0 +1,13 @@ +import type { ThemeProps } from "@radix-ui/themes"; + +// Shared Radix Themes config for the app. `iris` is the nearest +// Radix accent to the grid's #4F46E5 selection highlight; `slate` matches the +// grid's cool-gray tints. `as const` narrows each value to the literal Radix +// prop union (no TS enums — keeps the project's erasable-syntax rule). One prop +// each to revisit later (e.g. accentColor: 'indigo'). +export const radixThemeConfig = { + accentColor: "iris", + grayColor: "slate", + radius: "medium", + scaling: "100%", +} as const satisfies Pick; diff --git a/src/web/src/theme/useGridTheme.ts b/src/web/src/theme/useGridTheme.ts new file mode 100644 index 0000000..cbda63a --- /dev/null +++ b/src/web/src/theme/useGridTheme.ts @@ -0,0 +1,22 @@ +import type { Theme } from "@glideapps/glide-data-grid"; +import { useEffect } from "react"; +import { useAppSelector } from "../app/store"; +import { invalidateBadgeColorCache } from "../lib/grid/softBadge"; +import { themeForAppearance } from "./gridThemes"; + +/** + * Current grid theme, driven by the OS colour scheme. Grids pass the + * result straight to . + * + * Badge colors are read from CSS vars and cached; the cache is dropped whenever + * appearance changes so canvas pills re-resolve for the new light/dark theme. + */ +export function useGridTheme(): Partial { + const appearance = useAppSelector((s) => s.appearance.appearance); + + useEffect(() => { + invalidateBadgeColorCache(); + }, [appearance]); + + return themeForAppearance(appearance); +} diff --git a/src/web/src/vite-env.d.ts b/src/web/src/vite-env.d.ts new file mode 100644 index 0000000..8304c33 --- /dev/null +++ b/src/web/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/src/web/tsconfig.app.json b/src/web/tsconfig.app.json index 6830b6f..57b8e73 100644 --- a/src/web/tsconfig.app.json +++ b/src/web/tsconfig.app.json @@ -17,6 +17,7 @@ "jsx": "react-jsx", /* Linting */ + "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "erasableSyntaxOnly": true, diff --git a/src/web/tsconfig.json b/src/web/tsconfig.json index 1ffef60..d32ff68 100644 --- a/src/web/tsconfig.json +++ b/src/web/tsconfig.json @@ -1,7 +1,4 @@ { "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] } diff --git a/src/web/tsconfig.node.json b/src/web/tsconfig.node.json index 8455dcb..85f3474 100644 --- a/src/web/tsconfig.node.json +++ b/src/web/tsconfig.node.json @@ -14,6 +14,7 @@ "noEmit": true, /* Linting */ + "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "erasableSyntaxOnly": true, diff --git a/src/web/vite.config.ts b/src/web/vite.config.ts index 804be73..194079a 100644 --- a/src/web/vite.config.ts +++ b/src/web/vite.config.ts @@ -1,9 +1,18 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import { tanstackRouter } from "@tanstack/router-plugin/vite"; // HybridWebView serves from a virtual root; assets must use relative paths. export default defineConfig({ - plugins: [react()], + plugins: [ + // Must precede react(): the generator rewrites route files before the + // React plugin transforms them. Code splitting stays off — dynamic-import + // chunk URLs are a resolution risk under the virtual root. + tanstackRouter({ target: "react", autoCodeSplitting: false }), + react(), + tailwindcss(), + ], base: "./", build: { outDir: "../AppName.Maui/Resources/Raw/web",