From 1976b348a1fcb915308cd47a304dbb19c0e1c2bf Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Sat, 22 Aug 2026 15:38:29 -0600 Subject: [PATCH 1/7] feat: enhance data grid and valuation use cases --- src/AppName.Api/Endpoints/BondEndpoints.cs | 53 ++- src/AppName.Api/Endpoints/EnumWire.cs | 97 +++++ .../Endpoints/ValuationQueryBody.cs | 332 ++++++++++++++++++ .../Endpoints/ValuationRequests.cs | 231 ++++++++++++ ...sformer.cs => NumericSchemaTransformer.cs} | 33 +- .../OpenApi/ValuationEnumSchemaTransformer.cs | 99 ++++++ src/AppName.Api/Program.cs | 3 +- .../DependencyInjection.cs | 2 + src/AppName.Application/Dtos/ValuationPage.cs | 17 + .../UseCases/Bonds/DailyRefreshGate.cs | 62 ++++ .../Bonds/GetValuationCountUseCase.cs | 33 ++ .../UseCases/Bonds/GetValuationPageUseCase.cs | 43 +++ .../UseCases/Bonds/GetValuationsUseCase.cs | 41 +-- .../UseCases/Bonds/ValuationSnapshotMapper.cs | 22 ++ .../IBondValuationRefreshStateRepository.cs | 17 + .../IValuationSnapshotRepository.cs | 13 +- src/AppName.Domain/Querying/FilterGroup.cs | 88 +++++ src/AppName.Domain/Querying/IFilterNode.cs | 21 ++ .../Querying/LogicalOperator.cs | 17 + .../Querying/PredicateComposer.cs | 72 ++++ src/AppName.Domain/Querying/SortSpec.cs | 47 +++ .../ValueObjects/ValuationFilter.cs | 43 +++ .../ValueObjects/ValuationQuery.cs | 17 + .../ValueObjects/ValuationSortField.cs | 44 +++ ..._SeedBondValuationRefreshState.Designer.cs | 104 ++++++ ...811065935_SeedBondValuationRefreshState.cs | 37 ++ .../Migrations/AppDbContextModelSnapshot.cs | 8 +- .../Persistence/AppDbContext.cs | 13 + .../BondValuationRefreshStateRepository.cs | 21 ++ .../ValuationSnapshotQueryBuilder.cs | 87 +++++ .../ValuationSnapshotRepository.cs | 22 +- .../Querying/QueryBuilderBase.cs | 215 ++++++++++++ src/web/src/api/schema.gen.ts | 185 +++++++++- .../features/bonds/api/bondQueries.test.ts | 130 ++++++- src/web/src/features/bonds/api/bondQueries.ts | 150 +++++++- src/web/src/features/bonds/bondGrid.test.ts | 160 ++++----- src/web/src/features/bonds/bondGrid.ts | 43 +-- .../features/dataGrid/data/rowCount.test.tsx | 59 +++- .../src/features/dataGrid/data/rowCount.ts | 29 +- src/web/src/features/dataGrid/types.ts | 11 +- .../Bonds/GetValuationCountUseCaseTests.cs | 111 ++++++ .../Bonds/GetValuationPageUseCaseTests.cs | 151 ++++++++ .../Bonds/GetValuationsUseCaseTests.cs | 85 +++-- .../Querying/FilterGroupTests.cs | 81 +++++ .../Querying/PredicateComposerTests.cs | 63 ++++ ...ondValuationRefreshStateRepositoryTests.cs | 64 ++++ .../ValuationSnapshotRepositoryTests.cs | 331 ++++++++++++++++- 47 files changed, 3366 insertions(+), 241 deletions(-) create mode 100644 src/AppName.Api/Endpoints/EnumWire.cs create mode 100644 src/AppName.Api/Endpoints/ValuationQueryBody.cs create mode 100644 src/AppName.Api/Endpoints/ValuationRequests.cs rename src/AppName.Api/OpenApi/{DecimalSchemaTransformer.cs => NumericSchemaTransformer.cs} (52%) create mode 100644 src/AppName.Api/OpenApi/ValuationEnumSchemaTransformer.cs create mode 100644 src/AppName.Application/Dtos/ValuationPage.cs create mode 100644 src/AppName.Application/UseCases/Bonds/DailyRefreshGate.cs create mode 100644 src/AppName.Application/UseCases/Bonds/GetValuationCountUseCase.cs create mode 100644 src/AppName.Application/UseCases/Bonds/GetValuationPageUseCase.cs create mode 100644 src/AppName.Application/UseCases/Bonds/ValuationSnapshotMapper.cs create mode 100644 src/AppName.Domain/Querying/FilterGroup.cs create mode 100644 src/AppName.Domain/Querying/IFilterNode.cs create mode 100644 src/AppName.Domain/Querying/LogicalOperator.cs create mode 100644 src/AppName.Domain/Querying/PredicateComposer.cs create mode 100644 src/AppName.Domain/Querying/SortSpec.cs create mode 100644 src/AppName.Domain/ValueObjects/ValuationFilter.cs create mode 100644 src/AppName.Domain/ValueObjects/ValuationQuery.cs create mode 100644 src/AppName.Domain/ValueObjects/ValuationSortField.cs create mode 100644 src/AppName.Infrastructure/Migrations/20260811065935_SeedBondValuationRefreshState.Designer.cs create mode 100644 src/AppName.Infrastructure/Migrations/20260811065935_SeedBondValuationRefreshState.cs create mode 100644 src/AppName.Infrastructure/Persistence/ValuationSnapshotQueryBuilder.cs create mode 100644 src/AppName.Infrastructure/Querying/QueryBuilderBase.cs create mode 100644 tests/AppName.Application.Tests/UseCases/Bonds/GetValuationCountUseCaseTests.cs create mode 100644 tests/AppName.Application.Tests/UseCases/Bonds/GetValuationPageUseCaseTests.cs create mode 100644 tests/AppName.Domain.Tests/Querying/FilterGroupTests.cs create mode 100644 tests/AppName.Domain.Tests/Querying/PredicateComposerTests.cs diff --git a/src/AppName.Api/Endpoints/BondEndpoints.cs b/src/AppName.Api/Endpoints/BondEndpoints.cs index e09e665..d0cdc24 100644 --- a/src/AppName.Api/Endpoints/BondEndpoints.cs +++ b/src/AppName.Api/Endpoints/BondEndpoints.cs @@ -15,14 +15,63 @@ public static class BondEndpoints /// public static RouteGroupBuilder MapBondEndpoints(this RouteGroupBuilder group) { - group.MapGet("/valuations", async Task>> (GetValuationsUseCase useCase, CancellationToken ct) => + group.MapGet("/valuations", async Task>, ValidationProblem>> ( + [AsParameters] ValuationQueryRequest request, GetValuationsUseCase useCase, CancellationToken ct) => { - var valuations = await useCase.ExecuteAsync(ct); + if (!request.TryToQuery(out var query, out var errors)) + { + return TypedResults.ValidationProblem(errors); + } + + var valuations = await useCase.ExecuteAsync(query, ct); + return TypedResults.Ok(valuations); }) .RequireAuthorization() .WithName("GetValuations"); + // The count is a separate endpoint, not a field on the response above, because the + // grid keeps its total in its own cached query keyed on the view rather than the + // window. An envelope on the GET would run one COUNT per window scroll and discard + // every result. + // + // This route and "/valuations/{symbol}" share a shape. ASP.NET Core ranks a literal + // segment above a parameter segment, so "count" never binds to {symbol}. + group.MapGet("/valuations/count", async Task, ValidationProblem>> ( + [AsParameters] ValuationFilterRequest request, GetValuationCountUseCase useCase, CancellationToken ct) => + { + if (!request.TryToFilter(out var filter, out var errors)) + { + return TypedResults.ValidationProblem(errors); + } + + var count = await useCase.ExecuteAsync(filter, ct); + + return TypedResults.Ok(new CountDto(count)); + }) + .RequireAuthorization() + .WithName("GetValuationCount"); + + // This endpoint returns an envelope while the GET above returns a bare array. The + // two callers differ. Splitting this one would force the caller to send the same + // filter TREE twice, which invites two requests that disagree, and its slice and + // total come from one filter on one context, so they cannot tear. The report caller + // wants the total with the page; the grid does not. + group.MapPost("/valuations/query", async Task, ValidationProblem>> ( + ValuationQueryBody body, GetValuationPageUseCase useCase, CancellationToken ct) => + { + if (!body.TryToQuery(out var query, out var errors)) + { + return TypedResults.ValidationProblem(errors); + } + + var page = await useCase.ExecuteAsync(query, ct); + + return TypedResults.Ok(new ValuationPageDto(page.Items, page.Total)); + }) + .RequireAuthorization() + .WithName("QueryValuations"); + group.MapGet("/valuations/{symbol}", async Task, NotFound>> (string symbol, GetConversionValuationUseCase useCase, CancellationToken ct) => { var valuation = await useCase.ExecuteAsync(symbol, ct); diff --git a/src/AppName.Api/Endpoints/EnumWire.cs b/src/AppName.Api/Endpoints/EnumWire.cs new file mode 100644 index 0000000..bc78d84 --- /dev/null +++ b/src/AppName.Api/Endpoints/EnumWire.cs @@ -0,0 +1,97 @@ +using System.Text.Json; + +namespace AppName.Api.Endpoints; + +/// +/// The wire spelling of an enum value, and the tolerant parser that reads it back. +/// +/// Every enum this API accepts from a caller travels as a camelCase string, never as +/// the CLR member name and never as an integer. Query-string binding and JSON body +/// binding both need that spelling, and so does the OpenAPI document, so the list +/// lives here once. If the schema advertised one spelling and the parser accepted +/// another, a generated client would send values the server rejects. +/// +/// +/// The request records deliberately bind these values as rather +/// than as the enum itself. Minimal-API query binding parses an enum through the +/// two-argument Enum.TryParse overload, which is case-sensitive and which +/// happily accepts a numeric string, so sortField=conversionValue would fail +/// while sortField=99 would succeed and carry an undefined enum value into the +/// query builder. A binder failure also escapes as a raw BadHttpRequestException +/// instead of the RFC 9457 payload the endpoints promise. Parsing here restores both. +/// +/// +internal static class EnumWire +{ + /// + /// The camelCase wire values of , in declaration order. + /// + /// Enum whose members name the allowed wire values. + public static string[] Names() + where TEnum : struct, Enum + => Cache.Names; + + /// + /// Reads one enum value from its wire spelling, recording a validation error when the + /// caller sent something the enum does not name. + /// + /// Enum to read. + /// The value as it arrived, or null when the caller omitted it. + /// The value to use when the caller omitted this parameter. + /// Wire name of the parameter, used as the error key. + /// Collects one entry per rejected parameter. + /// + /// The parsed value, or when the value was absent or + /// rejected. A rejected value also lands in , so the caller + /// tests that dictionary rather than this result to decide whether the request stands. + /// + public static TEnum Parse( + string? raw, + TEnum fallback, + string name, + Dictionary errors) + where TEnum : struct, Enum + { + if (string.IsNullOrEmpty(raw)) + { + return fallback; + } + + // Enum.TryParse also succeeds for any numeric string, mapping "99" onto an enum + // value no member declares. Enum.IsDefined is what actually enforces the + // allow-list; without it an undefined value reaches the sort switch and throws. + if (Enum.TryParse(raw, ignoreCase: true, out var parsed) && Enum.IsDefined(parsed)) + { + return parsed; + } + + errors[name] = [$"{name} must be one of: {string.Join(", ", Names())}."]; + + return fallback; + } + + /// + /// Holds the converted names once per closed generic type. A static field on a generic + /// class is initialised once for each , so reading the list + /// costs a field load rather than a dictionary probe and an allocation per request. + /// + /// Enum the names belong to. + private static class Cache + where TEnum : struct, Enum + { + public static readonly string[] Names = Convert(); + + private static string[] Convert() + { + var members = Enum.GetNames(); + var names = new string[members.Length]; + + for (var i = 0; i < members.Length; i++) + { + names[i] = JsonNamingPolicy.CamelCase.ConvertName(members[i]); + } + + return names; + } + } +} diff --git a/src/AppName.Api/Endpoints/ValuationQueryBody.cs b/src/AppName.Api/Endpoints/ValuationQueryBody.cs new file mode 100644 index 0000000..b3eb8aa --- /dev/null +++ b/src/AppName.Api/Endpoints/ValuationQueryBody.cs @@ -0,0 +1,332 @@ +using AppName.Application.Dtos; +using AppName.Domain.Querying; +using AppName.Domain.ValueObjects; + +namespace AppName.Api.Endpoints; + +/// +/// One node of the filter tree that POST /api/valuations/query accepts. A node +/// carries its own leaf values and its child , which is the shape +/// models, so a node maps onto one domain node. +/// +public sealed record ValuationFilterNode +{ + /// + /// How this node joins its own leaves and its child groups, as a camelCase + /// name. Defaults to and. + /// + public string? Operator { get; init; } + + /// + /// Prefix match on the bond symbol, case-sensitive. + /// + public string? Symbol { get; init; } + + /// + /// Exact match on the in-the-money flag. A row whose flag is null matches neither value. + /// + public bool? InTheMoney { get; init; } + + /// + /// Inclusive lower bound on the conversion value. + /// + public decimal? MinConversionValue { get; init; } + + /// + /// Inclusive upper bound on the conversion value. + /// + public decimal? MaxConversionValue { get; init; } + + /// + /// Inclusive lower bound on the bond price. Excludes rows with no bond quote. + /// + public decimal? MinBondPrice { get; init; } + + /// + /// Inclusive upper bound on the bond price. Excludes rows with no bond quote. + /// + public decimal? MaxBondPrice { get; init; } + + /// + /// Inclusive earliest trading date. + /// + public DateOnly? AsOfFrom { get; init; } + + /// + /// Inclusive latest trading date. + /// + public DateOnly? AsOfTo { get; init; } + + /// + /// The child nodes nested under this one. Empty for a flat filter. + /// + public IReadOnlyList Groups { get; init; } = []; +} + +/// +/// The order one POST /api/valuations/query request asks for. Every member is +/// optional and every default matches the GET endpoint, so the two paths order +/// an unspecified request identically. +/// +public sealed record ValuationSortBody +{ + /// + /// Column to order by, as a camelCase name. + /// Defaults to symbol. + /// + public string? Field { get; init; } + + /// + /// Direction to order in, as a camelCase name. + /// Defaults to asc. + /// + public string? Direction { get; init; } + + /// + /// Where the null values of the sorted column sit, as a camelCase + /// name. Defaults to last. + /// + public string? Nulls { get; init; } +} + +/// +/// The request body of POST /api/valuations/query: a nested AND/OR filter tree, +/// an order, and a window. This is the ad-hoc and reporting path; the grid uses the +/// GET endpoints. +/// +/// Every member is declared as an optional property rather than a positional record +/// parameter. A positional parameter with no default is required for deserialization, +/// which OpenAPI reports as a required field, so a caller who wants the first hundred +/// unfiltered rows would have to send all four. +/// +/// +public sealed record ValuationQueryBody +{ + /// + /// The default window size, applied when the caller omits . + /// + private const int DefaultLimit = 100; + + /// + /// The largest window a caller may ask for. A request above it is rejected. + /// + private const int MaxLimit = 500; + + /// + /// The deepest the filter tree may nest, counting the root as level 1. + /// + private const int MaxDepth = 4; + + /// + /// The most nodes the filter tree may hold, counting the root. + /// + private const int MaxNodes = 50; + + /// + /// The filter tree. Null restricts nothing. + /// + public ValuationFilterNode? Filter { get; init; } + + /// + /// The order to return the window in. Null takes every sort default. + /// + public ValuationSortBody? Sort { get; init; } + + /// + /// Rows to skip. Defaults to 0. + /// + public int? Offset { get; init; } + + /// + /// Rows to return, from 1 to 500 inclusive. Defaults to 100. + /// + public int? Limit { get; init; } + + /// + /// Converts the body into the domain query, applying every default and rejecting a + /// tree past the limits, a malformed range, a bad offset or limit, and an unknown + /// sort value. + /// + /// The domain query, valid only when this method returns true. + /// One entry per rejected value, keyed by its path in the body. + /// True when the whole body is within range. + public bool TryToQuery(out ValuationQuery query, out Dictionary errors) + { + errors = []; + + var filter = ValuationFilter.None; + + if (Filter is { } root) + { + // The size guard runs to completion before a single predicate is built. It is + // the walk itself that has to stay bounded, so nothing may recurse over the + // tree until the tree is known to be small enough to recurse over. + var count = 0; + + if (Validate(root, depth: 1, ref count, errors)) + { + filter = ToFilter(root, "filter", errors); + } + } + + if (Offset is < 0) + { + errors["offset"] = ["offset must not be negative."]; + } + + // Rejected rather than clamped, for the reason given on the GET request: a client + // that asks for 1000 rows and silently reads 100 skips the 400 rows in between. + if (Limit is < 1 or > MaxLimit) + { + errors["limit"] = [$"limit must be between 1 and {MaxLimit}."]; + } + + var sort = new SortSpec( + EnumWire.Parse(Sort?.Field, ValuationSortField.Symbol, "sort.field", errors), + EnumWire.Parse(Sort?.Direction, SortDirection.Asc, "sort.direction", errors), + EnumWire.Parse(Sort?.Nulls, NullPlacement.Last, "sort.nulls", errors)); + + query = new ValuationQuery(filter, sort, Offset ?? 0, Limit ?? DefaultLimit); + + return errors.Count == 0; + } + + /// + /// Walks the filter tree and rejects one that nests deeper than + /// or holds more than nodes. + /// + /// Neither limit is defensive coding. Building the predicate recurses, so an unbounded + /// tree is a stack-overflow path, and a wide tree becomes a WHERE clause the + /// query planner cannot handle. System.Text.Json already refuses to deserialize + /// past , 64 by default, + /// so a deeper body fails as a JsonException before this method ever runs. That + /// default guards the deserializer's stack; these limits guard the query planner, which + /// a 60-level tree would reach comfortably within it. + /// + /// + /// Node to check, then its children. + /// Level of , counting the root as 1. + /// Running node total, carried across the whole walk. + /// Collects the first limit the tree breaks. + /// True when this node and everything below it are within both limits. + private static bool Validate( + ValuationFilterNode node, + int depth, + ref int count, + Dictionary errors) + { + // Tested on entry, before recursing, so this walk can never nest past MaxDepth + // frames however deep the caller's tree goes. + if (depth > MaxDepth) + { + errors["filter"] = [$"filter must not nest deeper than {MaxDepth} levels."]; + + return false; + } + + count++; + + if (count > MaxNodes) + { + errors["filter"] = [$"filter must not hold more than {MaxNodes} nodes."]; + + return false; + } + + foreach (var child in node.Groups) + { + if (!Validate(child, depth + 1, ref count, errors)) + { + return false; + } + } + + return true; + } + + /// + /// Maps one validated node, and everything below it, onto the domain filter tree. + /// + /// Node to map. Already known to be within the size limits. + /// + /// Position of in the body, such as + /// filter.groups[0]. Errors are keyed by it, so two nodes that both reverse a + /// range produce two entries instead of overwriting one another. + /// + /// Collects one entry per rejected value. + /// The domain filter for this node. + private static ValuationFilter ToFilter( + ValuationFilterNode node, + string path, + Dictionary errors) + { + Check(node.MinConversionValue, node.MaxConversionValue, path, "minConversionValue", "maxConversionValue", errors); + Check(node.MinBondPrice, node.MaxBondPrice, path, "minBondPrice", "maxBondPrice", errors); + Check(node.AsOfFrom, node.AsOfTo, path, "asOfFrom", "asOfTo", errors); + + var groups = new ValuationFilter[node.Groups.Count]; + + for (var i = 0; i < groups.Length; i++) + { + groups[i] = ToFilter(node.Groups[i], $"{path}.groups[{i}]", errors); + } + + return new ValuationFilter( + node.Symbol, + node.InTheMoney, + node.MinConversionValue, + node.MaxConversionValue, + node.MinBondPrice, + node.MaxBondPrice, + node.AsOfFrom, + node.AsOfTo) + { + Operator = EnumWire.Parse(node.Operator, LogicalOperator.And, $"{path}.operator", errors), + Groups = groups, + }; + } + + /// + /// Records an error against the lower bound when a supplied range runs backwards. + /// + /// Comparable bound type. + /// Lower bound, or null when the caller omitted it. + /// Upper bound, or null when the caller omitted it. + /// Position of the owning node in the body. + /// Name of the lower bound, used in the error key. + /// Name of the upper bound, named in the message. + /// Collects one entry per rejected value. + private static void Check( + T? min, + T? max, + string path, + string minName, + string maxName, + Dictionary errors) + where T : struct, IComparable + { + // A half-open range is legitimate, so only a pair of supplied bounds is compared. + if (min is { } low && max is { } high && low.CompareTo(high) > 0) + { + errors[$"{path}.{minName}"] = [$"{minName} must not be greater than {maxName}."]; + } + } +} + +/// +/// The response of POST /api/valuations/query: one window and the total the same +/// filter matches. +/// +/// The valuations inside the requested offset and limit. +/// Rows the filter matches, ignoring the offset and the limit. +public sealed record ValuationPageDto( + IReadOnlyList Items, + int Total); + +/// +/// The response of GET /api/valuations/count. The total travels as an object +/// rather than a bare integer, matching the shape the grid already reads in +/// testRowQueries.ts and leaving room for a second field without breaking callers. +/// +/// Rows the filter matches. +public sealed record CountDto(int Count); diff --git a/src/AppName.Api/Endpoints/ValuationRequests.cs b/src/AppName.Api/Endpoints/ValuationRequests.cs new file mode 100644 index 0000000..2c21af3 --- /dev/null +++ b/src/AppName.Api/Endpoints/ValuationRequests.cs @@ -0,0 +1,231 @@ +using AppName.Domain.Querying; +using AppName.Domain.ValueObjects; +using Microsoft.AspNetCore.Mvc; + +namespace AppName.Api.Endpoints; + +/// +/// The eight valuation filters, bound from the query string with +/// [AsParameters]. GET /api/valuations/count binds this type directly, +/// and extends it with paging and sorting, so the +/// two endpoints cannot drift apart: a filter one honoured and the other ignored would +/// make the grid's scrollbar disagree with its rows. +/// +/// Every property carries with an explicit camelCase +/// name. Without it the OpenAPI document advertises the CLR names (MinBondPrice), +/// the generated TypeScript client sends those, and the wire contract stops matching +/// every other DTO in this API. The binder itself is case-insensitive, so the attribute +/// changes the document rather than what the server accepts. +/// +/// +public record ValuationFilterRequest +{ + /// + /// Prefix match on the bond symbol. The match is case-sensitive, because the symbols + /// in this dataset are numeric and a case-insensitive match would need + /// EF.Functions.ILike, which SQLite does not translate. + /// + [FromQuery(Name = "symbol")] + public string? Symbol { get; init; } + + /// + /// Exact match on the in-the-money flag. A row whose flag is null matches neither + /// true nor false. + /// + [FromQuery(Name = "inTheMoney")] + public bool? InTheMoney { get; init; } + + /// + /// Inclusive lower bound on the conversion value. + /// + [FromQuery(Name = "minConversionValue")] + public decimal? MinConversionValue { get; init; } + + /// + /// Inclusive upper bound on the conversion value. + /// + [FromQuery(Name = "maxConversionValue")] + public decimal? MaxConversionValue { get; init; } + + /// + /// Inclusive lower bound on the bond price. Excludes rows with no bond quote. + /// + [FromQuery(Name = "minBondPrice")] + public decimal? MinBondPrice { get; init; } + + /// + /// Inclusive upper bound on the bond price. Excludes rows with no bond quote. + /// + [FromQuery(Name = "maxBondPrice")] + public decimal? MaxBondPrice { get; init; } + + /// + /// Inclusive earliest trading date. + /// + [FromQuery(Name = "asOfFrom")] + public DateOnly? AsOfFrom { get; init; } + + /// + /// Inclusive latest trading date. + /// + [FromQuery(Name = "asOfTo")] + public DateOnly? AsOfTo { get; init; } + + /// + /// Converts the bound values into the domain filter, rejecting a range whose lower + /// bound sits above its upper bound. + /// + /// The range check lives here rather than in because a + /// reversed range is a malformed request, not an invalid domain state. The domain + /// type is happy to describe an empty match; a caller who asks for a bond priced + /// above 100 and below 50 has made a mistake and needs a 400, not an empty array. + /// + /// + /// The domain filter, valid only when this method returns true. + /// One entry per rejected parameter, keyed by its wire name. + /// True when every range is ordered. + public bool TryToFilter(out ValuationFilter filter, out Dictionary errors) + { + errors = []; + + Check(MinConversionValue, MaxConversionValue, "minConversionValue", "maxConversionValue", errors); + Check(MinBondPrice, MaxBondPrice, "minBondPrice", "maxBondPrice", errors); + Check(AsOfFrom, AsOfTo, "asOfFrom", "asOfTo", errors); + + filter = new ValuationFilter( + Symbol, + InTheMoney, + MinConversionValue, + MaxConversionValue, + MinBondPrice, + MaxBondPrice, + AsOfFrom, + AsOfTo); + + return errors.Count == 0; + } + + /// + /// Records an error against the lower bound when a supplied range runs backwards. + /// + /// Comparable bound type. + /// Lower bound, or null when the caller omitted it. + /// Upper bound, or null when the caller omitted it. + /// Wire name of the lower bound, used as the error key. + /// Wire name of the upper bound, named in the message. + /// Collects one entry per rejected parameter. + private static void Check( + T? min, + T? max, + string minName, + string maxName, + Dictionary errors) + where T : struct, IComparable + { + // A half-open range is legitimate, so only a pair of supplied bounds is compared. + if (min is { } low && max is { } high && low.CompareTo(high) > 0) + { + errors[minName] = [$"{minName} must not be greater than {maxName}."]; + } + } +} + +/// +/// The eight filters plus the paging and sorting parameters, bound from the query string +/// with [AsParameters] for GET /api/valuations. +/// +/// Inheriting the filters is deliberate and verified: minimal-API [AsParameters] +/// binding reflects over Type.GetProperties() without BindingFlags.DeclaredOnly, +/// so base-declared properties bind, and the OpenAPI document lists all thirteen +/// parameters. The compile-time request-delegate generator walks only the declared type, +/// so enabling Native AOT or EnableRequestDelegateGenerator would silently leave +/// the eight inherited filters at null. This project enables neither. Flatten this type +/// before turning either on. +/// +/// +public sealed record ValuationQueryRequest : ValuationFilterRequest +{ + /// + /// The default window size, applied when the caller omits . + /// + private const int DefaultLimit = 100; + + /// + /// The largest window a caller may ask for. A request above it is rejected. + /// + private const int MaxLimit = 500; + + /// + /// Rows to skip. An offset past the end of the match returns an empty array rather + /// than an error, because a grid scrolled to a shrinking result set asks for one. + /// + [FromQuery(Name = "offset")] + public int? Offset { get; init; } + + /// + /// Rows to return, from 1 to 500 inclusive. Defaults to 100. + /// + [FromQuery(Name = "limit")] + public int? Limit { get; init; } + + /// + /// Column to order by, as a camelCase name. + /// Defaults to symbol. + /// + [FromQuery(Name = "sortField")] + public string? SortField { get; init; } + + /// + /// Direction to order in, as a camelCase name. + /// Defaults to asc. + /// + [FromQuery(Name = "sortDir")] + public string? SortDir { get; init; } + + /// + /// Where the null values of the sorted column sit, as a camelCase + /// name. Defaults to last. + /// + [FromQuery(Name = "nulls")] + public string? Nulls { get; init; } + + /// + /// Converts the bound values into the domain query, applying every default and + /// rejecting a malformed range, offset, limit, or sort value. + /// + /// Each property is nullable and each default is applied here rather than as a C# + /// initialiser. A non-nullable int Limit would make OpenAPI advertise + /// limit as required, or silently document its default as 0, and a caller + /// reading the generated client would believe it had to send one. + /// + /// + /// The domain query, valid only when this method returns true. + /// One entry per rejected parameter, keyed by its wire name. + /// True when every parameter is within range. + public bool TryToQuery(out ValuationQuery query, out Dictionary errors) + { + TryToFilter(out var filter, out errors); + + if (Offset is < 0) + { + errors["offset"] = ["offset must not be negative."]; + } + + // An out-of-range limit is rejected, never clamped. A silent clamp makes a client + // that asks for 1000 rows read 100 and believe it read 1000, so its next request + // starts at offset 1000 and the 400 rows in between are never shown. + if (Limit is < 1 or > MaxLimit) + { + errors["limit"] = [$"limit must be between 1 and {MaxLimit}."]; + } + + var sort = new SortSpec( + EnumWire.Parse(SortField, ValuationSortField.Symbol, "sortField", errors), + EnumWire.Parse(SortDir, SortDirection.Asc, "sortDir", errors), + EnumWire.Parse(Nulls, NullPlacement.Last, "nulls", errors)); + + query = new ValuationQuery(filter, sort, Offset ?? 0, Limit ?? DefaultLimit); + + return errors.Count == 0; + } +} diff --git a/src/AppName.Api/OpenApi/DecimalSchemaTransformer.cs b/src/AppName.Api/OpenApi/NumericSchemaTransformer.cs similarity index 52% rename from src/AppName.Api/OpenApi/DecimalSchemaTransformer.cs rename to src/AppName.Api/OpenApi/NumericSchemaTransformer.cs index 74f6fe0..e9054d0 100644 --- a/src/AppName.Api/OpenApi/DecimalSchemaTransformer.cs +++ b/src/AppName.Api/OpenApi/NumericSchemaTransformer.cs @@ -4,8 +4,8 @@ namespace AppName.Api.OpenApi; /// -/// Narrows the OpenAPI schema for and of -/// from "number or string" down to "number" (or "number or null"). +/// Narrows the OpenAPI schema for and , and their +/// forms, from "number or string" down to a plain number. /// 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 @@ -16,22 +16,41 @@ namespace AppName.Api.OpenApi; /// 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. +/// +/// int is covered for the same reason and by the same rule. The generator gives it +/// the identical ["integer", "string"] union, which the valuation endpoints made +/// visible: offset, limit and the count response would otherwise generate as +/// number | string. The decimal case simply reached the document first. +/// /// -public sealed class DecimalSchemaTransformer : IOpenApiSchemaTransformer +public sealed class NumericSchemaTransformer : IOpenApiSchemaTransformer { /// public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) { var type = context.JsonTypeInfo.Type; - if (type != typeof(decimal) && type != typeof(decimal?)) + JsonSchemaType number; + + if (type == typeof(decimal) || type == typeof(decimal?)) + { + number = JsonSchemaType.Number; + } + else if (type == typeof(int) || type == typeof(int?)) + { + number = JsonSchemaType.Integer; + } + else { return Task.CompletedTask; } - schema.Type = type == typeof(decimal?) - ? JsonSchemaType.Number | JsonSchemaType.Null - : JsonSchemaType.Number; + // The null arm is carried over rather than derived from the CLR type. The generator + // adds it to a nullable body property but not to a nullable query parameter, which + // is already absent when omitted, so copying it keeps both cases accurate. + var nullArm = schema.Type is { } existing ? existing & JsonSchemaType.Null : default; + + schema.Type = number | nullArm; schema.Pattern = null; diff --git a/src/AppName.Api/OpenApi/ValuationEnumSchemaTransformer.cs b/src/AppName.Api/OpenApi/ValuationEnumSchemaTransformer.cs new file mode 100644 index 0000000..fead215 --- /dev/null +++ b/src/AppName.Api/OpenApi/ValuationEnumSchemaTransformer.cs @@ -0,0 +1,99 @@ +using System.Text.Json.Nodes; +using AppName.Api.Endpoints; +using AppName.Domain.Querying; +using AppName.Domain.ValueObjects; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace AppName.Api.OpenApi; + +/// +/// Restores the allowed values onto the valuation sort and filter-operator parameters, +/// which bind as rather than as their enums. +/// +/// The enums cannot be the bound type. Minimal-API query binding parses an enum with the +/// case-sensitive two-argument Enum.TryParse, which rejects the camelCase values +/// this API's wire contract specifies and accepts a numeric string that names no member. +/// parses the string instead. That fixes the server but empties the +/// schema: without this transformer the OpenAPI document describes sortField as a +/// bare string, openapi-typescript generates string instead of a union, and +/// the client loses the compile-time allow-list the enum was chosen to give it. +/// +/// +/// The values come from the same list the parser reads, so the +/// document and the server cannot drift. Do not hand-write the values here: a schema that +/// advertises a value the parser rejects is worse than no schema at all. +/// +/// +public sealed class ValuationEnumSchemaTransformer : IOpenApiSchemaTransformer +{ + /// + /// Body properties that carry an enum value, keyed by the declaring type and the JSON + /// property name. The declaring type is part of the key because names like + /// field and operator are generic enough that a future DTO could reuse + /// one and inherit these values by accident. + /// + private static readonly Dictionary<(Type Declaring, string Property), string[]> BodyValues = new() + { + [(typeof(ValuationSortBody), "field")] = EnumWire.Names(), + [(typeof(ValuationSortBody), "direction")] = EnumWire.Names(), + [(typeof(ValuationSortBody), "nulls")] = EnumWire.Names(), + [(typeof(ValuationFilterNode), "operator")] = EnumWire.Names(), + }; + + /// + /// Query parameters that carry an enum value, keyed by their wire name. A flattened + /// [AsParameters] property exposes no declaring type here, so the name is the + /// only key available. These three names appear on the valuation endpoints alone. + /// + private static readonly Dictionary QueryValues = new(StringComparer.Ordinal) + { + ["sortField"] = EnumWire.Names(), + ["sortDir"] = EnumWire.Names(), + ["nulls"] = EnumWire.Names(), + }; + + /// + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + string[]? values = null; + + // A body property reports both a JsonPropertyInfo and the enclosing handler + // parameter, so the property has to be tested first. A query parameter reports + // only the parameter, and its name is already the camelCase wire name. + if (context.JsonPropertyInfo is { } property) + { + BodyValues.TryGetValue((property.DeclaringType, property.Name), out values); + } + else if (context.ParameterDescription?.Name is { } parameterName) + { + QueryValues.TryGetValue(parameterName, out values); + } + + if (values is null) + { + return Task.CompletedTask; + } + + var allowed = new List(values.Length + 1); + + foreach (var value in values) + { + allowed.Add(JsonValue.Create(value)); + } + + // An optional body property is typed "null or string". Omitting null from the enum + // would make the schema contradict itself and reject a value its own type allows. + if (schema.Type is { } type && type.HasFlag(JsonSchemaType.Null)) + { + allowed.Add(null); + } + + // Microsoft.OpenApi declares the member list as IList, but a JSON null + // enum member has no JsonNode representation other than a null element, so the + // element type has to be widened here and the mismatch suppressed on assignment. + schema.Enum = allowed!; + + return Task.CompletedTask; + } +} diff --git a/src/AppName.Api/Program.cs b/src/AppName.Api/Program.cs index 1b5061e..172be3d 100644 --- a/src/AppName.Api/Program.cs +++ b/src/AppName.Api/Program.cs @@ -17,7 +17,8 @@ builder.Services.AddOpenApi(options => { - options.AddSchemaTransformer(); + options.AddSchemaTransformer(); + options.AddSchemaTransformer(); }); // --- Auth (JWT bearer, dev stub) ---------------------------------------- diff --git a/src/AppName.Application/DependencyInjection.cs b/src/AppName.Application/DependencyInjection.cs index 44931e9..6cdd119 100644 --- a/src/AppName.Application/DependencyInjection.cs +++ b/src/AppName.Application/DependencyInjection.cs @@ -12,6 +12,8 @@ public static IServiceCollection AddApplication(this IServiceCollection services services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); return services; } } diff --git a/src/AppName.Application/Dtos/ValuationPage.cs b/src/AppName.Application/Dtos/ValuationPage.cs new file mode 100644 index 0000000..4cdd0c5 --- /dev/null +++ b/src/AppName.Application/Dtos/ValuationPage.cs @@ -0,0 +1,17 @@ +using System.ComponentModel; + +namespace AppName.Application.Dtos; + +/// +/// One window of conversion valuations together with the total row count the +/// same filter matches. The pair travels as one value because the count and the +/// slice come from one filter tree, so they cannot disagree. +/// +/// The valuations inside the requested offset and limit. +/// +/// Rows the filter matches, ignoring the offset and the limit. A scrollbar sizes +/// itself from this number, so it counts the whole match, not the window. +/// +public sealed record ValuationPage( + [property: DisplayName("Items")] IReadOnlyList Items, + [property: DisplayName("Total")] int Total); diff --git a/src/AppName.Application/UseCases/Bonds/DailyRefreshGate.cs b/src/AppName.Application/UseCases/Bonds/DailyRefreshGate.cs new file mode 100644 index 0000000..352aeed --- /dev/null +++ b/src/AppName.Application/UseCases/Bonds/DailyRefreshGate.cs @@ -0,0 +1,62 @@ +using AppName.Application.Time; +using AppName.Domain.Abstractions; + +namespace AppName.Application.UseCases.Bonds; + +/// +/// The once-per-Taiwan-trading-day refresh gate every valuation read passes +/// through. +/// +/// +/// +/// The gate is a static internal collaborator rather than a private helper on +/// each use case, because three use cases share it and three copies drift. It +/// stays static and takes its dependencies as arguments so it needs no DI +/// registration, and so the use-case tests reach it through the use cases they +/// already build instead of through InternalsVisibleTo. +/// +/// +/// The gate claims the day instead of reading the marker and comparing it. The +/// grid starts the count query and the first window query together on mount, and +/// neither is guaranteed to arrive first. A read followed by a separate write +/// lets both callers see the stale marker, so both start a whole-market refresh. +/// tests +/// and sets in one statement, so exactly one caller wins the day. +/// +/// +internal static class DailyRefreshGate +{ + /// + /// Refreshes the valuation cache when this caller wins today's claim, and + /// returns without refreshing when it loses. + /// + /// Holds the singleton refresh marker the claim updates. + /// Runs the whole-market refresh. + /// Resolves today's Taiwan trading date. + /// Cancels the claim and the refresh. + internal static async Task EnsureFreshAsync( + IBondValuationRefreshStateRepository refreshStateRepo, + IRefreshAllBondsUseCase refreshAllBonds, + TimeProvider timeProvider, + CancellationToken ct) + { + var today = TaiwanClock.Today(timeProvider); + + // A losing claim falls through and serves the cache. It does not wait for + // the winner, because freshness is best-effort and waiting would couple + // every window request to one whole-market fetch. + if (await refreshStateRepo.TryClaimAttemptAsync(today, ct)) + { + try + { + await refreshAllBonds.ExecuteAsync(ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Freshness is best-effort; a provider outage must serve stale cache, + // not fail the caller. The claim already stamped today, so a failed + // attempt still consumes the day and no marker write belongs here. + } + } + } +} diff --git a/src/AppName.Application/UseCases/Bonds/GetValuationCountUseCase.cs b/src/AppName.Application/UseCases/Bonds/GetValuationCountUseCase.cs new file mode 100644 index 0000000..96f6452 --- /dev/null +++ b/src/AppName.Application/UseCases/Bonds/GetValuationCountUseCase.cs @@ -0,0 +1,33 @@ +using AppName.Domain.Abstractions; +using AppName.Domain.ValueObjects; + +namespace AppName.Application.UseCases.Bonds; + +/// +/// Returns how many cached valuations one filter matches, refreshing the cache +/// once per Taiwan trading day. Refresh is an internal, lazy step — never +/// surfaced as an error to the caller. +/// +/// +/// The grid keeps its row count in a query of its own, so this path can arrive +/// before any window query. It therefore passes the same refresh gate; whichever +/// request arrives first wins the day. +/// +public sealed class GetValuationCountUseCase( + IBondValuationRefreshStateRepository refreshStateRepo, + IValuationSnapshotRepository snapshotRepo, + IRefreshAllBondsUseCase refreshAllBonds, + TimeProvider timeProvider) +{ + /// + /// Ensures freshness (best-effort) then returns the filtered total. + /// + /// The filter to count. Paging cannot change a count, so it takes no window. + /// Cancels the refresh and the count. + public async Task ExecuteAsync(ValuationFilter filter, CancellationToken ct = default) + { + await DailyRefreshGate.EnsureFreshAsync(refreshStateRepo, refreshAllBonds, timeProvider, ct); + + return await snapshotRepo.CountAsync(filter, ct); + } +} diff --git a/src/AppName.Application/UseCases/Bonds/GetValuationPageUseCase.cs b/src/AppName.Application/UseCases/Bonds/GetValuationPageUseCase.cs new file mode 100644 index 0000000..80cca06 --- /dev/null +++ b/src/AppName.Application/UseCases/Bonds/GetValuationPageUseCase.cs @@ -0,0 +1,43 @@ +using AppName.Application.Dtos; +using AppName.Domain.Abstractions; +using AppName.Domain.ValueObjects; + +namespace AppName.Application.UseCases.Bonds; + +/// +/// Returns one window of cached conversion valuations together with the total the +/// same filter matches, refreshing the cache once per Taiwan trading day. Refresh +/// is an internal, lazy step — never surfaced as an error to the caller. +/// +/// +/// This use case returns both halves because its caller sends a nested filter +/// tree. Split into a slice call and a count call, that caller would send the same +/// tree twice, and two trees can disagree, so the total would size a window it +/// does not describe. The eight flat filters of the grid path are cheap to repeat, +/// which is why and +/// stay separate there. +/// +public sealed class GetValuationPageUseCase( + IBondValuationRefreshStateRepository refreshStateRepo, + IValuationSnapshotRepository snapshotRepo, + IRefreshAllBondsUseCase refreshAllBonds, + TimeProvider timeProvider) +{ + /// + /// Ensures freshness (best-effort) then returns the slice and the filtered total. + /// + /// The filter, the sort, and the window to read. + /// Cancels the refresh, the read, and the count. + public async Task ExecuteAsync(ValuationQuery query, CancellationToken ct = default) + { + await DailyRefreshGate.EnsureFreshAsync(refreshStateRepo, refreshAllBonds, timeProvider, ct); + + var snapshots = await snapshotRepo.QueryAsync(query, ct); + + // The count reads the filter alone. The window must not change the total, + // or the scrollbar would shrink as the caller scrolls. + var total = await snapshotRepo.CountAsync(query.Filter, ct); + + return new ValuationPage(snapshots.Select(ValuationSnapshotMapper.ToDto).ToList(), total); + } +} diff --git a/src/AppName.Application/UseCases/Bonds/GetValuationsUseCase.cs b/src/AppName.Application/UseCases/Bonds/GetValuationsUseCase.cs index 1286630..e6def64 100644 --- a/src/AppName.Application/UseCases/Bonds/GetValuationsUseCase.cs +++ b/src/AppName.Application/UseCases/Bonds/GetValuationsUseCase.cs @@ -1,14 +1,13 @@ using AppName.Application.Dtos; -using AppName.Application.Time; using AppName.Domain.Abstractions; -using AppName.Domain.Entities; +using AppName.Domain.ValueObjects; namespace AppName.Application.UseCases.Bonds; /// -/// Returns today's conversion valuation for every cached convertible bond, -/// refreshing the cache once per Taiwan trading day. Refresh is an internal, -/// lazy step — never surfaced as an error to the caller. +/// Returns one window of cached conversion valuations, ordered and filtered as +/// the query asks, refreshing the cache once per Taiwan trading day. Refresh is +/// an internal, lazy step — never surfaced as an error to the caller. /// public sealed class GetValuationsUseCase( IBondValuationRefreshStateRepository refreshStateRepo, @@ -17,32 +16,16 @@ public sealed class GetValuationsUseCase( TimeProvider timeProvider) { /// - /// Ensures freshness (best-effort) then returns all cached valuations. + /// Ensures freshness (best-effort) then returns the requested slice. /// - public async Task> ExecuteAsync(CancellationToken ct = default) + /// The filter, the sort, and the window to read. + /// Cancels the refresh and the read. + public async Task> ExecuteAsync( + ValuationQuery query, CancellationToken ct = default) { - var state = await refreshStateRepo.GetAsync(ct); - var today = TaiwanClock.Today(timeProvider); + await DailyRefreshGate.EnsureFreshAsync(refreshStateRepo, refreshAllBonds, timeProvider, ct); - if (state.LastAttemptDate != today) - { - try - { - await refreshAllBonds.ExecuteAsync(ct); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - // Freshness is best-effort; a provider outage must serve stale cache, - // not fail the caller. - await refreshStateRepo.SetAsync( - new BondValuationRefreshState(BondValuationRefreshState.SingletonId, state.LastAsOf, today), ct); - } - } - - var snapshots = await snapshotRepo.GetAllAsync(ct); - return snapshots.Select(ToDto).ToList(); + var snapshots = await snapshotRepo.QueryAsync(query, ct); + return snapshots.Select(ValuationSnapshotMapper.ToDto).ToList(); } - - private static ConversionValuationDto ToDto(BondValuationSnapshot s) => - new(s.Symbol, s.ConversionShares, s.ConversionValue, s.StockPrice, s.AsOf, s.BondPrice, s.IsInTheMoney); } diff --git a/src/AppName.Application/UseCases/Bonds/ValuationSnapshotMapper.cs b/src/AppName.Application/UseCases/Bonds/ValuationSnapshotMapper.cs new file mode 100644 index 0000000..a618932 --- /dev/null +++ b/src/AppName.Application/UseCases/Bonds/ValuationSnapshotMapper.cs @@ -0,0 +1,22 @@ +using AppName.Application.Dtos; +using AppName.Domain.Entities; + +namespace AppName.Application.UseCases.Bonds; + +/// +/// Projects a cached valuation snapshot onto its transport shape. +/// +/// +/// The projection lives here, not on each read use case, because two use cases +/// return valuation DTOs. Four of the seven members are , so +/// two hand-written copies could swap a pair of arguments and still compile. +/// +internal static class ValuationSnapshotMapper +{ + /// + /// Returns the DTO for one snapshot. + /// + /// The cached snapshot. + internal static ConversionValuationDto ToDto(this BondValuationSnapshot s) => + new(s.Symbol, s.ConversionShares, s.ConversionValue, s.StockPrice, s.AsOf, s.BondPrice, s.IsInTheMoney); +} diff --git a/src/AppName.Domain/Abstractions/IBondValuationRefreshStateRepository.cs b/src/AppName.Domain/Abstractions/IBondValuationRefreshStateRepository.cs index 0e378a9..dc3933e 100644 --- a/src/AppName.Domain/Abstractions/IBondValuationRefreshStateRepository.cs +++ b/src/AppName.Domain/Abstractions/IBondValuationRefreshStateRepository.cs @@ -16,4 +16,21 @@ public interface IBondValuationRefreshStateRepository /// Inserts or updates the single refresh-marker row. /// Task SetAsync(BondValuationRefreshState state, CancellationToken ct = default); + + /// + /// Claims today's refresh attempt for exactly one caller. + /// + /// The Taiwan calendar date of the attempt. + /// Cancels the claim. + /// + /// True when this caller won the claim and must run the refresh; false when + /// another caller already claimed and this caller + /// must serve the cached data. + /// + /// + /// A read-then-write gate lets concurrent requests all observe the stale + /// marker and all start a whole-market refresh. This claim is one + /// conditional statement, so only the first caller of the day wins. + /// + Task TryClaimAttemptAsync(DateOnly today, CancellationToken ct = default); } diff --git a/src/AppName.Domain/Abstractions/IValuationSnapshotRepository.cs b/src/AppName.Domain/Abstractions/IValuationSnapshotRepository.cs index 852641f..ac1da1a 100644 --- a/src/AppName.Domain/Abstractions/IValuationSnapshotRepository.cs +++ b/src/AppName.Domain/Abstractions/IValuationSnapshotRepository.cs @@ -1,4 +1,5 @@ using AppName.Domain.Entities; +using AppName.Domain.ValueObjects; namespace AppName.Domain.Abstractions; @@ -8,9 +9,17 @@ namespace AppName.Domain.Abstractions; public interface IValuationSnapshotRepository { /// - /// Returns all cached valuation snapshots. + /// Returns one ordered slice of the snapshots the query keeps. The filter, + /// the order, and the slice all run in the database. /// - Task> GetAllAsync(CancellationToken ct = default); + Task> QueryAsync(ValuationQuery query, CancellationToken ct = default); + + /// + /// Counts the snapshots the filter keeps. The parameter is the filter and + /// not a , because a count cannot depend on the + /// offset, the limit, or the order. + /// + Task CountAsync(ValuationFilter filter, CancellationToken ct = default); /// /// Inserts each snapshot, or overwrites the existing snapshot for that symbol. diff --git a/src/AppName.Domain/Querying/FilterGroup.cs b/src/AppName.Domain/Querying/FilterGroup.cs new file mode 100644 index 0000000..5bfe97a --- /dev/null +++ b/src/AppName.Domain/Querying/FilterGroup.cs @@ -0,0 +1,88 @@ +using System.Linq.Expressions; + +namespace AppName.Domain.Querying; + +/// +/// One node of a filter tree: the typed predicates it holds, its child nodes, and the +/// operator that joins them all. folds the node and every +/// descendant into a single predicate. +/// +/// Entity the predicates test. +/// Operator joining the leaves and the child groups. +/// Predicates this node holds directly. +/// Child nodes of this node. +public sealed class FilterGroup( + LogicalOperator op, + IReadOnlyList>> leaves, + IReadOnlyList> groups) +{ + /// + /// Operator joining the leaves and the child groups of this node. + /// + public LogicalOperator Operator { get; } = op; + + /// + /// Predicates this node holds directly. + /// + public IReadOnlyList>> Leaves { get; } = leaves; + + /// + /// Child nodes of this node. + /// + public IReadOnlyList> Groups { get; } = groups; + + /// + /// True when this node and every descendant carry no leaf. A node that holds only empty + /// child groups is empty, so the test recurses instead of counting the child list. + /// + public bool IsEmpty => Leaves.Count == 0 && Groups.All(g => g.IsEmpty); + + /// + /// Folds this node and every descendant into one predicate, or returns null when + /// the node carries no leaf at any depth. + /// + /// The null result applies to as well as to + /// . In strict boolean algebra an empty Or is + /// match-none, but a filter that the user left blank must never hide every row. Every + /// caller therefore reads null as "no restriction" and applies no WHERE. + /// Returning null also removes the need for a false-predicate sentinel that a + /// later stage would have to detect. + /// + /// + /// The folded predicate, or null when the subtree is empty. + public Expression>? ToExpression() + { + if (IsEmpty) + { + return null; + } + + Expression>? folded = null; + + foreach (var leaf in Leaves) + { + folded = folded is null ? leaf : Join(folded, leaf); + } + + foreach (var group in Groups) + { + var child = group.ToExpression(); + + // An empty child restricts nothing, so it must not become a false arm of an Or + // or a redundant true arm of an And. Skipping it keeps the tree free of both. + if (child is null) + { + continue; + } + + folded = folded is null ? child : Join(folded, child); + } + + return folded; + } + + private Expression> Join( + Expression> left, + Expression> right) + => Operator == LogicalOperator.And ? left.AndAlso(right) : left.OrElse(right); +} diff --git a/src/AppName.Domain/Querying/IFilterNode.cs b/src/AppName.Domain/Querying/IFilterNode.cs new file mode 100644 index 0000000..bf02b57 --- /dev/null +++ b/src/AppName.Domain/Querying/IFilterNode.cs @@ -0,0 +1,21 @@ +namespace AppName.Domain.Querying; + +/// +/// The contract a filter type implements to nest inside itself. One node carries its own +/// leaf values, the operator that joins them, and its child nodes of the same type. The +/// self-referencing type parameter keeps the tree homogeneous, so a node can hold only +/// nodes of its own aggregate. +/// +/// The implementing filter type. +public interface IFilterNode where TSelf : IFilterNode +{ + /// + /// Operator joining the leaves of this node and its child nodes. + /// + LogicalOperator Operator { get; } + + /// + /// Child nodes of this node. Empty for a flat filter. + /// + IReadOnlyList Groups { get; } +} diff --git a/src/AppName.Domain/Querying/LogicalOperator.cs b/src/AppName.Domain/Querying/LogicalOperator.cs new file mode 100644 index 0000000..e8d0c47 --- /dev/null +++ b/src/AppName.Domain/Querying/LogicalOperator.cs @@ -0,0 +1,17 @@ +namespace AppName.Domain.Querying; + +/// +/// Joins the leaves and the child groups of one filter node. +/// +public enum LogicalOperator +{ + /// + /// Every leaf and every child group of the node must match. + /// + And, + + /// + /// At least one leaf or one child group of the node must match. + /// + Or +} diff --git a/src/AppName.Domain/Querying/PredicateComposer.cs b/src/AppName.Domain/Querying/PredicateComposer.cs new file mode 100644 index 0000000..ed5c6ff --- /dev/null +++ b/src/AppName.Domain/Querying/PredicateComposer.cs @@ -0,0 +1,72 @@ +using System.Linq.Expressions; + +namespace AppName.Domain.Querying; + +/// +/// Joins two typed predicates into one lambda that keeps a single parameter. The right +/// lambda's parameter is rebound to the left lambda's parameter with an +/// , so the result is a plain binary expression over one +/// parameter and every LINQ provider translates it. +/// +/// The obvious alternative, (the +/// mechanism behind LinqKit's PredicateBuilder), is rejected on purpose. EF Core +/// translates an invocation node only when the query also calls AsExpandable(). One +/// forgotten call does not fail: the query silently falls back to client-side evaluation, +/// pulls the whole table, and no type or compiler check catches it. Rebinding the parameter +/// costs about thirty lines and removes both the dependency and that trap. +/// +/// +public static class PredicateComposer +{ + /// + /// Returns a predicate that matches when both and + /// match. + /// + /// Entity the predicates test. + /// Left predicate. Its parameter becomes the result's parameter. + /// Right predicate. Its parameter is rebound to the left one. + /// One lambda with exactly one parameter. + public static Expression> AndAlso( + this Expression> left, + Expression> right) + => Compose(left, right, Expression.AndAlso); + + /// + /// Returns a predicate that matches when or + /// matches. + /// + /// Entity the predicates test. + /// Left predicate. Its parameter becomes the result's parameter. + /// Right predicate. Its parameter is rebound to the left one. + /// One lambda with exactly one parameter. + public static Expression> OrElse( + this Expression> left, + Expression> right) + => Compose(left, right, Expression.OrElse); + + private static Expression> Compose( + Expression> left, + Expression> right, + Func join) + { + var parameter = left.Parameters[0]; + + // Visit the right BODY, not the right lambda: a rewritten lambda would still declare + // its own parameter, and the join would then hold two unrelated parameters. + var reboundRight = new ParameterReplacer(right.Parameters[0], parameter).Visit(right.Body); + + return Expression.Lambda>(join(left.Body, reboundRight), parameter); + } + + /// + /// Replaces every occurrence of one parameter node with another. + /// + private sealed class ParameterReplacer(ParameterExpression from, ParameterExpression to) : ExpressionVisitor + { + /// + protected override Expression VisitParameter(ParameterExpression node) + // Parameter identity is reference identity. Two parameters of the same name and + // type are still different nodes, so comparing names would rebind the wrong one. + => ReferenceEquals(node, from) ? to : node; + } +} diff --git a/src/AppName.Domain/Querying/SortSpec.cs b/src/AppName.Domain/Querying/SortSpec.cs new file mode 100644 index 0000000..2e06a2b --- /dev/null +++ b/src/AppName.Domain/Querying/SortSpec.cs @@ -0,0 +1,47 @@ +namespace AppName.Domain.Querying; + +/// +/// Direction of one sorted column. +/// +public enum SortDirection +{ + /// + /// Smallest value first. + /// + Asc, + + /// + /// Largest value first. + /// + Desc +} + +/// +/// Where the null values of a sorted column sit, relative to the values that are not null. +/// The caller always states this, because the two database providers disagree: PostgreSQL +/// places nulls last on ASC and first on DESC, and SQLite does the opposite. +/// +public enum NullPlacement +{ + /// + /// Null values come before the values that are not null. + /// + First, + + /// + /// Null values come after the values that are not null. + /// + Last +} + +/// +/// The order of one column. is the per-aggregate enum that +/// lists the sortable columns, so the enum is also the allow-list: a value outside it does +/// not parse, and no hand-written runtime check is needed. +/// +/// Enum naming the sortable columns of one aggregate. +/// Column to sort on. +/// Direction to sort in. +/// Placement of the null values of the column. +public sealed record SortSpec(TField Field, SortDirection Direction, NullPlacement Nulls) + where TField : struct, Enum; diff --git a/src/AppName.Domain/ValueObjects/ValuationFilter.cs b/src/AppName.Domain/ValueObjects/ValuationFilter.cs new file mode 100644 index 0000000..a47ef84 --- /dev/null +++ b/src/AppName.Domain/ValueObjects/ValuationFilter.cs @@ -0,0 +1,43 @@ +using AppName.Domain.Querying; + +namespace AppName.Domain.ValueObjects; + +/// +/// One node of a valuation snapshot filter tree. A node carries its own leaf +/// values and its child , so the same type describes a flat +/// filter and a nested one. +/// +/// Prefix match on the bond symbol, case-sensitive. +/// Exact match on the in-the-money flag. A null-flagged row matches neither value. +/// Inclusive lower bound on the conversion value. +/// Inclusive upper bound on the conversion value. +/// Inclusive lower bound on the bond price. Excludes null-priced rows. +/// Inclusive upper bound on the bond price. Excludes null-priced rows. +/// Inclusive earliest trading date. +/// Inclusive latest trading date. +public sealed record ValuationFilter( + string? Symbol = null, + bool? InTheMoney = null, + decimal? MinConversionValue = null, + decimal? MaxConversionValue = null, + decimal? MinBondPrice = null, + decimal? MaxBondPrice = null, + DateOnly? AsOfFrom = null, + DateOnly? AsOfTo = null) : IFilterNode +{ + /// + /// The filter that restricts nothing. Callers that page an unfiltered + /// collection share this instance instead of allocating an empty node. + /// + public static readonly ValuationFilter None = new(); + + /// + /// How this node joins its own leaves and its child groups. + /// + public LogicalOperator Operator { get; init; } = LogicalOperator.And; + + /// + /// The child nodes nested under this one. Empty for a flat filter. + /// + public IReadOnlyList Groups { get; init; } = []; +} diff --git a/src/AppName.Domain/ValueObjects/ValuationQuery.cs b/src/AppName.Domain/ValueObjects/ValuationQuery.cs new file mode 100644 index 0000000..1b057c2 --- /dev/null +++ b/src/AppName.Domain/ValueObjects/ValuationQuery.cs @@ -0,0 +1,17 @@ +using AppName.Domain.Querying; + +namespace AppName.Domain.ValueObjects; + +/// +/// One window over the cached valuation snapshots: what to keep, in what order, +/// and which slice of that order to return. +/// +/// The filter tree the rows must satisfy. +/// The column, direction, and null placement of the order. +/// Rows to skip. An offset past the end yields no rows. +/// Maximum rows to return. +public sealed record ValuationQuery( + ValuationFilter Filter, + SortSpec Sort, + int Offset, + int Limit); diff --git a/src/AppName.Domain/ValueObjects/ValuationSortField.cs b/src/AppName.Domain/ValueObjects/ValuationSortField.cs new file mode 100644 index 0000000..8974416 --- /dev/null +++ b/src/AppName.Domain/ValueObjects/ValuationSortField.cs @@ -0,0 +1,44 @@ +namespace AppName.Domain.ValueObjects; + +/// +/// The columns a valuation snapshot query can order by. The enum is the +/// allow-list: a value outside it cannot bind, so no sort column reaches the +/// database from caller input. +/// +public enum ValuationSortField +{ + /// + /// Exchange ticker of the convertible bond. + /// + Symbol, + + /// + /// Number of shares the bond converts into. + /// + ConversionShares, + + /// + /// Market value of the converted shares. + /// + ConversionValue, + + /// + /// Underlying stock's price used to compute the valuation. + /// + StockPrice, + + /// + /// Trading date the valuation applies to. + /// + AsOf, + + /// + /// Market price of the bond, which is nullable. + /// + BondPrice, + + /// + /// Whether the bond is in the money, which is nullable. + /// + IsInTheMoney, +} diff --git a/src/AppName.Infrastructure/Migrations/20260811065935_SeedBondValuationRefreshState.Designer.cs b/src/AppName.Infrastructure/Migrations/20260811065935_SeedBondValuationRefreshState.Designer.cs new file mode 100644 index 0000000..3b2677d --- /dev/null +++ b/src/AppName.Infrastructure/Migrations/20260811065935_SeedBondValuationRefreshState.Designer.cs @@ -0,0 +1,104 @@ +// +using System; +using AppName.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AppName.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260811065935_SeedBondValuationRefreshState")] + partial class SeedBondValuationRefreshState + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AppName.Domain.Entities.BondValuationRefreshState", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("LastAsOf") + .HasColumnType("date"); + + b.Property("LastAttemptDate") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.ToTable("BondValuationRefreshStates"); + + b.HasData( + new + { + Id = 1L + }); + }); + + modelBuilder.Entity("AppName.Domain.Entities.BondValuationSnapshot", b => + { + b.Property("Symbol") + .HasColumnType("text"); + + b.Property("AsOf") + .HasColumnType("date"); + + b.Property("BondPrice") + .HasColumnType("numeric"); + + b.Property("ConversionShares") + .HasColumnType("numeric"); + + b.Property("ConversionValue") + .HasColumnType("numeric"); + + b.Property("IsInTheMoney") + .HasColumnType("boolean"); + + b.Property("StockPrice") + .HasColumnType("numeric"); + + b.HasKey("Symbol"); + + b.ToTable("BondValuationSnapshots"); + }); + + modelBuilder.Entity("AppName.Domain.Entities.ConvertibleBond", b => + { + b.Property("Symbol") + .HasColumnType("text"); + + b.Property("ConversionPrice") + .HasColumnType("numeric"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParValue") + .HasColumnType("numeric"); + + b.Property("UnderlyingSymbol") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Symbol"); + + b.ToTable("ConvertibleBonds"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/AppName.Infrastructure/Migrations/20260811065935_SeedBondValuationRefreshState.cs b/src/AppName.Infrastructure/Migrations/20260811065935_SeedBondValuationRefreshState.cs new file mode 100644 index 0000000..f394f9f --- /dev/null +++ b/src/AppName.Infrastructure/Migrations/20260811065935_SeedBondValuationRefreshState.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AppName.Infrastructure.Migrations +{ + /// + public partial class SeedBondValuationRefreshState : Migration + { + /// + /// + /// A conditional insert, not InsertData. Before this migration the + /// marker row was created lazily by the first SetAsync, so a database + /// that has already run one refresh holds it and a plain INSERT would fail on + /// the primary key. TryClaimAttemptAsync only needs the row to exist; + /// it does not care which of the two created it. + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + INSERT INTO "BondValuationRefreshStates" ("Id", "LastAsOf", "LastAttemptDate") + VALUES (1, NULL, NULL) + ON CONFLICT ("Id") DO NOTHING; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + table: "BondValuationRefreshStates", + keyColumn: "Id", + keyValue: 1L); + } + } +} diff --git a/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index b37889d..dccd010 100644 --- a/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("ProductVersion", "9.0.1") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -36,6 +36,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); b.ToTable("BondValuationRefreshStates"); + + b.HasData( + new + { + Id = 1L + }); }); modelBuilder.Entity("AppName.Domain.Entities.BondValuationSnapshot", b => diff --git a/src/AppName.Infrastructure/Persistence/AppDbContext.cs b/src/AppName.Infrastructure/Persistence/AppDbContext.cs index d29ee55..bb1a7e6 100644 --- a/src/AppName.Infrastructure/Persistence/AppDbContext.cs +++ b/src/AppName.Infrastructure/Persistence/AppDbContext.cs @@ -28,5 +28,18 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) var bondValuationRefreshState = modelBuilder.Entity(); bondValuationRefreshState.HasKey(r => r.Id); bondValuationRefreshState.Property(r => r.Id).ValueGeneratedNever(); + + // Seed the singleton marker row. TryClaimAttemptAsync claims the daily + // refresh with a conditional UPDATE, and an UPDATE needs a row to hit. + // Without the seed the row appears only on the first SetAsync, so every + // caller would lose the claim until then and no refresh would ever run. + // An anonymous object is used because the entity exposes Id as get-only + // and EF cannot write it back on an instance-based seed. + bondValuationRefreshState.HasData(new + { + Id = BondValuationRefreshState.SingletonId, + LastAsOf = (DateOnly?)null, + LastAttemptDate = (DateOnly?)null, + }); } } diff --git a/src/AppName.Infrastructure/Persistence/BondValuationRefreshStateRepository.cs b/src/AppName.Infrastructure/Persistence/BondValuationRefreshStateRepository.cs index 9449456..897396f 100644 --- a/src/AppName.Infrastructure/Persistence/BondValuationRefreshStateRepository.cs +++ b/src/AppName.Infrastructure/Persistence/BondValuationRefreshStateRepository.cs @@ -38,4 +38,25 @@ public async Task SetAsync(BondValuationRefreshState state, CancellationToken ct await db.SaveChangesAsync(ct); } + + /// + public async Task TryClaimAttemptAsync(DateOnly today, CancellationToken ct = default) + { + await using var db = factory.CreateDbContext(); + + // ExecuteUpdateAsync keeps the compare and the set inside one UPDATE + // statement, so the database decides the winner. Raw SQL is not an + // option here: the natural predicate is IS DISTINCT FROM, and SQLite + // does not support it, but the repository tests run on SQLite. + var claimed = await db.BondValuationRefreshStates + .Where(r => r.Id == BondValuationRefreshState.SingletonId + // The null arm is REQUIRED. SQL evaluates NULL != @today as + // NULL, not true, so without it the very first claim of all + // time matches no row and the refresh never starts. + && (r.LastAttemptDate == null || r.LastAttemptDate != today)) + .ExecuteUpdateAsync(s => s.SetProperty(r => r.LastAttemptDate, today), ct); + + // Zero rows means another caller already claimed today. + return claimed > 0; + } } diff --git a/src/AppName.Infrastructure/Persistence/ValuationSnapshotQueryBuilder.cs b/src/AppName.Infrastructure/Persistence/ValuationSnapshotQueryBuilder.cs new file mode 100644 index 0000000..20dff20 --- /dev/null +++ b/src/AppName.Infrastructure/Persistence/ValuationSnapshotQueryBuilder.cs @@ -0,0 +1,87 @@ +using System.Linq.Expressions; +using AppName.Domain.Entities; +using AppName.Domain.Querying; +using AppName.Domain.ValueObjects; +using AppName.Infrastructure.Querying; + +namespace AppName.Infrastructure.Persistence; + +/// +/// Translates a and a +/// into one snapshot query. The base +/// class owns the recursion, the paging, and the tie-break rule; this class +/// supplies only the per-entity parts. +/// +internal sealed class ValuationSnapshotQueryBuilder(AppDbContext context) + : QueryBuilderBase(context) +{ + /// + /// + /// Each leaf is a typed expression closing over a local, so EF sends the + /// value as a parameter and never re-parses it from a string. A value the + /// caller left null yields no leaf at all. + /// + protected override IEnumerable>> BuildLeaves(ValuationFilter filter) + { + // An empty symbol would match every row, so it is not a restriction. + if (filter.Symbol is { Length: > 0 } symbol) + yield return x => x.Symbol.StartsWith(symbol); + + // A row with a null flag matches neither true nor false, which is the + // SQL result of comparing NULL to a parameter. + if (filter.InTheMoney is { } inTheMoney) + yield return x => x.IsInTheMoney == inTheMoney; + + if (filter.MinConversionValue is { } minConversionValue) + yield return x => x.ConversionValue >= minConversionValue; + + if (filter.MaxConversionValue is { } maxConversionValue) + yield return x => x.ConversionValue <= maxConversionValue; + + // Both bond-price bounds drop null-priced rows, because a comparison + // against NULL is unknown and never passes the WHERE clause. + if (filter.MinBondPrice is { } minBondPrice) + yield return x => x.BondPrice >= minBondPrice; + + if (filter.MaxBondPrice is { } maxBondPrice) + yield return x => x.BondPrice <= maxBondPrice; + + if (filter.AsOfFrom is { } asOfFrom) + yield return x => x.AsOf >= asOfFrom; + + if (filter.AsOfTo is { } asOfTo) + yield return x => x.AsOf <= asOfTo; + } + + /// + /// + /// The two nullable columns route through OrderNullable, because + /// PostgreSQL and SQLite disagree on where they put nulls by default. + /// + protected override IOrderedQueryable ApplySort( + IQueryable query, + SortSpec sort) => sort.Field switch + { + ValuationSortField.Symbol => Order(query, x => x.Symbol, sort.Direction), + ValuationSortField.ConversionShares => Order(query, x => x.ConversionShares, sort.Direction), + ValuationSortField.ConversionValue => Order(query, x => x.ConversionValue, sort.Direction), + ValuationSortField.StockPrice => Order(query, x => x.StockPrice, sort.Direction), + ValuationSortField.AsOf => Order(query, x => x.AsOf, sort.Direction), + ValuationSortField.BondPrice => + OrderNullable(query, x => x.BondPrice == null, x => x.BondPrice, sort.Direction, sort.Nulls), + ValuationSortField.IsInTheMoney => + OrderNullable(query, x => x.IsInTheMoney == null, x => x.IsInTheMoney, sort.Direction, sort.Nulls), + _ => throw new ArgumentOutOfRangeException(nameof(sort), sort.Field, "Unknown valuation sort field."), + }; + + /// + /// + /// A sort on Symbol needs no tie-break, because Symbol is the primary key, + /// so that order is already total. Every other column can repeat a value, + /// and two windows over a non-total order can show one row twice or lose it. + /// + protected override IOrderedQueryable ApplyTieBreak( + IOrderedQueryable query, + SortSpec sort) => + sort.Field == ValuationSortField.Symbol ? query : query.ThenBy(x => x.Symbol); +} diff --git a/src/AppName.Infrastructure/Persistence/ValuationSnapshotRepository.cs b/src/AppName.Infrastructure/Persistence/ValuationSnapshotRepository.cs index 481f305..4a22006 100644 --- a/src/AppName.Infrastructure/Persistence/ValuationSnapshotRepository.cs +++ b/src/AppName.Infrastructure/Persistence/ValuationSnapshotRepository.cs @@ -1,5 +1,6 @@ using AppName.Domain.Abstractions; using AppName.Domain.Entities; +using AppName.Domain.ValueObjects; using Microsoft.EntityFrameworkCore; namespace AppName.Infrastructure.Persistence; @@ -8,11 +9,28 @@ namespace AppName.Infrastructure.Persistence; public sealed class ValuationSnapshotRepository(IDbContextFactory factory) : IValuationSnapshotRepository { /// - public async Task> GetAllAsync(CancellationToken ct = default) + public async Task> QueryAsync(ValuationQuery query, CancellationToken ct = default) { await using var db = factory.CreateDbContext(); - return await db.BondValuationSnapshots.AsNoTracking().ToListAsync(ct); + return await new ValuationSnapshotQueryBuilder(db) + .WithFilter(query.Filter) + .WithSorting(query.Sort) + .WithPage(query.Offset, query.Limit) + .Build() + .ToListAsync(ct); + } + + /// + public async Task CountAsync(ValuationFilter filter, CancellationToken ct = default) + { + await using var db = factory.CreateDbContext(); + + // No sort and no page: neither changes a count, and both cost SQL. + return await new ValuationSnapshotQueryBuilder(db) + .WithFilter(filter) + .Build() + .CountAsync(ct); } /// diff --git a/src/AppName.Infrastructure/Querying/QueryBuilderBase.cs b/src/AppName.Infrastructure/Querying/QueryBuilderBase.cs new file mode 100644 index 0000000..7649505 --- /dev/null +++ b/src/AppName.Infrastructure/Querying/QueryBuilderBase.cs @@ -0,0 +1,215 @@ +using System.Linq.Expressions; +using AppName.Domain.Querying; +using Microsoft.EntityFrameworkCore; + +namespace AppName.Infrastructure.Querying; + +/// +/// Fluent builder that turns a filter tree, a sort, and a window into one +/// . The base owns the recursion, the AND/OR composition, the +/// tie-break rule, and paging. Each aggregate supplies only , +/// , and . +/// +/// This is a class holding the , not a set of extension methods, +/// because a leaf that correlates against a second table needs that context and a stateless +/// helper cannot reach it. +/// +/// +/// The concrete builder, so every step returns the derived type. +/// Entity the query returns. +/// Filter node type of the aggregate. +/// Enum naming the sortable columns of the aggregate. +internal abstract class QueryBuilderBase + where TBuilder : QueryBuilderBase + where TEntity : class + where TFilter : IFilterNode + where TSortField : struct, Enum +{ + /// + /// Seeds the query with every row of the entity set, without change tracking. Every read + /// path of this builder is a projection or a snapshot read, so tracking would only cost + /// memory and identity-resolution work. + /// + /// Context the query runs on. + protected QueryBuilderBase(DbContext context) + { + Context = context; + Query = context.Set().AsNoTracking(); + } + + /// + /// Context the query runs on. A derived builder uses it to correlate a leaf against a + /// second table. + /// + protected DbContext Context { get; } + + /// + /// The query built so far. Each step replaces it. + /// + protected IQueryable Query { get; set; } + + /// + /// Returns the query built so far. Nothing runs against the database until the caller + /// enumerates the result. + /// + public IQueryable Build() => Query; + + /// + /// Applies the filter tree. An empty tree applies no WHERE. + /// + /// Root filter node of the aggregate. + /// This builder. + public virtual TBuilder WithFilter(TFilter filter) + { + var tree = BuildTree(filter); + + if (tree.IsEmpty) + { + return Self; + } + + // Flat path: one Where per leaf. The SQL is identical to the folded predicate, but + // the expression trees stay short, so a query log reads clearly and EF caches each + // shape as its own entry instead of one large combined tree. + if (tree.Operator == LogicalOperator.And && tree.Groups.Count == 0) + { + foreach (var leaf in tree.Leaves) + { + Query = Query.Where(leaf); + } + + return Self; + } + + // A tree that is not empty always folds to a predicate, so the result is not null. + Query = Query.Where(tree.ToExpression()!); + + return Self; + } + + /// + /// Applies the sort and then the tie-break. + /// + /// The tie-break is not optional and is not the caller's job. A sort on a column with + /// duplicate values is not a total order, so the database may return two windows over + /// the same data in different orders. The same row then appears in two windows, or in + /// none. + /// + /// + /// Column, direction, and null placement. + /// This builder. + public TBuilder WithSorting(SortSpec sort) + { + Query = ApplyTieBreak(ApplySort(Query, sort), sort); + + return Self; + } + + /// + /// Applies the window. Call it after , because a window over an + /// unordered query has no defined content. + /// + /// Rows to skip. An offset past the end gives an empty result. + /// Rows to take. + /// This builder. + public TBuilder WithPage(int offset, int limit) + { + Query = Query.Skip(offset).Take(limit); + + return Self; + } + + /// + /// Adds the related data the aggregate needs. The base adds none; a derived builder + /// overrides this when its entity has navigations to load. + /// + /// This builder. + public virtual TBuilder WithIncludes() => Self; + + /// + /// Turns one filter node and every child node into a + /// tree of typed predicates. + /// + /// Node to convert. + /// The predicate tree of the node. + protected FilterGroup BuildTree(TFilter filter) + => new(filter.Operator, + BuildLeaves(filter).ToList(), + filter.Groups.Select(BuildTree).ToList()); + + /// + /// Yields one typed predicate for each filter value the node supplies, and nothing for a + /// value the caller left null. + /// + /// Node to read the values from. Its child nodes are not read here. + /// The predicates of this node only. + protected abstract IEnumerable>> BuildLeaves(TFilter filter); + + /// + /// Orders the query by the requested column. + /// + /// Query to order. + /// Column, direction, and null placement. + /// The ordered query. + protected abstract IOrderedQueryable ApplySort(IQueryable query, SortSpec sort); + + /// + /// Appends the key that makes the order total. The implementation skips the key when the + /// requested column is already unique. + /// + /// Query already ordered by . + /// The sort that was applied, so the implementation can skip a redundant key. + /// The query under a total order. + protected abstract IOrderedQueryable ApplyTieBreak(IOrderedQueryable query, SortSpec sort); + + /// + /// Orders on a key that is never null. + /// + /// Type of the sort key. + /// Query to order. + /// Key to order on. + /// Direction to order in. + /// The ordered query. + protected static IOrderedQueryable Order( + IQueryable query, + Expression> key, + SortDirection direction) + => direction == SortDirection.Asc ? query.OrderBy(key) : query.OrderByDescending(key); + + /// + /// Orders on a key that can be null, with the null placement stated explicitly. + /// + /// The order runs on the boolean key first, and only then on + /// the real key. PostgreSQL places nulls last on ASC and first on DESC, and + /// SQLite does the opposite. The boolean key removes both defaults, so the placement no + /// longer depends on the direction or on the provider, and a test on SQLite proves the + /// behaviour on YugabyteDB. + /// + /// + /// Type of the sort key. + /// Query to order. + /// Predicate that is true for a row whose key is null. + /// Key to order on. + /// Direction to order the key in. + /// Placement of the null rows. + /// The ordered query. + protected static IOrderedQueryable OrderNullable( + IQueryable query, + Expression> isNull, + Expression> key, + SortDirection direction, + NullPlacement nulls) + { + // False sorts before true, so ascending on "is null" puts the null rows last. + var byNull = nulls == NullPlacement.Last + ? query.OrderBy(isNull) + : query.OrderByDescending(isNull); + + return direction == SortDirection.Asc ? byNull.ThenBy(key) : byNull.ThenByDescending(key); + } + + /// + /// This builder as the derived type, so every step chains without a cast at the callsite. + /// + private TBuilder Self => (TBuilder)this; +} diff --git a/src/web/src/api/schema.gen.ts b/src/web/src/api/schema.gen.ts index b09da36..590474b 100644 --- a/src/web/src/api/schema.gen.ts +++ b/src/web/src/api/schema.gen.ts @@ -36,6 +36,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/valuations/count": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["GetValuationCount"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/valuations/query": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["QueryValuations"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/valuations/{symbol}": { parameters: { query?: never; @@ -102,10 +134,65 @@ export interface components { bondPrice: null | number; isInTheMoney: null | boolean; }; + CountDto: { + /** Format: int32 */ + count: number; + }; + HttpValidationProblemDetails: { + type?: null | string; + title?: null | string; + /** Format: int32 */ + status?: null | number; + detail?: null | string; + instance?: null | string; + errors?: { + [key: string]: string[]; + }; + }; TokenResponse: { access_token: string; /** Format: int32 */ - expires_in: number | string; + expires_in: number; + }; + ValuationFilterNode: { + /** @enum {null|string} */ + operator?: "and" | "or" | null; + symbol?: null | string; + inTheMoney?: null | boolean; + /** Format: double */ + minConversionValue?: null | number; + /** Format: double */ + maxConversionValue?: null | number; + /** Format: double */ + minBondPrice?: null | number; + /** Format: double */ + maxBondPrice?: null | number; + /** Format: date */ + asOfFrom?: null | string; + /** Format: date */ + asOfTo?: null | string; + groups?: components["schemas"]["ValuationFilterNode"][]; + }; + ValuationPageDto: { + items: components["schemas"]["ConversionValuationDto"][]; + /** Format: int32 */ + total: number; + }; + ValuationQueryBody: { + filter?: null | components["schemas"]["ValuationFilterNode"]; + sort?: null | components["schemas"]["ValuationSortBody"]; + /** Format: int32 */ + offset?: null | number; + /** Format: int32 */ + limit?: null | number; + }; + ValuationSortBody: { + /** @enum {null|string} */ + field?: "symbol" | "conversionShares" | "conversionValue" | "stockPrice" | "asOf" | "bondPrice" | "isInTheMoney" | null; + /** @enum {null|string} */ + direction?: "asc" | "desc" | null; + /** @enum {null|string} */ + nulls?: "first" | "last" | null; }; }; responses: never; @@ -138,7 +225,21 @@ export interface operations { }; GetValuations: { parameters: { - query?: never; + query?: { + offset?: number; + limit?: number; + sortField?: "symbol" | "conversionShares" | "conversionValue" | "stockPrice" | "asOf" | "bondPrice" | "isInTheMoney"; + sortDir?: "asc" | "desc"; + nulls?: "first" | "last"; + symbol?: string; + inTheMoney?: boolean; + minConversionValue?: number; + maxConversionValue?: number; + minBondPrice?: number; + maxBondPrice?: number; + asOfFrom?: string; + asOfTo?: string; + }; header?: never; path?: never; cookie?: never; @@ -154,6 +255,86 @@ export interface operations { "application/json": components["schemas"]["ConversionValuationDto"][]; }; }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; + }; + }; + }; + }; + GetValuationCount: { + parameters: { + query?: { + symbol?: string; + inTheMoney?: boolean; + minConversionValue?: number; + maxConversionValue?: number; + minBondPrice?: number; + maxBondPrice?: number; + asOfFrom?: string; + asOfTo?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CountDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; + }; + }; + }; + }; + QueryValuations: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ValuationQueryBody"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ValuationPageDto"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; + }; + }; }; }; GetValuationBySymbol: { diff --git a/src/web/src/features/bonds/api/bondQueries.test.ts b/src/web/src/features/bonds/api/bondQueries.test.ts index 34c0a88..ecf9d45 100644 --- a/src/web/src/features/bonds/api/bondQueries.test.ts +++ b/src/web/src/features/bonds/api/bondQueries.test.ts @@ -1,14 +1,48 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import axios from "axios"; -import { bondKeys, fetchValuations, valuationsQuery } from "./bondQueries"; +import { + bondKeys, + fetchValuation, + fetchValuationCount, + fetchValuations, + valuationCountQuery, + valuationsQuery, +} from "./bondQueries"; + +/** The window the grid asks for first, unsorted and unfiltered. */ +const firstPage = { offset: 0, limit: 100, sort: null }; + +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("bondKeys", () => { - it("namespaces valuations under the bonds root", () => { - expect(bondKeys.valuations()).toEqual(["bonds", "valuations"]); + it("namespaces the slice and the count under the bonds root", () => { + expect(bondKeys.valuations(firstPage)).toEqual(["bonds", "valuations", firstPage]); + expect(bondKeys.valuationCount({ symbol: "110" })).toEqual([ + "bonds", + "valuations", + "count", + { symbol: "110" }, + ]); + }); + + it("keys two windows of the same order apart", () => { + expect(bondKeys.valuations(firstPage)).not.toEqual( + bondKeys.valuations({ ...firstPage, offset: 100 }), + ); }); }); -describe("valuationsQuery", () => { +describe("valuation queries", () => { beforeEach(() => { vi.stubEnv("VITE_API_BASE_URL", "http://api.test"); vi.stubGlobal("fetch", vi.fn()); @@ -19,23 +53,85 @@ describe("valuationsQuery", () => { vi.unstubAllGlobals(); }); - it("uses the valuations key", () => { - expect(valuationsQuery().queryKey).toEqual(["bonds", "valuations"]); + it("uses the slice key and the count key", () => { + expect(valuationsQuery(firstPage).queryKey).toEqual(bondKeys.valuations(firstPage)); + expect(valuationCountQuery().queryKey).toEqual(bondKeys.valuationCount(undefined)); }); - it("fetches the valuations endpoint", async () => { - vi.mocked(fetch).mockResolvedValue( - new Response(JSON.stringify([{ symbol: "12345" }]), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); + it("sends the window and the order, and returns the slice", async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse([{ symbol: "12345" }])); - const rows = await fetchValuations(); + const rows = await fetchValuations({ + offset: 200, + limit: 50, + sort: { field: "conversionValue", direction: "desc", nulls: "first" }, + }); 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"); + expect(requestAt(0).url).toBe( + "http://api.test/api/valuations" + + "?offset=200&limit=50&sortField=conversionValue&sortDir=desc&nulls=first", + ); + }); + + it("omits the sort parameters when nothing is sorted", async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse([])); + + await fetchValuations(firstPage); + + // The server reads a missing sortField as its default order, so sending the + // three names empty would only invite a 400 on an unknown enum value. + expect(requestAt(0).url).toBe("http://api.test/api/valuations?offset=0&limit=100"); + }); + + it("sends only the filter fields the caller defined", async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse([])); + + await fetchValuations({ + ...firstPage, + filter: { symbol: "110", inTheMoney: true, minConversionValue: 100000 }, + }); + + const url = new URL(requestAt(0).url); + expect(url.searchParams.get("symbol")).toBe("110"); + expect(url.searchParams.get("inTheMoney")).toBe("true"); + expect(url.searchParams.get("minConversionValue")).toBe("100000"); + expect(url.searchParams.has("maxConversionValue")).toBe(false); + expect(url.searchParams.has("asOfFrom")).toBe(false); + }); + + it("reads the total out of the count envelope", async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse({ count: 412 })); + + const total = await fetchValuationCount({ filter: { minBondPrice: 95 } }); + + expect(total).toBe(412); + expect(requestAt(0).url).toBe("http://api.test/api/valuations/count?minBondPrice=95"); + }); + + it("asks the count endpoint for nothing but the filter", async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse({ count: 0 })); + + await fetchValuationCount({}); + + // No offset, limit, or sort: none of them changes a count, and the + // endpoint does not declare them. + expect(requestAt(0).url).toBe("http://api.test/api/valuations/count"); + }); + + it("resolves one valuation by symbol", async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse({ symbol: "12345" })); + + const row = await fetchValuation("12345"); + + expect(row).toEqual({ symbol: "12345" }); + expect(requestAt(0).url).toBe("http://api.test/api/valuations/12345"); + }); + + it("reads a 404 on one symbol as a missing row, not a failure", async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse({ title: "Not Found" }, 404)); + + await expect(fetchValuation("99999")).resolves.toBeNull(); }); it("aborts in flight when the caller's signal fires", async () => { @@ -49,7 +145,7 @@ describe("valuationsQuery", () => { }); const controller = new AbortController(); - const promise = fetchValuations(controller.signal); + const promise = fetchValuations({ ...firstPage, signal: controller.signal }); controller.abort(); diff --git a/src/web/src/features/bonds/api/bondQueries.ts b/src/web/src/features/bonds/api/bondQueries.ts index 6fa265a..1822b24 100644 --- a/src/web/src/features/bonds/api/bondQueries.ts +++ b/src/web/src/features/bonds/api/bondQueries.ts @@ -1,32 +1,156 @@ import { queryOptions } from "@tanstack/react-query"; import { apiClient } from "../../../lib/http/apiClient"; +import { ApiError } from "../../../lib/http/ApiError"; +import type { SortSpec } from "../../dataGrid/data/sortSpec"; import type { ConversionValuation } from "./types"; -/** Cache keys for bond data. Every key that affects row order belongs here. */ +const PATH = "/api/valuations"; + +/** + * The eight filters the slice endpoint and the count endpoint share. + * + * Both endpoints read the same filter, because a filter one honours and the + * other ignores makes the scroll bar disagree with the rows. + * + * Every field name is the wire name, so the whole record is spread straight + * into the query parameters below. That keeps the two lists in step by + * construction instead of by review. + */ +export interface ValuationFilter { + /** Prefix match on the bond symbol. Case-sensitive; the symbols are numeric. */ + symbol?: string; + + /** Matches `isInTheMoney` exactly. A row with no value matches neither side. */ + inTheMoney?: boolean; + + /** Inclusive lower bound on `conversionValue`. */ + minConversionValue?: number; + + /** Inclusive upper bound on `conversionValue`. */ + maxConversionValue?: number; + + /** Inclusive lower bound on `bondPrice`. Excludes rows with no price. */ + minBondPrice?: number; + + /** Inclusive upper bound on `bondPrice`. Excludes rows with no price. */ + maxBondPrice?: number; + + /** Inclusive lower bound on `asOf`, as an ISO date (`YYYY-MM-DD`). */ + asOfFrom?: string; + + /** Inclusive upper bound on `asOf`, as an ISO date (`YYYY-MM-DD`). */ + asOfTo?: string; +} + +/** One slice of the valuation order: the window, the order, and the filter. */ +export interface ValuationsParams { + /** Index of the first row in the slice. */ + offset: number; + + /** Number of rows in the slice. The server rejects anything outside 1 to 500. */ + limit: number; + + /** Order the server applies before it cuts the slice. Null means the default. */ + sort: SortSpec | null; + + /** Optional restriction. Omitted fields put no restriction on their column. */ + filter?: ValuationFilter; +} + +/** + * Cache keys for bond data. Every key that affects row order belongs here. + * + * The count sits under the `valuations` segment so that one prefix + * invalidation reaches the slices and the total together. + */ export const bondKeys = { all: ["bonds"] as const, - valuations: () => [...bondKeys.all, "valuations"] as const, + valuations: (params: ValuationsParams) => [...bondKeys.all, "valuations", params] as const, + valuationCount: (filter?: ValuationFilter) => + [...bondKeys.all, "valuations", "count", filter] as const, }; /** - * Requests all current valuations. Exported separately from the query options so - * it can be tested without fabricating a TanStack query-function context. + * Requests one ordered slice. Exported separately from the query options so it + * can be tested without fabricating a TanStack query-function context. + * + * The sort and the filter parameters are omitted rather than sent empty when + * they carry nothing: axios drops an undefined param, and the server treats a + * missing `sortField` as the default order. */ -export async function fetchValuations(signal?: AbortSignal): Promise { - const { data } = await apiClient.get("/api/valuations", { signal }); +export async function fetchValuations( + p: ValuationsParams & { signal?: AbortSignal }, +): 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, + ...p.filter, + }, + signal: p.signal, + }); + return data; } /** - * All current valuations. + * The total under the current filter. * - * 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. + * The endpoint declares no `offset`, `limit`, or sort, because none of them + * changes a count. */ -export function valuationsQuery() { +export async function fetchValuationCount(p: { + filter?: ValuationFilter; + signal?: AbortSignal; +}): Promise { + const { data } = await apiClient.get<{ count: number }>(`${PATH}/count`, { + params: { ...p.filter }, + signal: p.signal, + }); + + return data.count; +} + +/** + * One valuation by symbol, for an id-only refresh of a single row. + * + * A 404 is a legitimate answer here — the bond is gone — so it becomes null + * rather than an exception. Every other failure still throws. + */ +export async function fetchValuation( + symbol: string, + signal?: AbortSignal, +): Promise { + try { + const { data } = await apiClient.get( + `${PATH}/${encodeURIComponent(symbol)}`, + { signal }, + ); + return data; + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + return null; + } + + throw error; + } +} + +/** One slice of the valuations, cached under the window, the order, and the filter. */ +export function valuationsQuery(params: ValuationsParams) { + return queryOptions({ + queryKey: bondKeys.valuations(params), + queryFn: ({ signal }) => fetchValuations({ ...params, signal }), + }); +} + +/** The filtered total, cached apart from the slices because it changes far less. */ +export function valuationCountQuery(filter?: ValuationFilter) { return queryOptions({ - queryKey: bondKeys.valuations(), - queryFn: ({ signal }) => fetchValuations(signal), + queryKey: bondKeys.valuationCount(filter), + queryFn: ({ signal }) => fetchValuationCount({ filter, signal }), }); } diff --git a/src/web/src/features/bonds/bondGrid.test.ts b/src/web/src/features/bonds/bondGrid.test.ts index 9358329..80c0ac1 100644 --- a/src/web/src/features/bonds/bondGrid.test.ts +++ b/src/web/src/features/bonds/bondGrid.test.ts @@ -1,13 +1,21 @@ 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 { fetchValuation, fetchValuationCount, fetchValuations } from "./api/bondQueries"; import { bondGrid, createBondGridApi, GRID_NAME } from "./bondGrid"; import type { ConversionValuation } from "./api/types"; +// The adapter's whole job is to hand the grid's parameters to the right query +// function, so the query module is the seam these tests assert on. The +// transport has its own tests (src/lib/http), and the ORDER is the server's +// answer now, so there is nothing left here to re-sort or re-count. +vi.mock("./api/bondQueries", () => ({ + fetchValuations: vi.fn(), + fetchValuationCount: vi.fn(), + fetchValuation: vi.fn(), +})); + const sample: ConversionValuation[] = [ { symbol: "B", @@ -27,94 +35,80 @@ const sample: ConversionValuation[] = [ 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. */ +/** The collection's default ask: the first window, unsorted. */ const wholeList = { offset: 0, limit: 10, sort: null, collapsedGroups: [] as never[] }; +beforeEach(() => { + vi.mocked(fetchValuations).mockReset().mockResolvedValue(sample); + vi.mocked(fetchValuationCount).mockReset().mockResolvedValue(3); + vi.mocked(fetchValuation).mockReset().mockResolvedValue(sample[0]); +}); + 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("asks the server for the requested window and returns it untouched", async () => { + const rows = await createBondGridApi().fetchRows({ ...wholeList, offset: 20, limit: 5 }); + + expect(vi.mocked(fetchValuations).mock.calls[0][0]).toMatchObject({ offset: 20, limit: 5 }); + expect(rows).toBe(sample); }); - 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("passes the sort to the server rather than reordering the slice", async () => { + const sort = { field: "conversionValue", direction: "desc", nulls: "last" } as const; + + const rows = await createBondGridApi().fetchRows({ ...wholeList, sort }); + + expect(vi.mocked(fetchValuations).mock.calls[0][0]).toMatchObject({ sort }); + // Source order, untouched: re-sorting here would fight the server's order + // and make rows swap under a still viewport. + expect(rows.map((r) => r.symbol)).toEqual(["B", "A"]); }); - 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("sends no sort parameters when the user has sorted nothing", async () => { + await createBondGridApi().fetchRows(wholeList); + + expect(vi.mocked(fetchValuations).mock.calls[0][0].sort).toBeNull(); }); - 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("forwards the slice's abort signal", async () => { + const controller = new AbortController(); + + await createBondGridApi().fetchRows({ ...wholeList, signal: controller.signal }); + + expect(vi.mocked(fetchValuations).mock.calls[0][0].signal).toBe(controller.signal); }); - 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("takes the total from the count endpoint, never from a row list", async () => { + vi.mocked(fetchValuationCount).mockResolvedValue(412); + + const total = await createBondGridApi().fetchCount({ collapsedGroups: [] }); + + expect(total).toBe(412); + expect(vi.mocked(fetchValuations)).not.toHaveBeenCalled(); }); - 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("resolves one row by symbol, without scanning a list", async () => { + const row = await createBondGridApi().fetchRow("B"); + + expect(vi.mocked(fetchValuation).mock.calls[0][0]).toBe("B"); + expect(row).toBe(sample[0]); + expect(vi.mocked(fetchValuations)).not.toHaveBeenCalled(); }); - 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"]); + it("reports a missing symbol as null", async () => { + vi.mocked(fetchValuation).mockResolvedValue(null); + + expect(await createBondGridApi().fetchRow("gone")).toBeNull(); }); -}); -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("updateRow is a no-op returning ok:false (read-only)", async () => { + const api = createBondGridApi(); + expect(await api.updateRow({ id: "B", changes: { symbol: "X" } })).toEqual({ ok: false }); }); +}); +describe("bondGrid instance", () => { 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( @@ -137,7 +131,7 @@ describe("bondGrid instance", () => { ]); }); - it("starts with an empty store cell, and loads through the app-wide cache", async () => { + it("starts with an empty store cell, and loads every window from the server", async () => { expect(bondGrid.storeRef.current).toBeNull(); const rows = await bondGrid.descriptor.api.fetchRows({ @@ -150,31 +144,13 @@ describe("bondGrid instance", () => { 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( + it("asks once per window, each carrying its own offset", async () => { + 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(); + expect(vi.mocked(fetchValuations).mock.calls.map((c) => c[0].offset)).toEqual([0, 1, 2]); }); }); diff --git a/src/web/src/features/bonds/bondGrid.ts b/src/web/src/features/bonds/bondGrid.ts index 28c985c..a3cdc3d 100644 --- a/src/web/src/features/bonds/bondGrid.ts +++ b/src/web/src/features/bonds/bondGrid.ts @@ -1,7 +1,4 @@ -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 { @@ -11,7 +8,7 @@ import type { GridSliceState, UpdateRowParams, } from "../dataGrid/types"; -import { valuationsQuery } from "./api/bondQueries"; +import { fetchValuation, fetchValuationCount, fetchValuations } from "./api/bondQueries"; import { bondCells } from "./bondCells"; import type { ConversionValuation } from "./api/types"; @@ -21,32 +18,30 @@ 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. + * Adapts the valuations endpoints onto the grid's slice API. The window, the + * order, and the total all resolve on the server, so this adapter only renames + * parameters. * * `updateRow` is a no-op — the grid is read-only. */ -export function createBondGridApi(client: QueryClient) { +export function createBondGridApi() { 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); + return fetchValuations({ + offset: p.offset, + limit: p.limit, + sort: p.sort, + signal: p.signal, + }); }, - async fetchCount(): Promise { - const all = await client.fetchQuery(valuationsQuery()); - return all.length; + // The grid's optional count filter is not accepted here on purpose: + // `FetchRowsParams` carries no filter, so a filtered total would size the + // scroll bar for rows `fetchRows` never asks for. Both gain it in one step. + async fetchCount(p: { collapsedGroups: never[]; signal?: AbortSignal }): Promise { + return fetchValuationCount({ signal: p.signal }); }, - async fetchRow(id: string): Promise { - const all = await client.fetchQuery(valuationsQuery()); - return all.find((r) => r.symbol === id) ?? null; + async fetchRow(id: string, signal?: AbortSignal): Promise { + return fetchValuation(id, signal); }, async updateRow(_p: UpdateRowParams): Promise<{ ok: boolean }> { return { ok: false }; @@ -115,7 +110,7 @@ export const bondGridDescriptor: GridDescriptor = { sortable: () => true, }, api: { - ...createBondGridApi(queryClient), + ...createBondGridApi(), loadColumns: columnsAdapter.loadColumns, saveColumns: columnsAdapter.saveColumns, }, diff --git a/src/web/src/features/dataGrid/data/rowCount.test.tsx b/src/web/src/features/dataGrid/data/rowCount.test.tsx index 87b583b..66d609a 100644 --- a/src/web/src/features/dataGrid/data/rowCount.test.tsx +++ b/src/web/src/features/dataGrid/data/rowCount.test.tsx @@ -9,8 +9,13 @@ interface Row { id: number; } +/** The shape `useRowCount` hands the descriptor, so the calls can be asserted on. */ +type CountParams = { collapsedGroups: number[]; filter?: unknown; signal?: AbortSignal }; + function harness(count = 42, fetchCountImpl?: () => Promise) { - const fetchCount = vi.fn(fetchCountImpl ?? (async () => count)); + const fetchCount = vi.fn<(p: CountParams) => Promise>( + fetchCountImpl ?? (async () => count), + ); const descriptor = { name: "demo", rowKey: (r: Row) => r.id, @@ -68,10 +73,62 @@ describe("useRowCount", () => { rerender({ groups: [1] }); await waitFor(() => expect(fetchCount).toHaveBeenCalledTimes(2)); }); + + it("asks again when the filter changes", async () => { + const { descriptor, fetchCount, wrapper } = harness(); + const { rerender } = renderHook(({ filter }) => useRowCount(descriptor, [], filter), { + wrapper, + initialProps: { filter: { symbol: "110" } as { symbol: string } }, + }); + + await waitFor(() => expect(fetchCount).toHaveBeenCalledTimes(1)); + rerender({ filter: { symbol: "220" } }); + // Without the filter in the key the grid would keep the first total and + // size its scroll bar for rows the new filter excludes. + await waitFor(() => expect(fetchCount).toHaveBeenCalledTimes(2)); + expect(fetchCount.mock.calls[1][0]).toMatchObject({ filter: { symbol: "220" } }); + }); + + it("hashes the filter structurally, so an equal filter rebuilt on a render asks nothing", async () => { + const { descriptor, fetchCount, wrapper } = harness(); + const { rerender } = renderHook(({ filter }) => useRowCount(descriptor, [], filter), { + wrapper, + initialProps: { filter: { symbol: "110" } as { symbol: string } }, + }); + + await waitFor(() => expect(fetchCount).toHaveBeenCalledTimes(1)); + + // A new object holding the same values. `rerender` flushes the effects, so + // a changed key would have fetched by the time it returns. + rerender({ filter: { symbol: "110" } }); + expect(fetchCount).toHaveBeenCalledTimes(1); + + rerender({ filter: { symbol: "220" } }); + 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])); }); + + it("keys two filters apart, and an absent filter apart from any filter", () => { + expect(rowCountKey("demo", [], { symbol: "1" })).not.toEqual( + rowCountKey("demo", [], { symbol: "2" }), + ); + expect(rowCountKey("demo", [])).not.toEqual(rowCountKey("demo", [], { symbol: "1" })); + }); + + it("appends nothing for an absent filter, so the unfiltered key stays a prefix", () => { + // `useRowSync` invalidates on this shorter key after a push. TanStack + // matches by prefix, so one recount must reach every filter variant. + expect(rowCountKey("demo", [1])).toEqual(["demo", "count", [1]]); + expect(rowCountKey("demo", [1], { symbol: "1" })).toEqual([ + "demo", + "count", + [1], + { symbol: "1" }, + ]); + }); }); diff --git a/src/web/src/features/dataGrid/data/rowCount.ts b/src/web/src/features/dataGrid/data/rowCount.ts index aad397a..0a30ca2 100644 --- a/src/web/src/features/dataGrid/data/rowCount.ts +++ b/src/web/src/features/dataGrid/data/rowCount.ts @@ -4,25 +4,39 @@ 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. + * + * A filter changes the count too, so it goes in the key as well. It is opaque: + * TanStack hashes the key structurally, so any serialisable value works and + * this module never reads inside it. + * + * An absent filter appends nothing, which keeps the unfiltered key a PREFIX of + * every filtered one. `data/sync/useRowSync.ts` invalidates by that prefix + * after a push, so one recount reaches every filter variant. */ -export function rowCountKey(name: string, collapsedGroups: unknown[]): readonly unknown[] { - return [name, "count", [...collapsedGroups].sort()]; +export function rowCountKey( + name: string, + collapsedGroups: unknown[], + filter?: unknown, +): readonly unknown[] { + const base = [name, "count", [...collapsedGroups].sort()]; + return filter === undefined ? base : [...base, filter]; } function rowCountQuery( descriptor: GridDescriptor, collapsedGroups: TGroup[], + filter?: unknown, ) { return queryOptions({ - queryKey: rowCountKey(descriptor.name, collapsedGroups), - queryFn: ({ signal }) => descriptor.api.fetchCount({ collapsedGroups, signal }), + queryKey: rowCountKey(descriptor.name, collapsedGroups, filter), + queryFn: ({ signal }) => descriptor.api.fetchCount({ collapsedGroups, filter, 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. + * CURRENT view — collapse set and filter alike — 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 @@ -35,8 +49,9 @@ function rowCountQuery( export function useRowCount( descriptor: GridDescriptor, collapsedGroups: TGroup[], + filter?: unknown, ): { total: number; settled: boolean } { - const { data, status } = useQuery(rowCountQuery(descriptor, collapsedGroups)); + const { data, status } = useQuery(rowCountQuery(descriptor, collapsedGroups, filter)); return { total: data ?? 0, settled: status !== "pending" }; } diff --git a/src/web/src/features/dataGrid/types.ts b/src/web/src/features/dataGrid/types.ts index 11cc9ad..acd1f7f 100644 --- a/src/web/src/features/dataGrid/types.ts +++ b/src/web/src/features/dataGrid/types.ts @@ -223,8 +223,17 @@ export interface GridDescriptor Promise; + fetchCount: (p: { + collapsedGroups: TGroup[]; + filter?: unknown; + signal?: AbortSignal; + }) => Promise; /** * One row by id, for an id-only push notification. diff --git a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationCountUseCaseTests.cs b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationCountUseCaseTests.cs new file mode 100644 index 0000000..0c3fa7e --- /dev/null +++ b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationCountUseCaseTests.cs @@ -0,0 +1,111 @@ +using AppName.Application.UseCases.Bonds; +using AppName.Domain.Abstractions; +using AppName.Domain.ValueObjects; + +namespace AppName.Application.Tests.UseCases.Bonds; + +public class GetValuationCountUseCaseTests +{ + private static readonly DateTimeOffset FixedUtc = new(2026, 7, 2, 4, 0, 0, TimeSpan.Zero); // TW 2026-07-02 + private static readonly DateOnly TwToday = new(2026, 7, 2); + + private sealed class Fixture + { + public Mock RefreshState { get; } = new(); + public Mock Snapshots { get; } = new(); + public Mock Refresh { get; } = new(); + + public Fixture() + { + RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + Snapshots.Setup(s => s.CountAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(0); + Refresh.Setup(r => r.ExecuteAsync(It.IsAny())).Returns(Task.CompletedTask); + } + + public Fixture WithLostClaim() + { + RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + return this; + } + + public Fixture WithCount(int count) + { + Snapshots.Setup(s => s.CountAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(count); + return this; + } + + public Fixture WithFailingRefresh() + { + Refresh.Setup(r => r.ExecuteAsync(It.IsAny())) + .ThrowsAsync(new HttpRequestException("network down")); + return this; + } + + public GetValuationCountUseCase Build() => + new(RefreshState.Object, Snapshots.Object, Refresh.Object, new FixedTimeProvider(FixedUtc)); + } + + [Fact] + public async Task ExecuteAsync_RefreshesOnce_WhenClaimIsWon() + { + var fx = new Fixture(); + + await fx.Build().ExecuteAsync(ValuationFilter.None); + + fx.Refresh.Verify(r => r.ExecuteAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task ExecuteAsync_ClaimsTaiwanToday() + { + var fx = new Fixture(); + + await fx.Build().ExecuteAsync(ValuationFilter.None); + + fx.RefreshState.Verify(r => r.TryClaimAttemptAsync(TwToday, It.IsAny()), Times.Once); + } + + [Fact] + public async Task ExecuteAsync_ServesCacheWithoutRefreshing_WhenClaimIsLost() + { + var fx = new Fixture().WithLostClaim().WithCount(412); + + var count = await fx.Build().ExecuteAsync(ValuationFilter.None); + + fx.Refresh.Verify(r => r.ExecuteAsync(It.IsAny()), Times.Never); + Assert.Equal(412, count); + } + + [Fact] + public async Task ExecuteAsync_ReturnsRepositoryCount() + { + var fx = new Fixture().WithCount(7); + + Assert.Equal(7, await fx.Build().ExecuteAsync(ValuationFilter.None)); + } + + [Fact] + public async Task ExecuteAsync_PassesFilterToRepository() + { + var fx = new Fixture(); + var filter = new ValuationFilter(Symbol: "110", InTheMoney: true); + + await fx.Build().ExecuteAsync(filter); + + fx.Snapshots.Verify(s => s.CountAsync(filter, It.IsAny()), Times.Once); + } + + [Fact] + public async Task ExecuteAsync_ReturnsCache_WhenRefreshThrows() + { + var fx = new Fixture().WithFailingRefresh().WithCount(412); + + var count = await fx.Build().ExecuteAsync(ValuationFilter.None); + + Assert.Equal(412, count); // no throw; stale cache counted + } +} diff --git a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationPageUseCaseTests.cs b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationPageUseCaseTests.cs new file mode 100644 index 0000000..d0dfe46 --- /dev/null +++ b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationPageUseCaseTests.cs @@ -0,0 +1,151 @@ +using AppName.Application.UseCases.Bonds; +using AppName.Domain.Abstractions; +using AppName.Domain.Entities; +using AppName.Domain.Querying; +using AppName.Domain.ValueObjects; + +namespace AppName.Application.Tests.UseCases.Bonds; + +public class GetValuationPageUseCaseTests +{ + private static readonly DateTimeOffset FixedUtc = new(2026, 7, 2, 4, 0, 0, TimeSpan.Zero); // TW 2026-07-02 + private static readonly DateOnly TwToday = new(2026, 7, 2); + + private static ValuationQuery QueryFor(ValuationFilter filter) => new( + filter, + new SortSpec(ValuationSortField.ConversionValue, SortDirection.Desc, NullPlacement.Last), + Offset: 0, + Limit: 100); + + private static readonly ValuationQuery DefaultQuery = QueryFor(ValuationFilter.None); + + private sealed class Fixture + { + public Mock RefreshState { get; } = new(); + public Mock Snapshots { get; } = new(); + public Mock Refresh { get; } = new(); + + public Fixture() + { + RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + Snapshots.Setup(s => s.QueryAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + Snapshots.Setup(s => s.CountAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(0); + Refresh.Setup(r => r.ExecuteAsync(It.IsAny())).Returns(Task.CompletedTask); + } + + public Fixture WithLostClaim() + { + RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + return this; + } + + public Fixture WithSnapshots(params BondValuationSnapshot[] snaps) + { + Snapshots.Setup(s => s.QueryAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(snaps); + return this; + } + + public Fixture WithCount(int count) + { + Snapshots.Setup(s => s.CountAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(count); + return this; + } + + public Fixture WithFailingRefresh() + { + Refresh.Setup(r => r.ExecuteAsync(It.IsAny())) + .ThrowsAsync(new HttpRequestException("network down")); + return this; + } + + public GetValuationPageUseCase Build() => + new(RefreshState.Object, Snapshots.Object, Refresh.Object, new FixedTimeProvider(FixedUtc)); + } + + [Fact] + public async Task ExecuteAsync_RefreshesOnce_WhenClaimIsWon() + { + var fx = new Fixture(); + + await fx.Build().ExecuteAsync(DefaultQuery); + + fx.Refresh.Verify(r => r.ExecuteAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task ExecuteAsync_ClaimsTaiwanToday() + { + var fx = new Fixture(); + + await fx.Build().ExecuteAsync(DefaultQuery); + + fx.RefreshState.Verify(r => r.TryClaimAttemptAsync(TwToday, It.IsAny()), Times.Once); + } + + [Fact] + public async Task ExecuteAsync_ServesCacheWithoutRefreshing_WhenClaimIsLost() + { + var fx = new Fixture() + .WithLostClaim() + .WithCount(412) + .WithSnapshots(new BondValuationSnapshot("11011", 2_000m, 120_000m, 60m, new DateOnly(2026, 7, 1), null, null)); + + var page = await fx.Build().ExecuteAsync(DefaultQuery); + + fx.Refresh.Verify(r => r.ExecuteAsync(It.IsAny()), Times.Never); + Assert.Single(page.Items); + Assert.Equal(412, page.Total); + } + + [Fact] + public async Task ExecuteAsync_ReturnsItemsFromQueryAndTotalFromCount() + { + var fx = new Fixture() + .WithCount(412) + .WithSnapshots( + new BondValuationSnapshot("11011", 2_000m, 120_000m, 60m, new DateOnly(2026, 7, 2), 100_000m, true), + new BondValuationSnapshot("11012", 1_000m, 50_000m, 50m, new DateOnly(2026, 7, 2), null, null)); + + var page = await fx.Build().ExecuteAsync(DefaultQuery); + + Assert.Equal(412, page.Total); // the filtered total, not the window size + Assert.Equal(2, page.Items.Count); + Assert.Equal("11011", page.Items[0].Symbol); + Assert.Equal("11012", page.Items[1].Symbol); + Assert.Equal(120_000m, page.Items[0].ConversionValue); + Assert.Null(page.Items[1].BondPrice); + } + + [Fact] + public async Task ExecuteAsync_CountsTheFilterAlone_NotTheWholeQuery() + { + var fx = new Fixture(); + var filter = new ValuationFilter(Symbol: "110", MinConversionValue: 100_000m); + var query = QueryFor(filter) with { Offset = 200, Limit = 50 }; + + await fx.Build().ExecuteAsync(query); + + fx.Snapshots.Verify(s => s.CountAsync(filter, It.IsAny()), Times.Once); + fx.Snapshots.Verify(s => s.QueryAsync(query, It.IsAny()), Times.Once); + } + + [Fact] + public async Task ExecuteAsync_ReturnsCache_WhenRefreshThrows() + { + var fx = new Fixture() + .WithFailingRefresh() + .WithCount(1) + .WithSnapshots(new BondValuationSnapshot("11011", 2_000m, 120_000m, 60m, new DateOnly(2026, 7, 1), null, null)); + + var page = await fx.Build().ExecuteAsync(DefaultQuery); + + Assert.Single(page.Items); // no throw; stale cache served + Assert.Equal(1, page.Total); + } +} diff --git a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs index c6a9fa7..c8d8d32 100644 --- a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs +++ b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs @@ -1,6 +1,8 @@ using AppName.Application.UseCases.Bonds; using AppName.Domain.Abstractions; using AppName.Domain.Entities; +using AppName.Domain.Querying; +using AppName.Domain.ValueObjects; namespace AppName.Application.Tests.UseCases.Bonds; @@ -9,6 +11,12 @@ public class GetValuationsUseCaseTests private static readonly DateTimeOffset FixedUtc = new(2026, 7, 2, 4, 0, 0, TimeSpan.Zero); // TW 2026-07-02 private static readonly DateOnly TwToday = new(2026, 7, 2); + private static readonly ValuationQuery DefaultQuery = new( + ValuationFilter.None, + new SortSpec(ValuationSortField.Symbol, SortDirection.Asc, NullPlacement.Last), + Offset: 0, + Limit: 100); + private sealed class Fixture { public Mock RefreshState { get; } = new(); @@ -17,25 +25,24 @@ private sealed class Fixture public Fixture() { - RefreshState.Setup(r => r.GetAsync(It.IsAny())) - .ReturnsAsync(new BondValuationRefreshState(BondValuationRefreshState.SingletonId, null, null)); - Snapshots.Setup(s => s.GetAllAsync(It.IsAny())) + RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + Snapshots.Setup(s => s.QueryAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Array.Empty()); Refresh.Setup(r => r.ExecuteAsync(It.IsAny())).Returns(Task.CompletedTask); - RefreshState.Setup(r => r.SetAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); } - public Fixture WithLastAttempt(DateOnly date) + public Fixture WithLostClaim() { - RefreshState.Setup(r => r.GetAsync(It.IsAny())) - .ReturnsAsync(new BondValuationRefreshState(BondValuationRefreshState.SingletonId, date, date)); + RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); return this; } public Fixture WithSnapshots(params BondValuationSnapshot[] snaps) { - Snapshots.Setup(s => s.GetAllAsync(It.IsAny())).ReturnsAsync(snaps); + Snapshots.Setup(s => s.QueryAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(snaps); return this; } @@ -51,65 +58,81 @@ public GetValuationsUseCase Build() => } [Fact] - public async Task ExecuteAsync_Refreshes_WhenLastAttemptIsNotToday() + public async Task ExecuteAsync_RefreshesOnce_WhenClaimIsWon() { - var fx = new Fixture().WithLastAttempt(new DateOnly(2026, 7, 1)); + var fx = new Fixture(); - await fx.Build().ExecuteAsync(); + await fx.Build().ExecuteAsync(DefaultQuery); fx.Refresh.Verify(r => r.ExecuteAsync(It.IsAny()), Times.Once); } [Fact] - public async Task ExecuteAsync_ServesCache_WhenLastAttemptIsToday() + public async Task ExecuteAsync_ClaimsTaiwanToday() + { + var fx = new Fixture(); + + await fx.Build().ExecuteAsync(DefaultQuery); + + fx.RefreshState.Verify(r => r.TryClaimAttemptAsync(TwToday, It.IsAny()), Times.Once); + } + + [Fact] + public async Task ExecuteAsync_ServesCacheWithoutRefreshing_WhenClaimIsLost() { - var fx = new Fixture().WithLastAttempt(TwToday); + var fx = new Fixture() + .WithLostClaim() + .WithSnapshots(new BondValuationSnapshot("11011", 2_000m, 120_000m, 60m, new DateOnly(2026, 7, 1), null, null)); - await fx.Build().ExecuteAsync(); + var dtos = await fx.Build().ExecuteAsync(DefaultQuery); fx.Refresh.Verify(r => r.ExecuteAsync(It.IsAny()), Times.Never); + Assert.Single(dtos); } [Fact] public async Task ExecuteAsync_ReturnsSnapshotsMappedToDtos() { var fx = new Fixture() - .WithLastAttempt(TwToday) .WithSnapshots(new BondValuationSnapshot("11011", 2_000m, 120_000m, 60m, new DateOnly(2026, 7, 2), 100_000m, true)); - var dtos = await fx.Build().ExecuteAsync(); + var dtos = await fx.Build().ExecuteAsync(DefaultQuery); var dto = Assert.Single(dtos); Assert.Equal("11011", dto.Symbol); + Assert.Equal(2_000m, dto.ConversionShares); Assert.Equal(120_000m, dto.ConversionValue); + Assert.Equal(60m, dto.StockPrice); + Assert.Equal(new DateOnly(2026, 7, 2), dto.AsOf); Assert.Equal(100_000m, dto.BondPrice); Assert.True(dto.IsInTheMoney); } [Fact] - public async Task ExecuteAsync_ReturnsCache_WhenRefreshThrows() + public async Task ExecuteAsync_PassesQueryToRepository() { - var fx = new Fixture() - .WithLastAttempt(new DateOnly(2026, 7, 1)) - .WithFailingRefresh() - .WithSnapshots(new BondValuationSnapshot("11011", 2_000m, 120_000m, 60m, new DateOnly(2026, 7, 1), null, null)); + var fx = new Fixture(); + var query = new ValuationQuery( + new ValuationFilter(Symbol: "110"), + new SortSpec(ValuationSortField.BondPrice, SortDirection.Desc, NullPlacement.Last), + Offset: 200, + Limit: 50); - var dtos = await fx.Build().ExecuteAsync(); + await fx.Build().ExecuteAsync(query); - var dto = Assert.Single(dtos); // no throw; stale cache served - Assert.Null(dto.BondPrice); + fx.Snapshots.Verify(s => s.QueryAsync(query, It.IsAny()), Times.Once); } [Fact] - public async Task ExecuteAsync_StampsTodayAttempt_WhenRefreshThrows() + public async Task ExecuteAsync_ReturnsCache_WhenRefreshThrows() { var fx = new Fixture() - .WithLastAttempt(new DateOnly(2026, 7, 1)) - .WithFailingRefresh(); + .WithFailingRefresh() + .WithSnapshots(new BondValuationSnapshot("11011", 2_000m, 120_000m, 60m, new DateOnly(2026, 7, 1), null, null)); - await fx.Build().ExecuteAsync(); + var dtos = await fx.Build().ExecuteAsync(DefaultQuery); - fx.RefreshState.Verify(r => r.SetAsync( - It.Is(s => s.LastAttemptDate == TwToday), It.IsAny()), Times.Once); + var dto = Assert.Single(dtos); // no throw; stale cache served + Assert.Null(dto.BondPrice); } } diff --git a/tests/AppName.Domain.Tests/Querying/FilterGroupTests.cs b/tests/AppName.Domain.Tests/Querying/FilterGroupTests.cs new file mode 100644 index 0000000..baa688c --- /dev/null +++ b/tests/AppName.Domain.Tests/Querying/FilterGroupTests.cs @@ -0,0 +1,81 @@ +using System.Linq.Expressions; +using AppName.Domain.Querying; + +namespace AppName.Domain.Tests.Querying; + +public class FilterGroupTests +{ + private sealed record Row(int Id, string Name, int Score); + + private static readonly Row[] Rows = + [ + new(1, "a", 10), + new(2, "a", 1), + new(3, "b", 1), + new(4, "b", 10), + new(5, "c", 10) + ]; + + private static FilterGroup Empty(LogicalOperator op) => new(op, [], []); + + private static FilterGroup Leaves(LogicalOperator op, params Expression>[] leaves) + => new(op, leaves, []); + + [Theory] + [InlineData(LogicalOperator.And)] + [InlineData(LogicalOperator.Or)] + public void ToExpression_ReturnsNull_ForEmptyGroup(LogicalOperator op) + { + // An empty Or is match-none in strict boolean algebra. A blank filter must not hide + // every row, so both operators give the same "no restriction" result. + Assert.Null(Empty(op).ToExpression()); + } + + [Theory] + [InlineData(LogicalOperator.And)] + [InlineData(LogicalOperator.Or)] + public void ToExpression_ReturnsNull_WhenEveryChildIsEmpty(LogicalOperator op) + { + var group = new FilterGroup(op, [], [Empty(LogicalOperator.And), Empty(LogicalOperator.Or)]); + + Assert.Null(group.ToExpression()); + } + + [Fact] + public void IsEmpty_IsTrue_ForGroupOfEmptyGroups() + { + var group = new FilterGroup( + LogicalOperator.Or, + [], + [Empty(LogicalOperator.And), new FilterGroup(LogicalOperator.Or, [], [Empty(LogicalOperator.And)])]); + + Assert.True(group.IsEmpty); + } + + [Fact] + public void IsEmpty_IsFalse_WhenDescendantHoldsLeaf() + { + var group = new FilterGroup( + LogicalOperator.And, + [], + [new FilterGroup(LogicalOperator.Or, [], [Leaves(LogicalOperator.And, r => r.Id == 1)])]); + + Assert.False(group.IsEmpty); + } + + [Fact] + public void ToExpression_OrRootOverTwoAndGroups_MatchesEitherBranch() + { + var expensive = Leaves(LogicalOperator.And, r => r.Score >= 10, r => r.Name == "a"); + var cheap = Leaves(LogicalOperator.And, r => r.Score < 5, r => r.Name == "b"); + var root = new FilterGroup(LogicalOperator.Or, [], [expensive, cheap]); + + var predicate = root.ToExpression(); + + Assert.NotNull(predicate); + + var matched = Rows.Where(predicate.Compile()).Select(r => r.Id).ToArray(); + + Assert.Equal(new[] { 1, 3 }, matched); + } +} diff --git a/tests/AppName.Domain.Tests/Querying/PredicateComposerTests.cs b/tests/AppName.Domain.Tests/Querying/PredicateComposerTests.cs new file mode 100644 index 0000000..8ec0361 --- /dev/null +++ b/tests/AppName.Domain.Tests/Querying/PredicateComposerTests.cs @@ -0,0 +1,63 @@ +using System.Linq.Expressions; +using AppName.Domain.Querying; + +namespace AppName.Domain.Tests.Querying; + +public class PredicateComposerTests +{ + private sealed record Row(int Id, int Score); + + private static readonly Row[] Rows = + [ + new(1, 5), + new(2, 10), + new(3, 15) + ]; + + [Fact] + public void AndAlso_ProducesLambdaWithOneParameter() + { + Expression> left = r => r.Score >= 10; + Expression> right = r => r.Id < 3; + + var composed = left.AndAlso(right); + + // The regression guard for the parameter rebinding. A composer that keeps both + // parameters still compiles, but EF cannot translate it and the query silently + // evaluates on the client. + Assert.Single(composed.Parameters); + } + + [Fact] + public void OrElse_ProducesLambdaWithOneParameter() + { + Expression> left = r => r.Score >= 10; + Expression> right = r => r.Id < 3; + + var composed = left.OrElse(right); + + Assert.Single(composed.Parameters); + } + + [Fact] + public void AndAlso_Compiled_ReturnsIntersection() + { + Expression> left = r => r.Score >= 10; + Expression> right = r => r.Id < 3; + + var matched = Rows.Where(left.AndAlso(right).Compile()).Select(r => r.Id).ToArray(); + + Assert.Equal(new[] { 2 }, matched); + } + + [Fact] + public void OrElse_Compiled_ReturnsUnion() + { + Expression> left = r => r.Score >= 15; + Expression> right = r => r.Id == 1; + + var matched = Rows.Where(left.OrElse(right).Compile()).Select(r => r.Id).ToArray(); + + Assert.Equal(new[] { 1, 3 }, matched); + } +} diff --git a/tests/AppName.Infrastructure.Tests/Persistence/BondValuationRefreshStateRepositoryTests.cs b/tests/AppName.Infrastructure.Tests/Persistence/BondValuationRefreshStateRepositoryTests.cs index d2f0d0b..fc9a98a 100644 --- a/tests/AppName.Infrastructure.Tests/Persistence/BondValuationRefreshStateRepositoryTests.cs +++ b/tests/AppName.Infrastructure.Tests/Persistence/BondValuationRefreshStateRepositoryTests.cs @@ -5,6 +5,10 @@ namespace AppName.Infrastructure.Tests.Persistence; +// The TryClaimAttemptAsync tests below are compare-and-set tests, not concurrency +// tests. The fixture shares one SQLite connection, so it cannot run two callers at +// once. The claim is correct because it is a single conditional UPDATE statement: +// the database, not the test, picks the winner. These tests prove the predicate. public class BondValuationRefreshStateRepositoryTests { private sealed class Fixture : IDisposable @@ -24,6 +28,9 @@ public Fixture() _connection.Open(); var options = new DbContextOptionsBuilder().UseSqlite(_connection).Options; + // EnsureCreated applies HasData, so the seeded singleton marker row + // exists before any test runs. TryClaimAttemptAsync needs that row, + // because a conditional UPDATE cannot hit a table with no rows. using (var ctx = new AppDbContext(options)) ctx.Database.EnsureCreated(); @@ -68,4 +75,61 @@ public async Task SetAsync_Twice_UpdatesSingleRowInPlace() Assert.Equal(new DateOnly(2026, 7, 2), state.LastAttemptDate); } + + [Fact] + public async Task TryClaimAttemptAsync_ReturnsTrue_OnFreshDatabase() + { + using var fx = new Fixture(); + + // The seeded row has a null LastAttemptDate. This fails if the claim + // predicate drops its "== null" arm, because SQL evaluates + // NULL != @today as NULL and the UPDATE then matches no row. + Assert.True(await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1))); + } + + [Fact] + public async Task TryClaimAttemptAsync_ReturnsFalse_OnSecondClaimForSameDay() + { + using var fx = new Fixture(); + + await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1)); + + Assert.False(await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1))); + } + + [Fact] + public async Task TryClaimAttemptAsync_ReturnsTrue_OnNextDay() + { + using var fx = new Fixture(); + + await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1)); + + Assert.True(await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 2))); + } + + [Fact] + public async Task TryClaimAttemptAsync_RecordsClaimedDay_WhenClaimWins() + { + using var fx = new Fixture(); + + await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1)); + var state = await fx.Repo.GetAsync(); + + Assert.Equal(new DateOnly(2026, 7, 1), state.LastAttemptDate); + } + + [Fact] + public async Task TryClaimAttemptAsync_LeavesLastAsOfUnchanged_WhenClaimWins() + { + using var fx = new Fixture(); + + await fx.Repo.SetAsync(new BondValuationRefreshState(BondValuationRefreshState.SingletonId, new DateOnly(2026, 6, 30), null)); + + await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1)); + var state = await fx.Repo.GetAsync(); + + // The claim marks only the attempt. LastAsOf still records the last + // successful pull, so a losing refresh must not appear as a success. + Assert.Equal(new DateOnly(2026, 6, 30), state.LastAsOf); + } } diff --git a/tests/AppName.Infrastructure.Tests/Persistence/ValuationSnapshotRepositoryTests.cs b/tests/AppName.Infrastructure.Tests/Persistence/ValuationSnapshotRepositoryTests.cs index e69e170..1715a87 100644 --- a/tests/AppName.Infrastructure.Tests/Persistence/ValuationSnapshotRepositoryTests.cs +++ b/tests/AppName.Infrastructure.Tests/Persistence/ValuationSnapshotRepositoryTests.cs @@ -1,12 +1,62 @@ using AppName.Domain.Entities; +using AppName.Domain.Querying; +using AppName.Domain.ValueObjects; using AppName.Infrastructure.Persistence; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace AppName.Infrastructure.Tests.Persistence; public class ValuationSnapshotRepositoryTests { + /// + /// Maps every decimal property to a SQLite REAL column. + /// + /// + /// SQLite has no decimal type. EF writes a decimal as a TEXT literal, so it + /// refuses to translate ORDER BY over one and compares one as text inside a + /// WHERE clause. Every sort and every money bound in these tests needs a + /// numeric column instead. The SQLite guard reads the provider type off the + /// type mapping's converter, so a decimal-to-double converter satisfies it. + /// The customizer runs only in this fixture; production uses Npgsql, which + /// orders numeric natively and keeps full precision. + /// + internal sealed class SqliteDecimalAsDouble(ModelCustomizerDependencies dependencies) + : RelationalModelCustomizer(dependencies) + { + private static readonly ValueConverter Required = + new(v => (double)v, v => (decimal)v); + + private static readonly ValueConverter Optional = + new(v => v == null ? null : (double?)v.Value, v => v == null ? null : (decimal?)v.Value); + + public override void Customize(ModelBuilder modelBuilder, DbContext context) + { + // The base call runs AppDbContext.OnModelCreating, so every property + // exists before the conversion is applied. + base.Customize(modelBuilder, context); + + foreach (var entityType in modelBuilder.Model.GetEntityTypes().ToList()) + { + var entity = modelBuilder.Entity(entityType.ClrType); + + foreach (var property in entityType.GetProperties().ToList()) + { + if (property.ClrType == typeof(decimal)) + { + entity.Property(property.Name).HasConversion(Required); + } + else if (property.ClrType == typeof(decimal?)) + { + entity.Property(property.Name).HasConversion(Optional); + } + } + } + } + } + private sealed class Fixture : IDisposable { private sealed class SingleContextFactory(DbContextOptions options) : IDbContextFactory @@ -23,7 +73,11 @@ public Fixture() _connection = new SqliteConnection("DataSource=:memory:"); _connection.Open(); - var options = new DbContextOptionsBuilder().UseSqlite(_connection).Options; + var builder = new DbContextOptionsBuilder(); + builder.UseSqlite(_connection).ReplaceService(); + + var options = builder.Options; + using (var ctx = new AppDbContext(options)) ctx.Database.EnsureCreated(); @@ -36,13 +90,38 @@ public Fixture() private static BondValuationSnapshot Snap(string symbol, decimal value, bool? itm) => new(symbol, 2_000m, value, 60m, new DateOnly(2026, 7, 2), itm is null ? null : 100_000m, itm); + /// + /// A row with an explicit bond price, so a test can seed priced and + /// null-priced rows side by side. + /// + private static BondValuationSnapshot Row(string symbol, decimal value, decimal? bondPrice, DateOnly? asOf = null) => + new(symbol, + 2_000m, + value, + 60m, + asOf ?? new DateOnly(2026, 7, 2), + bondPrice, + bondPrice is null ? (bool?)null : bondPrice < value); + + private static ValuationQuery Query( + ValuationFilter filter, + ValuationSortField field = ValuationSortField.Symbol, + SortDirection direction = SortDirection.Asc, + NullPlacement nulls = NullPlacement.Last, + int offset = 0, + int limit = 100) => + new(filter, new SortSpec(field, direction, nulls), offset, limit); + + private static string[] Symbols(IReadOnlyList rows) => + rows.Select(r => r.Symbol).ToArray(); + [Fact] - public async Task UpsertManyAsync_ThenGetAll_RoundTripsAllRows() + public async Task UpsertManyAsync_ThenQueryAsync_RoundTripsAllRows() { using var fx = new Fixture(); - await fx.Repo.UpsertManyAsync(new[] { Snap("11011", 120_000m, true), Snap("33033", 80_000m, false) }); - var all = await fx.Repo.GetAllAsync(); + await fx.Repo.UpsertManyAsync([Snap("11011", 120_000m, true), Snap("33033", 80_000m, false)]); + var all = await fx.Repo.QueryAsync(Query(ValuationFilter.None)); Assert.Equal(2, all.Count); Assert.Contains(all, s => s.Symbol == "11011" && s.IsInTheMoney == true); @@ -54,13 +133,14 @@ public async Task UpsertManyAsync_Existing_OverwritesInPlace_WithoutDuplicating( { using var fx = new Fixture(); - await fx.Repo.UpsertManyAsync(new[] { Snap("11011", 120_000m, true) }); - await fx.Repo.UpsertManyAsync(new[] { Snap("11011", 90_000m, false) }); - var all = await fx.Repo.GetAllAsync(); + await fx.Repo.UpsertManyAsync([Snap("11011", 120_000m, true)]); + await fx.Repo.UpsertManyAsync([Snap("11011", 90_000m, false)]); + var all = await fx.Repo.QueryAsync(Query(ValuationFilter.None)); var only = Assert.Single(all); Assert.Equal(90_000m, only.ConversionValue); Assert.False(only.IsInTheMoney); + Assert.Equal(1, await fx.Repo.CountAsync(ValuationFilter.None)); } [Fact] @@ -68,10 +148,243 @@ public async Task UpsertManyAsync_PreservesNullBondPrice() { using var fx = new Fixture(); - await fx.Repo.UpsertManyAsync(new[] { Snap("11011", 120_000m, null) }); - var only = Assert.Single(await fx.Repo.GetAllAsync()); + await fx.Repo.UpsertManyAsync([Snap("11011", 120_000m, null)]); + var only = Assert.Single(await fx.Repo.QueryAsync(Query(ValuationFilter.None))); Assert.Null(only.BondPrice); Assert.Null(only.IsInTheMoney); } + + [Fact] + public async Task QueryAsync_And_CountAsync_AgreeOnWhichRowsAFilterPasses() + { + using var fx = new Fixture(); + + await fx.Repo.UpsertManyAsync( + [ + Row("11011", 100_000m, 95_000m), + Row("22022", 200_000m, 150_000m), + Row("33033", 300_000m, null), + Row("44044", 50_000m, 40_000m), + ]); + + var filter = new ValuationFilter(MinConversionValue: 150_000m); + + var rows = await fx.Repo.QueryAsync(Query(filter)); + var count = await fx.Repo.CountAsync(filter); + + Assert.Equal(new[] { "22022", "33033" }, Symbols(rows)); + Assert.Equal(rows.Count, count); + } + + [Fact] + public async Task CountAsync_ReturnsFilteredTotal_NotTableTotal() + { + using var fx = new Fixture(); + + await fx.Repo.UpsertManyAsync( + [ + Row("11011", 100_000m, 95_000m), + Row("11022", 100_000m, 95_000m), + Row("22022", 100_000m, 95_000m), + Row("33033", 100_000m, 95_000m), + Row("44044", 100_000m, 95_000m), + ]); + + Assert.Equal(5, await fx.Repo.CountAsync(ValuationFilter.None)); + Assert.Equal(2, await fx.Repo.CountAsync(new ValuationFilter(Symbol: "11"))); + } + + [Fact] + public async Task CountAsync_IgnoresOffsetAndLimit() + { + using var fx = new Fixture(); + + await fx.Repo.UpsertManyAsync( + [ + Row("11011", 100_000m, 95_000m), + Row("22022", 110_000m, 95_000m), + Row("33033", 120_000m, 95_000m), + Row("44044", 130_000m, 95_000m), + Row("55055", 140_000m, 95_000m), + ]); + + var filter = new ValuationFilter(MinConversionValue: 100_000m); + + // The same filter also drives a two-row window. The count cannot see + // that window, so it stays at the filtered total. + var window = await fx.Repo.QueryAsync(Query(filter, offset: 1, limit: 2)); + + Assert.Equal(2, window.Count); + Assert.Equal(5, await fx.Repo.CountAsync(filter)); + } + + [Fact] + public async Task QueryAsync_BondPriceDescending_NullsLast_PlacesNullRowsAfterPricedRows() + { + using var fx = new Fixture(); + + await fx.Repo.UpsertManyAsync( + [ + Row("11011", 100_000m, 90_000m), + Row("22022", 100_000m, null), + Row("33033", 100_000m, 120_000m), + Row("44044", 100_000m, null), + ]); + + var rows = await fx.Repo.QueryAsync( + Query(ValuationFilter.None, ValuationSortField.BondPrice, SortDirection.Desc, NullPlacement.Last)); + + Assert.Equal(new[] { "33033", "11011", "22022", "44044" }, Symbols(rows)); + Assert.All(rows.Take(2), r => Assert.NotNull(r.BondPrice)); + Assert.All(rows.Skip(2), r => Assert.Null(r.BondPrice)); + } + + [Fact] + public async Task QueryAsync_BondPriceAscending_NullsLast_PlacesNullRowsAfterPricedRows() + { + using var fx = new Fixture(); + + await fx.Repo.UpsertManyAsync( + [ + Row("11011", 100_000m, 90_000m), + Row("22022", 100_000m, null), + Row("33033", 100_000m, 120_000m), + Row("44044", 100_000m, null), + ]); + + var rows = await fx.Repo.QueryAsync( + Query(ValuationFilter.None, ValuationSortField.BondPrice, SortDirection.Asc, NullPlacement.Last)); + + Assert.Equal(new[] { "11011", "33033", "22022", "44044" }, Symbols(rows)); + Assert.All(rows.Take(2), r => Assert.NotNull(r.BondPrice)); + Assert.All(rows.Skip(2), r => Assert.Null(r.BondPrice)); + } + + [Fact] + public async Task QueryAsync_AdjacentWindows_OverTiedConversionValues_NeitherRepeatNorSkipARow() + { + using var fx = new Fixture(); + + // Insertion order runs against symbol order inside each tie group, so a + // query without the Symbol tie-break returns a different order. + await fx.Repo.UpsertManyAsync( + [ + Row("33033", 100_000m, 95_000m), + Row("22022", 100_000m, 95_000m), + Row("11011", 100_000m, 95_000m), + Row("55055", 200_000m, 95_000m), + Row("44044", 200_000m, 95_000m), + ]); + + var first = await fx.Repo.QueryAsync( + Query(ValuationFilter.None, ValuationSortField.ConversionValue, offset: 0, limit: 2)); + var second = await fx.Repo.QueryAsync( + Query(ValuationFilter.None, ValuationSortField.ConversionValue, offset: 2, limit: 2)); + + Assert.Equal(new[] { "11011", "22022" }, Symbols(first)); + Assert.Equal(new[] { "33033", "44044" }, Symbols(second)); + Assert.Empty(Symbols(first).Intersect(Symbols(second))); + } + + [Fact] + public async Task QueryAsync_OffsetPastTheEnd_ReturnsEmptyList() + { + using var fx = new Fixture(); + + await fx.Repo.UpsertManyAsync([Row("11011", 100_000m, 95_000m), Row("22022", 110_000m, 95_000m)]); + + var rows = await fx.Repo.QueryAsync(Query(ValuationFilter.None, offset: 50, limit: 10)); + + Assert.Empty(rows); + } + + [Fact] + public async Task QueryAsync_MinBondPrice_ExcludesNullPricedRows() + { + using var fx = new Fixture(); + + await fx.Repo.UpsertManyAsync( + [ + Row("11011", 100_000m, 95_000m), + Row("22022", 100_000m, null), + Row("33033", 100_000m, null), + ]); + + var filter = new ValuationFilter(MinBondPrice: 1m); + var rows = await fx.Repo.QueryAsync(Query(filter)); + + Assert.Equal(new[] { "11011" }, Symbols(rows)); + Assert.Equal(1, await fx.Repo.CountAsync(filter)); + } + + [Fact] + public async Task QueryAsync_NestedOrFilter_ReturnsTheUnionOfWhatTheGroupsMatch() + { + using var fx = new Fixture(); + + await fx.Repo.UpsertManyAsync( + [ + Row("11011", 100_000m, 95_000m), + Row("22022", 600_000m, 95_000m), + Row("33033", 100_000m, 95_000m), + ]); + + var filter = new ValuationFilter + { + Operator = LogicalOperator.Or, + Groups = + [ + new ValuationFilter(Symbol: "11"), + new ValuationFilter(MinConversionValue: 500_000m), + ], + }; + + var rows = await fx.Repo.QueryAsync(Query(filter)); + + Assert.Equal(new[] { "11011", "22022" }, Symbols(rows)); + Assert.Equal(2, await fx.Repo.CountAsync(filter)); + } + + [Fact] + public async Task QueryAsync_AsOfRange_KeepsOnlyRowsInsideTheInclusiveBounds() + { + using var fx = new Fixture(); + + await fx.Repo.UpsertManyAsync( + [ + Row("11011", 100_000m, 95_000m, new DateOnly(2026, 7, 1)), + Row("22022", 100_000m, 95_000m, new DateOnly(2026, 7, 2)), + Row("33033", 100_000m, 95_000m, new DateOnly(2026, 7, 3)), + Row("44044", 100_000m, 95_000m, new DateOnly(2026, 7, 4)), + ]); + + var filter = new ValuationFilter( + AsOfFrom: new DateOnly(2026, 7, 2), + AsOfTo: new DateOnly(2026, 7, 3)); + + var rows = await fx.Repo.QueryAsync(Query(filter)); + + Assert.Equal(new[] { "22022", "33033" }, Symbols(rows)); + Assert.Equal(2, await fx.Repo.CountAsync(filter)); + } + + [Fact] + public async Task QueryAsync_IsInTheMoneyAscending_NullsLast_PlacesNullFlagsAfterBothValues() + { + using var fx = new Fixture(); + + await fx.Repo.UpsertManyAsync( + [ + Row("11011", 100_000m, 95_000m), // in the money + Row("22022", 100_000m, 120_000m), // out of the money + Row("33033", 100_000m, null), // no bond quote, so no flag + ]); + + var rows = await fx.Repo.QueryAsync( + Query(ValuationFilter.None, ValuationSortField.IsInTheMoney, SortDirection.Asc, NullPlacement.Last)); + + Assert.Equal(new[] { "22022", "11011", "33033" }, Symbols(rows)); + Assert.Null(rows[^1].IsInTheMoney); + } } From c9fc98d7af39cf1b539f3863395ba6dc6d73a63f Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Sat, 22 Aug 2026 16:15:49 -0600 Subject: [PATCH 2/7] feat: Implement Bond Valuation Snapshot Repository and related database context --- src/AppName.Api/Endpoints/{ => Auth}/AuthEndpoints.cs | 2 +- .../Endpoints/{ => Bonds}/BondEndpoints.cs | 4 ++-- .../Endpoints/{ => Bonds}/ValuationQueryBody.cs | 7 ++++--- .../Endpoints/{ => Bonds}/ValuationRequests.cs | 5 +++-- src/AppName.Api/Endpoints/{ => Shared}/EnumWire.cs | 2 +- .../OpenApi/ValuationEnumSchemaTransformer.cs | 5 +++-- src/AppName.Api/Program.cs | 3 ++- .../Dtos/{ => Bonds}/ConversionValuationDto.cs | 2 +- .../Dtos/{ => Bonds}/ValuationPage.cs | 2 +- .../UseCases/Bonds/DailyRefreshGate.cs | 2 +- .../UseCases/Bonds/GetConversionValuationUseCase.cs | 9 +++++---- .../UseCases/Bonds/GetValuationCountUseCase.cs | 4 ++-- .../UseCases/Bonds/GetValuationPageUseCase.cs | 6 +++--- .../UseCases/Bonds/GetValuationsUseCase.cs | 6 +++--- .../UseCases/Bonds/RefreshAllBondsUseCase.cs | 7 ++++--- .../UseCases/Bonds/RefreshBondDataUseCase.cs | 3 ++- .../UseCases/Bonds/ValuationSnapshotMapper.cs | 4 ++-- .../IBondValuationRefreshStateRepository.cs | 4 ++-- .../{ => Bonds}/IConvertibleBondRepository.cs | 4 ++-- .../{ => Bonds}/IValuationSnapshotRepository.cs | 6 +++--- .../{ => MarketData}/IMarketDataProvider.cs | 5 +++-- .../Entities/{ => Bonds}/BondValuationRefreshState.cs | 2 +- .../Entities/{ => Bonds}/BondValuationSnapshot.cs | 2 +- .../Entities/{ => Bonds}/ConvertibleBond.cs | 2 +- src/AppName.Domain/Entities/{ => Quotes}/BondQuote.cs | 2 +- .../Entities/{ => Quotes}/StockQuote.cs | 2 +- .../{ => Valuations}/ConversionValuation.cs | 2 +- .../ValueObjects/{ => Valuations}/ValuationFilter.cs | 2 +- .../ValueObjects/{ => Valuations}/ValuationQuery.cs | 2 +- .../{ => Valuations}/ValuationSortField.cs | 2 +- src/AppName.Infrastructure/DependencyInjection.cs | 8 +++++--- .../Gateways/MarketDataProvider.cs | 5 +++-- .../20260715180907_InitialCreate.Designer.cs | 10 +++++----- ...11065935_SeedBondValuationRefreshState.Designer.cs | 10 +++++----- .../Migrations/AppDbContextModelSnapshot.cs | 10 +++++----- .../BondValuationRefreshStateRepository.cs | 7 ++++--- .../{ => Bonds}/ConvertibleBondRepository.cs | 7 ++++--- .../{ => Bonds}/ValuationSnapshotQueryBuilder.cs | 7 ++++--- .../{ => Bonds}/ValuationSnapshotRepository.cs | 9 +++++---- .../Persistence/{ => Context}/AppDbContext.cs | 4 ++-- .../Persistence/{ => Context}/AppDbContextFactory.cs | 2 +- .../{ => Context}/NpgsqlConnectionString.cs | 2 +- .../{ => Context}/YugabyteHistoryRepository.cs | 2 +- src/AppName.Maui/Bridge/AppBridge.cs | 2 +- src/AppName.Maui/Bridge/BondsBridge.cs | 2 +- src/AppName.Maui/Clients/Bonds/BondsApiClient.cs | 2 +- src/AppName.Maui/Clients/Bonds/IBondsApiClient.cs | 2 +- .../Bonds/GetConversionValuationUseCaseTests.cs | 6 ++++-- .../UseCases/Bonds/GetValuationCountUseCaseTests.cs | 4 ++-- .../UseCases/Bonds/GetValuationPageUseCaseTests.cs | 6 +++--- .../UseCases/Bonds/GetValuationsUseCaseTests.cs | 6 +++--- .../UseCases/Bonds/RefreshAllBondsUseCaseTests.cs | 6 ++++-- .../UseCases/Bonds/RefreshBondDataUseCaseTests.cs | 5 +++-- .../Entities/{ => Bonds}/ConvertibleBondTests.cs | 4 ++-- .../{ => Valuations}/ConversionValuationTests.cs | 4 ++-- .../DependencyInjectionTests.cs | 3 ++- .../BondValuationRefreshStateRepositoryTests.cs | 7 ++++--- .../{ => Bonds}/ConvertibleBondRepositoryTests.cs | 7 ++++--- .../{ => Bonds}/ValuationSnapshotRepositoryTests.cs | 11 ++++++----- 59 files changed, 147 insertions(+), 123 deletions(-) rename src/AppName.Api/Endpoints/{ => Auth}/AuthEndpoints.cs (98%) rename src/AppName.Api/Endpoints/{ => Bonds}/BondEndpoints.cs (98%) rename src/AppName.Api/Endpoints/{ => Bonds}/ValuationQueryBody.cs (98%) rename src/AppName.Api/Endpoints/{ => Bonds}/ValuationRequests.cs (98%) rename src/AppName.Api/Endpoints/{ => Shared}/EnumWire.cs (99%) rename src/AppName.Application/Dtos/{ => Bonds}/ConversionValuationDto.cs (98%) rename src/AppName.Application/Dtos/{ => Bonds}/ValuationPage.cs (94%) rename src/AppName.Domain/Abstractions/{ => Bonds}/IBondValuationRefreshStateRepository.cs (94%) rename src/AppName.Domain/Abstractions/{ => Bonds}/IConvertibleBondRepository.cs (89%) rename src/AppName.Domain/Abstractions/{ => Bonds}/IValuationSnapshotRepository.cs (88%) rename src/AppName.Domain/Abstractions/{ => MarketData}/IMarketDataProvider.cs (94%) rename src/AppName.Domain/Entities/{ => Bonds}/BondValuationRefreshState.cs (97%) rename src/AppName.Domain/Entities/{ => Bonds}/BondValuationSnapshot.cs (98%) rename src/AppName.Domain/Entities/{ => Bonds}/ConvertibleBond.cs (97%) rename src/AppName.Domain/Entities/{ => Quotes}/BondQuote.cs (94%) rename src/AppName.Domain/Entities/{ => Quotes}/StockQuote.cs (94%) rename src/AppName.Domain/ValueObjects/{ => Valuations}/ConversionValuation.cs (96%) rename src/AppName.Domain/ValueObjects/{ => Valuations}/ValuationFilter.cs (97%) rename src/AppName.Domain/ValueObjects/{ => Valuations}/ValuationQuery.cs (92%) rename src/AppName.Domain/ValueObjects/{ => Valuations}/ValuationSortField.cs (95%) rename src/AppName.Infrastructure/Persistence/{ => Bonds}/BondValuationRefreshStateRepository.cs (93%) rename src/AppName.Infrastructure/Persistence/{ => Bonds}/ConvertibleBondRepository.cs (87%) rename src/AppName.Infrastructure/Persistence/{ => Bonds}/ValuationSnapshotQueryBuilder.cs (95%) rename src/AppName.Infrastructure/Persistence/{ => Bonds}/ValuationSnapshotRepository.cs (89%) rename src/AppName.Infrastructure/Persistence/{ => Context}/AppDbContext.cs (95%) rename src/AppName.Infrastructure/Persistence/{ => Context}/AppDbContextFactory.cs (96%) rename src/AppName.Infrastructure/Persistence/{ => Context}/NpgsqlConnectionString.cs (95%) rename src/AppName.Infrastructure/Persistence/{ => Context}/YugabyteHistoryRepository.cs (98%) rename tests/AppName.Domain.Tests/Entities/{ => Bonds}/ConvertibleBondTests.cs (91%) rename tests/AppName.Domain.Tests/ValueObjects/{ => Valuations}/ConversionValuationTests.cs (88%) rename tests/AppName.Infrastructure.Tests/Persistence/{ => Bonds}/BondValuationRefreshStateRepositoryTests.cs (96%) rename tests/AppName.Infrastructure.Tests/Persistence/{ => Bonds}/ConvertibleBondRepositoryTests.cs (90%) rename tests/AppName.Infrastructure.Tests/Persistence/{ => Bonds}/ValuationSnapshotRepositoryTests.cs (98%) diff --git a/src/AppName.Api/Endpoints/AuthEndpoints.cs b/src/AppName.Api/Endpoints/Auth/AuthEndpoints.cs similarity index 98% rename from src/AppName.Api/Endpoints/AuthEndpoints.cs rename to src/AppName.Api/Endpoints/Auth/AuthEndpoints.cs index 5340a53..752080b 100644 --- a/src/AppName.Api/Endpoints/AuthEndpoints.cs +++ b/src/AppName.Api/Endpoints/Auth/AuthEndpoints.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.IdentityModel.Tokens; -namespace AppName.Api.Endpoints; +namespace AppName.Api.Endpoints.Auth; /// /// Local development authentication endpoints. diff --git a/src/AppName.Api/Endpoints/BondEndpoints.cs b/src/AppName.Api/Endpoints/Bonds/BondEndpoints.cs similarity index 98% rename from src/AppName.Api/Endpoints/BondEndpoints.cs rename to src/AppName.Api/Endpoints/Bonds/BondEndpoints.cs index d0cdc24..df9713a 100644 --- a/src/AppName.Api/Endpoints/BondEndpoints.cs +++ b/src/AppName.Api/Endpoints/Bonds/BondEndpoints.cs @@ -1,8 +1,8 @@ -using AppName.Application.Dtos; +using AppName.Application.Dtos.Bonds; using AppName.Application.UseCases.Bonds; using Microsoft.AspNetCore.Http.HttpResults; -namespace AppName.Api.Endpoints; +namespace AppName.Api.Endpoints.Bonds; /// /// Convertible-bond valuation and refresh endpoints. diff --git a/src/AppName.Api/Endpoints/ValuationQueryBody.cs b/src/AppName.Api/Endpoints/Bonds/ValuationQueryBody.cs similarity index 98% rename from src/AppName.Api/Endpoints/ValuationQueryBody.cs rename to src/AppName.Api/Endpoints/Bonds/ValuationQueryBody.cs index b3eb8aa..eb87718 100644 --- a/src/AppName.Api/Endpoints/ValuationQueryBody.cs +++ b/src/AppName.Api/Endpoints/Bonds/ValuationQueryBody.cs @@ -1,8 +1,9 @@ -using AppName.Application.Dtos; +using AppName.Api.Endpoints.Shared; +using AppName.Application.Dtos.Bonds; using AppName.Domain.Querying; -using AppName.Domain.ValueObjects; +using AppName.Domain.ValueObjects.Valuations; -namespace AppName.Api.Endpoints; +namespace AppName.Api.Endpoints.Bonds; /// /// One node of the filter tree that POST /api/valuations/query accepts. A node diff --git a/src/AppName.Api/Endpoints/ValuationRequests.cs b/src/AppName.Api/Endpoints/Bonds/ValuationRequests.cs similarity index 98% rename from src/AppName.Api/Endpoints/ValuationRequests.cs rename to src/AppName.Api/Endpoints/Bonds/ValuationRequests.cs index 2c21af3..d4d913f 100644 --- a/src/AppName.Api/Endpoints/ValuationRequests.cs +++ b/src/AppName.Api/Endpoints/Bonds/ValuationRequests.cs @@ -1,8 +1,9 @@ +using AppName.Api.Endpoints.Shared; using AppName.Domain.Querying; -using AppName.Domain.ValueObjects; +using AppName.Domain.ValueObjects.Valuations; using Microsoft.AspNetCore.Mvc; -namespace AppName.Api.Endpoints; +namespace AppName.Api.Endpoints.Bonds; /// /// The eight valuation filters, bound from the query string with diff --git a/src/AppName.Api/Endpoints/EnumWire.cs b/src/AppName.Api/Endpoints/Shared/EnumWire.cs similarity index 99% rename from src/AppName.Api/Endpoints/EnumWire.cs rename to src/AppName.Api/Endpoints/Shared/EnumWire.cs index bc78d84..fda0b0e 100644 --- a/src/AppName.Api/Endpoints/EnumWire.cs +++ b/src/AppName.Api/Endpoints/Shared/EnumWire.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace AppName.Api.Endpoints; +namespace AppName.Api.Endpoints.Shared; /// /// The wire spelling of an enum value, and the tolerant parser that reads it back. diff --git a/src/AppName.Api/OpenApi/ValuationEnumSchemaTransformer.cs b/src/AppName.Api/OpenApi/ValuationEnumSchemaTransformer.cs index fead215..fbf7ba9 100644 --- a/src/AppName.Api/OpenApi/ValuationEnumSchemaTransformer.cs +++ b/src/AppName.Api/OpenApi/ValuationEnumSchemaTransformer.cs @@ -1,7 +1,8 @@ using System.Text.Json.Nodes; -using AppName.Api.Endpoints; +using AppName.Api.Endpoints.Bonds; +using AppName.Api.Endpoints.Shared; using AppName.Domain.Querying; -using AppName.Domain.ValueObjects; +using AppName.Domain.ValueObjects.Valuations; using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi; diff --git a/src/AppName.Api/Program.cs b/src/AppName.Api/Program.cs index 172be3d..9087f68 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.Endpoints.Auth; +using AppName.Api.Endpoints.Bonds; using AppName.Api.OpenApi; using AppName.Application; using AppName.Infrastructure; diff --git a/src/AppName.Application/Dtos/ConversionValuationDto.cs b/src/AppName.Application/Dtos/Bonds/ConversionValuationDto.cs similarity index 98% rename from src/AppName.Application/Dtos/ConversionValuationDto.cs rename to src/AppName.Application/Dtos/Bonds/ConversionValuationDto.cs index db38214..31a98ce 100644 --- a/src/AppName.Application/Dtos/ConversionValuationDto.cs +++ b/src/AppName.Application/Dtos/Bonds/ConversionValuationDto.cs @@ -1,6 +1,6 @@ using System.ComponentModel; -namespace AppName.Application.Dtos; +namespace AppName.Application.Dtos.Bonds; /// /// Conversion valuation result for display. and diff --git a/src/AppName.Application/Dtos/ValuationPage.cs b/src/AppName.Application/Dtos/Bonds/ValuationPage.cs similarity index 94% rename from src/AppName.Application/Dtos/ValuationPage.cs rename to src/AppName.Application/Dtos/Bonds/ValuationPage.cs index 4cdd0c5..c908e16 100644 --- a/src/AppName.Application/Dtos/ValuationPage.cs +++ b/src/AppName.Application/Dtos/Bonds/ValuationPage.cs @@ -1,6 +1,6 @@ using System.ComponentModel; -namespace AppName.Application.Dtos; +namespace AppName.Application.Dtos.Bonds; /// /// One window of conversion valuations together with the total row count the diff --git a/src/AppName.Application/UseCases/Bonds/DailyRefreshGate.cs b/src/AppName.Application/UseCases/Bonds/DailyRefreshGate.cs index 352aeed..8c99082 100644 --- a/src/AppName.Application/UseCases/Bonds/DailyRefreshGate.cs +++ b/src/AppName.Application/UseCases/Bonds/DailyRefreshGate.cs @@ -1,5 +1,5 @@ using AppName.Application.Time; -using AppName.Domain.Abstractions; +using AppName.Domain.Abstractions.Bonds; namespace AppName.Application.UseCases.Bonds; diff --git a/src/AppName.Application/UseCases/Bonds/GetConversionValuationUseCase.cs b/src/AppName.Application/UseCases/Bonds/GetConversionValuationUseCase.cs index 80e75ae..42579a7 100644 --- a/src/AppName.Application/UseCases/Bonds/GetConversionValuationUseCase.cs +++ b/src/AppName.Application/UseCases/Bonds/GetConversionValuationUseCase.cs @@ -1,7 +1,8 @@ -using AppName.Application.Dtos; -using AppName.Domain.Abstractions; -using AppName.Domain.Entities; -using AppName.Domain.ValueObjects; +using AppName.Application.Dtos.Bonds; +using AppName.Domain.Abstractions.Bonds; +using AppName.Domain.Abstractions.MarketData; +using AppName.Domain.Entities.Quotes; +using AppName.Domain.ValueObjects.Valuations; namespace AppName.Application.UseCases.Bonds; diff --git a/src/AppName.Application/UseCases/Bonds/GetValuationCountUseCase.cs b/src/AppName.Application/UseCases/Bonds/GetValuationCountUseCase.cs index 96f6452..fec5882 100644 --- a/src/AppName.Application/UseCases/Bonds/GetValuationCountUseCase.cs +++ b/src/AppName.Application/UseCases/Bonds/GetValuationCountUseCase.cs @@ -1,5 +1,5 @@ -using AppName.Domain.Abstractions; -using AppName.Domain.ValueObjects; +using AppName.Domain.Abstractions.Bonds; +using AppName.Domain.ValueObjects.Valuations; namespace AppName.Application.UseCases.Bonds; diff --git a/src/AppName.Application/UseCases/Bonds/GetValuationPageUseCase.cs b/src/AppName.Application/UseCases/Bonds/GetValuationPageUseCase.cs index 80cca06..6df1c0c 100644 --- a/src/AppName.Application/UseCases/Bonds/GetValuationPageUseCase.cs +++ b/src/AppName.Application/UseCases/Bonds/GetValuationPageUseCase.cs @@ -1,6 +1,6 @@ -using AppName.Application.Dtos; -using AppName.Domain.Abstractions; -using AppName.Domain.ValueObjects; +using AppName.Application.Dtos.Bonds; +using AppName.Domain.Abstractions.Bonds; +using AppName.Domain.ValueObjects.Valuations; namespace AppName.Application.UseCases.Bonds; diff --git a/src/AppName.Application/UseCases/Bonds/GetValuationsUseCase.cs b/src/AppName.Application/UseCases/Bonds/GetValuationsUseCase.cs index e6def64..47bfb88 100644 --- a/src/AppName.Application/UseCases/Bonds/GetValuationsUseCase.cs +++ b/src/AppName.Application/UseCases/Bonds/GetValuationsUseCase.cs @@ -1,6 +1,6 @@ -using AppName.Application.Dtos; -using AppName.Domain.Abstractions; -using AppName.Domain.ValueObjects; +using AppName.Application.Dtos.Bonds; +using AppName.Domain.Abstractions.Bonds; +using AppName.Domain.ValueObjects.Valuations; namespace AppName.Application.UseCases.Bonds; diff --git a/src/AppName.Application/UseCases/Bonds/RefreshAllBondsUseCase.cs b/src/AppName.Application/UseCases/Bonds/RefreshAllBondsUseCase.cs index 5c01409..8815d82 100644 --- a/src/AppName.Application/UseCases/Bonds/RefreshAllBondsUseCase.cs +++ b/src/AppName.Application/UseCases/Bonds/RefreshAllBondsUseCase.cs @@ -1,7 +1,8 @@ using AppName.Application.Time; -using AppName.Domain.Abstractions; -using AppName.Domain.Entities; -using AppName.Domain.ValueObjects; +using AppName.Domain.Abstractions.Bonds; +using AppName.Domain.Abstractions.MarketData; +using AppName.Domain.Entities.Bonds; +using AppName.Domain.ValueObjects.Valuations; namespace AppName.Application.UseCases.Bonds; diff --git a/src/AppName.Application/UseCases/Bonds/RefreshBondDataUseCase.cs b/src/AppName.Application/UseCases/Bonds/RefreshBondDataUseCase.cs index e37888d..af115b2 100644 --- a/src/AppName.Application/UseCases/Bonds/RefreshBondDataUseCase.cs +++ b/src/AppName.Application/UseCases/Bonds/RefreshBondDataUseCase.cs @@ -1,4 +1,5 @@ -using AppName.Domain.Abstractions; +using AppName.Domain.Abstractions.Bonds; +using AppName.Domain.Abstractions.MarketData; namespace AppName.Application.UseCases.Bonds; diff --git a/src/AppName.Application/UseCases/Bonds/ValuationSnapshotMapper.cs b/src/AppName.Application/UseCases/Bonds/ValuationSnapshotMapper.cs index a618932..44d1e39 100644 --- a/src/AppName.Application/UseCases/Bonds/ValuationSnapshotMapper.cs +++ b/src/AppName.Application/UseCases/Bonds/ValuationSnapshotMapper.cs @@ -1,5 +1,5 @@ -using AppName.Application.Dtos; -using AppName.Domain.Entities; +using AppName.Application.Dtos.Bonds; +using AppName.Domain.Entities.Bonds; namespace AppName.Application.UseCases.Bonds; diff --git a/src/AppName.Domain/Abstractions/IBondValuationRefreshStateRepository.cs b/src/AppName.Domain/Abstractions/Bonds/IBondValuationRefreshStateRepository.cs similarity index 94% rename from src/AppName.Domain/Abstractions/IBondValuationRefreshStateRepository.cs rename to src/AppName.Domain/Abstractions/Bonds/IBondValuationRefreshStateRepository.cs index dc3933e..5615ed1 100644 --- a/src/AppName.Domain/Abstractions/IBondValuationRefreshStateRepository.cs +++ b/src/AppName.Domain/Abstractions/Bonds/IBondValuationRefreshStateRepository.cs @@ -1,6 +1,6 @@ -using AppName.Domain.Entities; +using AppName.Domain.Entities.Bonds; -namespace AppName.Domain.Abstractions; +namespace AppName.Domain.Abstractions.Bonds; /// /// Local persistence of the single bond-data refresh marker. diff --git a/src/AppName.Domain/Abstractions/IConvertibleBondRepository.cs b/src/AppName.Domain/Abstractions/Bonds/IConvertibleBondRepository.cs similarity index 89% rename from src/AppName.Domain/Abstractions/IConvertibleBondRepository.cs rename to src/AppName.Domain/Abstractions/Bonds/IConvertibleBondRepository.cs index aaf22ad..2117df6 100644 --- a/src/AppName.Domain/Abstractions/IConvertibleBondRepository.cs +++ b/src/AppName.Domain/Abstractions/Bonds/IConvertibleBondRepository.cs @@ -1,6 +1,6 @@ -using AppName.Domain.Entities; +using AppName.Domain.Entities.Bonds; -namespace AppName.Domain.Abstractions; +namespace AppName.Domain.Abstractions.Bonds; /// /// Local persistence of convertible bonds and their terms. diff --git a/src/AppName.Domain/Abstractions/IValuationSnapshotRepository.cs b/src/AppName.Domain/Abstractions/Bonds/IValuationSnapshotRepository.cs similarity index 88% rename from src/AppName.Domain/Abstractions/IValuationSnapshotRepository.cs rename to src/AppName.Domain/Abstractions/Bonds/IValuationSnapshotRepository.cs index ac1da1a..6008310 100644 --- a/src/AppName.Domain/Abstractions/IValuationSnapshotRepository.cs +++ b/src/AppName.Domain/Abstractions/Bonds/IValuationSnapshotRepository.cs @@ -1,7 +1,7 @@ -using AppName.Domain.Entities; -using AppName.Domain.ValueObjects; +using AppName.Domain.Entities.Bonds; +using AppName.Domain.ValueObjects.Valuations; -namespace AppName.Domain.Abstractions; +namespace AppName.Domain.Abstractions.Bonds; /// /// Local persistence of daily convertible-bond valuation snapshots. diff --git a/src/AppName.Domain/Abstractions/IMarketDataProvider.cs b/src/AppName.Domain/Abstractions/MarketData/IMarketDataProvider.cs similarity index 94% rename from src/AppName.Domain/Abstractions/IMarketDataProvider.cs rename to src/AppName.Domain/Abstractions/MarketData/IMarketDataProvider.cs index 89bdcb5..3f1f407 100644 --- a/src/AppName.Domain/Abstractions/IMarketDataProvider.cs +++ b/src/AppName.Domain/Abstractions/MarketData/IMarketDataProvider.cs @@ -1,6 +1,7 @@ -using AppName.Domain.Entities; +using AppName.Domain.Entities.Bonds; +using AppName.Domain.Entities.Quotes; -namespace AppName.Domain.Abstractions; +namespace AppName.Domain.Abstractions.MarketData; /// /// Pulls convertible-bond reference data and daily prices from an external market-data source. diff --git a/src/AppName.Domain/Entities/BondValuationRefreshState.cs b/src/AppName.Domain/Entities/Bonds/BondValuationRefreshState.cs similarity index 97% rename from src/AppName.Domain/Entities/BondValuationRefreshState.cs rename to src/AppName.Domain/Entities/Bonds/BondValuationRefreshState.cs index 8b9dd28..1f1dc30 100644 --- a/src/AppName.Domain/Entities/BondValuationRefreshState.cs +++ b/src/AppName.Domain/Entities/Bonds/BondValuationRefreshState.cs @@ -1,6 +1,6 @@ using System.ComponentModel; -namespace AppName.Domain.Entities; +namespace AppName.Domain.Entities.Bonds; /// /// Single-row marker recording when bond data was last pulled, used to gate diff --git a/src/AppName.Domain/Entities/BondValuationSnapshot.cs b/src/AppName.Domain/Entities/Bonds/BondValuationSnapshot.cs similarity index 98% rename from src/AppName.Domain/Entities/BondValuationSnapshot.cs rename to src/AppName.Domain/Entities/Bonds/BondValuationSnapshot.cs index 74ed144..627fb89 100644 --- a/src/AppName.Domain/Entities/BondValuationSnapshot.cs +++ b/src/AppName.Domain/Entities/Bonds/BondValuationSnapshot.cs @@ -1,6 +1,6 @@ using System.ComponentModel; -namespace AppName.Domain.Entities; +namespace AppName.Domain.Entities.Bonds; /// /// A cached conversion valuation for one convertible bond on a given trading day. diff --git a/src/AppName.Domain/Entities/ConvertibleBond.cs b/src/AppName.Domain/Entities/Bonds/ConvertibleBond.cs similarity index 97% rename from src/AppName.Domain/Entities/ConvertibleBond.cs rename to src/AppName.Domain/Entities/Bonds/ConvertibleBond.cs index cb53b50..a5cd7c2 100644 --- a/src/AppName.Domain/Entities/ConvertibleBond.cs +++ b/src/AppName.Domain/Entities/Bonds/ConvertibleBond.cs @@ -1,6 +1,6 @@ using System.ComponentModel; -namespace AppName.Domain.Entities; +namespace AppName.Domain.Entities.Bonds; /// /// A convertible corporate bond and its static conversion terms. diff --git a/src/AppName.Domain/Entities/BondQuote.cs b/src/AppName.Domain/Entities/Quotes/BondQuote.cs similarity index 94% rename from src/AppName.Domain/Entities/BondQuote.cs rename to src/AppName.Domain/Entities/Quotes/BondQuote.cs index 85ae198..4087ebb 100644 --- a/src/AppName.Domain/Entities/BondQuote.cs +++ b/src/AppName.Domain/Entities/Quotes/BondQuote.cs @@ -1,6 +1,6 @@ using System.ComponentModel; -namespace AppName.Domain.Entities; +namespace AppName.Domain.Entities.Quotes; /// /// A convertible bond's market price on a given trading day. diff --git a/src/AppName.Domain/Entities/StockQuote.cs b/src/AppName.Domain/Entities/Quotes/StockQuote.cs similarity index 94% rename from src/AppName.Domain/Entities/StockQuote.cs rename to src/AppName.Domain/Entities/Quotes/StockQuote.cs index c62d11c..90cdd3b 100644 --- a/src/AppName.Domain/Entities/StockQuote.cs +++ b/src/AppName.Domain/Entities/Quotes/StockQuote.cs @@ -1,6 +1,6 @@ using System.ComponentModel; -namespace AppName.Domain.Entities; +namespace AppName.Domain.Entities.Quotes; /// /// An underlying stock's market price on a given trading day. diff --git a/src/AppName.Domain/ValueObjects/ConversionValuation.cs b/src/AppName.Domain/ValueObjects/Valuations/ConversionValuation.cs similarity index 96% rename from src/AppName.Domain/ValueObjects/ConversionValuation.cs rename to src/AppName.Domain/ValueObjects/Valuations/ConversionValuation.cs index 6001377..e15f6e4 100644 --- a/src/AppName.Domain/ValueObjects/ConversionValuation.cs +++ b/src/AppName.Domain/ValueObjects/Valuations/ConversionValuation.cs @@ -1,6 +1,6 @@ using System.ComponentModel; -namespace AppName.Domain.ValueObjects; +namespace AppName.Domain.ValueObjects.Valuations; /// /// Conversion economics of a convertible bond at a point in time (shares + value). diff --git a/src/AppName.Domain/ValueObjects/ValuationFilter.cs b/src/AppName.Domain/ValueObjects/Valuations/ValuationFilter.cs similarity index 97% rename from src/AppName.Domain/ValueObjects/ValuationFilter.cs rename to src/AppName.Domain/ValueObjects/Valuations/ValuationFilter.cs index a47ef84..41f7037 100644 --- a/src/AppName.Domain/ValueObjects/ValuationFilter.cs +++ b/src/AppName.Domain/ValueObjects/Valuations/ValuationFilter.cs @@ -1,6 +1,6 @@ using AppName.Domain.Querying; -namespace AppName.Domain.ValueObjects; +namespace AppName.Domain.ValueObjects.Valuations; /// /// One node of a valuation snapshot filter tree. A node carries its own leaf diff --git a/src/AppName.Domain/ValueObjects/ValuationQuery.cs b/src/AppName.Domain/ValueObjects/Valuations/ValuationQuery.cs similarity index 92% rename from src/AppName.Domain/ValueObjects/ValuationQuery.cs rename to src/AppName.Domain/ValueObjects/Valuations/ValuationQuery.cs index 1b057c2..da41799 100644 --- a/src/AppName.Domain/ValueObjects/ValuationQuery.cs +++ b/src/AppName.Domain/ValueObjects/Valuations/ValuationQuery.cs @@ -1,6 +1,6 @@ using AppName.Domain.Querying; -namespace AppName.Domain.ValueObjects; +namespace AppName.Domain.ValueObjects.Valuations; /// /// One window over the cached valuation snapshots: what to keep, in what order, diff --git a/src/AppName.Domain/ValueObjects/ValuationSortField.cs b/src/AppName.Domain/ValueObjects/Valuations/ValuationSortField.cs similarity index 95% rename from src/AppName.Domain/ValueObjects/ValuationSortField.cs rename to src/AppName.Domain/ValueObjects/Valuations/ValuationSortField.cs index 8974416..6e9d6be 100644 --- a/src/AppName.Domain/ValueObjects/ValuationSortField.cs +++ b/src/AppName.Domain/ValueObjects/Valuations/ValuationSortField.cs @@ -1,4 +1,4 @@ -namespace AppName.Domain.ValueObjects; +namespace AppName.Domain.ValueObjects.Valuations; /// /// The columns a valuation snapshot query can order by. The enum is the diff --git a/src/AppName.Infrastructure/DependencyInjection.cs b/src/AppName.Infrastructure/DependencyInjection.cs index 46dc9e9..af1f0d2 100644 --- a/src/AppName.Infrastructure/DependencyInjection.cs +++ b/src/AppName.Infrastructure/DependencyInjection.cs @@ -1,11 +1,13 @@ -using AppName.Domain.Abstractions; +using AppName.Domain.Abstractions.Bonds; +using AppName.Domain.Abstractions.MarketData; using AppName.Infrastructure.Clients.Tpex; using AppName.Infrastructure.Clients.Twse; using AppName.Infrastructure.Clients.TwseMis; using AppName.Infrastructure.Gateways; -using AppName.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; +using AppName.Infrastructure.Persistence.Bonds; +using AppName.Infrastructure.Persistence.Context; using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; diff --git a/src/AppName.Infrastructure/Gateways/MarketDataProvider.cs b/src/AppName.Infrastructure/Gateways/MarketDataProvider.cs index 94fcebd..c56e563 100644 --- a/src/AppName.Infrastructure/Gateways/MarketDataProvider.cs +++ b/src/AppName.Infrastructure/Gateways/MarketDataProvider.cs @@ -1,6 +1,7 @@ using System.Globalization; -using AppName.Domain.Abstractions; -using AppName.Domain.Entities; +using AppName.Domain.Abstractions.MarketData; +using AppName.Domain.Entities.Bonds; +using AppName.Domain.Entities.Quotes; using AppName.Infrastructure.Clients.Tpex; using AppName.Infrastructure.Clients.Twse; using AppName.Infrastructure.Clients.TwseMis; diff --git a/src/AppName.Infrastructure/Migrations/20260715180907_InitialCreate.Designer.cs b/src/AppName.Infrastructure/Migrations/20260715180907_InitialCreate.Designer.cs index 912adbc..e4c2248 100644 --- a/src/AppName.Infrastructure/Migrations/20260715180907_InitialCreate.Designer.cs +++ b/src/AppName.Infrastructure/Migrations/20260715180907_InitialCreate.Designer.cs @@ -1,10 +1,10 @@ // using System; -using AppName.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; +using AppName.Infrastructure.Persistence.Context; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Microsoft.EntityFrameworkCore; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable @@ -25,7 +25,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("AppName.Domain.Entities.BondValuationRefreshState", b => + modelBuilder.Entity("AppName.Domain.Entities.Bonds.BondValuationRefreshState", b => { b.Property("Id") .HasColumnType("bigint"); @@ -41,7 +41,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("BondValuationRefreshStates"); }); - modelBuilder.Entity("AppName.Domain.Entities.BondValuationSnapshot", b => + modelBuilder.Entity("AppName.Domain.Entities.Bonds.BondValuationSnapshot", b => { b.Property("Symbol") .HasColumnType("text"); @@ -69,7 +69,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("BondValuationSnapshots"); }); - modelBuilder.Entity("AppName.Domain.Entities.ConvertibleBond", b => + modelBuilder.Entity("AppName.Domain.Entities.Bonds.ConvertibleBond", b => { b.Property("Symbol") .HasColumnType("text"); diff --git a/src/AppName.Infrastructure/Migrations/20260811065935_SeedBondValuationRefreshState.Designer.cs b/src/AppName.Infrastructure/Migrations/20260811065935_SeedBondValuationRefreshState.Designer.cs index 3b2677d..98ae800 100644 --- a/src/AppName.Infrastructure/Migrations/20260811065935_SeedBondValuationRefreshState.Designer.cs +++ b/src/AppName.Infrastructure/Migrations/20260811065935_SeedBondValuationRefreshState.Designer.cs @@ -1,10 +1,10 @@ // using System; -using AppName.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; +using AppName.Infrastructure.Persistence.Context; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Microsoft.EntityFrameworkCore; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable @@ -25,7 +25,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("AppName.Domain.Entities.BondValuationRefreshState", b => + modelBuilder.Entity("AppName.Domain.Entities.Bonds.BondValuationRefreshState", b => { b.Property("Id") .HasColumnType("bigint"); @@ -47,7 +47,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) }); }); - modelBuilder.Entity("AppName.Domain.Entities.BondValuationSnapshot", b => + modelBuilder.Entity("AppName.Domain.Entities.Bonds.BondValuationSnapshot", b => { b.Property("Symbol") .HasColumnType("text"); @@ -75,7 +75,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("BondValuationSnapshots"); }); - modelBuilder.Entity("AppName.Domain.Entities.ConvertibleBond", b => + modelBuilder.Entity("AppName.Domain.Entities.Bonds.ConvertibleBond", b => { b.Property("Symbol") .HasColumnType("text"); diff --git a/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index dccd010..a3733ac 100644 --- a/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -1,9 +1,9 @@ // using System; -using AppName.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; +using AppName.Infrastructure.Persistence.Context; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Microsoft.EntityFrameworkCore; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable @@ -22,7 +22,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("AppName.Domain.Entities.BondValuationRefreshState", b => + modelBuilder.Entity("AppName.Domain.Entities.Bonds.BondValuationRefreshState", b => { b.Property("Id") .HasColumnType("bigint"); @@ -44,7 +44,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); - modelBuilder.Entity("AppName.Domain.Entities.BondValuationSnapshot", b => + modelBuilder.Entity("AppName.Domain.Entities.Bonds.BondValuationSnapshot", b => { b.Property("Symbol") .HasColumnType("text"); @@ -72,7 +72,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("BondValuationSnapshots"); }); - modelBuilder.Entity("AppName.Domain.Entities.ConvertibleBond", b => + modelBuilder.Entity("AppName.Domain.Entities.Bonds.ConvertibleBond", b => { b.Property("Symbol") .HasColumnType("text"); diff --git a/src/AppName.Infrastructure/Persistence/BondValuationRefreshStateRepository.cs b/src/AppName.Infrastructure/Persistence/Bonds/BondValuationRefreshStateRepository.cs similarity index 93% rename from src/AppName.Infrastructure/Persistence/BondValuationRefreshStateRepository.cs rename to src/AppName.Infrastructure/Persistence/Bonds/BondValuationRefreshStateRepository.cs index 897396f..e8a3f32 100644 --- a/src/AppName.Infrastructure/Persistence/BondValuationRefreshStateRepository.cs +++ b/src/AppName.Infrastructure/Persistence/Bonds/BondValuationRefreshStateRepository.cs @@ -1,8 +1,9 @@ -using AppName.Domain.Abstractions; -using AppName.Domain.Entities; +using AppName.Domain.Abstractions.Bonds; +using AppName.Domain.Entities.Bonds; +using AppName.Infrastructure.Persistence.Context; using Microsoft.EntityFrameworkCore; -namespace AppName.Infrastructure.Persistence; +namespace AppName.Infrastructure.Persistence.Bonds; /// public sealed class BondValuationRefreshStateRepository(IDbContextFactory factory) : IBondValuationRefreshStateRepository diff --git a/src/AppName.Infrastructure/Persistence/ConvertibleBondRepository.cs b/src/AppName.Infrastructure/Persistence/Bonds/ConvertibleBondRepository.cs similarity index 87% rename from src/AppName.Infrastructure/Persistence/ConvertibleBondRepository.cs rename to src/AppName.Infrastructure/Persistence/Bonds/ConvertibleBondRepository.cs index 9bf8575..21b0b1c 100644 --- a/src/AppName.Infrastructure/Persistence/ConvertibleBondRepository.cs +++ b/src/AppName.Infrastructure/Persistence/Bonds/ConvertibleBondRepository.cs @@ -1,8 +1,9 @@ -using AppName.Domain.Abstractions; -using AppName.Domain.Entities; +using AppName.Domain.Abstractions.Bonds; +using AppName.Domain.Entities.Bonds; +using AppName.Infrastructure.Persistence.Context; using Microsoft.EntityFrameworkCore; -namespace AppName.Infrastructure.Persistence; +namespace AppName.Infrastructure.Persistence.Bonds; /// public sealed class ConvertibleBondRepository(IDbContextFactory factory) : IConvertibleBondRepository diff --git a/src/AppName.Infrastructure/Persistence/ValuationSnapshotQueryBuilder.cs b/src/AppName.Infrastructure/Persistence/Bonds/ValuationSnapshotQueryBuilder.cs similarity index 95% rename from src/AppName.Infrastructure/Persistence/ValuationSnapshotQueryBuilder.cs rename to src/AppName.Infrastructure/Persistence/Bonds/ValuationSnapshotQueryBuilder.cs index 20dff20..9c608c0 100644 --- a/src/AppName.Infrastructure/Persistence/ValuationSnapshotQueryBuilder.cs +++ b/src/AppName.Infrastructure/Persistence/Bonds/ValuationSnapshotQueryBuilder.cs @@ -1,10 +1,11 @@ using System.Linq.Expressions; -using AppName.Domain.Entities; +using AppName.Domain.Entities.Bonds; using AppName.Domain.Querying; -using AppName.Domain.ValueObjects; +using AppName.Domain.ValueObjects.Valuations; +using AppName.Infrastructure.Persistence.Context; using AppName.Infrastructure.Querying; -namespace AppName.Infrastructure.Persistence; +namespace AppName.Infrastructure.Persistence.Bonds; /// /// Translates a and a diff --git a/src/AppName.Infrastructure/Persistence/ValuationSnapshotRepository.cs b/src/AppName.Infrastructure/Persistence/Bonds/ValuationSnapshotRepository.cs similarity index 89% rename from src/AppName.Infrastructure/Persistence/ValuationSnapshotRepository.cs rename to src/AppName.Infrastructure/Persistence/Bonds/ValuationSnapshotRepository.cs index 4a22006..127d010 100644 --- a/src/AppName.Infrastructure/Persistence/ValuationSnapshotRepository.cs +++ b/src/AppName.Infrastructure/Persistence/Bonds/ValuationSnapshotRepository.cs @@ -1,9 +1,10 @@ -using AppName.Domain.Abstractions; -using AppName.Domain.Entities; -using AppName.Domain.ValueObjects; +using AppName.Domain.Abstractions.Bonds; +using AppName.Domain.Entities.Bonds; +using AppName.Domain.ValueObjects.Valuations; +using AppName.Infrastructure.Persistence.Context; using Microsoft.EntityFrameworkCore; -namespace AppName.Infrastructure.Persistence; +namespace AppName.Infrastructure.Persistence.Bonds; /// public sealed class ValuationSnapshotRepository(IDbContextFactory factory) : IValuationSnapshotRepository diff --git a/src/AppName.Infrastructure/Persistence/AppDbContext.cs b/src/AppName.Infrastructure/Persistence/Context/AppDbContext.cs similarity index 95% rename from src/AppName.Infrastructure/Persistence/AppDbContext.cs rename to src/AppName.Infrastructure/Persistence/Context/AppDbContext.cs index bb1a7e6..cf5a0cb 100644 --- a/src/AppName.Infrastructure/Persistence/AppDbContext.cs +++ b/src/AppName.Infrastructure/Persistence/Context/AppDbContext.cs @@ -1,7 +1,7 @@ -using AppName.Domain.Entities; +using AppName.Domain.Entities.Bonds; using Microsoft.EntityFrameworkCore; -namespace AppName.Infrastructure.Persistence; +namespace AppName.Infrastructure.Persistence.Context; public sealed class AppDbContext(DbContextOptions options) : DbContext(options) { diff --git a/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs b/src/AppName.Infrastructure/Persistence/Context/AppDbContextFactory.cs similarity index 96% rename from src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs rename to src/AppName.Infrastructure/Persistence/Context/AppDbContextFactory.cs index 703fea0..46c3cea 100644 --- a/src/AppName.Infrastructure/Persistence/AppDbContextFactory.cs +++ b/src/AppName.Infrastructure/Persistence/Context/AppDbContextFactory.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.Extensions.Configuration; -namespace AppName.Infrastructure.Persistence; +namespace AppName.Infrastructure.Persistence.Context; /// /// Creates an at design time so the EF Core CLI can generate and apply diff --git a/src/AppName.Infrastructure/Persistence/NpgsqlConnectionString.cs b/src/AppName.Infrastructure/Persistence/Context/NpgsqlConnectionString.cs similarity index 95% rename from src/AppName.Infrastructure/Persistence/NpgsqlConnectionString.cs rename to src/AppName.Infrastructure/Persistence/Context/NpgsqlConnectionString.cs index 646067e..f391b0a 100644 --- a/src/AppName.Infrastructure/Persistence/NpgsqlConnectionString.cs +++ b/src/AppName.Infrastructure/Persistence/Context/NpgsqlConnectionString.cs @@ -1,6 +1,6 @@ using Npgsql; -namespace AppName.Infrastructure.Persistence; +namespace AppName.Infrastructure.Persistence.Context; /// /// Helpers for normalizing a Npgsql/PostgreSQL connection string before it is handed to EF Core. diff --git a/src/AppName.Infrastructure/Persistence/YugabyteHistoryRepository.cs b/src/AppName.Infrastructure/Persistence/Context/YugabyteHistoryRepository.cs similarity index 98% rename from src/AppName.Infrastructure/Persistence/YugabyteHistoryRepository.cs rename to src/AppName.Infrastructure/Persistence/Context/YugabyteHistoryRepository.cs index a619fdb..e77aeb9 100644 --- a/src/AppName.Infrastructure/Persistence/YugabyteHistoryRepository.cs +++ b/src/AppName.Infrastructure/Persistence/Context/YugabyteHistoryRepository.cs @@ -1,7 +1,7 @@ using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations.Internal; -namespace AppName.Infrastructure.Persistence; +namespace AppName.Infrastructure.Persistence.Context; // EF1001: NpgsqlHistoryRepository lives in the provider's ...Migrations.Internal namespace and is // marked as an internal API. Subclassing it is the only supported way to change the migration lock diff --git a/src/AppName.Maui/Bridge/AppBridge.cs b/src/AppName.Maui/Bridge/AppBridge.cs index 0a27a5d..c50a6b0 100644 --- a/src/AppName.Maui/Bridge/AppBridge.cs +++ b/src/AppName.Maui/Bridge/AppBridge.cs @@ -1,4 +1,4 @@ -using AppName.Application.Dtos; +using AppName.Application.Dtos.Bonds; namespace AppName.Maui.Bridge; diff --git a/src/AppName.Maui/Bridge/BondsBridge.cs b/src/AppName.Maui/Bridge/BondsBridge.cs index 409395a..f2b1309 100644 --- a/src/AppName.Maui/Bridge/BondsBridge.cs +++ b/src/AppName.Maui/Bridge/BondsBridge.cs @@ -1,4 +1,4 @@ -using AppName.Application.Dtos; +using AppName.Application.Dtos.Bonds; using AppName.Maui.Clients.Bonds; namespace AppName.Maui.Bridge; diff --git a/src/AppName.Maui/Clients/Bonds/BondsApiClient.cs b/src/AppName.Maui/Clients/Bonds/BondsApiClient.cs index 438e441..e57292b 100644 --- a/src/AppName.Maui/Clients/Bonds/BondsApiClient.cs +++ b/src/AppName.Maui/Clients/Bonds/BondsApiClient.cs @@ -1,5 +1,5 @@ using System.Net.Http.Json; -using AppName.Application.Dtos; +using AppName.Application.Dtos.Bonds; namespace AppName.Maui.Clients.Bonds; diff --git a/src/AppName.Maui/Clients/Bonds/IBondsApiClient.cs b/src/AppName.Maui/Clients/Bonds/IBondsApiClient.cs index db4b843..d97c60c 100644 --- a/src/AppName.Maui/Clients/Bonds/IBondsApiClient.cs +++ b/src/AppName.Maui/Clients/Bonds/IBondsApiClient.cs @@ -1,4 +1,4 @@ -using AppName.Application.Dtos; +using AppName.Application.Dtos.Bonds; namespace AppName.Maui.Clients.Bonds; diff --git a/tests/AppName.Application.Tests/UseCases/Bonds/GetConversionValuationUseCaseTests.cs b/tests/AppName.Application.Tests/UseCases/Bonds/GetConversionValuationUseCaseTests.cs index 0134f7c..664a89e 100644 --- a/tests/AppName.Application.Tests/UseCases/Bonds/GetConversionValuationUseCaseTests.cs +++ b/tests/AppName.Application.Tests/UseCases/Bonds/GetConversionValuationUseCaseTests.cs @@ -1,6 +1,8 @@ using AppName.Application.UseCases.Bonds; -using AppName.Domain.Abstractions; -using AppName.Domain.Entities; +using AppName.Domain.Abstractions.Bonds; +using AppName.Domain.Abstractions.MarketData; +using AppName.Domain.Entities.Bonds; +using AppName.Domain.Entities.Quotes; namespace AppName.Application.Tests.UseCases.Bonds; diff --git a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationCountUseCaseTests.cs b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationCountUseCaseTests.cs index 0c3fa7e..c59660e 100644 --- a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationCountUseCaseTests.cs +++ b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationCountUseCaseTests.cs @@ -1,6 +1,6 @@ using AppName.Application.UseCases.Bonds; -using AppName.Domain.Abstractions; -using AppName.Domain.ValueObjects; +using AppName.Domain.Abstractions.Bonds; +using AppName.Domain.ValueObjects.Valuations; namespace AppName.Application.Tests.UseCases.Bonds; diff --git a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationPageUseCaseTests.cs b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationPageUseCaseTests.cs index d0dfe46..1723370 100644 --- a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationPageUseCaseTests.cs +++ b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationPageUseCaseTests.cs @@ -1,8 +1,8 @@ using AppName.Application.UseCases.Bonds; -using AppName.Domain.Abstractions; -using AppName.Domain.Entities; +using AppName.Domain.Abstractions.Bonds; +using AppName.Domain.Entities.Bonds; using AppName.Domain.Querying; -using AppName.Domain.ValueObjects; +using AppName.Domain.ValueObjects.Valuations; namespace AppName.Application.Tests.UseCases.Bonds; diff --git a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs index c8d8d32..011f911 100644 --- a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs +++ b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs @@ -1,8 +1,8 @@ using AppName.Application.UseCases.Bonds; -using AppName.Domain.Abstractions; -using AppName.Domain.Entities; +using AppName.Domain.Abstractions.Bonds; +using AppName.Domain.Entities.Bonds; using AppName.Domain.Querying; -using AppName.Domain.ValueObjects; +using AppName.Domain.ValueObjects.Valuations; namespace AppName.Application.Tests.UseCases.Bonds; diff --git a/tests/AppName.Application.Tests/UseCases/Bonds/RefreshAllBondsUseCaseTests.cs b/tests/AppName.Application.Tests/UseCases/Bonds/RefreshAllBondsUseCaseTests.cs index 7887623..7d08ea6 100644 --- a/tests/AppName.Application.Tests/UseCases/Bonds/RefreshAllBondsUseCaseTests.cs +++ b/tests/AppName.Application.Tests/UseCases/Bonds/RefreshAllBondsUseCaseTests.cs @@ -1,6 +1,8 @@ using AppName.Application.UseCases.Bonds; -using AppName.Domain.Abstractions; -using AppName.Domain.Entities; +using AppName.Domain.Abstractions.Bonds; +using AppName.Domain.Abstractions.MarketData; +using AppName.Domain.Entities.Bonds; +using AppName.Domain.Entities.Quotes; namespace AppName.Application.Tests.UseCases.Bonds; diff --git a/tests/AppName.Application.Tests/UseCases/Bonds/RefreshBondDataUseCaseTests.cs b/tests/AppName.Application.Tests/UseCases/Bonds/RefreshBondDataUseCaseTests.cs index d539bd4..d361705 100644 --- a/tests/AppName.Application.Tests/UseCases/Bonds/RefreshBondDataUseCaseTests.cs +++ b/tests/AppName.Application.Tests/UseCases/Bonds/RefreshBondDataUseCaseTests.cs @@ -1,6 +1,7 @@ using AppName.Application.UseCases.Bonds; -using AppName.Domain.Abstractions; -using AppName.Domain.Entities; +using AppName.Domain.Abstractions.Bonds; +using AppName.Domain.Abstractions.MarketData; +using AppName.Domain.Entities.Bonds; namespace AppName.Application.Tests.UseCases.Bonds; diff --git a/tests/AppName.Domain.Tests/Entities/ConvertibleBondTests.cs b/tests/AppName.Domain.Tests/Entities/Bonds/ConvertibleBondTests.cs similarity index 91% rename from tests/AppName.Domain.Tests/Entities/ConvertibleBondTests.cs rename to tests/AppName.Domain.Tests/Entities/Bonds/ConvertibleBondTests.cs index b036c58..801f37e 100644 --- a/tests/AppName.Domain.Tests/Entities/ConvertibleBondTests.cs +++ b/tests/AppName.Domain.Tests/Entities/Bonds/ConvertibleBondTests.cs @@ -1,6 +1,6 @@ -using AppName.Domain.Entities; +using AppName.Domain.Entities.Bonds; -namespace AppName.Domain.Tests.Entities; +namespace AppName.Domain.Tests.Entities.Bonds; public class ConvertibleBondTests { diff --git a/tests/AppName.Domain.Tests/ValueObjects/ConversionValuationTests.cs b/tests/AppName.Domain.Tests/ValueObjects/Valuations/ConversionValuationTests.cs similarity index 88% rename from tests/AppName.Domain.Tests/ValueObjects/ConversionValuationTests.cs rename to tests/AppName.Domain.Tests/ValueObjects/Valuations/ConversionValuationTests.cs index 40fc84d..e6d6bff 100644 --- a/tests/AppName.Domain.Tests/ValueObjects/ConversionValuationTests.cs +++ b/tests/AppName.Domain.Tests/ValueObjects/Valuations/ConversionValuationTests.cs @@ -1,6 +1,6 @@ -using AppName.Domain.ValueObjects; +using AppName.Domain.ValueObjects.Valuations; -namespace AppName.Domain.Tests.ValueObjects; +namespace AppName.Domain.Tests.ValueObjects.Valuations; public class ConversionValuationTests { diff --git a/tests/AppName.Infrastructure.Tests/DependencyInjectionTests.cs b/tests/AppName.Infrastructure.Tests/DependencyInjectionTests.cs index 81cea07..1097ee4 100644 --- a/tests/AppName.Infrastructure.Tests/DependencyInjectionTests.cs +++ b/tests/AppName.Infrastructure.Tests/DependencyInjectionTests.cs @@ -1,4 +1,5 @@ -using AppName.Domain.Abstractions; +using AppName.Domain.Abstractions.Bonds; +using AppName.Domain.Abstractions.MarketData; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; diff --git a/tests/AppName.Infrastructure.Tests/Persistence/BondValuationRefreshStateRepositoryTests.cs b/tests/AppName.Infrastructure.Tests/Persistence/Bonds/BondValuationRefreshStateRepositoryTests.cs similarity index 96% rename from tests/AppName.Infrastructure.Tests/Persistence/BondValuationRefreshStateRepositoryTests.cs rename to tests/AppName.Infrastructure.Tests/Persistence/Bonds/BondValuationRefreshStateRepositoryTests.cs index fc9a98a..68d4d92 100644 --- a/tests/AppName.Infrastructure.Tests/Persistence/BondValuationRefreshStateRepositoryTests.cs +++ b/tests/AppName.Infrastructure.Tests/Persistence/Bonds/BondValuationRefreshStateRepositoryTests.cs @@ -1,9 +1,10 @@ -using AppName.Domain.Entities; -using AppName.Infrastructure.Persistence; +using AppName.Domain.Entities.Bonds; +using AppName.Infrastructure.Persistence.Bonds; +using AppName.Infrastructure.Persistence.Context; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; -namespace AppName.Infrastructure.Tests.Persistence; +namespace AppName.Infrastructure.Tests.Persistence.Bonds; // The TryClaimAttemptAsync tests below are compare-and-set tests, not concurrency // tests. The fixture shares one SQLite connection, so it cannot run two callers at diff --git a/tests/AppName.Infrastructure.Tests/Persistence/ConvertibleBondRepositoryTests.cs b/tests/AppName.Infrastructure.Tests/Persistence/Bonds/ConvertibleBondRepositoryTests.cs similarity index 90% rename from tests/AppName.Infrastructure.Tests/Persistence/ConvertibleBondRepositoryTests.cs rename to tests/AppName.Infrastructure.Tests/Persistence/Bonds/ConvertibleBondRepositoryTests.cs index 5dbcffb..68a3188 100644 --- a/tests/AppName.Infrastructure.Tests/Persistence/ConvertibleBondRepositoryTests.cs +++ b/tests/AppName.Infrastructure.Tests/Persistence/Bonds/ConvertibleBondRepositoryTests.cs @@ -1,9 +1,10 @@ -using AppName.Domain.Entities; -using AppName.Infrastructure.Persistence; +using AppName.Domain.Entities.Bonds; +using AppName.Infrastructure.Persistence.Bonds; +using AppName.Infrastructure.Persistence.Context; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; -namespace AppName.Infrastructure.Tests.Persistence; +namespace AppName.Infrastructure.Tests.Persistence.Bonds; public class ConvertibleBondRepositoryTests { diff --git a/tests/AppName.Infrastructure.Tests/Persistence/ValuationSnapshotRepositoryTests.cs b/tests/AppName.Infrastructure.Tests/Persistence/Bonds/ValuationSnapshotRepositoryTests.cs similarity index 98% rename from tests/AppName.Infrastructure.Tests/Persistence/ValuationSnapshotRepositoryTests.cs rename to tests/AppName.Infrastructure.Tests/Persistence/Bonds/ValuationSnapshotRepositoryTests.cs index 1715a87..93d23c5 100644 --- a/tests/AppName.Infrastructure.Tests/Persistence/ValuationSnapshotRepositoryTests.cs +++ b/tests/AppName.Infrastructure.Tests/Persistence/Bonds/ValuationSnapshotRepositoryTests.cs @@ -1,13 +1,14 @@ -using AppName.Domain.Entities; +using AppName.Domain.Entities.Bonds; using AppName.Domain.Querying; -using AppName.Domain.ValueObjects; -using AppName.Infrastructure.Persistence; +using AppName.Domain.ValueObjects.Valuations; +using AppName.Infrastructure.Persistence.Bonds; +using AppName.Infrastructure.Persistence.Context; using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Microsoft.EntityFrameworkCore; -namespace AppName.Infrastructure.Tests.Persistence; +namespace AppName.Infrastructure.Tests.Persistence.Bonds; public class ValuationSnapshotRepositoryTests { From 768e2c962c856203742f5fdc03517807b06abde4 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Sun, 30 Aug 2026 00:54:36 -0600 Subject: [PATCH 3/7] feat: add .serena to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 55279e0..926a207 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ src/AppName.Maui/Resources/Raw/web/ .DS_Store .playwright-cli +.serena \ No newline at end of file From 3eae6a9641617b9397de2bf44e1738bc6005cdc1 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Sun, 30 Aug 2026 02:23:01 -0600 Subject: [PATCH 4/7] refactor: remove unused number, phone, and text validation utilities --- src/web/.prettierignore | 3 - src/web/package-lock.json | 593 +--------- src/web/package.json | 19 +- src/web/public/mockServiceWorker.js | 361 ------ src/web/src/app/rootReducer.ts | 2 +- src/web/src/components/ui/calendar.tsx | 37 - src/web/src/components/ui/input.tsx | 17 - src/web/src/components/ui/numericInput.tsx | 69 -- src/web/src/components/ui/validatedInput.tsx | 69 -- src/web/src/features/bonds/BondsPage.tsx | 2 +- src/web/src/features/bonds/api/bondQueries.ts | 2 +- src/web/src/features/bonds/bondCells.test.ts | 37 - src/web/src/features/bonds/bondCells.ts | 128 --- src/web/src/features/bonds/bondGrid.ts | 79 +- src/web/src/features/dataGrid/DataGrid.tsx | 168 --- .../features/dataGrid/GridStatusBar.test.tsx | 62 - .../src/features/dataGrid/GridStatusBar.tsx | 124 -- .../dataGrid/data/editOverlay.test.ts | 120 -- .../src/features/dataGrid/data/rowCount.ts | 57 - .../features/dataGrid/data/rowStore.test.ts | 228 ---- .../src/features/dataGrid/data/rowStore.ts | 271 ----- .../features/dataGrid/data/sortSpec.test.ts | 70 -- .../src/features/dataGrid/data/sortSpec.ts | 67 -- .../dataGrid/data/spanFromStore.test.ts | 98 -- .../features/dataGrid/data/spanFromStore.ts | 73 -- .../dataGrid/data/sync/planPushes.test.ts | 102 -- .../features/dataGrid/data/sync/planPushes.ts | 115 -- .../dataGrid/data/sync/useRowSync.test.tsx | 602 ---------- .../features/dataGrid/data/sync/useRowSync.ts | 388 ------- .../features/dataGrid/displayModel.test.ts | 401 ------- src/web/src/features/dataGrid/displayModel.ts | 436 ------- .../dataGrid/gridComposition.test.tsx | 1000 ----------------- .../dataGrid/gridStatusWiring.test.tsx | 294 ----- src/web/src/features/dataGrid/headerIcons.ts | 40 - .../dataGrid/hooks/useCellRenderer.test.tsx | 354 ------ .../dataGrid/hooks/useCellRenderer.ts | 328 ------ .../dataGrid/hooks/useDisplayModel.test.tsx | 176 --- .../dataGrid/hooks/useDisplayModel.ts | 143 --- .../dataGrid/hooks/useGridColumns.test.tsx | 113 -- .../features/dataGrid/hooks/useGridColumns.ts | 63 -- .../dataGrid/hooks/useGridData.test.tsx | 706 ------------ .../features/dataGrid/hooks/useGridData.ts | 686 ----------- .../dataGrid/hooks/useGridSelection.ts | 50 - .../dataGrid/hooks/useGridSort.test.tsx | 122 -- .../features/dataGrid/hooks/useGridSort.ts | 49 - .../features/dataGrid/hooks/useRepaintRows.ts | 108 -- .../dataGrid/hooks/useRowPages.test.tsx | 418 ------- .../features/dataGrid/hooks/useRowPages.ts | 425 ------- .../dataGrid/hooks/useWindowRange.test.tsx | 134 --- .../features/dataGrid/hooks/useWindowRange.ts | 147 --- .../dataGrid/store/columnsSlice.test.ts | 89 -- .../features/dataGrid/store/columnsSlice.ts | 52 - .../dataGrid/store/createGridInstance.test.ts | 54 - .../dataGrid/store/createGridInstance.ts | 136 --- .../dataGrid/store/editsSlice.test.ts | 54 - .../src/features/dataGrid/store/editsSlice.ts | 53 - .../store/effects/columnsPersistenceEffect.ts | 28 - .../dataGrid/store/effects/index.test.ts | 63 -- .../features/dataGrid/store/effects/index.ts | 37 - .../features/dataGrid/store/effects/types.ts | 75 -- .../dataGrid/store/groupsSlice.test.ts | 46 - .../features/dataGrid/store/groupsSlice.ts | 50 - .../store/localStorageColumnsAdapter.test.ts | 66 -- .../store/localStorageColumnsAdapter.ts | 40 - .../features/dataGrid/store/selectionSlice.ts | 21 - .../dataGrid/testing/fakeRowServer.test.ts | 76 -- .../dataGrid/testing/fakeRowServer.ts | 88 -- src/web/src/features/dataGrid/types.ts | 518 --------- .../src/features/dataGrid/useGridDispatch.ts | 7 - .../features/testGrid/MockControls.test.tsx | 92 -- .../src/features/testGrid/MockControls.tsx | 105 -- .../src/features/testGrid/TestGridPage.tsx | 23 - .../testGrid/api/testRowQueries.test.ts | 127 --- .../features/testGrid/api/testRowQueries.ts | 85 -- src/web/src/features/testGrid/api/types.ts | 95 -- src/web/src/features/testGrid/index.ts | 2 - .../testGrid/mocks/generateRows.test.ts | 59 - .../features/testGrid/mocks/generateRows.ts | 92 -- .../features/testGrid/mocks/handlers.test.ts | 126 --- .../src/features/testGrid/mocks/handlers.ts | 133 --- .../testGrid/mocks/mockConfig.test.ts | 10 - .../src/features/testGrid/mocks/mockConfig.ts | 38 - .../testGrid/mocks/testRowStore.test.ts | 118 -- .../features/testGrid/mocks/testRowStore.ts | 215 ---- .../src/features/testGrid/testGrid.test.ts | 69 -- src/web/src/features/testGrid/testGrid.ts | 103 -- .../features/testGrid/testGridCells.test.ts | 34 - .../src/features/testGrid/testGridCells.ts | 135 --- src/web/src/index.css | 23 +- src/web/src/lib/date/dateUtils.test.ts | 151 --- src/web/src/lib/date/dateUtils.ts | 151 --- src/web/src/lib/grid/cellRegistry.test.ts | 78 -- src/web/src/lib/grid/cellRegistry.ts | 75 -- src/web/src/lib/grid/createCustomCell.test.ts | 42 - src/web/src/lib/grid/createCustomCell.tsx | 157 --- src/web/src/lib/grid/createDateCell.test.ts | 140 --- src/web/src/lib/grid/createDateCell.tsx | 161 --- src/web/src/lib/grid/createEnumCell.test.ts | 104 -- src/web/src/lib/grid/createEnumCell.tsx | 132 --- src/web/src/lib/grid/createNumberCell.test.ts | 120 -- src/web/src/lib/grid/createNumberCell.tsx | 122 -- src/web/src/lib/grid/createPhoneCell.test.ts | 105 -- src/web/src/lib/grid/createPhoneCell.tsx | 121 -- src/web/src/lib/grid/createTextCell.test.ts | 103 -- src/web/src/lib/grid/createTextCell.tsx | 104 -- .../src/lib/grid/radixBadgePalette.test.ts | 28 - src/web/src/lib/grid/radixBadgePalette.ts | 50 - src/web/src/lib/grid/softBadge.test.ts | 133 --- src/web/src/lib/grid/softBadge.tsx | 124 -- src/web/src/lib/grid/softBadgeView.tsx | 11 - src/web/src/lib/grid/useEditHighlight.test.ts | 51 - src/web/src/lib/grid/useEditHighlight.ts | 49 - src/web/src/lib/number/numberUtils.test.ts | 106 -- src/web/src/lib/number/numberUtils.ts | 119 -- src/web/src/lib/phone/phoneUtils.test.ts | 66 -- src/web/src/lib/phone/phoneUtils.ts | 73 -- src/web/src/lib/text/textValidation.test.ts | 98 -- src/web/src/lib/text/textValidation.ts | 72 -- src/web/src/lib/utils.ts | 7 - src/web/src/main.tsx | 52 +- src/web/src/mocks/browser.ts | 20 - src/web/src/routeTree.gen.ts | 24 +- src/web/src/routes/test-grid.tsx | 21 - src/web/src/tailwind.css | 8 - src/web/src/theme/RadixThemeProvider.tsx | 13 +- src/web/src/theme/appearanceSlice.test.ts | 20 - src/web/src/theme/appearanceSlice.ts | 23 - src/web/src/theme/colorSchemeWatcher.ts | 2 +- src/web/src/theme/gridThemes.test.ts | 18 - src/web/src/theme/gridThemes.ts | 102 -- src/web/src/theme/radixStyles.ts | 22 - src/web/src/theme/radixTheme.test.ts | 17 - src/web/src/theme/radixTheme.ts | 13 - src/web/src/theme/useGridTheme.ts | 22 - src/web/vitest.config.ts | 9 + 135 files changed, 125 insertions(+), 16507 deletions(-) delete mode 100644 src/web/public/mockServiceWorker.js delete mode 100644 src/web/src/components/ui/calendar.tsx delete mode 100644 src/web/src/components/ui/input.tsx delete mode 100644 src/web/src/components/ui/numericInput.tsx delete mode 100644 src/web/src/components/ui/validatedInput.tsx delete mode 100644 src/web/src/features/bonds/bondCells.test.ts delete mode 100644 src/web/src/features/bonds/bondCells.ts delete mode 100644 src/web/src/features/dataGrid/DataGrid.tsx delete mode 100644 src/web/src/features/dataGrid/GridStatusBar.test.tsx delete mode 100644 src/web/src/features/dataGrid/GridStatusBar.tsx delete mode 100644 src/web/src/features/dataGrid/data/editOverlay.test.ts delete mode 100644 src/web/src/features/dataGrid/data/rowCount.ts delete mode 100644 src/web/src/features/dataGrid/data/rowStore.test.ts delete mode 100644 src/web/src/features/dataGrid/data/rowStore.ts delete mode 100644 src/web/src/features/dataGrid/data/sortSpec.test.ts delete mode 100644 src/web/src/features/dataGrid/data/sortSpec.ts delete mode 100644 src/web/src/features/dataGrid/data/spanFromStore.test.ts delete mode 100644 src/web/src/features/dataGrid/data/spanFromStore.ts delete mode 100644 src/web/src/features/dataGrid/data/sync/planPushes.test.ts delete mode 100644 src/web/src/features/dataGrid/data/sync/planPushes.ts delete mode 100644 src/web/src/features/dataGrid/data/sync/useRowSync.test.tsx delete mode 100644 src/web/src/features/dataGrid/data/sync/useRowSync.ts delete mode 100644 src/web/src/features/dataGrid/displayModel.test.ts delete mode 100644 src/web/src/features/dataGrid/displayModel.ts delete mode 100644 src/web/src/features/dataGrid/gridComposition.test.tsx delete mode 100644 src/web/src/features/dataGrid/gridStatusWiring.test.tsx delete mode 100644 src/web/src/features/dataGrid/headerIcons.ts delete mode 100644 src/web/src/features/dataGrid/hooks/useCellRenderer.test.tsx delete mode 100644 src/web/src/features/dataGrid/hooks/useCellRenderer.ts delete mode 100644 src/web/src/features/dataGrid/hooks/useDisplayModel.test.tsx delete mode 100644 src/web/src/features/dataGrid/hooks/useDisplayModel.ts delete mode 100644 src/web/src/features/dataGrid/hooks/useGridColumns.test.tsx delete mode 100644 src/web/src/features/dataGrid/hooks/useGridColumns.ts delete mode 100644 src/web/src/features/dataGrid/hooks/useGridData.test.tsx delete mode 100644 src/web/src/features/dataGrid/hooks/useGridData.ts delete mode 100644 src/web/src/features/dataGrid/hooks/useGridSelection.ts delete mode 100644 src/web/src/features/dataGrid/hooks/useGridSort.test.tsx delete mode 100644 src/web/src/features/dataGrid/hooks/useGridSort.ts delete mode 100644 src/web/src/features/dataGrid/hooks/useRepaintRows.ts delete mode 100644 src/web/src/features/dataGrid/hooks/useRowPages.test.tsx delete mode 100644 src/web/src/features/dataGrid/hooks/useRowPages.ts delete mode 100644 src/web/src/features/dataGrid/hooks/useWindowRange.test.tsx delete mode 100644 src/web/src/features/dataGrid/hooks/useWindowRange.ts delete mode 100644 src/web/src/features/dataGrid/store/columnsSlice.test.ts delete mode 100644 src/web/src/features/dataGrid/store/columnsSlice.ts delete mode 100644 src/web/src/features/dataGrid/store/createGridInstance.test.ts delete mode 100644 src/web/src/features/dataGrid/store/createGridInstance.ts delete mode 100644 src/web/src/features/dataGrid/store/editsSlice.test.ts delete mode 100644 src/web/src/features/dataGrid/store/editsSlice.ts delete mode 100644 src/web/src/features/dataGrid/store/effects/columnsPersistenceEffect.ts delete mode 100644 src/web/src/features/dataGrid/store/effects/index.test.ts delete mode 100644 src/web/src/features/dataGrid/store/effects/index.ts delete mode 100644 src/web/src/features/dataGrid/store/effects/types.ts delete mode 100644 src/web/src/features/dataGrid/store/groupsSlice.test.ts delete mode 100644 src/web/src/features/dataGrid/store/groupsSlice.ts delete mode 100644 src/web/src/features/dataGrid/store/localStorageColumnsAdapter.test.ts delete mode 100644 src/web/src/features/dataGrid/store/localStorageColumnsAdapter.ts delete mode 100644 src/web/src/features/dataGrid/store/selectionSlice.ts delete mode 100644 src/web/src/features/dataGrid/testing/fakeRowServer.test.ts delete mode 100644 src/web/src/features/dataGrid/testing/fakeRowServer.ts delete mode 100644 src/web/src/features/dataGrid/types.ts delete mode 100644 src/web/src/features/dataGrid/useGridDispatch.ts delete mode 100644 src/web/src/features/testGrid/MockControls.test.tsx delete mode 100644 src/web/src/features/testGrid/MockControls.tsx delete mode 100644 src/web/src/features/testGrid/TestGridPage.tsx delete mode 100644 src/web/src/features/testGrid/api/testRowQueries.test.ts delete mode 100644 src/web/src/features/testGrid/api/testRowQueries.ts delete mode 100644 src/web/src/features/testGrid/api/types.ts delete mode 100644 src/web/src/features/testGrid/index.ts delete mode 100644 src/web/src/features/testGrid/mocks/generateRows.test.ts delete mode 100644 src/web/src/features/testGrid/mocks/generateRows.ts delete mode 100644 src/web/src/features/testGrid/mocks/handlers.test.ts delete mode 100644 src/web/src/features/testGrid/mocks/handlers.ts delete mode 100644 src/web/src/features/testGrid/mocks/mockConfig.test.ts delete mode 100644 src/web/src/features/testGrid/mocks/mockConfig.ts delete mode 100644 src/web/src/features/testGrid/mocks/testRowStore.test.ts delete mode 100644 src/web/src/features/testGrid/mocks/testRowStore.ts delete mode 100644 src/web/src/features/testGrid/testGrid.test.ts delete mode 100644 src/web/src/features/testGrid/testGrid.ts delete mode 100644 src/web/src/features/testGrid/testGridCells.test.ts delete mode 100644 src/web/src/features/testGrid/testGridCells.ts delete mode 100644 src/web/src/lib/date/dateUtils.test.ts delete mode 100644 src/web/src/lib/date/dateUtils.ts delete mode 100644 src/web/src/lib/grid/cellRegistry.test.ts delete mode 100644 src/web/src/lib/grid/cellRegistry.ts delete mode 100644 src/web/src/lib/grid/createCustomCell.test.ts delete mode 100644 src/web/src/lib/grid/createCustomCell.tsx delete mode 100644 src/web/src/lib/grid/createDateCell.test.ts delete mode 100644 src/web/src/lib/grid/createDateCell.tsx delete mode 100644 src/web/src/lib/grid/createEnumCell.test.ts delete mode 100644 src/web/src/lib/grid/createEnumCell.tsx delete mode 100644 src/web/src/lib/grid/createNumberCell.test.ts delete mode 100644 src/web/src/lib/grid/createNumberCell.tsx delete mode 100644 src/web/src/lib/grid/createPhoneCell.test.ts delete mode 100644 src/web/src/lib/grid/createPhoneCell.tsx delete mode 100644 src/web/src/lib/grid/createTextCell.test.ts delete mode 100644 src/web/src/lib/grid/createTextCell.tsx delete mode 100644 src/web/src/lib/grid/radixBadgePalette.test.ts delete mode 100644 src/web/src/lib/grid/radixBadgePalette.ts delete mode 100644 src/web/src/lib/grid/softBadge.test.ts delete mode 100644 src/web/src/lib/grid/softBadge.tsx delete mode 100644 src/web/src/lib/grid/softBadgeView.tsx delete mode 100644 src/web/src/lib/grid/useEditHighlight.test.ts delete mode 100644 src/web/src/lib/grid/useEditHighlight.ts delete mode 100644 src/web/src/lib/number/numberUtils.test.ts delete mode 100644 src/web/src/lib/number/numberUtils.ts delete mode 100644 src/web/src/lib/phone/phoneUtils.test.ts delete mode 100644 src/web/src/lib/phone/phoneUtils.ts delete mode 100644 src/web/src/lib/text/textValidation.test.ts delete mode 100644 src/web/src/lib/text/textValidation.ts delete mode 100644 src/web/src/lib/utils.ts delete mode 100644 src/web/src/mocks/browser.ts delete mode 100644 src/web/src/routes/test-grid.tsx delete mode 100644 src/web/src/theme/appearanceSlice.test.ts delete mode 100644 src/web/src/theme/appearanceSlice.ts delete mode 100644 src/web/src/theme/gridThemes.test.ts delete mode 100644 src/web/src/theme/gridThemes.ts delete mode 100644 src/web/src/theme/radixStyles.ts delete mode 100644 src/web/src/theme/radixTheme.test.ts delete mode 100644 src/web/src/theme/radixTheme.ts delete mode 100644 src/web/src/theme/useGridTheme.ts diff --git a/src/web/.prettierignore b/src/web/.prettierignore index 4abe88e..23d0d8f 100644 --- a/src/web/.prettierignore +++ b/src/web/.prettierignore @@ -13,6 +13,3 @@ 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/package-lock.json b/src/web/package-lock.json index 97c057a..db739b0 100644 --- a/src/web/package-lock.json +++ b/src/web/package-lock.json @@ -9,24 +9,15 @@ "version": "0.0.0", "dependencies": { "@glideapps/glide-data-grid": "6.0.4-alpha24", + "@matthewhsu1/datagrid": "^0.3.0", "@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-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" + "react-redux": "^9.3.0" }, "devDependencies": { "@tailwindcss/vite": "^4.3.2", @@ -41,7 +32,6 @@ "@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", @@ -646,93 +636,6 @@ "react-responsive-carousel": "^3.2.7" } }, - "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", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - } - }, - "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", - "dependencies": { - "@inquirer/core": "^11.2.1", - "@inquirer/type": "^4.0.7" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "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", - "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": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "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", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - } - }, - "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", - "engines": { - "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", @@ -813,31 +716,31 @@ "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, + "node_modules/@matthewhsu1/datagrid": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@matthewhsu1/datagrid/-/datagrid-0.3.0.tgz", + "integrity": "sha512-viP39/yeA4VZz0DV428d4U5ZHqEY3KnfOAMTDg1ZNVIAOT+YmSbnj/v+Ww6HMho1fPpFQNrlZTvnrW/FESc27Q==", "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" + "@tanstack/react-pacer": "^0.22.1", + "lucide-react": "^1.23.0", + "react-day-picker": "^9.14.0", + "react-number-format": "^5.4.5", + "react-phone-number-input": "^3.4.17" }, "engines": { - "node": ">=18" + "node": ">=20.19.0" + }, + "peerDependencies": { + "@glideapps/glide-data-grid": "6.0.4-alpha24", + "@radix-ui/themes": "^3.0.0", + "@reduxjs/toolkit": "^2.0.0", + "@tanstack/react-query": "^5.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-redux": "^9.0.0" } }, - "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", @@ -857,31 +760,6 @@ "@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", @@ -3996,23 +3874,6 @@ "@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", @@ -4211,6 +4072,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -4485,60 +4347,16 @@ "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": ">=6" } }, - "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", @@ -4565,20 +4383,6 @@ "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", @@ -4747,13 +4551,6 @@ "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", @@ -4870,33 +4667,6 @@ "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", @@ -5001,16 +4771,6 @@ "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", @@ -5086,16 +4846,6 @@ "dev": true, "license": "ISC" }, - "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", - "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -5135,17 +4885,6 @@ "node": ">= 0.4" } }, - "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, - "license": "MIT", - "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", @@ -5248,23 +4987,6 @@ "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", @@ -5701,7 +5423,8 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/loose-envify": { "version": "1.4.0", @@ -5760,6 +5483,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -5835,77 +5559,6 @@ "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": { "version": "3.3.15", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", @@ -5979,13 +5632,6 @@ "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", @@ -6111,13 +5757,6 @@ "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", @@ -6377,6 +6016,7 @@ "resolved": "https://registry.npmjs.org/react-easy-swipe/-/react-easy-swipe-0.0.21.tgz", "integrity": "sha512-OeR2jAxdoqUMHIn/nS9fgreI5hSpgGoL5ezdal4+oO7YSSgJR8ga+PkYGJrSrJ9MKlPcQjMQXnketrD7WNmNsg==", "license": "MIT", + "peer": true, "dependencies": { "prop-types": "^15.5.8" }, @@ -6503,6 +6143,7 @@ "resolved": "https://registry.npmjs.org/react-responsive-carousel/-/react-responsive-carousel-3.2.23.tgz", "integrity": "sha512-pqJLsBaKHWJhw/ItODgbVoziR2z4lpcJg+YwmRlSk4rKH32VE633mAtZZ9kDXjy4wFO+pgUZmDKPsPe1fPmHCg==", "license": "MIT", + "peer": true, "dependencies": { "classnames": "^2.2.5", "prop-types": "^15.5.8", @@ -6574,16 +6215,6 @@ "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", @@ -6621,13 +6252,6 @@ "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", @@ -6712,13 +6336,6 @@ "seroval": "^1.0" } }, - "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -6726,19 +6343,6 @@ "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", @@ -6756,16 +6360,6 @@ "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", @@ -6773,41 +6367,6 @@ "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", @@ -6853,29 +6412,6 @@ "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", @@ -7104,16 +6640,6 @@ } } }, - "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", @@ -7444,40 +6970,6 @@ "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", @@ -7495,16 +6987,6 @@ "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", @@ -7519,25 +7001,6 @@ "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", diff --git a/src/web/package.json b/src/web/package.json index 36ee6f2..fbd40de 100644 --- a/src/web/package.json +++ b/src/web/package.json @@ -16,24 +16,15 @@ }, "dependencies": { "@glideapps/glide-data-grid": "6.0.4-alpha24", + "@matthewhsu1/datagrid": "^0.3.0", "@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-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" + "react-redux": "^9.3.0" }, "devDependencies": { "@tailwindcss/vite": "^4.3.2", @@ -48,7 +39,6 @@ "@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", @@ -61,10 +51,5 @@ "openapi-typescript": { "typescript": "$typescript" } - }, - "msw": { - "workerDirectory": [ - "public" - ] } } diff --git a/src/web/public/mockServiceWorker.js b/src/web/public/mockServiceWorker.js deleted file mode 100644 index 0c970ef..0000000 --- a/src/web/public/mockServiceWorker.js +++ /dev/null @@ -1,361 +0,0 @@ -/* 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/rootReducer.ts b/src/web/src/app/rootReducer.ts index e3498d9..a5530ae 100644 --- a/src/web/src/app/rootReducer.ts +++ b/src/web/src/app/rootReducer.ts @@ -4,7 +4,7 @@ import { type ThunkDispatch, type UnknownAction, } from "@reduxjs/toolkit"; -import appearance from "../theme/appearanceSlice"; +import { appearanceReducer as appearance } from "@matthewhsu1/datagrid"; /** * Slices that register themselves at import time rather than being listed here. diff --git a/src/web/src/components/ui/calendar.tsx b/src/web/src/components/ui/calendar.tsx deleted file mode 100644 index 0c210cf..0000000 --- a/src/web/src/components/ui/calendar.tsx +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index c7f56fe..0000000 --- a/src/web/src/components/ui/input.tsx +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 962b365..0000000 --- a/src/web/src/components/ui/numericInput.tsx +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index 1ddb2db..0000000 --- a/src/web/src/components/ui/validatedInput.tsx +++ /dev/null @@ -1,69 +0,0 @@ -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 index 80c9789..13dd198 100644 --- a/src/web/src/features/bonds/BondsPage.tsx +++ b/src/web/src/features/bonds/BondsPage.tsx @@ -1,4 +1,4 @@ -import { DataGrid } from "../dataGrid/DataGrid"; +import { DataGrid } from "@matthewhsu1/datagrid"; import { bondGrid } from "./bondGrid"; /** diff --git a/src/web/src/features/bonds/api/bondQueries.ts b/src/web/src/features/bonds/api/bondQueries.ts index 1822b24..18559c6 100644 --- a/src/web/src/features/bonds/api/bondQueries.ts +++ b/src/web/src/features/bonds/api/bondQueries.ts @@ -1,7 +1,7 @@ import { queryOptions } from "@tanstack/react-query"; import { apiClient } from "../../../lib/http/apiClient"; import { ApiError } from "../../../lib/http/ApiError"; -import type { SortSpec } from "../../dataGrid/data/sortSpec"; +import type { SortSpec } from "@matthewhsu1/datagrid"; import type { ConversionValuation } from "./types"; const PATH = "/api/valuations"; diff --git a/src/web/src/features/bonds/bondCells.test.ts b/src/web/src/features/bonds/bondCells.test.ts deleted file mode 100644 index d3c02c6..0000000 --- a/src/web/src/features/bonds/bondCells.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 3dfaf69..0000000 --- a/src/web/src/features/bonds/bondCells.ts +++ /dev/null @@ -1,128 +0,0 @@ -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.ts b/src/web/src/features/bonds/bondGrid.ts index a3cdc3d..2ef9da0 100644 --- a/src/web/src/features/bonds/bondGrid.ts +++ b/src/web/src/features/bonds/bondGrid.ts @@ -1,15 +1,13 @@ +import { + createGridInstance, + type ColumnDef, + type FetchRowsParams, + type GridDescriptor, + type GridSliceState, + type UpdateRowParams, +} from "@matthewhsu1/datagrid"; import { injectSlice } from "../../app/rootReducer"; -import { createGridInstance } from "../dataGrid/store/createGridInstance"; -import { localStorageColumnsAdapter } from "../dataGrid/store/localStorageColumnsAdapter"; -import type { - ColumnDef, - FetchRowsParams, - GridDescriptor, - GridSliceState, - UpdateRowParams, -} from "../dataGrid/types"; import { fetchValuation, fetchValuationCount, fetchValuations } 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. */ @@ -49,48 +47,79 @@ export function createBondGridApi() { }; } +// Every column names one of the engine's own cell types (the `dg:` prefix) and +// carries that cell's settings on `options`. Nothing is registered and nothing +// is built at module scope — the engine draws all five. +// +// Every bond column is read-only, so no editor ever opens; a null value draws +// as "—". const COLUMN_DEFS: Record = { - symbol: { field: "symbol", title: "Symbol", defaultWidth: 110, editable: false, type: "symbol" }, + symbol: { + field: "symbol", + title: "Symbol", + defaultWidth: 110, + editable: false, + type: "dg:text", + }, conversionShares: { field: "conversionShares", title: "Conversion Shares", defaultWidth: 150, editable: false, - type: "shares", + type: "dg:number", + options: { format: "integer", thousandSeparator: true }, }, conversionValue: { field: "conversionValue", title: "Conversion Value", defaultWidth: 160, editable: false, - type: "ntCurrency0", + type: "dg:number", + options: { format: "currency", currency: "TWD", decimalScale: 0 }, }, stockPrice: { field: "stockPrice", title: "Stock Price", defaultWidth: 130, editable: false, - type: "ntCurrency2", + type: "dg:number", + options: { format: "currency", currency: "TWD", decimalScale: 2 }, }, bondPrice: { field: "bondPrice", title: "Bond Price", defaultWidth: 130, editable: false, - type: "ntCurrency2", + type: "dg:number", + options: { format: "currency", currency: "TWD", decimalScale: 2 }, }, + // `isInTheMoney` is a nullable boolean on the wire. The enum cell coerces the + // raw value with `Number`, so false lands on 0 and true on 1, and null stays + // null and draws as an empty cell. isInTheMoney: { field: "isInTheMoney", title: "Status", defaultWidth: 160, editable: false, - type: "status", + type: "dg:enum", + options: { + nullable: true, + choices: [ + { value: 0, label: "Out of the money", color: "red" }, + { value: 1, label: "In the money", color: "green" }, + ], + }, + }, + asOf: { + field: "asOf", + title: "As Of", + defaultWidth: 120, + editable: false, + type: "dg:date", + options: { nullable: true }, }, - 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, @@ -105,16 +134,10 @@ export const bondGridDescriptor: GridDescriptor = { "isInTheMoney", "asOf", ], - // All columns are read-only; without this the default ("editable only") - // would make nothing sortable. - sortable: () => true, - }, - api: { - ...createBondGridApi(), - loadColumns: columnsAdapter.loadColumns, - saveColumns: columnsAdapter.saveColumns, }, - cells: bondCells, + // The engine persists the user's column layout to Web Storage on its own, + // under `datagrid:bonds:columns`. There is no adapter to hand it. + api: createBondGridApi(), }; export const bondGrid = createGridInstance(bondGridDescriptor); diff --git a/src/web/src/features/dataGrid/DataGrid.tsx b/src/web/src/features/dataGrid/DataGrid.tsx deleted file mode 100644 index 589daa1..0000000 --- a/src/web/src/features/dataGrid/DataGrid.tsx +++ /dev/null @@ -1,168 +0,0 @@ -// 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 deleted file mode 100644 index a92b68c..0000000 --- a/src/web/src/features/dataGrid/GridStatusBar.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index e245bf3..0000000 --- a/src/web/src/features/dataGrid/GridStatusBar.tsx +++ /dev/null @@ -1,124 +0,0 @@ -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 deleted file mode 100644 index 39e8a07..0000000 --- a/src/web/src/features/dataGrid/data/editOverlay.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -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/rowCount.ts b/src/web/src/features/dataGrid/data/rowCount.ts deleted file mode 100644 index 0a30ca2..0000000 --- a/src/web/src/features/dataGrid/data/rowCount.ts +++ /dev/null @@ -1,57 +0,0 @@ -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. - * - * A filter changes the count too, so it goes in the key as well. It is opaque: - * TanStack hashes the key structurally, so any serialisable value works and - * this module never reads inside it. - * - * An absent filter appends nothing, which keeps the unfiltered key a PREFIX of - * every filtered one. `data/sync/useRowSync.ts` invalidates by that prefix - * after a push, so one recount reaches every filter variant. - */ -export function rowCountKey( - name: string, - collapsedGroups: unknown[], - filter?: unknown, -): readonly unknown[] { - const base = [name, "count", [...collapsedGroups].sort()]; - return filter === undefined ? base : [...base, filter]; -} - -function rowCountQuery( - descriptor: GridDescriptor, - collapsedGroups: TGroup[], - filter?: unknown, -) { - return queryOptions({ - queryKey: rowCountKey(descriptor.name, collapsedGroups, filter), - queryFn: ({ signal }) => descriptor.api.fetchCount({ collapsedGroups, filter, signal }), - }); -} - -/** - * The total glide needs to size its scroll bar, and whether the request for the - * CURRENT view — collapse set and filter alike — 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[], - filter?: unknown, -): { total: number; settled: boolean } { - const { data, status } = useQuery(rowCountQuery(descriptor, collapsedGroups, filter)); - - 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 deleted file mode 100644 index a8c586d..0000000 --- a/src/web/src/features/dataGrid/data/rowStore.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -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 deleted file mode 100644 index d87f1ae..0000000 --- a/src/web/src/features/dataGrid/data/rowStore.ts +++ /dev/null @@ -1,271 +0,0 @@ -/** - * 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 deleted file mode 100644 index c33fcfd..0000000 --- a/src/web/src/features/dataGrid/data/sortSpec.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index 9454003..0000000 --- a/src/web/src/features/dataGrid/data/sortSpec.ts +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index 34df9d4..0000000 --- a/src/web/src/features/dataGrid/data/spanFromStore.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -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 deleted file mode 100644 index 45d142a..0000000 --- a/src/web/src/features/dataGrid/data/spanFromStore.ts +++ /dev/null @@ -1,73 +0,0 @@ -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 deleted file mode 100644 index a8b7088..0000000 --- a/src/web/src/features/dataGrid/data/sync/planPushes.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -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 deleted file mode 100644 index dfaea59..0000000 --- a/src/web/src/features/dataGrid/data/sync/planPushes.ts +++ /dev/null @@ -1,115 +0,0 @@ -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 deleted file mode 100644 index 84cd432..0000000 --- a/src/web/src/features/dataGrid/data/sync/useRowSync.test.tsx +++ /dev/null @@ -1,602 +0,0 @@ -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 deleted file mode 100644 index 2b3c055..0000000 --- a/src/web/src/features/dataGrid/data/sync/useRowSync.ts +++ /dev/null @@ -1,388 +0,0 @@ -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 deleted file mode 100644 index 374b9e6..0000000 --- a/src/web/src/features/dataGrid/displayModel.test.ts +++ /dev/null @@ -1,401 +0,0 @@ -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 deleted file mode 100644 index b615900..0000000 --- a/src/web/src/features/dataGrid/displayModel.ts +++ /dev/null @@ -1,436 +0,0 @@ -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 deleted file mode 100644 index edde237..0000000 --- a/src/web/src/features/dataGrid/gridComposition.test.tsx +++ /dev/null @@ -1,1000 +0,0 @@ -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 deleted file mode 100644 index 02b9ba0..0000000 --- a/src/web/src/features/dataGrid/gridStatusWiring.test.tsx +++ /dev/null @@ -1,294 +0,0 @@ -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 deleted file mode 100644 index 43b0092..0000000 --- a/src/web/src/features/dataGrid/headerIcons.ts +++ /dev/null @@ -1,40 +0,0 @@ -// 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 deleted file mode 100644 index b481234..0000000 --- a/src/web/src/features/dataGrid/hooks/useCellRenderer.test.tsx +++ /dev/null @@ -1,354 +0,0 @@ -// 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 deleted file mode 100644 index 712e569..0000000 --- a/src/web/src/features/dataGrid/hooks/useCellRenderer.ts +++ /dev/null @@ -1,328 +0,0 @@ -// 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 deleted file mode 100644 index fa50af5..0000000 --- a/src/web/src/features/dataGrid/hooks/useDisplayModel.test.tsx +++ /dev/null @@ -1,176 +0,0 @@ -// 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 deleted file mode 100644 index a4998e3..0000000 --- a/src/web/src/features/dataGrid/hooks/useDisplayModel.ts +++ /dev/null @@ -1,143 +0,0 @@ -// 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 deleted file mode 100644 index c7abede..0000000 --- a/src/web/src/features/dataGrid/hooks/useGridColumns.test.tsx +++ /dev/null @@ -1,113 +0,0 @@ -// 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 deleted file mode 100644 index 6ef5506..0000000 --- a/src/web/src/features/dataGrid/hooks/useGridColumns.ts +++ /dev/null @@ -1,63 +0,0 @@ -// 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 deleted file mode 100644 index 7d14828..0000000 --- a/src/web/src/features/dataGrid/hooks/useGridData.test.tsx +++ /dev/null @@ -1,706 +0,0 @@ -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 deleted file mode 100644 index f2723fa..0000000 --- a/src/web/src/features/dataGrid/hooks/useGridData.ts +++ /dev/null @@ -1,686 +0,0 @@ -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 deleted file mode 100644 index 80c10d4..0000000 --- a/src/web/src/features/dataGrid/hooks/useGridSelection.ts +++ /dev/null @@ -1,50 +0,0 @@ -// 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 deleted file mode 100644 index 1c72d52..0000000 --- a/src/web/src/features/dataGrid/hooks/useGridSort.test.tsx +++ /dev/null @@ -1,122 +0,0 @@ -// 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 deleted file mode 100644 index d28842f..0000000 --- a/src/web/src/features/dataGrid/hooks/useGridSort.ts +++ /dev/null @@ -1,49 +0,0 @@ -// 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 deleted file mode 100644 index 09e3c5d..0000000 --- a/src/web/src/features/dataGrid/hooks/useRepaintRows.ts +++ /dev/null @@ -1,108 +0,0 @@ -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 deleted file mode 100644 index 9e17db1..0000000 --- a/src/web/src/features/dataGrid/hooks/useRowPages.test.tsx +++ /dev/null @@ -1,418 +0,0 @@ -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 deleted file mode 100644 index 7767c4f..0000000 --- a/src/web/src/features/dataGrid/hooks/useRowPages.ts +++ /dev/null @@ -1,425 +0,0 @@ -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 deleted file mode 100644 index 0e4dd32..0000000 --- a/src/web/src/features/dataGrid/hooks/useWindowRange.test.tsx +++ /dev/null @@ -1,134 +0,0 @@ -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 deleted file mode 100644 index 615c195..0000000 --- a/src/web/src/features/dataGrid/hooks/useWindowRange.ts +++ /dev/null @@ -1,147 +0,0 @@ -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 deleted file mode 100644 index d064714..0000000 --- a/src/web/src/features/dataGrid/store/columnsSlice.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index e666a7f..0000000 --- a/src/web/src/features/dataGrid/store/columnsSlice.ts +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index 9c59421..0000000 --- a/src/web/src/features/dataGrid/store/createGridInstance.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -// 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 deleted file mode 100644 index afa52b5..0000000 --- a/src/web/src/features/dataGrid/store/createGridInstance.ts +++ /dev/null @@ -1,136 +0,0 @@ -// 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 deleted file mode 100644 index a1e588e..0000000 --- a/src/web/src/features/dataGrid/store/editsSlice.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index 8920947..0000000 --- a/src/web/src/features/dataGrid/store/editsSlice.ts +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index 92da682..0000000 --- a/src/web/src/features/dataGrid/store/effects/columnsPersistenceEffect.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index ac44815..0000000 --- a/src/web/src/features/dataGrid/store/effects/index.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 17a96ca..0000000 --- a/src/web/src/features/dataGrid/store/effects/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 67f8702..0000000 --- a/src/web/src/features/dataGrid/store/effects/types.ts +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index f5d7a84..0000000 --- a/src/web/src/features/dataGrid/store/groupsSlice.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 038db99..0000000 --- a/src/web/src/features/dataGrid/store/groupsSlice.ts +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 0081d7a..0000000 --- a/src/web/src/features/dataGrid/store/localStorageColumnsAdapter.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index fe2fb47..0000000 --- a/src/web/src/features/dataGrid/store/localStorageColumnsAdapter.ts +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 189e1b3..0000000 --- a/src/web/src/features/dataGrid/store/selectionSlice.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 8332404..0000000 --- a/src/web/src/features/dataGrid/testing/fakeRowServer.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -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 deleted file mode 100644 index baa4cd0..0000000 --- a/src/web/src/features/dataGrid/testing/fakeRowServer.ts +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index acd1f7f..0000000 --- a/src/web/src/features/dataGrid/types.ts +++ /dev/null @@ -1,518 +0,0 @@ -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. - * - * `filter` is opaque here: the descriptor's own implementation knows its - * shape, and the grid only carries it. It belongs in the count's query key, - * because a filter change changes the total, and a stale total sizes the - * scroll bar for rows the grid never receives. - */ - fetchCount: (p: { - collapsedGroups: TGroup[]; - filter?: unknown; - 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 deleted file mode 100644 index 2b28817..0000000 --- a/src/web/src/features/dataGrid/useGridDispatch.ts +++ /dev/null @@ -1,7 +0,0 @@ -// 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 deleted file mode 100644 index 0a35c4d..0000000 --- a/src/web/src/features/testGrid/MockControls.test.tsx +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index 4ea4188..0000000 --- a/src/web/src/features/testGrid/MockControls.tsx +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index 6037c02..0000000 --- a/src/web/src/features/testGrid/TestGridPage.tsx +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index a738ae2..0000000 --- a/src/web/src/features/testGrid/api/testRowQueries.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -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 deleted file mode 100644 index 03013e3..0000000 --- a/src/web/src/features/testGrid/api/testRowQueries.ts +++ /dev/null @@ -1,85 +0,0 @@ -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 deleted file mode 100644 index 14d2924..0000000 --- a/src/web/src/features/testGrid/api/types.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * 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 deleted file mode 100644 index 37e066e..0000000 --- a/src/web/src/features/testGrid/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index daa7fc4..0000000 --- a/src/web/src/features/testGrid/mocks/generateRows.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 028f09c..0000000 --- a/src/web/src/features/testGrid/mocks/generateRows.ts +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index 42c5521..0000000 --- a/src/web/src/features/testGrid/mocks/handlers.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -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 deleted file mode 100644 index 66d7fcb..0000000 --- a/src/web/src/features/testGrid/mocks/handlers.ts +++ /dev/null @@ -1,133 +0,0 @@ -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 deleted file mode 100644 index 74cb172..0000000 --- a/src/web/src/features/testGrid/mocks/mockConfig.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 78aeb28..0000000 --- a/src/web/src/features/testGrid/mocks/mockConfig.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * 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 deleted file mode 100644 index c38d882..0000000 --- a/src/web/src/features/testGrid/mocks/testRowStore.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -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 deleted file mode 100644 index 1cf43c6..0000000 --- a/src/web/src/features/testGrid/mocks/testRowStore.ts +++ /dev/null @@ -1,215 +0,0 @@ -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 deleted file mode 100644 index 1472d35..0000000 --- a/src/web/src/features/testGrid/testGrid.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index b06aaef..0000000 --- a/src/web/src/features/testGrid/testGrid.ts +++ /dev/null @@ -1,103 +0,0 @@ -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 deleted file mode 100644 index 8412f93..0000000 --- a/src/web/src/features/testGrid/testGridCells.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index c7478c6..0000000 --- a/src/web/src/features/testGrid/testGridCells.ts +++ /dev/null @@ -1,135 +0,0 @@ -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 550f8fc..f539c5b 100644 --- a/src/web/src/index.css +++ b/src/web/src/index.css @@ -1,6 +1,5 @@ /* 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. */ + This file carries only what Radix cannot: the document layout chain. */ /* 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 @@ -30,23 +29,3 @@ body, 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 deleted file mode 100644 index 1c302bc..0000000 --- a/src/web/src/lib/date/dateUtils.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -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 deleted file mode 100644 index 4fef6c6..0000000 --- a/src/web/src/lib/date/dateUtils.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * 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 deleted file mode 100644 index ed381ac..0000000 --- a/src/web/src/lib/grid/cellRegistry.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index ed690da..0000000 --- a/src/web/src/lib/grid/cellRegistry.ts +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index 24da759..0000000 --- a/src/web/src/lib/grid/createCustomCell.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index 034d1cd..0000000 --- a/src/web/src/lib/grid/createCustomCell.tsx +++ /dev/null @@ -1,157 +0,0 @@ -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 deleted file mode 100644 index f7c32bf..0000000 --- a/src/web/src/lib/grid/createDateCell.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -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 deleted file mode 100644 index d4f9d8e..0000000 --- a/src/web/src/lib/grid/createDateCell.tsx +++ /dev/null @@ -1,161 +0,0 @@ -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 deleted file mode 100644 index 6eb9d93..0000000 --- a/src/web/src/lib/grid/createEnumCell.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index 7fc4ff5..0000000 --- a/src/web/src/lib/grid/createEnumCell.tsx +++ /dev/null @@ -1,132 +0,0 @@ -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 deleted file mode 100644 index c52f73b..0000000 --- a/src/web/src/lib/grid/createNumberCell.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -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 deleted file mode 100644 index 20edd95..0000000 --- a/src/web/src/lib/grid/createNumberCell.tsx +++ /dev/null @@ -1,122 +0,0 @@ -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 deleted file mode 100644 index f85e43a..0000000 --- a/src/web/src/lib/grid/createPhoneCell.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index e45ad79..0000000 --- a/src/web/src/lib/grid/createPhoneCell.tsx +++ /dev/null @@ -1,121 +0,0 @@ -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 deleted file mode 100644 index c976c03..0000000 --- a/src/web/src/lib/grid/createTextCell.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -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 deleted file mode 100644 index 0333a66..0000000 --- a/src/web/src/lib/grid/createTextCell.tsx +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index 0f0044d..0000000 --- a/src/web/src/lib/grid/radixBadgePalette.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 97d082a..0000000 --- a/src/web/src/lib/grid/radixBadgePalette.ts +++ /dev/null @@ -1,50 +0,0 @@ -// 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 deleted file mode 100644 index dd79e2e..0000000 --- a/src/web/src/lib/grid/softBadge.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -// @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 deleted file mode 100644 index 7269c45..0000000 --- a/src/web/src/lib/grid/softBadge.tsx +++ /dev/null @@ -1,124 +0,0 @@ -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 deleted file mode 100644 index 2191f0c..0000000 --- a/src/web/src/lib/grid/softBadgeView.tsx +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 802e0f7..0000000 --- a/src/web/src/lib/grid/useEditHighlight.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 7fb2e7f..0000000 --- a/src/web/src/lib/grid/useEditHighlight.ts +++ /dev/null @@ -1,49 +0,0 @@ -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/number/numberUtils.test.ts b/src/web/src/lib/number/numberUtils.test.ts deleted file mode 100644 index 2c97ccd..0000000 --- a/src/web/src/lib/number/numberUtils.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -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 deleted file mode 100644 index f16e1dc..0000000 --- a/src/web/src/lib/number/numberUtils.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * 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 deleted file mode 100644 index 7da7a08..0000000 --- a/src/web/src/lib/phone/phoneUtils.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index e6cd8a1..0000000 --- a/src/web/src/lib/phone/phoneUtils.ts +++ /dev/null @@ -1,73 +0,0 @@ -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 deleted file mode 100644 index 67e61ec..0000000 --- a/src/web/src/lib/text/textValidation.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -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 deleted file mode 100644 index 382bceb..0000000 --- a/src/web/src/lib/text/textValidation.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * 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 deleted file mode 100644 index f2ad2e7..0000000 --- a/src/web/src/lib/utils.ts +++ /dev/null @@ -1,7 +0,0 @@ -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 d65db39..96aeede 100644 --- a/src/web/src/main.tsx +++ b/src/web/src/main.tsx @@ -8,7 +8,12 @@ import { router } from "./app/router"; import { queryClient } from "./app/queryClient"; import "./index.css"; import "./tailwind.css"; -import "./theme/radixStyles"; +// The grid's own stylesheets. `radix-styles` is a side-effect module carrying +// the Radix Themes component CSS plus every colour scale an enum badge can land +// on; without it the badges draw with unresolved CSS variables. `datagrid.css` +// is layout only, on `dg:`-prefixed classes, and resets nothing of ours. +import "@matthewhsu1/datagrid/radix-styles"; +import "@matthewhsu1/datagrid/datagrid.css"; import { RadixThemeProvider } from "./theme/RadixThemeProvider"; import { startColorSchemeWatcher } from "./theme/colorSchemeWatcher"; import { setRefreshHandler } from "./lib/http/authTokens"; @@ -29,37 +34,14 @@ setRefreshHandler(async () => { 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(); +createRoot(document.getElementById("root")!).render( + + + + + + + + + , +); diff --git a/src/web/src/mocks/browser.ts b/src/web/src/mocks/browser.ts deleted file mode 100644 index 20e77c8..0000000 --- a/src/web/src/mocks/browser.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 index fdbee5a..4f784ca 100644 --- a/src/web/src/routeTree.gen.ts +++ b/src/web/src/routeTree.gen.ts @@ -9,15 +9,9 @@ // 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', @@ -32,42 +26,31 @@ const IndexRoute = IndexRouteImport.update({ 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' + fullPaths: '/' | '/bonds' fileRoutesByTo: FileRoutesByTo - to: '/' | '/bonds' | '/test-grid' - id: '__root__' | '/' | '/bonds' | '/test-grid' + to: '/' | '/bonds' + id: '__root__' | '/' | '/bonds' 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' @@ -88,7 +71,6 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, BondsRoute: BondsRoute, - TestGridRoute: TestGridRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/web/src/routes/test-grid.tsx b/src/web/src/routes/test-grid.tsx deleted file mode 100644 index 5103a4a..0000000 --- a/src/web/src/routes/test-grid.tsx +++ /dev/null @@ -1,21 +0,0 @@ -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 index f3dc5d3..080147b 100644 --- a/src/web/src/tailwind.css +++ b/src/web/src/tailwind.css @@ -3,11 +3,3 @@ @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 index f50906c..9f84ae6 100644 --- a/src/web/src/theme/RadixThemeProvider.tsx +++ b/src/web/src/theme/RadixThemeProvider.tsx @@ -1,20 +1,27 @@ +import { DEFAULT_GRID_RADIX_THEME } from "@matthewhsu1/datagrid"; 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 + * light/dark mode from it — the same source the grid's own theme uses. Wrap the * app once; portalled Radix components (dialogs, menus) re-apply the theme * automatically. + * + * The accent comes from the grid engine's default rather than a config of our + * own, so the app and the grid's portalled cell editors agree by construction. + * A cell editor mounts its own `` outside this provider's React tree, so + * matching it here is the only thing that keeps the two in step. Diverge from + * the default and this must pass the same values to `configureGridTheme` at + * app start. */ 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 deleted file mode 100644 index b4c5fd6..0000000 --- a/src/web/src/theme/appearanceSlice.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 89250be..0000000 --- a/src/web/src/theme/appearanceSlice.ts +++ /dev/null @@ -1,23 +0,0 @@ -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.ts b/src/web/src/theme/colorSchemeWatcher.ts index 2caa6c1..6dddb89 100644 --- a/src/web/src/theme/colorSchemeWatcher.ts +++ b/src/web/src/theme/colorSchemeWatcher.ts @@ -1,4 +1,4 @@ -import { setAppearance, type Appearance } from "./appearanceSlice"; +import { setAppearance, type Appearance } from "@matthewhsu1/datagrid"; // The WebView inherits the OS/app colour scheme, so the CSS media query is our // appearance signal. diff --git a/src/web/src/theme/gridThemes.test.ts b/src/web/src/theme/gridThemes.test.ts deleted file mode 100644 index 98ead3f..0000000 --- a/src/web/src/theme/gridThemes.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 5af5726..0000000 --- a/src/web/src/theme/gridThemes.ts +++ /dev/null @@ -1,102 +0,0 @@ -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 deleted file mode 100644 index f9d7e3c..0000000 --- a/src/web/src/theme/radixStyles.ts +++ /dev/null @@ -1,22 +0,0 @@ -// 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 deleted file mode 100644 index 1521693..0000000 --- a/src/web/src/theme/radixTheme.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 71084a5..0000000 --- a/src/web/src/theme/radixTheme.ts +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index cbda63a..0000000 --- a/src/web/src/theme/useGridTheme.ts +++ /dev/null @@ -1,22 +0,0 @@ -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/vitest.config.ts b/src/web/vitest.config.ts index f409aba..366cec5 100644 --- a/src/web/vitest.config.ts +++ b/src/web/vitest.config.ts @@ -7,5 +7,14 @@ export default defineConfig({ environment: "jsdom", globals: true, setupFiles: ["./src/test/setup.ts"], + server: { + deps: { + // The grid engine's entry imports glide-data-grid's stylesheet. Vitest + // externalises node_modules by default, so that import reaches Node + // untransformed and throws "Unknown file extension .css". Inlining the + // package routes it through Vite, which handles the CSS. + inline: ["@matthewhsu1/datagrid"], + }, + }, }, }); From e7a766805afb1929034d0efe04557f81786b158a Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Sun, 30 Aug 2026 02:23:08 -0600 Subject: [PATCH 5/7] refactor: remove unused number, phone, and text validation utilities --- .../src/features/dataGrid/data/editOverlay.ts | 106 -------------- .../features/dataGrid/data/rowCount.test.tsx | 134 ------------------ 2 files changed, 240 deletions(-) delete mode 100644 src/web/src/features/dataGrid/data/editOverlay.ts delete mode 100644 src/web/src/features/dataGrid/data/rowCount.test.tsx diff --git a/src/web/src/features/dataGrid/data/editOverlay.ts b/src/web/src/features/dataGrid/data/editOverlay.ts deleted file mode 100644 index 3a4a9aa..0000000 --- a/src/web/src/features/dataGrid/data/editOverlay.ts +++ /dev/null @@ -1,106 +0,0 @@ -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 deleted file mode 100644 index 66d609a..0000000 --- a/src/web/src/features/dataGrid/data/rowCount.test.tsx +++ /dev/null @@ -1,134 +0,0 @@ -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; -} - -/** The shape `useRowCount` hands the descriptor, so the calls can be asserted on. */ -type CountParams = { collapsedGroups: number[]; filter?: unknown; signal?: AbortSignal }; - -function harness(count = 42, fetchCountImpl?: () => Promise) { - const fetchCount = vi.fn<(p: CountParams) => Promise>( - 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)); - }); - - it("asks again when the filter changes", async () => { - const { descriptor, fetchCount, wrapper } = harness(); - const { rerender } = renderHook(({ filter }) => useRowCount(descriptor, [], filter), { - wrapper, - initialProps: { filter: { symbol: "110" } as { symbol: string } }, - }); - - await waitFor(() => expect(fetchCount).toHaveBeenCalledTimes(1)); - rerender({ filter: { symbol: "220" } }); - // Without the filter in the key the grid would keep the first total and - // size its scroll bar for rows the new filter excludes. - await waitFor(() => expect(fetchCount).toHaveBeenCalledTimes(2)); - expect(fetchCount.mock.calls[1][0]).toMatchObject({ filter: { symbol: "220" } }); - }); - - it("hashes the filter structurally, so an equal filter rebuilt on a render asks nothing", async () => { - const { descriptor, fetchCount, wrapper } = harness(); - const { rerender } = renderHook(({ filter }) => useRowCount(descriptor, [], filter), { - wrapper, - initialProps: { filter: { symbol: "110" } as { symbol: string } }, - }); - - await waitFor(() => expect(fetchCount).toHaveBeenCalledTimes(1)); - - // A new object holding the same values. `rerender` flushes the effects, so - // a changed key would have fetched by the time it returns. - rerender({ filter: { symbol: "110" } }); - expect(fetchCount).toHaveBeenCalledTimes(1); - - rerender({ filter: { symbol: "220" } }); - 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])); - }); - - it("keys two filters apart, and an absent filter apart from any filter", () => { - expect(rowCountKey("demo", [], { symbol: "1" })).not.toEqual( - rowCountKey("demo", [], { symbol: "2" }), - ); - expect(rowCountKey("demo", [])).not.toEqual(rowCountKey("demo", [], { symbol: "1" })); - }); - - it("appends nothing for an absent filter, so the unfiltered key stays a prefix", () => { - // `useRowSync` invalidates on this shorter key after a push. TanStack - // matches by prefix, so one recount must reach every filter variant. - expect(rowCountKey("demo", [1])).toEqual(["demo", "count", [1]]); - expect(rowCountKey("demo", [1], { symbol: "1" })).toEqual([ - "demo", - "count", - [1], - { symbol: "1" }, - ]); - }); -}); From 25fb784b4a91e77ddf07b957b9a6c28b9aff9443 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Sun, 30 Aug 2026 02:38:43 -0600 Subject: [PATCH 6/7] feat: update .gitignore and add user secrets ID to project files --- .gitignore | 4 +- justfile | 78 ++++++++++ src/AppName.Api/AppName.Api.csproj | 1 + src/AppName.Maui/AppName.Maui.csproj | 215 ++++++++++++++------------- 4 files changed, 190 insertions(+), 108 deletions(-) diff --git a/.gitignore b/.gitignore index 926a207..b5d38b1 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,6 @@ src/AppName.Maui/Resources/Raw/web/ .DS_Store .playwright-cli -.serena \ No newline at end of file +.serena +# `just dev` PID and log files. +.just-dev/ diff --git a/justfile b/justfile index fa43453..962cf05 100644 --- a/justfile +++ b/justfile @@ -36,6 +36,84 @@ web-build: web-dev: cd src/web && npm run dev +# ---- Dev servers ------------------------------------------------------------- + +# PID and log files for `just dev`. Git-ignored; nothing else reads it. +run_dir := ".just-dev" + +# Where the API listens. src/web/.env.development points VITE_API_BASE_URL at +# this exact URL, and the API's Cors:Origins allows Vite's own :5173 back. +# Passed explicitly because the API project has no launchSettings.json to +# default from. +api_url := "http://localhost:5000" + +# Start the API and Vite in the background (stop: `just dev-stop`, output: `just dev-logs`). +dev: dev-stop + #!/usr/bin/env bash + set -euo pipefail + + mkdir -p "{{run_dir}}" + + # Each server is started under `setsid`, in its own process group, and + # records its OWN pid rather than the one the shell hands back. + # + # Both wrappers fork the thing that actually holds the port -- `dotnet run` + # execs the built binary, `npm run dev` spawns vite -- so killing the pid + # `$!` gives us would reap the wrapper and leave the server behind, still + # holding :5000 or :5173. Writing `$$` from inside the new session records + # the group leader instead, and `exec` keeps that same pid for the server. + # `dev-stop` then signals the whole group. + # ASPNETCORE_ENVIRONMENT is set by hand because the API project has no + # launchSettings.json to carry it, and `dotnet run` then defaults to + # Production. That default is not cosmetic: WebApplication.CreateBuilder + # loads user-secrets ONLY in Development, so a Production run cannot see + # ConnectionStrings:AppDb and dies in AddInfrastructure before it listens. + # appsettings.Development.json -- the dev JWT key, and the Cors:Origins + # entry for Vite's :5173 -- is skipped for the same reason. + setsid bash -c "echo \$\$ > '{{run_dir}}/api.pid'; ASPNETCORE_ENVIRONMENT=Development exec dotnet run --project src/AppName.Api --urls '{{api_url}}'" \ + > "{{run_dir}}/api.log" 2>&1 & + + setsid bash -c "echo \$\$ > '{{run_dir}}/web.pid'; exec npm --prefix src/web run dev" \ + > "{{run_dir}}/web.log" 2>&1 & + + echo "api {{api_url}} -> {{run_dir}}/api.log" + echo "web http://localhost:5173 -> {{run_dir}}/web.log" + echo + echo "just dev-logs follow both" + echo "just dev-stop stop both" + +# Stop whatever `just dev` started. Safe to run when nothing is up. +dev-stop: + #!/usr/bin/env bash + # No `-e`: a stale pid file whose process is already gone is the normal + # case, not a failure. + set -uo pipefail + + for name in api web; do + pid_file="{{run_dir}}/${name}.pid" + + [[ -f "$pid_file" ]] || continue + + pid="$(cat "$pid_file")" + + # A NEGATIVE pid signals the whole process group -- see the note in + # `dev` for why the group and not the single process. + if [[ -n "$pid" ]] && kill -TERM -- "-${pid}" 2>/dev/null; then + echo "stopped ${name}" + fi + + rm -f "$pid_file" + done + +# Follow both dev-server logs. Ctrl+C stops following; the servers keep running. +dev-logs: + #!/usr/bin/env bash + set -euo pipefail + + # -F rather than -f so a log that has not been created yet is waited for + # instead of aborting the whole tail. + tail -n 40 -F "{{run_dir}}/api.log" "{{run_dir}}/web.log" + # ---- MAUI app ---------------------------------------------------------------- # Build the MAUI head (builds the React bundle first via the BuildReactApp MSBuild target). diff --git a/src/AppName.Api/AppName.Api.csproj b/src/AppName.Api/AppName.Api.csproj index 70c6d38..1b2ff39 100644 --- a/src/AppName.Api/AppName.Api.csproj +++ b/src/AppName.Api/AppName.Api.csproj @@ -2,6 +2,7 @@ net10.0 + 408b55d2-b12e-4515-8cac-fa34dbc48f54 diff --git a/src/AppName.Maui/AppName.Maui.csproj b/src/AppName.Maui/AppName.Maui.csproj index 4047977..678f0e6 100644 --- a/src/AppName.Maui/AppName.Maui.csproj +++ b/src/AppName.Maui/AppName.Maui.csproj @@ -1,107 +1,108 @@ - - - - net10.0-android - $(TargetFrameworks);net10.0-ios;net10.0-maccatalyst - $(TargetFrameworks);net10.0-windows10.0.19041.0 - - - - - Exe - AppName.Maui - true - true - enable - enable - - - SourceGen - - - AppName.Maui - - - com.companyname.appname.maui - - - 1.0 - 1 - - - None - - 15.0 - 15.0 - 21.0 - 10.0.17763.0 - 10.0.17763.0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - false - $(MSBuildProjectDirectory)/../web - - - - - - - - - - + + + + net10.0-android + $(TargetFrameworks);net10.0-ios;net10.0-maccatalyst + $(TargetFrameworks);net10.0-windows10.0.19041.0 + + + + + Exe + AppName.Maui + true + true + enable + enable + + + SourceGen + + + AppName.Maui + + + com.companyname.appname.maui + + + 1.0 + 1 + + + None + + 15.0 + 15.0 + 21.0 + 10.0.17763.0 + 10.0.17763.0 + a184d6d7-822c-4663-a154-2dc7f794b037 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + false + $(MSBuildProjectDirectory)/../web + + + + + + + + + + From 5741d7e38b6e1c3dac49736205a6e735d24bb395 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Sun, 30 Aug 2026 03:18:22 -0600 Subject: [PATCH 7/7] feat: Add logging support to DailyRefreshGate and related use cases --- Directory.Packages.props | 1 + .../AppName.Application.csproj | 1 + .../UseCases/Bonds/DailyRefreshGate.cs | 90 ++++++++++----- .../Bonds/GetValuationCountUseCase.cs | 12 +- .../UseCases/Bonds/GetValuationPageUseCase.cs | 14 +-- .../UseCases/Bonds/GetValuationsUseCase.cs | 6 +- .../IBondValuationRefreshStateRepository.cs | 25 +++- .../Bonds/BondValuationRefreshState.cs | 27 ++++- ...ValuationRefreshRetryNotBefore.Designer.cs | 107 ++++++++++++++++++ ...5_AddBondValuationRefreshRetryNotBefore.cs | 36 ++++++ .../Migrations/AppDbContextModelSnapshot.cs | 5 +- .../BondValuationRefreshStateRepository.cs | 32 +++++- .../Persistence/Context/AppDbContext.cs | 1 + .../Bonds/GetValuationCountUseCaseTests.cs | 80 ++++++++++++- .../Bonds/GetValuationPageUseCaseTests.cs | 9 +- .../Bonds/GetValuationsUseCaseTests.cs | 9 +- ...ondValuationRefreshStateRepositoryTests.cs | 83 ++++++++++++-- 17 files changed, 462 insertions(+), 76 deletions(-) create mode 100644 src/AppName.Infrastructure/Migrations/20260830085145_AddBondValuationRefreshRetryNotBefore.Designer.cs create mode 100644 src/AppName.Infrastructure/Migrations/20260830085145_AddBondValuationRefreshRetryNotBefore.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index f88d422..928c4ad 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,7 @@ + diff --git a/src/AppName.Application/AppName.Application.csproj b/src/AppName.Application/AppName.Application.csproj index dde67e6..d57d982 100644 --- a/src/AppName.Application/AppName.Application.csproj +++ b/src/AppName.Application/AppName.Application.csproj @@ -10,6 +10,7 @@ + diff --git a/src/AppName.Application/UseCases/Bonds/DailyRefreshGate.cs b/src/AppName.Application/UseCases/Bonds/DailyRefreshGate.cs index 8c99082..fb4bac4 100644 --- a/src/AppName.Application/UseCases/Bonds/DailyRefreshGate.cs +++ b/src/AppName.Application/UseCases/Bonds/DailyRefreshGate.cs @@ -1,5 +1,6 @@ using AppName.Application.Time; using AppName.Domain.Abstractions.Bonds; +using Microsoft.Extensions.Logging; namespace AppName.Application.UseCases.Bonds; @@ -7,25 +8,13 @@ namespace AppName.Application.UseCases.Bonds; /// The once-per-Taiwan-trading-day refresh gate every valuation read passes /// through. ///
-/// -/// -/// The gate is a static internal collaborator rather than a private helper on -/// each use case, because three use cases share it and three copies drift. It -/// stays static and takes its dependencies as arguments so it needs no DI -/// registration, and so the use-case tests reach it through the use cases they -/// already build instead of through InternalsVisibleTo. -/// -/// -/// The gate claims the day instead of reading the marker and comparing it. The -/// grid starts the count query and the first window query together on mount, and -/// neither is guaranteed to arrive first. A read followed by a separate write -/// lets both callers see the stale marker, so both start a whole-market refresh. -/// tests -/// and sets in one statement, so exactly one caller wins the day. -/// -/// internal static class DailyRefreshGate { + /// + /// How long a failed attempt blocks the next one. + /// + internal static readonly TimeSpan RetryBackoff = TimeSpan.FromMinutes(5); + /// /// Refreshes the valuation cache when this caller wins today's claim, and /// returns without refreshing when it loses. @@ -33,30 +22,75 @@ internal static class DailyRefreshGate /// Holds the singleton refresh marker the claim updates. /// Runs the whole-market refresh. /// Resolves today's Taiwan trading date. + /// Records a failed attempt, which is otherwise invisible to the caller. /// Cancels the claim and the refresh. internal static async Task EnsureFreshAsync( IBondValuationRefreshStateRepository refreshStateRepo, IRefreshAllBondsUseCase refreshAllBonds, TimeProvider timeProvider, + ILogger logger, CancellationToken ct) { + // UtcDateTime because the marker is compared in SQL, and EF Core's + // SQLite provider cannot translate a DateTimeOffset comparison. + var now = timeProvider.GetUtcNow().UtcDateTime; var today = TaiwanClock.Today(timeProvider); // A losing claim falls through and serves the cache. It does not wait for // the winner, because freshness is best-effort and waiting would couple // every window request to one whole-market fetch. - if (await refreshStateRepo.TryClaimAttemptAsync(today, ct)) + if (!await refreshStateRepo.TryClaimAttemptAsync(today, now, ct)) + { + return; + } + + try + { + await refreshAllBonds.ExecuteAsync(ct); + } + catch (OperationCanceledException) + { + // The caller walked away mid-refresh. Nothing failed, so this is not + // logged as an error, but the day must still be handed back: an + // attempt that never finished has not refreshed anything. The retry + // is immediate because there is no upstream fault to back off from. + await ReleaseQuietlyAsync(refreshStateRepo, now, logger); + throw; + } + catch (Exception ex) + { + // Freshness is best-effort; a provider outage must serve stale cache, + // not fail the caller. That silence is exactly why this is logged -- + // without it, an empty grid and a working-but-empty market look + // identical from the outside. + logger.LogError( + ex, + "Daily bond valuation refresh failed for Taiwan date {TaiwanDate}. Serving the cached valuations. The day has been released; the next read after {RetryNotBefore:O} will try again.", + today, + now + RetryBackoff); + + await ReleaseQuietlyAsync(refreshStateRepo, now + RetryBackoff, logger); + } + } + + /// + /// Releases the claim, swallowing any failure to do so. + /// + /// + private static async Task ReleaseQuietlyAsync( + IBondValuationRefreshStateRepository refreshStateRepo, + DateTime retryNotBefore, + ILogger logger) + { + try + { + await refreshStateRepo.ReleaseClaimAsync(retryNotBefore, CancellationToken.None); + } + catch (Exception ex) { - try - { - await refreshAllBonds.ExecuteAsync(ct); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - // Freshness is best-effort; a provider outage must serve stale cache, - // not fail the caller. The claim already stamped today, so a failed - // attempt still consumes the day and no marker write belongs here. - } + logger.LogError( + ex, + "Could not release the bond valuation refresh claim. No further refresh will be attempted until tomorrow."); } } } diff --git a/src/AppName.Application/UseCases/Bonds/GetValuationCountUseCase.cs b/src/AppName.Application/UseCases/Bonds/GetValuationCountUseCase.cs index fec5882..1c86190 100644 --- a/src/AppName.Application/UseCases/Bonds/GetValuationCountUseCase.cs +++ b/src/AppName.Application/UseCases/Bonds/GetValuationCountUseCase.cs @@ -1,5 +1,6 @@ using AppName.Domain.Abstractions.Bonds; using AppName.Domain.ValueObjects.Valuations; +using Microsoft.Extensions.Logging; namespace AppName.Application.UseCases.Bonds; @@ -7,17 +8,12 @@ namespace AppName.Application.UseCases.Bonds; /// Returns how many cached valuations one filter matches, refreshing the cache /// once per Taiwan trading day. Refresh is an internal, lazy step — never /// surfaced as an error to the caller. -/// -/// -/// The grid keeps its row count in a query of its own, so this path can arrive -/// before any window query. It therefore passes the same refresh gate; whichever -/// request arrives first wins the day. -/// public sealed class GetValuationCountUseCase( IBondValuationRefreshStateRepository refreshStateRepo, IValuationSnapshotRepository snapshotRepo, IRefreshAllBondsUseCase refreshAllBonds, - TimeProvider timeProvider) + TimeProvider timeProvider, + ILogger logger) { /// /// Ensures freshness (best-effort) then returns the filtered total. @@ -26,7 +22,7 @@ public sealed class GetValuationCountUseCase( /// Cancels the refresh and the count. public async Task ExecuteAsync(ValuationFilter filter, CancellationToken ct = default) { - await DailyRefreshGate.EnsureFreshAsync(refreshStateRepo, refreshAllBonds, timeProvider, ct); + await DailyRefreshGate.EnsureFreshAsync(refreshStateRepo, refreshAllBonds, timeProvider, logger, ct); return await snapshotRepo.CountAsync(filter, ct); } diff --git a/src/AppName.Application/UseCases/Bonds/GetValuationPageUseCase.cs b/src/AppName.Application/UseCases/Bonds/GetValuationPageUseCase.cs index 6df1c0c..5f080f4 100644 --- a/src/AppName.Application/UseCases/Bonds/GetValuationPageUseCase.cs +++ b/src/AppName.Application/UseCases/Bonds/GetValuationPageUseCase.cs @@ -1,6 +1,7 @@ using AppName.Application.Dtos.Bonds; using AppName.Domain.Abstractions.Bonds; using AppName.Domain.ValueObjects.Valuations; +using Microsoft.Extensions.Logging; namespace AppName.Application.UseCases.Bonds; @@ -9,19 +10,12 @@ namespace AppName.Application.UseCases.Bonds; /// same filter matches, refreshing the cache once per Taiwan trading day. Refresh /// is an internal, lazy step — never surfaced as an error to the caller. /// -/// -/// This use case returns both halves because its caller sends a nested filter -/// tree. Split into a slice call and a count call, that caller would send the same -/// tree twice, and two trees can disagree, so the total would size a window it -/// does not describe. The eight flat filters of the grid path are cheap to repeat, -/// which is why and -/// stay separate there. -/// public sealed class GetValuationPageUseCase( IBondValuationRefreshStateRepository refreshStateRepo, IValuationSnapshotRepository snapshotRepo, IRefreshAllBondsUseCase refreshAllBonds, - TimeProvider timeProvider) + TimeProvider timeProvider, + ILogger logger) { /// /// Ensures freshness (best-effort) then returns the slice and the filtered total. @@ -30,7 +24,7 @@ public sealed class GetValuationPageUseCase( /// Cancels the refresh, the read, and the count. public async Task ExecuteAsync(ValuationQuery query, CancellationToken ct = default) { - await DailyRefreshGate.EnsureFreshAsync(refreshStateRepo, refreshAllBonds, timeProvider, ct); + await DailyRefreshGate.EnsureFreshAsync(refreshStateRepo, refreshAllBonds, timeProvider, logger, ct); var snapshots = await snapshotRepo.QueryAsync(query, ct); diff --git a/src/AppName.Application/UseCases/Bonds/GetValuationsUseCase.cs b/src/AppName.Application/UseCases/Bonds/GetValuationsUseCase.cs index 47bfb88..1b60ede 100644 --- a/src/AppName.Application/UseCases/Bonds/GetValuationsUseCase.cs +++ b/src/AppName.Application/UseCases/Bonds/GetValuationsUseCase.cs @@ -1,5 +1,6 @@ using AppName.Application.Dtos.Bonds; using AppName.Domain.Abstractions.Bonds; +using Microsoft.Extensions.Logging; using AppName.Domain.ValueObjects.Valuations; namespace AppName.Application.UseCases.Bonds; @@ -13,7 +14,8 @@ public sealed class GetValuationsUseCase( IBondValuationRefreshStateRepository refreshStateRepo, IValuationSnapshotRepository snapshotRepo, IRefreshAllBondsUseCase refreshAllBonds, - TimeProvider timeProvider) + TimeProvider timeProvider, + ILogger logger) { /// /// Ensures freshness (best-effort) then returns the requested slice. @@ -23,7 +25,7 @@ public sealed class GetValuationsUseCase( public async Task> ExecuteAsync( ValuationQuery query, CancellationToken ct = default) { - await DailyRefreshGate.EnsureFreshAsync(refreshStateRepo, refreshAllBonds, timeProvider, ct); + await DailyRefreshGate.EnsureFreshAsync(refreshStateRepo, refreshAllBonds, timeProvider, logger, ct); var snapshots = await snapshotRepo.QueryAsync(query, ct); return snapshots.Select(ValuationSnapshotMapper.ToDto).ToList(); diff --git a/src/AppName.Domain/Abstractions/Bonds/IBondValuationRefreshStateRepository.cs b/src/AppName.Domain/Abstractions/Bonds/IBondValuationRefreshStateRepository.cs index 5615ed1..649d4b0 100644 --- a/src/AppName.Domain/Abstractions/Bonds/IBondValuationRefreshStateRepository.cs +++ b/src/AppName.Domain/Abstractions/Bonds/IBondValuationRefreshStateRepository.cs @@ -21,6 +21,7 @@ public interface IBondValuationRefreshStateRepository /// Claims today's refresh attempt for exactly one caller. /// /// The Taiwan calendar date of the attempt. + /// The UTC instant the claim is made, compared against a pending retry. /// Cancels the claim. /// /// True when this caller won the claim and must run the refresh; false when @@ -28,9 +29,31 @@ public interface IBondValuationRefreshStateRepository /// must serve the cached data. /// /// + /// /// A read-then-write gate lets concurrent requests all observe the stale /// marker and all start a whole-market refresh. This claim is one /// conditional statement, so only the first caller of the day wins. + /// + /// + /// A caller also wins when the day is already claimed but a previous + /// attempt released it and its + /// has passed. A + /// won claim clears that marker, so the retry is handed to exactly one + /// caller too. + /// /// - Task TryClaimAttemptAsync(DateOnly today, CancellationToken ct = default); + Task TryClaimAttemptAsync(DateOnly today, DateTime now, CancellationToken ct = default); + + /// + /// Gives today's claim back after a failed attempt, so it can be retried + /// once has passed. + /// + /// The earliest UTC instant another caller may claim the day again. + /// Cancels the release. + /// + /// Without this, a single upstream failure consumes the whole trading day: + /// the claim is stamped before the refresh runs, so every later read sees + /// the day as done and serves an empty cache until tomorrow. + /// + Task ReleaseClaimAsync(DateTime retryNotBefore, CancellationToken ct = default); } diff --git a/src/AppName.Domain/Entities/Bonds/BondValuationRefreshState.cs b/src/AppName.Domain/Entities/Bonds/BondValuationRefreshState.cs index 1f1dc30..9800f7f 100644 --- a/src/AppName.Domain/Entities/Bonds/BondValuationRefreshState.cs +++ b/src/AppName.Domain/Entities/Bonds/BondValuationRefreshState.cs @@ -9,7 +9,11 @@ namespace AppName.Domain.Entities.Bonds; /// /// Creates a refresh-state marker. /// -public sealed class BondValuationRefreshState(long id, DateOnly? lastAsOf, DateOnly? lastAttemptDate) +public sealed class BondValuationRefreshState( + long id, + DateOnly? lastAsOf, + DateOnly? lastAttemptDate, + DateTime? retryNotBefore = null) { /// /// The fixed primary-key value of the single marker row. @@ -34,12 +38,31 @@ public sealed class BondValuationRefreshState(long id, DateOnly? lastAsOf, DateO [DisplayName("Last Attempt Date")] public DateOnly? LastAttemptDate { get; private set; } = lastAttemptDate; + /// + /// UTC instant before which a released claim must not be retried; null when + /// no retry is pending. + /// + /// + /// Set only when an attempt failed and gave its day back. It is what lets a + /// failed refresh be retried within the same day without every request + /// paying the upstream timeout: the day is claimable again, but not until + /// this instant has passed. A successful attempt clears it. + /// + /// Stored as a UTC rather than a + /// because the repository compares it in SQL, + /// and EF Core's SQLite provider -- which the repository tests run on -- + /// cannot translate a DateTimeOffset comparison. + /// + [DisplayName("Retry Not Before")] + public DateTime? RetryNotBefore { get; private set; } = retryNotBefore; + /// /// Replaces the recorded markers after a pull attempt. /// - public void Update(DateOnly? lastAsOf, DateOnly? lastAttemptDate) + public void Update(DateOnly? lastAsOf, DateOnly? lastAttemptDate, DateTime? retryNotBefore = null) { LastAsOf = lastAsOf; LastAttemptDate = lastAttemptDate; + RetryNotBefore = retryNotBefore; } } diff --git a/src/AppName.Infrastructure/Migrations/20260830085145_AddBondValuationRefreshRetryNotBefore.Designer.cs b/src/AppName.Infrastructure/Migrations/20260830085145_AddBondValuationRefreshRetryNotBefore.Designer.cs new file mode 100644 index 0000000..a08a1dc --- /dev/null +++ b/src/AppName.Infrastructure/Migrations/20260830085145_AddBondValuationRefreshRetryNotBefore.Designer.cs @@ -0,0 +1,107 @@ +// +using System; +using AppName.Infrastructure.Persistence.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AppName.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260830085145_AddBondValuationRefreshRetryNotBefore")] + partial class AddBondValuationRefreshRetryNotBefore + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AppName.Domain.Entities.Bonds.BondValuationRefreshState", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("LastAsOf") + .HasColumnType("date"); + + b.Property("LastAttemptDate") + .HasColumnType("date"); + + b.Property("RetryNotBefore") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("BondValuationRefreshStates"); + + b.HasData( + new + { + Id = 1L + }); + }); + + modelBuilder.Entity("AppName.Domain.Entities.Bonds.BondValuationSnapshot", b => + { + b.Property("Symbol") + .HasColumnType("text"); + + b.Property("AsOf") + .HasColumnType("date"); + + b.Property("BondPrice") + .HasColumnType("numeric"); + + b.Property("ConversionShares") + .HasColumnType("numeric"); + + b.Property("ConversionValue") + .HasColumnType("numeric"); + + b.Property("IsInTheMoney") + .HasColumnType("boolean"); + + b.Property("StockPrice") + .HasColumnType("numeric"); + + b.HasKey("Symbol"); + + b.ToTable("BondValuationSnapshots"); + }); + + modelBuilder.Entity("AppName.Domain.Entities.Bonds.ConvertibleBond", b => + { + b.Property("Symbol") + .HasColumnType("text"); + + b.Property("ConversionPrice") + .HasColumnType("numeric"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParValue") + .HasColumnType("numeric"); + + b.Property("UnderlyingSymbol") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Symbol"); + + b.ToTable("ConvertibleBonds"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/AppName.Infrastructure/Migrations/20260830085145_AddBondValuationRefreshRetryNotBefore.cs b/src/AppName.Infrastructure/Migrations/20260830085145_AddBondValuationRefreshRetryNotBefore.cs new file mode 100644 index 0000000..7ead556 --- /dev/null +++ b/src/AppName.Infrastructure/Migrations/20260830085145_AddBondValuationRefreshRetryNotBefore.cs @@ -0,0 +1,36 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AppName.Infrastructure.Migrations +{ + /// + public partial class AddBondValuationRefreshRetryNotBefore : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RetryNotBefore", + table: "BondValuationRefreshStates", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.UpdateData( + table: "BondValuationRefreshStates", + keyColumn: "Id", + keyValue: 1L, + column: "RetryNotBefore", + value: null); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RetryNotBefore", + table: "BondValuationRefreshStates"); + } + } +} diff --git a/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index a3733ac..42caa3d 100644 --- a/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/AppName.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -1,9 +1,9 @@ // using System; using AppName.Infrastructure.Persistence.Context; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Microsoft.EntityFrameworkCore; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable @@ -33,6 +33,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LastAttemptDate") .HasColumnType("date"); + b.Property("RetryNotBefore") + .HasColumnType("timestamp with time zone"); + b.HasKey("Id"); b.ToTable("BondValuationRefreshStates"); diff --git a/src/AppName.Infrastructure/Persistence/Bonds/BondValuationRefreshStateRepository.cs b/src/AppName.Infrastructure/Persistence/Bonds/BondValuationRefreshStateRepository.cs index e8a3f32..ddb41dd 100644 --- a/src/AppName.Infrastructure/Persistence/Bonds/BondValuationRefreshStateRepository.cs +++ b/src/AppName.Infrastructure/Persistence/Bonds/BondValuationRefreshStateRepository.cs @@ -30,18 +30,19 @@ public async Task SetAsync(BondValuationRefreshState state, CancellationToken ct if (existing is null) { - db.BondValuationRefreshStates.Add(new BondValuationRefreshState(BondValuationRefreshState.SingletonId, state.LastAsOf, state.LastAttemptDate)); + db.BondValuationRefreshStates.Add(new BondValuationRefreshState( + BondValuationRefreshState.SingletonId, state.LastAsOf, state.LastAttemptDate, state.RetryNotBefore)); } else { - existing.Update(state.LastAsOf, state.LastAttemptDate); + existing.Update(state.LastAsOf, state.LastAttemptDate, state.RetryNotBefore); } await db.SaveChangesAsync(ct); } /// - public async Task TryClaimAttemptAsync(DateOnly today, CancellationToken ct = default) + public async Task TryClaimAttemptAsync(DateOnly today, DateTime now, CancellationToken ct = default) { await using var db = factory.CreateDbContext(); @@ -54,10 +55,31 @@ public async Task TryClaimAttemptAsync(DateOnly today, CancellationToken c // The null arm is REQUIRED. SQL evaluates NULL != @today as // NULL, not true, so without it the very first claim of all // time matches no row and the refresh never starts. - && (r.LastAttemptDate == null || r.LastAttemptDate != today)) - .ExecuteUpdateAsync(s => s.SetProperty(r => r.LastAttemptDate, today), ct); + && (r.LastAttemptDate == null || r.LastAttemptDate != today + // A failed attempt gave the day back and named when it + // may be tried again. Clearing RetryNotBefore in the + // same statement is what stops two callers taking the + // retry, exactly as the day claim stops two taking the + // first attempt. + || (r.RetryNotBefore != null && r.RetryNotBefore <= now))) + .ExecuteUpdateAsync(s => s + .SetProperty(r => r.LastAttemptDate, today) + .SetProperty(r => r.RetryNotBefore, (DateTime?)null), ct); // Zero rows means another caller already claimed today. return claimed > 0; } + + /// + public async Task ReleaseClaimAsync(DateTime retryNotBefore, CancellationToken ct = default) + { + await using var db = factory.CreateDbContext(); + + // LastAttemptDate is deliberately left stamped. It still records when + // the attempt happened, and RetryNotBefore alone is what reopens the + // claim -- see the second arm of the predicate above. + await db.BondValuationRefreshStates + .Where(r => r.Id == BondValuationRefreshState.SingletonId) + .ExecuteUpdateAsync(s => s.SetProperty(r => r.RetryNotBefore, (DateTime?)retryNotBefore), ct); + } } diff --git a/src/AppName.Infrastructure/Persistence/Context/AppDbContext.cs b/src/AppName.Infrastructure/Persistence/Context/AppDbContext.cs index cf5a0cb..c3669f9 100644 --- a/src/AppName.Infrastructure/Persistence/Context/AppDbContext.cs +++ b/src/AppName.Infrastructure/Persistence/Context/AppDbContext.cs @@ -40,6 +40,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) Id = BondValuationRefreshState.SingletonId, LastAsOf = (DateOnly?)null, LastAttemptDate = (DateOnly?)null, + RetryNotBefore = (DateTime?)null, }); } } diff --git a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationCountUseCaseTests.cs b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationCountUseCaseTests.cs index c59660e..1af59c8 100644 --- a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationCountUseCaseTests.cs +++ b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationCountUseCaseTests.cs @@ -1,6 +1,7 @@ using AppName.Application.UseCases.Bonds; using AppName.Domain.Abstractions.Bonds; using AppName.Domain.ValueObjects.Valuations; +using Microsoft.Extensions.Logging.Abstractions; namespace AppName.Application.Tests.UseCases.Bonds; @@ -17,7 +18,7 @@ private sealed class Fixture public Fixture() { - RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny())) + RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(true); Snapshots.Setup(s => s.CountAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(0); @@ -26,7 +27,7 @@ public Fixture() public Fixture WithLostClaim() { - RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny())) + RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(false); return this; } @@ -45,8 +46,22 @@ public Fixture WithFailingRefresh() return this; } + public Fixture WithCancelledRefresh() + { + Refresh.Setup(r => r.ExecuteAsync(It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + return this; + } + + public Fixture WithFailingRelease() + { + RefreshState.Setup(r => r.ReleaseClaimAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("database down")); + return this; + } + public GetValuationCountUseCase Build() => - new(RefreshState.Object, Snapshots.Object, Refresh.Object, new FixedTimeProvider(FixedUtc)); + new(RefreshState.Object, Snapshots.Object, Refresh.Object, new FixedTimeProvider(FixedUtc), NullLogger.Instance); } [Fact] @@ -66,7 +81,7 @@ public async Task ExecuteAsync_ClaimsTaiwanToday() await fx.Build().ExecuteAsync(ValuationFilter.None); - fx.RefreshState.Verify(r => r.TryClaimAttemptAsync(TwToday, It.IsAny()), Times.Once); + fx.RefreshState.Verify(r => r.TryClaimAttemptAsync(TwToday, It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -108,4 +123,61 @@ public async Task ExecuteAsync_ReturnsCache_WhenRefreshThrows() Assert.Equal(412, count); // no throw; stale cache counted } + + // The four tests below cover DailyRefreshGate's release path. They go through + // this use case rather than the gate directly, because the gate is internal + // and reached through the use cases by design — see its class remarks. + + [Fact] + public async Task ExecuteAsync_ReleasesClaimAfterBackoff_WhenRefreshThrows() + { + var fx = new Fixture().WithFailingRefresh(); + + await fx.Build().ExecuteAsync(ValuationFilter.None); + + // Five minutes is DailyRefreshGate.RetryBackoff, spelled out because the + // constant is internal to the Application project. Without the release, + // one upstream failure would consume the whole trading day. + fx.RefreshState.Verify( + r => r.ReleaseClaimAsync(FixedUtc.UtcDateTime.AddMinutes(5), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ExecuteAsync_ReleasesClaimImmediately_WhenRefreshIsCancelled() + { + var fx = new Fixture().WithCancelledRefresh(); + + // Cancellation still propagates; only the claim is given back. + await Assert.ThrowsAsync( + () => fx.Build().ExecuteAsync(ValuationFilter.None)); + + // No backoff: a caller walking away is not an upstream fault, so the next + // reader should try at once. + fx.RefreshState.Verify( + r => r.ReleaseClaimAsync(FixedUtc.UtcDateTime, It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ExecuteAsync_KeepsClaim_WhenRefreshSucceeds() + { + var fx = new Fixture(); + + await fx.Build().ExecuteAsync(ValuationFilter.None); + + fx.RefreshState.Verify( + r => r.ReleaseClaimAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ExecuteAsync_ReturnsCache_WhenReleasingTheClaimAlsoThrows() + { + var fx = new Fixture().WithFailingRefresh().WithFailingRelease().WithCount(412); + + // The release runs on the failure path of a best-effort refresh, so it + // must not become the thing that fails the caller. + Assert.Equal(412, await fx.Build().ExecuteAsync(ValuationFilter.None)); + } } diff --git a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationPageUseCaseTests.cs b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationPageUseCaseTests.cs index 1723370..cfb3ccb 100644 --- a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationPageUseCaseTests.cs +++ b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationPageUseCaseTests.cs @@ -3,6 +3,7 @@ using AppName.Domain.Entities.Bonds; using AppName.Domain.Querying; using AppName.Domain.ValueObjects.Valuations; +using Microsoft.Extensions.Logging.Abstractions; namespace AppName.Application.Tests.UseCases.Bonds; @@ -27,7 +28,7 @@ private sealed class Fixture public Fixture() { - RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny())) + RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(true); Snapshots.Setup(s => s.QueryAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Array.Empty()); @@ -38,7 +39,7 @@ public Fixture() public Fixture WithLostClaim() { - RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny())) + RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(false); return this; } @@ -65,7 +66,7 @@ public Fixture WithFailingRefresh() } public GetValuationPageUseCase Build() => - new(RefreshState.Object, Snapshots.Object, Refresh.Object, new FixedTimeProvider(FixedUtc)); + new(RefreshState.Object, Snapshots.Object, Refresh.Object, new FixedTimeProvider(FixedUtc), NullLogger.Instance); } [Fact] @@ -85,7 +86,7 @@ public async Task ExecuteAsync_ClaimsTaiwanToday() await fx.Build().ExecuteAsync(DefaultQuery); - fx.RefreshState.Verify(r => r.TryClaimAttemptAsync(TwToday, It.IsAny()), Times.Once); + fx.RefreshState.Verify(r => r.TryClaimAttemptAsync(TwToday, It.IsAny(), It.IsAny()), Times.Once); } [Fact] diff --git a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs index 011f911..38bf138 100644 --- a/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs +++ b/tests/AppName.Application.Tests/UseCases/Bonds/GetValuationsUseCaseTests.cs @@ -3,6 +3,7 @@ using AppName.Domain.Entities.Bonds; using AppName.Domain.Querying; using AppName.Domain.ValueObjects.Valuations; +using Microsoft.Extensions.Logging.Abstractions; namespace AppName.Application.Tests.UseCases.Bonds; @@ -25,7 +26,7 @@ private sealed class Fixture public Fixture() { - RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny())) + RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(true); Snapshots.Setup(s => s.QueryAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Array.Empty()); @@ -34,7 +35,7 @@ public Fixture() public Fixture WithLostClaim() { - RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny())) + RefreshState.Setup(r => r.TryClaimAttemptAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(false); return this; } @@ -54,7 +55,7 @@ public Fixture WithFailingRefresh() } public GetValuationsUseCase Build() => - new(RefreshState.Object, Snapshots.Object, Refresh.Object, new FixedTimeProvider(FixedUtc)); + new(RefreshState.Object, Snapshots.Object, Refresh.Object, new FixedTimeProvider(FixedUtc), NullLogger.Instance); } [Fact] @@ -74,7 +75,7 @@ public async Task ExecuteAsync_ClaimsTaiwanToday() await fx.Build().ExecuteAsync(DefaultQuery); - fx.RefreshState.Verify(r => r.TryClaimAttemptAsync(TwToday, It.IsAny()), Times.Once); + fx.RefreshState.Verify(r => r.TryClaimAttemptAsync(TwToday, It.IsAny(), It.IsAny()), Times.Once); } [Fact] diff --git a/tests/AppName.Infrastructure.Tests/Persistence/Bonds/BondValuationRefreshStateRepositoryTests.cs b/tests/AppName.Infrastructure.Tests/Persistence/Bonds/BondValuationRefreshStateRepositoryTests.cs index 68d4d92..6fe6713 100644 --- a/tests/AppName.Infrastructure.Tests/Persistence/Bonds/BondValuationRefreshStateRepositoryTests.cs +++ b/tests/AppName.Infrastructure.Tests/Persistence/Bonds/BondValuationRefreshStateRepositoryTests.cs @@ -12,6 +12,8 @@ namespace AppName.Infrastructure.Tests.Persistence.Bonds; // the database, not the test, picks the winner. These tests prove the predicate. public class BondValuationRefreshStateRepositoryTests { + private static readonly DateTime Now = new(2026, 7, 1, 4, 0, 0, DateTimeKind.Utc); + private sealed class Fixture : IDisposable { private sealed class SingleContextFactory(DbContextOptions options) : IDbContextFactory @@ -85,7 +87,7 @@ public async Task TryClaimAttemptAsync_ReturnsTrue_OnFreshDatabase() // The seeded row has a null LastAttemptDate. This fails if the claim // predicate drops its "== null" arm, because SQL evaluates // NULL != @today as NULL and the UPDATE then matches no row. - Assert.True(await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1))); + Assert.True(await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now)); } [Fact] @@ -93,9 +95,9 @@ public async Task TryClaimAttemptAsync_ReturnsFalse_OnSecondClaimForSameDay() { using var fx = new Fixture(); - await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1)); + await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now); - Assert.False(await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1))); + Assert.False(await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now)); } [Fact] @@ -103,9 +105,9 @@ public async Task TryClaimAttemptAsync_ReturnsTrue_OnNextDay() { using var fx = new Fixture(); - await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1)); + await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now); - Assert.True(await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 2))); + Assert.True(await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 2), Now)); } [Fact] @@ -113,7 +115,7 @@ public async Task TryClaimAttemptAsync_RecordsClaimedDay_WhenClaimWins() { using var fx = new Fixture(); - await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1)); + await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now); var state = await fx.Repo.GetAsync(); Assert.Equal(new DateOnly(2026, 7, 1), state.LastAttemptDate); @@ -126,11 +128,78 @@ public async Task TryClaimAttemptAsync_LeavesLastAsOfUnchanged_WhenClaimWins() await fx.Repo.SetAsync(new BondValuationRefreshState(BondValuationRefreshState.SingletonId, new DateOnly(2026, 6, 30), null)); - await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1)); + await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now); var state = await fx.Repo.GetAsync(); // The claim marks only the attempt. LastAsOf still records the last // successful pull, so a losing refresh must not appear as a success. Assert.Equal(new DateOnly(2026, 6, 30), state.LastAsOf); } + + [Fact] + public async Task ReleaseClaimAsync_RecordsRetryNotBefore() + { + using var fx = new Fixture(); + var retryAt = Now.AddMinutes(5); + + await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now); + await fx.Repo.ReleaseClaimAsync(retryAt); + var state = await fx.Repo.GetAsync(); + + Assert.Equal(retryAt, state.RetryNotBefore); + } + + [Fact] + public async Task ReleaseClaimAsync_LeavesLastAttemptDateStamped() + { + using var fx = new Fixture(); + + await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now); + await fx.Repo.ReleaseClaimAsync(Now.AddMinutes(5)); + var state = await fx.Repo.GetAsync(); + + // RetryNotBefore alone reopens the claim. The attempt date still records + // that an attempt was made today. + Assert.Equal(new DateOnly(2026, 7, 1), state.LastAttemptDate); + } + + [Fact] + public async Task TryClaimAttemptAsync_ReturnsFalse_WhenReleasedButBackoffHasNotElapsed() + { + using var fx = new Fixture(); + + await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now); + await fx.Repo.ReleaseClaimAsync(Now.AddMinutes(5)); + + // This is the whole point of the backoff: an outage must not make every + // read pay the upstream timeout again. + Assert.False(await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now.AddMinutes(1))); + } + + [Fact] + public async Task TryClaimAttemptAsync_ReturnsTrue_WhenReleasedAndBackoffHasElapsed() + { + using var fx = new Fixture(); + + await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now); + await fx.Repo.ReleaseClaimAsync(Now.AddMinutes(5)); + + // Same day, already claimed once -- the retry is what reopens it. + Assert.True(await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now.AddMinutes(6))); + } + + [Fact] + public async Task TryClaimAttemptAsync_ClearsRetryNotBefore_WhenRetryIsWon() + { + using var fx = new Fixture(); + + await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now); + await fx.Repo.ReleaseClaimAsync(Now.AddMinutes(5)); + await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now.AddMinutes(6)); + + // Clearing it in the same statement is what stops a second caller also + // taking the retry. + Assert.Null((await fx.Repo.GetAsync()).RetryNotBefore); + Assert.False(await fx.Repo.TryClaimAttemptAsync(new DateOnly(2026, 7, 1), Now.AddMinutes(6))); + } }