From a3ea9bcbc17a09ecf3f0058d2549ef20eef05e2c Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 30 Aug 2026 10:19:02 +0300 Subject: [PATCH 1/8] perf: compress responses and cache hashed assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing was compressed before: the SPA bundle went out at its full 1,054 KB (now 288 KB). Gzip only — .NET's Brotli is either worse than gzip or costs ~0.9s of CPU per MB, recompressed on every cold visit. Assets under /assets are content-hashed, so they get a year and immutable; index.html stays revalidate-always; JSON keeps no-store. --- SW.Bitween.Web/Startup.cs | 71 +++++++++++++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/SW.Bitween.Web/Startup.cs b/SW.Bitween.Web/Startup.cs index 3edb1fb..45878e1 100644 --- a/SW.Bitween.Web/Startup.cs +++ b/SW.Bitween.Web/Startup.cs @@ -1,8 +1,11 @@ using System; +using System.IO.Compression; +using System.Linq; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.ResponseCompression; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -329,6 +332,39 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddControllers(); + // Nothing was compressed before this: the SPA bundle went out at its full ~1 MB, and + // JSON list responses grow with the customer's data. Measured on the current bundle, + // gzip at Optimal takes it from 1,054 KB to 288 KB for ~15 ms of CPU. + // + // Gzip only, deliberately. .NET exposes just two useful Brotli levels and neither wins + // here: Fastest produces 329 KB (worse than gzip at Optimal) and Optimal produces + // 233 KB but costs ~0.9 s of CPU per megabyte, paid again by every cold visitor because + // nothing caches the compressed bytes server-side. Browsers prefer Brotli when it's + // offered, so registering it at Fastest would actively make the common case worse. The + // remaining 54 KB is only worth chasing by pre-compressing at build time. + // + // The explicit "text/javascript" matters — the static file middleware labels .js files + // that way, while the framework's default list only names "application/javascript", so + // relying on the defaults would silently skip the single largest response we serve. + // + // EnableForHttps is deliberate. TLS terminates here in local dev (and may in a + // deployment that doesn't front the pod with a proxy), so leaving it off would mean no + // compression at all in exactly the place we test it. The BREACH risk it guards against + // needs a secret and attacker-controlled text in the same response body; the API returns + // neither — auth tokens travel in headers and the Set-Cookie, never in a GET body. + services.AddResponseCompression(options => + { + options.EnableForHttps = true; + options.Providers.Add(); + options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[] + { + "text/javascript", + "image/svg+xml", + }); + }); + services.Configure(options => + options.Level = CompressionLevel.Optimal); + services.AddAuthentication() .AddJwtBearer(configureOptions => @@ -395,6 +431,9 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { app.UseSWConsoleLogger(); app.UseForwardedHeaders(); + // Early, so everything downstream — static files, the SPA fallback, every API + // response — is compressed on the way out. + app.UseResponseCompression(); app.Use(async (context, next) => { @@ -418,17 +457,37 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) if (!context.Request.Path.StartsWithSegments("/swagger")) headers[ContentSecurityPolicyHeader] = ContentSecurityPolicy; - // Sensitive API responses (JSON) must not be cached by the browser or - // intermediaries. Scoped by content type so static assets stay cacheable. + // Every Cache-Control decision lives here, in one ordered set of rules, so they + // cannot contradict each other. Deferred to OnStarting because the content type + // is only known once whatever handled the request has decided what it's sending. context.Response.OnStarting(() => { - var contentType = context.Response.ContentType; - if (!string.IsNullOrEmpty(contentType) && - (contentType.Contains("application/json", StringComparison.OrdinalIgnoreCase) || - contentType.Contains("+json", StringComparison.OrdinalIgnoreCase))) + var contentType = context.Response.ContentType ?? ""; + var isJson = contentType.Contains("application/json", StringComparison.OrdinalIgnoreCase) || + contentType.Contains("+json", StringComparison.OrdinalIgnoreCase); + + if (isJson) { + // Sensitive API responses must not be cached by the browser or intermediaries. context.Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate"; } + else if (context.Request.Path.StartsWithSegments("/assets")) + { + // Vite content-hashes every filename under /assets, so the bytes behind a + // given URL never change — a new build produces new URLs. Saying so lets a + // returning browser skip the request entirely. Without this header it isn't + // told anything and falls back to guessing a freshness window from the + // file's age, which differs between browsers and shrinks after each deploy. + context.Response.Headers["Cache-Control"] = "public, max-age=31536000, immutable"; + } + else if (contentType.Contains("text/html", StringComparison.OrdinalIgnoreCase)) + { + // index.html is the one file whose URL survives a deploy, and it carries the + // hashed asset names. It has to be revalidated every time: a heuristically + // cached copy would keep pointing at assets the new build has replaced. + context.Response.Headers["Cache-Control"] = "no-cache"; + } + return Task.CompletedTask; }); From fd80f97dbe830631ceb7dee4bfd977e54de45826 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 30 Aug 2026 10:19:10 +0300 Subject: [PATCH 2/8] perf: count "used by" on the server, not by downloading every subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Information types, work groups, retry policies and global values each paired their own list request with an unpaginated GET /subscriptions, only to count rows against it. The Dashboard fetched that table twice for this reason. The counts now come down with the row. UsedByCount is not rendered yet — the "Used by" column builds its links from the shared subscriptions cache — but it makes the field on those rows honest instead of arithmetic on a full table. --- SW.Bitween.Api/Resources/Documents/Search.cs | 6 +++- .../Resources/RetryPolicies/Search.cs | 6 +++- SW.Bitween.Api/Resources/WorkGroups/Search.cs | 10 +++++++ SW.Bitween.Sdk/Model/Document.cs | 6 ++++ SW.Bitween.Sdk/Model/RetryPolicyModel.cs | 7 +++++ SW.Bitween.Sdk/Model/Workgroups.cs | 7 +++++ .../ClientApp/src/api/http/documents.ts | 24 +++++----------- .../ClientApp/src/api/http/retryPolicies.ts | 28 +++++-------------- .../ClientApp/src/api/http/workGroups.ts | 28 +++++-------------- 9 files changed, 61 insertions(+), 61 deletions(-) diff --git a/SW.Bitween.Api/Resources/Documents/Search.cs b/SW.Bitween.Api/Resources/Documents/Search.cs index 1b2938f..7a18569 100644 --- a/SW.Bitween.Api/Resources/Documents/Search.cs +++ b/SW.Bitween.Api/Resources/Documents/Search.cs @@ -40,7 +40,11 @@ async public Task Handle(SearchyRequest searchyRequest, bool lookup = fa BusEnabled = document.BusEnabled, DuplicateInterval = document.DuplicateInterval, PromotedProperties = document.PromotedProperties.ToKeyAndValueCollection(), - DocumentFormat = document.DocumentFormat + DocumentFormat = document.DocumentFormat, + // A correlated count, so the "used by" column the UI shows costs one + // subquery per row instead of the whole Subscription table over the wire. + UsedByCount = dbContext.Set() + .Count(subscription => subscription.DocumentId == document.Id) }; query = query.AsNoTracking(); diff --git a/SW.Bitween.Api/Resources/RetryPolicies/Search.cs b/SW.Bitween.Api/Resources/RetryPolicies/Search.cs index 39ab5e7..21a0820 100644 --- a/SW.Bitween.Api/Resources/RetryPolicies/Search.cs +++ b/SW.Bitween.Api/Resources/RetryPolicies/Search.cs @@ -31,7 +31,11 @@ public async Task Handle(SearchyRequest searchyRequest, bool lookup = fa { Id = policy.Id, Name = policy.Name, - GroupCount = policy.Groups.Count + GroupCount = policy.Groups.Count, + // A correlated count, so the "used by" column the UI shows costs one subquery per + // row instead of the whole Subscription table over the wire. + UsedByCount = _dbContext.Set() + .Count(subscription => subscription.RetryPolicyId == policy.Id) }; query = query.AsNoTracking(); diff --git a/SW.Bitween.Api/Resources/WorkGroups/Search.cs b/SW.Bitween.Api/Resources/WorkGroups/Search.cs index d5c85e7..e935359 100644 --- a/SW.Bitween.Api/Resources/WorkGroups/Search.cs +++ b/SW.Bitween.Api/Resources/WorkGroups/Search.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; @@ -35,6 +36,14 @@ public async Task Handle(SearchWorkGroupModel request) var workGroups = await dbContext.Set().AsNoTracking() .Where(w => request.Name == null || w.Name.Contains(request.Name)) .ToArrayAsync(); + // One grouped count for every group at once, so the "used by" column the UI shows costs a + // single aggregate query instead of the whole Subscription table over the wire. + var usedByWorkGroup = await dbContext.Set().AsNoTracking() + .Where(subscription => subscription.WorkGroupId != null) + .GroupBy(subscription => subscription.WorkGroupId!.Value) + .Select(group => new { WorkGroupId = group.Key, Count = group.Count() }) + .ToDictionaryAsync(row => row.WorkGroupId, row => row.Count); + var consumerCounts = Array.Empty(); try @@ -78,6 +87,7 @@ public async Task Handle(SearchWorkGroupModel request) NotifierProcessingCount = notifiersCounts?.ProcessingCount, NotifierQueueCount = notifiersCounts?.QueueCount, ProcessorNodeCount = processorsCounts?.TotalNodes, + UsedByCount = usedByWorkGroup.GetValueOrDefault(workGroup.Id), }; }).ToList(); diff --git a/SW.Bitween.Sdk/Model/Document.cs b/SW.Bitween.Sdk/Model/Document.cs index 40b0eba..bec888e 100644 --- a/SW.Bitween.Sdk/Model/Document.cs +++ b/SW.Bitween.Sdk/Model/Document.cs @@ -47,5 +47,11 @@ public class DocumentUpdate : DocumentCreate public class DocumentRow : DocumentUpdate { + /// + /// How many subscriptions carry this information type. Counted here because the admin UI + /// shows it in the list: computing it client-side meant downloading every subscription + /// alongside every page of this list. + /// + public int UsedByCount { get; set; } } } \ No newline at end of file diff --git a/SW.Bitween.Sdk/Model/RetryPolicyModel.cs b/SW.Bitween.Sdk/Model/RetryPolicyModel.cs index 20983ec..d2bd61b 100644 --- a/SW.Bitween.Sdk/Model/RetryPolicyModel.cs +++ b/SW.Bitween.Sdk/Model/RetryPolicyModel.cs @@ -25,6 +25,13 @@ public class RetryPolicyRow public int Id { get; set; } public string Name { get; set; } public int GroupCount { get; set; } + + /// + /// How many subscriptions this policy is assigned to. Counted here because the admin UI shows + /// it in the list: computing it client-side meant downloading every subscription alongside + /// every page of this list. + /// + public int UsedByCount { get; set; } } /// diff --git a/SW.Bitween.Sdk/Model/Workgroups.cs b/SW.Bitween.Sdk/Model/Workgroups.cs index 1cc88d4..ecaf2c1 100644 --- a/SW.Bitween.Sdk/Model/Workgroups.cs +++ b/SW.Bitween.Sdk/Model/Workgroups.cs @@ -28,6 +28,13 @@ public class WorkGroupModel public long? NotifierQueueCount { get; set; } /// Live count of active RabbitMQ consumer instances for this group's queue. public long? ProcessorNodeCount { get; set; } + + /// + /// How many subscriptions run in this work group. Counted here because the admin UI shows it + /// in the list: computing it client-side meant downloading every subscription alongside every + /// page of this list. + /// + public int UsedByCount { get; set; } } public class CreateWorkGroupModel diff --git a/SW.Bitween.Web/ClientApp/src/api/http/documents.ts b/SW.Bitween.Web/ClientApp/src/api/http/documents.ts index 8278b0d..058563a 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/documents.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/documents.ts @@ -10,7 +10,7 @@ import type { } from "../types"; import { exchangeMethods } from "./exchanges"; import { gatewayMethods } from "./gateways"; -import { get, getEnrichment, post, request } from "./request"; +import { get, post, request } from "./request"; import { buildListQuery, SEARCHY_RULE } from "./searchQuery"; interface SearchyResponse { @@ -31,6 +31,8 @@ interface RawDocument { duplicateInterval: number; disregardsUnfilteredMessages: boolean; promotedProperties: RawKeyAndValue[] | null; + /** Counted by the backend — see DocumentRow.UsedByCount. */ + usedByCount: number; } interface RawSubscriptionRef { id: number; @@ -138,14 +140,8 @@ const documentBody = (t: Omit) => ({ export const documentMethods = { async listInformationTypes(): Promise { - const [res, subs] = await Promise.all([ - get>("/documents"), - getEnrichment>("/subscriptions", { result: [], totalCount: 0 }), - ]); - const countByDocument = new Map(); - for (const s of subs.result ?? []) - countByDocument.set(s.documentId, (countByDocument.get(s.documentId) ?? 0) + 1); - return (res.result ?? []).map((d) => ({ ...toInformationType(d), usedByCount: countByDocument.get(d.id) ?? 0 })); + const res = await get>("/documents"); + return (res.result ?? []).map((d) => ({ ...toInformationType(d), usedByCount: d.usedByCount })); }, async searchInformationTypes(query: { @@ -164,16 +160,10 @@ export const documentMethods = { offset: query.offset, limit: query.limit, }); - const [res, subs] = await Promise.all([ - get>(`/documents?${qs}`), - getEnrichment>("/subscriptions", { result: [], totalCount: 0 }), - ]); - const countByDocument = new Map(); - for (const s of subs.result ?? []) - countByDocument.set(s.documentId, (countByDocument.get(s.documentId) ?? 0) + 1); + const res = await get>(`/documents?${qs}`); return { total: res.totalCount, - result: (res.result ?? []).map((d) => ({ ...toInformationType(d), usedByCount: countByDocument.get(d.id) ?? 0 })), + result: (res.result ?? []).map((d) => ({ ...toInformationType(d), usedByCount: d.usedByCount })), }; }, diff --git a/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts b/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts index 9d48872..0410489 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/retryPolicies.ts @@ -15,7 +15,7 @@ import { type RetryUsageRow, type Paged, } from "../types"; -import { get, getEnrichment, post, request } from "./request"; +import { get, post, request } from "./request"; import { buildListQuery, SEARCHY_RULE } from "./searchQuery"; interface SearchyResponse { @@ -26,6 +26,8 @@ interface RawRetryPolicyRow { id: number; name: string; groupCount: number; + /** Counted by the backend — see RetryPolicyRow.UsedByCount. */ + usedByCount: number; } interface RawRetryPolicy { name: string; @@ -228,21 +230,13 @@ interface RawAttempt { export const retryPolicyMethods = { async listRetryPolicies(): Promise { - const [res, subs] = await Promise.all([ - get>("/retrypolicies"), - getEnrichment>("/subscriptions", { result: [], totalCount: 0 }), - ]); - const countByRetryPolicy = new Map(); - for (const s of subs.result ?? []) { - if (s.retryPolicyId == null) continue; - countByRetryPolicy.set(s.retryPolicyId, (countByRetryPolicy.get(s.retryPolicyId) ?? 0) + 1); - } + const res = await get>("/retrypolicies"); return (res.result ?? []).map((p) => ({ id: p.id, name: p.name, groupCount: p.groupCount, createdOn: "", - usedByCount: countByRetryPolicy.get(p.id) ?? 0, + usedByCount: p.usedByCount, })); }, @@ -256,15 +250,7 @@ export const retryPolicyMethods = { offset: query.offset, limit: query.limit, }); - const [res, subs] = await Promise.all([ - get>(`/retrypolicies?${qs}`), - getEnrichment>("/subscriptions", { result: [], totalCount: 0 }), - ]); - const countByRetryPolicy = new Map(); - for (const s of subs.result ?? []) { - if (s.retryPolicyId == null) continue; - countByRetryPolicy.set(s.retryPolicyId, (countByRetryPolicy.get(s.retryPolicyId) ?? 0) + 1); - } + const res = await get>(`/retrypolicies?${qs}`); return { total: res.totalCount, result: (res.result ?? []).map((p) => ({ @@ -272,7 +258,7 @@ export const retryPolicyMethods = { name: p.name, groupCount: p.groupCount, createdOn: "", - usedByCount: countByRetryPolicy.get(p.id) ?? 0, + usedByCount: p.usedByCount, })), }; }, diff --git a/SW.Bitween.Web/ClientApp/src/api/http/workGroups.ts b/SW.Bitween.Web/ClientApp/src/api/http/workGroups.ts index 0e39494..5c2e080 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/workGroups.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/workGroups.ts @@ -7,7 +7,7 @@ import { type WorkGroupRow, type Paged, } from "../types"; -import { get, getEnrichment, post } from "./request"; +import { get, post } from "./request"; interface SearchyResponse { result: T[]; @@ -19,6 +19,8 @@ interface RawWorkGroup { busMessageName: string; options: { rabbitMqOptions: { prefetch: number | null; priority: number | null } | null } | null; processorNodeCount: number | null; + /** Counted by the backend — see WorkGroupModel.UsedByCount. */ + usedByCount: number; } interface RawSubscriptionRef { id: number; @@ -95,18 +97,10 @@ async function fetchPagedRows(query: { export const workGroupMethods = { async listWorkGroups(): Promise { - const [rows, subs] = await Promise.all([ - fetchRows(), - getEnrichment>("/subscriptions", { result: [], totalCount: 0 }), - ]); - const countByWorkGroup = new Map(); - for (const s of subs.result ?? []) { - if (s.workGroupId == null) continue; - countByWorkGroup.set(s.workGroupId, (countByWorkGroup.get(s.workGroupId) ?? 0) + 1); - } + const rows = await fetchRows(); return rows.map((w) => ({ ...toWorkGroup(w), - usedByCount: countByWorkGroup.get(w.id) ?? 0, + usedByCount: w.usedByCount, consumerCount: w.processorNodeCount ?? 0, })); }, @@ -116,20 +110,12 @@ export const workGroupMethods = { offset: number; limit: number; }): Promise> { - const [{ rows, total }, subs] = await Promise.all([ - fetchPagedRows(query), - getEnrichment>("/subscriptions", { result: [], totalCount: 0 }), - ]); - const countByWorkGroup = new Map(); - for (const s of subs.result ?? []) { - if (s.workGroupId == null) continue; - countByWorkGroup.set(s.workGroupId, (countByWorkGroup.get(s.workGroupId) ?? 0) + 1); - } + const { rows, total } = await fetchPagedRows(query); return { total, result: rows.map((w) => ({ ...toWorkGroup(w), - usedByCount: countByWorkGroup.get(w.id) ?? 0, + usedByCount: w.usedByCount, consumerCount: w.processorNodeCount ?? 0, })), }; From 04aa700d5950acb08ae550c42c343137caa9fa57 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 30 Aug 2026 10:19:20 +0300 Subject: [PATCH 3/8] refactor: one query-key catalog, and a central staleTime policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keys were hand-typed string literals at 97 sites and invalidated by hand at 102 more, so a variant could be missed silently — and four were: nothing ever invalidated partners-search, information-types-search, retry-policies-search or api-gateways-search, the four paged tables. They only looked right because the global 10s staleTime expired before anyone noticed. The e2e suite had been failing on a deleted retry policy staying on screen. Keys are now hierarchical and grouped by entity, so invalidating an entity covers every variant by prefix. Invalidation calls: 102 -> 70. Also folded in: information-types/information-types-all and partners/partners-all were the same fetch cached twice; appConfig/app-config were one endpoint under two labels with only one invalidated; AppShell read the settings cache by a key that no longer matched. staleTime moves out of 7 contradictory call sites into setQueryDefaults, in three tiers (fixed / reference 5min / operational 0). gcTime 5min -> 30min. --- .../ClientApp/src/api/permissions.ts | 4 +- SW.Bitween.Web/ClientApp/src/api/queryKeys.ts | 208 ++++++++++++++++++ .../src/components/config/AdapterConfig.tsx | 11 +- .../config/InformationTypeDialog.tsx | 6 +- .../src/components/config/PartnerDialog.tsx | 6 +- .../src/components/config/PartnerFields.tsx | 7 +- .../components/config/SubscriptionDialog.tsx | 8 +- .../src/components/config/WorkGroupDialog.tsx | 7 +- .../src/components/config/pickers.tsx | 7 +- .../src/components/config/shared.tsx | 18 +- .../src/components/layout/AppShell.tsx | 3 +- .../mapper/MappingEditorToolbar.tsx | 5 +- .../ClientApp/src/components/mapper/data.ts | 3 +- .../mapper/useMappingEditorLoader.ts | 3 +- .../src/components/mapper/useSave.ts | 3 +- SW.Bitween.Web/ClientApp/src/lib/branding.ts | 3 +- SW.Bitween.Web/ClientApp/src/main.tsx | 9 + .../pages/aggregations/AggregationsPage.tsx | 11 +- .../pages/api-gateways/ApiGatewayNewPage.tsx | 3 +- .../src/pages/api-gateways/ApiGatewayPage.tsx | 22 +- .../pages/api-gateways/ApiGatewaysPage.tsx | 3 +- .../pages/api-gateways/AttachPartnerPage.tsx | 3 +- .../pages/api-gateways/EditAttachmentPage.tsx | 8 +- .../NewGatewaySubscriptionPage.tsx | 7 +- .../ClientApp/src/pages/auth/Login.tsx | 3 +- .../pages/bus-gateways/BusGatewayNewPage.tsx | 3 +- .../src/pages/bus-gateways/BusGatewayPage.tsx | 40 ++-- .../pages/bus-gateways/BusGatewaysPage.tsx | 5 +- .../pages/bus-gateways/studio/Inspector.tsx | 11 +- .../src/pages/dashboard/DashboardPage.tsx | 3 +- .../src/pages/exchanges/ExchangeDrawer.tsx | 7 +- .../src/pages/exchanges/ExchangeNewPage.tsx | 3 +- .../src/pages/exchanges/ExchangesPage.tsx | 9 +- .../ClientApp/src/pages/flow/FlowPage.tsx | 7 +- .../global-values/GlobalValueSetPage.tsx | 10 +- .../global-values/GlobalValueSetsPage.tsx | 5 +- .../information-types/InformationTypePage.tsx | 10 +- .../InformationTypesPage.tsx | 5 +- .../src/pages/notifiers/NotifierPage.tsx | 12 +- .../src/pages/notifiers/NotifiersPage.tsx | 6 +- .../src/pages/partners/PartnerPage.tsx | 12 +- .../src/pages/partners/PartnersPage.tsx | 5 +- .../pages/queue-health/QueueHealthPage.tsx | 3 +- .../retry-policies/RetryPoliciesPage.tsx | 5 +- .../pages/retry-policies/RetryPolicyPage.tsx | 12 +- .../src/pages/retry-policies/UsagePanel.tsx | 9 +- .../scheduled-jobs/ScheduledJobsPage.tsx | 11 +- .../ScheduledRetriesPage.tsx | 9 +- .../src/pages/settings/SettingsPage.tsx | 7 +- .../pages/subscriptions/SubscriptionPage.tsx | 25 +-- .../pages/subscriptions/SubscriptionsPage.tsx | 7 +- .../pages/subscriptions/studio/Overview.tsx | 10 +- .../studio/ReceiveAttemptsPanel.tsx | 3 +- .../subscriptions/studio/ResponseFields.tsx | 4 +- .../subscriptions/studio/RetryBudget.tsx | 5 +- .../src/pages/team/AddMemberDialog.tsx | 7 +- .../ClientApp/src/pages/team/MemberDrawer.tsx | 10 +- .../ClientApp/src/pages/team/MembersTab.tsx | 5 +- .../ClientApp/src/pages/team/RoleEditor.tsx | 8 +- .../ClientApp/src/pages/team/RolesTab.tsx | 3 +- .../src/pages/work-groups/LiveQueueStats.tsx | 3 +- .../src/pages/work-groups/WorkGroupPage.tsx | 12 +- .../src/pages/work-groups/WorkGroupsPage.tsx | 5 +- 63 files changed, 459 insertions(+), 228 deletions(-) create mode 100644 SW.Bitween.Web/ClientApp/src/api/queryKeys.ts diff --git a/SW.Bitween.Web/ClientApp/src/api/permissions.ts b/SW.Bitween.Web/ClientApp/src/api/permissions.ts index dec228a..cf09e20 100644 --- a/SW.Bitween.Web/ClientApp/src/api/permissions.ts +++ b/SW.Bitween.Web/ClientApp/src/api/permissions.ts @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { api } from "."; import type { ActionId, PermissionArea, PermissionKey } from "./types"; +import { keys } from "./queryKeys"; /** * The permission catalog is defined and enforced in the backend (SW.Bitween.Sdk/Model/Permissions.cs) @@ -26,9 +27,8 @@ export const permissionKey = (areaId: string, actionId: ActionId): PermissionKey /** Static per deployment, so it's fetched once and kept. */ export function usePermissionCatalog() { return useQuery({ - queryKey: ["permission-catalog"], + queryKey: keys.permissionCatalog, queryFn: () => api.getPermissionCatalog(), - staleTime: Infinity, }); } diff --git a/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts new file mode 100644 index 0000000..7587270 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/queryKeys.ts @@ -0,0 +1,208 @@ +import type { QueryClient } from "@tanstack/react-query"; + +/** + * Every cache label in the app, in one place. + * + * Keys are hierarchical and grouped by *entity*, not by endpoint: a subscription's detail, its + * paged rows and the whole-list cache all start with `["subscriptions"]`. React Query matches + * invalidation by prefix, so `invalidateQueries({ queryKey: keys.subscriptions.all })` clears the + * lot — one line where the old hand-written string keys needed one line per variant, and where + * missing one was silent. + * + * It was not hypothetical: nothing in the app ever invalidated `partners-search`, + * `information-types-search`, `retry-policies-search` or `api-gateways-search` — the four paged + * tables people actually look at. They only appeared correct because the global 10s staleTime + * expired almost immediately and a remount refetched. Two more pairs (`information-types` / + * `information-types-all`, `partners` / `partners-all`) were the same fetch cached twice under + * different names, and `appConfig` / `app-config` were one endpoint under two labels, only one of + * which the settings page invalidated. + * + * Adding a query? Add its key here. A new key under an existing entity is covered by that + * entity's existing invalidations automatically, which is the whole point. + */ +export const keys = { + subscriptions: { + all: ["subscriptions"] as const, + /** The whole list, held once and shared by every "who uses this?" panel. */ + cache: ["subscriptions", "cache"] as const, + /** Live status for every subscription, keyed by id — shared by the gateway pages. */ + rows: ["subscriptions", "rows"] as const, + rowsSearch: (params: Record) => ["subscriptions", "rows", params] as const, + detail: (id: number | string | null | undefined) => ["subscriptions", "detail", id] as const, + runs: (id: number) => ["subscriptions", "runs", id] as const, + receiveAttempts: (id: number, params: Record) => + ["subscriptions", "receive-attempts", id, params] as const, + lastRuns: ["subscriptions", "last-runs"] as const, + scheduleHealth: ["subscriptions", "schedule-health"] as const, + }, + + informationTypes: { + all: ["information-types"] as const, + list: ["information-types", "list"] as const, + search: (params: Record) => ["information-types", "search", params] as const, + detail: (id: number | null | undefined) => ["information-types", "detail", id] as const, + }, + + partners: { + all: ["partners"] as const, + list: ["partners", "list"] as const, + search: (params: Record) => ["partners", "search", params] as const, + detail: (id: number | null | undefined) => ["partners", "detail", id] as const, + /** Adapter properties a partner overrides, per partner. */ + adapterProperties: (id: number | null | undefined) => + ["partners", "adapter-properties", id] as const, + }, + + apiGateways: { + all: ["api-gateways"] as const, + list: ["api-gateways", "list"] as const, + search: (params: Record) => ["api-gateways", "search", params] as const, + detail: (id: number | string) => ["api-gateways", "detail", id] as const, + attachments: (id: number | string, params: Record) => + ["api-gateways", "attachments", id, params] as const, + }, + + busGateways: { + all: ["bus-gateways"] as const, + list: ["bus-gateways", "list"] as const, + search: (params: Record) => ["bus-gateways", "search", params] as const, + detail: (id: number | string) => ["bus-gateways", "detail", id] as const, + }, + + workGroups: { + all: ["work-groups"] as const, + list: ["work-groups", "list"] as const, + search: (params: Record) => ["work-groups", "search", params] as const, + detail: (id: number | null | undefined) => ["work-groups", "detail", id] as const, + }, + + retryPolicies: { + all: ["retry-policies"] as const, + list: ["retry-policies", "list"] as const, + search: (params: Record) => ["retry-policies", "search", params] as const, + detail: (id: number | string) => ["retry-policies", "detail", id] as const, + }, + + /** + * Budget consumption, kept apart from the policies themselves: it moves on its own as exchanges + * fail and gets reset by operators, so it is never worth refetching a policy list for. + */ + retryUsage: { + all: ["retry-usage"] as const, + forPolicy: (policyId: number | string) => ["retry-usage", "policy", policyId] as const, + forSubscription: (id: number) => ["retry-usage", "subscription", id] as const, + attempts: (policyId: number | string, subscriptionId: number, groupId: number | string) => + ["retry-usage", "attempts", policyId, subscriptionId, groupId] as const, + }, + + valueSets: { + all: ["value-sets"] as const, + list: ["value-sets", "list"] as const, + detail: (id: string) => ["value-sets", "detail", id] as const, + }, + + notifiers: { + all: ["notifiers"] as const, + list: ["notifiers", "list"] as const, + search: (params: Record) => ["notifiers", "search", params] as const, + detail: (id: number | string) => ["notifiers", "detail", id] as const, + }, + + roles: { + all: ["roles"] as const, + list: ["roles", "list"] as const, + detail: (id: number | string | null | undefined) => ["roles", "detail", id] as const, + }, + + users: { + all: ["users"] as const, + list: ["users", "list"] as const, + detail: (id: number | string) => ["users", "detail", id] as const, + }, + + exchanges: { + all: ["exchanges"] as const, + search: (params: string) => ["exchanges", "search", params] as const, + document: (key: string | null) => ["exchanges", "document", key] as const, + }, + + scheduledRetries: { + all: ["scheduled-retries"] as const, + search: (params: string) => ["scheduled-retries", "search", params] as const, + }, + + queueHealth: ["queue-health"] as const, + dashboard: ["dashboard"] as const, + + settings: { + all: ["settings"] as const, + list: ["settings", "list"] as const, + }, + + /** The anonymous branding/config endpoint, read by the sign-in page and the app shell alike. */ + appConfig: ["app-config"] as const, + + /** Fixed for the lifetime of a deployment. */ + permissionCatalog: ["permission-catalog"] as const, + adapters: (kind: string) => ["adapters", kind] as const, +} as const; + +const MINUTE = 60_000; + +/** + * How long each kind of data is trusted before a remount refetches it. + * + * Declared centrally rather than at 97 call sites, so the policy for an entity is one number in + * one place. Mutations invalidate explicitly, so these windows only govern *background* + * refetching — an edit still shows up immediately. + * + * Three tiers: + * - **Fixed per deployment** — never refetched. + * - **Reference/config data** — changes only when someone edits it here, and that path + * invalidates. Minutes are safe. + * - **Operational data** — moves on its own as messages flow. Always refetched on mount; most of + * these screens also declare their own `refetchInterval`. + */ +export function applyQueryDefaults(queryClient: QueryClient): void { + const fixed = [keys.permissionCatalog, ["adapters"]]; + const reference = [ + keys.informationTypes.all, + keys.partners.all, + keys.workGroups.all, + keys.retryPolicies.all, + keys.valueSets.all, + keys.notifiers.all, + keys.roles.all, + keys.users.all, + keys.apiGateways.all, + keys.busGateways.all, + keys.subscriptions.all, + keys.settings.all, + keys.appConfig, + ]; + const operational = [ + keys.exchanges.all, + keys.scheduledRetries.all, + keys.queueHealth, + keys.dashboard, + keys.retryUsage.all, + keys.subscriptions.lastRuns, + keys.subscriptions.scheduleHealth, + ["subscriptions", "receive-attempts"], + ["subscriptions", "runs"], + // The row shape carries live state — is it running, how many consecutive failures, when it + // fires next — so the tables built on it have to refetch on mount like any other live view. + // (The whole-list `cache` below is a different query and stays held for the session.) + keys.subscriptions.rows, + ]; + + for (const key of fixed) queryClient.setQueryDefaults(key, { staleTime: Infinity }); + for (const key of reference) queryClient.setQueryDefaults(key, { staleTime: 5 * MINUTE }); + // Registered after `reference` on purpose: these are nested under `subscriptions`, and defaults + // merge in registration order, so the more specific prefix has to come second to win. + for (const key of operational) queryClient.setQueryDefaults(key, { staleTime: 0 }); + + // The heaviest response in the app, and the one every "who uses this?" panel reads. Held for the + // session rather than re-fetched every five minutes; edits still invalidate it explicitly. + queryClient.setQueryDefaults(keys.subscriptions.cache, { staleTime: Infinity }); +} diff --git a/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx b/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx index bb35f0c..26273d6 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx @@ -6,6 +6,7 @@ import { api, type AdapterInfo, type AdapterKind, type PartnerRow } from "../../ import { Button } from "../ui/basics"; import { Field } from "../ui/forms"; import { SearchSelect } from "../ui/SearchSelect"; +import { keys } from "../../api/queryKeys"; /** What the picker is choosing, named for the band above the fields. */ const KIND_LABELS: Record = { @@ -17,9 +18,8 @@ const KIND_LABELS: Record = { export function useAdapterCatalog(kind: AdapterKind) { return useQuery({ - queryKey: ["adapters", kind], + queryKey: keys.adapters(kind), queryFn: () => api.listAdapters(kind), - staleTime: Infinity, }); } @@ -32,8 +32,8 @@ interface ReferenceToken { /** All insertable reference tokens: every global key + every known partner property. */ function useReferenceTokens() { - const sets = useQuery({ queryKey: ["value-sets"], queryFn: () => api.listValueSets(), staleTime: 60_000 }); - const partners = useQuery({ queryKey: ["partners"], queryFn: () => api.listPartners(), staleTime: 60_000 }); + const sets = useQuery({ queryKey: keys.valueSets.list, queryFn: () => api.listValueSets() }); + const partners = useQuery({ queryKey: keys.partners.list, queryFn: () => api.listPartners() }); const globals: ReferenceToken[] = (sets.data ?? []).flatMap((s) => Object.entries(s.values).map(([key, value]) => ({ label: `${s.id}.${key}`, @@ -60,9 +60,8 @@ function useReferenceTokens() { */ function PartnerPropValue({ partnerId, propKey }: { partnerId: number; propKey: string }) { const props = useQuery({ - queryKey: ["partner-adapter-properties", partnerId], + queryKey: keys.partners.adapterProperties(partnerId), queryFn: () => api.getPartnerAdapterProperties(partnerId), - staleTime: 60_000, }); if (props.isPending) return loading…; const value = props.data?.[propKey]; diff --git a/SW.Bitween.Web/ClientApp/src/components/config/InformationTypeDialog.tsx b/SW.Bitween.Web/ClientApp/src/components/config/InformationTypeDialog.tsx index f1aaac6..70c45af 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/InformationTypeDialog.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/InformationTypeDialog.tsx @@ -4,6 +4,7 @@ import { api } from "../../api"; import { useSessionCan } from "../../auth/guards"; import { Button, FormError, LoadingBlock } from "../ui/basics"; import { Dialog } from "../ui/overlays"; +import { keys } from "../../api/queryKeys"; import { EMPTY_INFORMATION_TYPE, InformationTypeFields, @@ -41,7 +42,7 @@ export function InformationTypeDialog({ const canEdit = useSessionCan("documents.edit"); const existing = useQuery({ - queryKey: ["information-type", typeId], + queryKey: keys.informationTypes.detail(typeId), queryFn: () => api.getInformationType(typeId!), enabled: typeId !== null, }); @@ -78,8 +79,7 @@ export function InformationTypeDialog({ return (await api.createInformationType(body)).id; }, onSuccess: async (savedId) => { - void queryClient.invalidateQueries({ queryKey: ["information-types"] }); - await queryClient.invalidateQueries({ queryKey: ["information-type", savedId] }); + await queryClient.invalidateQueries({ queryKey: keys.informationTypes.all }); onSaved?.({ id: savedId, busMessageTypeName: informationTypeChanges(draft!).busMessageTypeName ?? "" }); if (typeId === null) { onClose(); diff --git a/SW.Bitween.Web/ClientApp/src/components/config/PartnerDialog.tsx b/SW.Bitween.Web/ClientApp/src/components/config/PartnerDialog.tsx index 0d7e88f..7940447 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/PartnerDialog.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/PartnerDialog.tsx @@ -4,6 +4,7 @@ import { api } from "../../api"; import { useSessionCan } from "../../auth/guards"; import { Button, FormError, LoadingBlock } from "../ui/basics"; import { Dialog } from "../ui/overlays"; +import { keys } from "../../api/queryKeys"; import { PartnerFields, partnerChanges, @@ -41,7 +42,7 @@ export function PartnerDialog({ const [justCreated, setJustCreated] = useState(false); const existing = useQuery({ - queryKey: ["partner", id], + queryKey: keys.partners.detail(id), queryFn: () => api.getPartner(id!), enabled: id !== null, }); @@ -75,8 +76,7 @@ export function PartnerDialog({ const creating = id === null; setId(savedId); onSaved?.(savedId); - void queryClient.invalidateQueries({ queryKey: ["partners"] }); - await queryClient.invalidateQueries({ queryKey: ["partner", savedId] }); + await queryClient.invalidateQueries({ queryKey: keys.partners.all }); // Re-seed from the server rather than from the draft, so what the dialog // compares against is what was actually stored. setDraft(null); diff --git a/SW.Bitween.Web/ClientApp/src/components/config/PartnerFields.tsx b/SW.Bitween.Web/ClientApp/src/components/config/PartnerFields.tsx index add1faa..a88fbb3 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/PartnerFields.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/PartnerFields.tsx @@ -11,6 +11,7 @@ import { ConfirmDialog, Dialog } from "../ui/overlays"; import { Panel } from "../ui/Panel"; import { MiniTable } from "../ui/Table"; import { SubscriptionMiniList, usePartnerSubscriptions } from "./shared"; +import { keys } from "../../api/queryKeys"; /** * Everything about a partner that can be *edited*, as one component. @@ -73,8 +74,7 @@ export function PartnerFields({ const revoke = useMutation({ mutationFn: (keyName: string) => api.revokePartnerCredential(partnerId!, keyName), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["partner", partnerId] }); - void queryClient.invalidateQueries({ queryKey: ["partners"] }); + void queryClient.invalidateQueries({ queryKey: keys.partners.all }); }, }); @@ -209,8 +209,7 @@ function AddKeyDialog({ partnerId, onClose }: { partnerId: number; onClose: () = mutationFn: () => api.addPartnerCredential(partnerId, name), onSuccess: ({ key }) => { setIssuedKey(key); - void queryClient.invalidateQueries({ queryKey: ["partner", partnerId] }); - void queryClient.invalidateQueries({ queryKey: ["partners"] }); + void queryClient.invalidateQueries({ queryKey: keys.partners.all }); }, }); diff --git a/SW.Bitween.Web/ClientApp/src/components/config/SubscriptionDialog.tsx b/SW.Bitween.Web/ClientApp/src/components/config/SubscriptionDialog.tsx index c1ddfd1..79feaee 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/SubscriptionDialog.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/SubscriptionDialog.tsx @@ -9,6 +9,7 @@ import { CodeBadge } from "../ui/Panel"; import { AdapterConfig, useAdapterCatalog } from "./AdapterConfig"; import { InfoTypePicker } from "./pickers"; import { adapterIncomplete } from "../../pages/subscriptions/studio/faces"; +import { keys } from "../../api/queryKeys"; /** * A new gateway-backed subscription, asked down to what it cannot run without: a @@ -35,9 +36,8 @@ export function SubscriptionDialog({ const queryClient = useQueryClient(); const handlers = useAdapterCatalog("handler"); const infoTypes = useQuery({ - queryKey: ["information-types"], + queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes(), - staleTime: Infinity, }); const [name, setName] = useState(""); @@ -61,9 +61,7 @@ export function SubscriptionDialog({ enabled: true, }), onSuccess: (created) => { - void queryClient.invalidateQueries({ queryKey: ["subscriptions"] }); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows"] }); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows-search"] }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); onCreated(created.id); onClose(); }, diff --git a/SW.Bitween.Web/ClientApp/src/components/config/WorkGroupDialog.tsx b/SW.Bitween.Web/ClientApp/src/components/config/WorkGroupDialog.tsx index 1c4a765..489f39f 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/WorkGroupDialog.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/WorkGroupDialog.tsx @@ -6,6 +6,7 @@ import { Button, FormError, LoadingBlock } from "../ui/basics"; import { Field, TextInput } from "../ui/forms"; import { Dialog } from "../ui/overlays"; import { suggestSlug } from "../../lib/identifiers"; +import { keys } from "../../api/queryKeys"; /** * A work group's editable settings, as one component. @@ -131,7 +132,7 @@ export function WorkGroupDialog({ const [draft, setDraft] = useState(groupId === null ? EMPTY : null); const existing = useQuery({ - queryKey: ["work-group", groupId], + queryKey: keys.workGroups.detail(groupId), queryFn: () => api.getWorkGroup(groupId!), enabled: groupId !== null, }); @@ -150,9 +151,7 @@ export function WorkGroupDialog({ return created.id; }, onSuccess: (id) => { - void queryClient.invalidateQueries({ queryKey: ["work-groups"] }); - void queryClient.invalidateQueries({ queryKey: ["work-groups-search"] }); - void queryClient.invalidateQueries({ queryKey: ["work-group", id] }); + void queryClient.invalidateQueries({ queryKey: keys.workGroups.all }); onSaved?.(id); onClose(); }, diff --git a/SW.Bitween.Web/ClientApp/src/components/config/pickers.tsx b/SW.Bitween.Web/ClientApp/src/components/config/pickers.tsx index 906e383..5fc630f 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/pickers.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/pickers.tsx @@ -9,6 +9,7 @@ import { InformationTypeDialog } from "./InformationTypeDialog"; import { SubscriptionDialog } from "./SubscriptionDialog"; import { PartnerDialog } from "./PartnerDialog"; import { useSubscriptionsCache } from "./shared"; +import { keys } from "../../api/queryKeys"; /* * Pick-one controls used inside flows. Creating or amending the thing you are @@ -77,7 +78,7 @@ export function InfoTypePicker({ busRequired?: boolean; id?: string; }) { - const types = useQuery({ queryKey: ["information-types"], queryFn: () => api.listInformationTypes() }); + const types = useQuery({ queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes() }); const canCreate = useSessionCan("documents.create"); const canEdit = useSessionCan("documents.edit"); /** undefined = closed, null = creating, number = editing that type. */ @@ -147,7 +148,7 @@ export function SubscriptionPicker({ onDefineHere?: () => void; }) { const subscriptions = useSubscriptionsCache(); - const infoTypes = useQuery({ queryKey: ["information-types"], queryFn: () => api.listInformationTypes() }); + const infoTypes = useQuery({ queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes() }); const canCreate = useSessionCan("subscriptions.create"); const [creating, setCreating] = useState(false); @@ -222,7 +223,7 @@ export function PartnerPicker({ excludeIds?: number[]; id?: string; }) { - const partners = useQuery({ queryKey: ["partners"], queryFn: () => api.listPartners() }); + const partners = useQuery({ queryKey: keys.partners.list, queryFn: () => api.listPartners() }); const canCreate = useSessionCan("partners.create"); const canEdit = useSessionCan("partners.edit"); /** undefined = closed, null = creating, number = editing that partner. */ diff --git a/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx b/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx index 66c49e9..ed4ef5d 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx @@ -17,6 +17,7 @@ import { Badge } from "../ui/basics"; import { Popover } from "../ui/Popover"; import { MiniTable, type Column } from "../ui/Table"; import { formatDate, timeAgo } from "../../lib/dates"; +import { keys } from "../../api/queryKeys"; /** * Display names for subscription types; Internal and ApiCall are legacy. @@ -386,9 +387,8 @@ export function SetupList({ items }: { items: SubscriptionSetupRef[] }) { */ export function useSubscriptionsCache() { return useQuery({ - queryKey: ["subscriptions"], + queryKey: keys.subscriptions.cache, queryFn: () => api.listSubscriptions(), - staleTime: Infinity, }); } @@ -407,13 +407,13 @@ export function usePartnerSubscriptions(): Map { const canSeeBus = useSessionCan("bus-gateways.view"); const apiGateways = useQuery({ - queryKey: ["api-gateways"], + queryKey: keys.apiGateways.list, queryFn: () => api.listApiGateways(), enabled: canSeeApi, }).data ?? []; const busGateways = useQuery({ - queryKey: ["bus-gateways"], + queryKey: keys.busGateways.list, queryFn: () => api.listBusGateways(), enabled: canSeeBus, }).data ?? []; @@ -457,13 +457,13 @@ export function useGatewayPartners(): Map< const canSeeBus = useSessionCan("bus-gateways.view"); const apiGateways = useQuery({ - queryKey: ["api-gateways"], + queryKey: keys.apiGateways.list, queryFn: () => api.listApiGateways(), enabled: canSeeApi, }).data ?? []; const busGateways = useQuery({ - queryKey: ["bus-gateways"], + queryKey: keys.busGateways.list, queryFn: () => api.listBusGateways(), enabled: canSeeBus, }).data ?? []; @@ -496,7 +496,7 @@ export function useGatewayPartners(): Map< export function useSubscriptionRowsById(): Map { const rows = useQuery({ - queryKey: ["subscription-rows"], + queryKey: keys.subscriptions.rows, queryFn: () => api.listSubscriptionRows(), }).data ?? []; return useMemo(() => new Map(rows.map((r) => [r.id, r])), [rows]); @@ -507,7 +507,7 @@ export function useWorkGroupNames(): Map { const canSee = useSessionCan("workgroups.view"); const groups = useQuery({ - queryKey: ["work-groups"], + queryKey: keys.workGroups.list, queryFn: () => api.listWorkGroups(), enabled: canSee, }).data ?? []; @@ -519,7 +519,7 @@ export function useRetryPolicyNames(): Map { const canSee = useSessionCan("retry-policies.view"); const policies = useQuery({ - queryKey: ["retry-policies"], + queryKey: keys.retryPolicies.list, queryFn: () => api.listRetryPolicies(), enabled: canSee, }).data ?? []; diff --git a/SW.Bitween.Web/ClientApp/src/components/layout/AppShell.tsx b/SW.Bitween.Web/ClientApp/src/components/layout/AppShell.tsx index 9909149..8ea3818 100644 --- a/SW.Bitween.Web/ClientApp/src/components/layout/AppShell.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/layout/AppShell.tsx @@ -20,6 +20,7 @@ import { visibleGroups } from "../../nav"; import { Avatar } from "../ui/Avatar"; import { Button } from "../ui/basics"; import { Menu, MenuItem } from "../ui/overlays"; +import { keys } from "../../api/queryKeys"; /** * Shown on every page while unsaved setting changes exist — the whole app @@ -35,7 +36,7 @@ function SettingsPreviewBanner() { if (count === 0 || pathname.startsWith("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/settings")) return null; // Land back on the section holding the first pending change. - const rows = queryClient.getQueryData(["settings"]); + const rows = queryClient.getQueryData(keys.settings.list); const section = rows?.find((r) => r.key in draft)?.section; const editUrl = section ? `/settings?section=${encodeURIComponent(section)}` : "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/settings"; diff --git a/SW.Bitween.Web/ClientApp/src/components/mapper/MappingEditorToolbar.tsx b/SW.Bitween.Web/ClientApp/src/components/mapper/MappingEditorToolbar.tsx index 983d4d4..4613ea6 100644 --- a/SW.Bitween.Web/ClientApp/src/components/mapper/MappingEditorToolbar.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/mapper/MappingEditorToolbar.tsx @@ -12,6 +12,7 @@ import { setSelectedPartner, } from "../../lib/mapping/MappingEditorContext"; import { api } from "../../api"; +import { keys } from "../../api/queryKeys"; // ─── Mode toggle button ─────────────────────────────────────────────────────── @@ -59,11 +60,11 @@ const MappingEditorToolbar: React.FC = ({ const { mode, fieldMappings, arrayMappings, past, future, selectedPartnerId } = useMappingEditorState(); // Sync local dropdown state when selectedPartnerId changes (including reset to null) - const { data: partners } = useQuery({ queryKey: ["partners"], queryFn: () => api.listPartners() }); + const { data: partners } = useQuery({ queryKey: keys.partners.list, queryFn: () => api.listPartners() }); // listPartners() is a light row (no adapterProperties) — fetch the selected partner's // properties separately so the "Partner" mode datalist actually has options to suggest. const { data: adapterProperties, isFetching: isPartnerFetching } = useQuery({ - queryKey: ["partner-adapter-properties", selectedPartnerId], + queryKey: keys.partners.adapterProperties(selectedPartnerId), queryFn: () => api.getPartnerAdapterProperties(selectedPartnerId!), enabled: selectedPartnerId != null, }); diff --git a/SW.Bitween.Web/ClientApp/src/components/mapper/data.ts b/SW.Bitween.Web/ClientApp/src/components/mapper/data.ts index 83d98e8..bad5e10 100644 --- a/SW.Bitween.Web/ClientApp/src/components/mapper/data.ts +++ b/SW.Bitween.Web/ClientApp/src/components/mapper/data.ts @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { api, type GlobalValuesSetRow } from "../../api"; import type { KeyValuePair } from "../../lib/mapping/types"; import type { ValuesSetMap } from "../../lib/mapping/scribanGenerator"; +import { keys } from "../../api/queryKeys"; /** Boundary adapters: the prototype persists mapperProperties as Record, * while the verbatim mapping reducer/generator speak the legacy KeyValuePair[] shape. */ @@ -20,7 +21,7 @@ export const kvpsToRecord = (kvps: KeyValuePair[]): Record => * the source of the data. */ export function useGlobalSets(): GlobalValuesSetRow[] { - const { data } = useQuery({ queryKey: ["value-sets"], queryFn: () => api.listValueSets() }); + const { data } = useQuery({ queryKey: keys.valueSets.list, queryFn: () => api.listValueSets() }); return data ?? []; } diff --git a/SW.Bitween.Web/ClientApp/src/components/mapper/useMappingEditorLoader.ts b/SW.Bitween.Web/ClientApp/src/components/mapper/useMappingEditorLoader.ts index 99a2f1a..3da6a56 100644 --- a/SW.Bitween.Web/ClientApp/src/components/mapper/useMappingEditorLoader.ts +++ b/SW.Bitween.Web/ClientApp/src/components/mapper/useMappingEditorLoader.ts @@ -6,13 +6,14 @@ import { loadEditorContext, } from "../../lib/mapping/MappingEditorContext"; import { recordToKvps } from "./data"; +import { keys } from "../../api/queryKeys"; // Handles the two data-loading effects: clear-on-id-change and populate-on-data-arrive. // Fully self-contained — callers get no return value. export function useMappingEditorLoader(subscriptionId: number): void { const dispatch = useMappingEditorDispatch(); const { data: subscriptionData } = useQuery({ - queryKey: ["subscription", subscriptionId], + queryKey: keys.subscriptions.detail(subscriptionId), queryFn: () => api.getSubscription(subscriptionId), enabled: !!subscriptionId, }); diff --git a/SW.Bitween.Web/ClientApp/src/components/mapper/useSave.ts b/SW.Bitween.Web/ClientApp/src/components/mapper/useSave.ts index c94839e..e276dad 100644 --- a/SW.Bitween.Web/ClientApp/src/components/mapper/useSave.ts +++ b/SW.Bitween.Web/ClientApp/src/components/mapper/useSave.ts @@ -13,6 +13,7 @@ import { import { NATIVE_JSON_MAPPER_ID, type ValidationError, type KeyValuePair } from "../../lib/mapping/types"; import { generateScriban, parseScriban, resolveParentArrayIds } from "../../lib/mapping/scribanGenerator"; import { kvpsToRecord, useValuesSetMap } from "./data"; +import { keys } from "../../api/queryKeys"; // ─── Return type ────────────────────────────────────────────────────────────── @@ -45,7 +46,7 @@ export function useSave(subscriptionId: number): UseSaveResult { mapperId: NATIVE_JSON_MAPPER_ID, mapperProperties: kvpsToRecord(props), }), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["subscription", subscriptionId] }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: keys.subscriptions.detail(subscriptionId) }), }); const isSaving = saveMapper.isPending; const [saveSuccess, setSaveSuccess] = useState(false); diff --git a/SW.Bitween.Web/ClientApp/src/lib/branding.ts b/SW.Bitween.Web/ClientApp/src/lib/branding.ts index ff4a61f..574ba6f 100644 --- a/SW.Bitween.Web/ClientApp/src/lib/branding.ts +++ b/SW.Bitween.Web/ClientApp/src/lib/branding.ts @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { getAppConfig } from "../api"; import { applyColorScale } from "./colorScale"; import { useSettingsDraft } from "./settingsDraft"; +import { keys } from "../api/queryKeys"; /** * The Brand & theme settings, resolved through the unsaved draft so edits @@ -40,7 +41,7 @@ const catalogKey = (prop: string) => `Theme.${prop[0].toUpperCase()}${prop.slice export function useBranding(): Branding { // Read through the anonymous config endpoint rather than the settings list: the sign-in page // has to brand itself with no session, and this way both sides of the door share one path. - const { data } = useQuery({ queryKey: ["appConfig"], queryFn: getAppConfig }); + const { data } = useQuery({ queryKey: keys.appConfig, queryFn: getAppConfig }); const draft = useSettingsDraft(); return useMemo(() => { diff --git a/SW.Bitween.Web/ClientApp/src/main.tsx b/SW.Bitween.Web/ClientApp/src/main.tsx index 1d62ad1..6d35125 100644 --- a/SW.Bitween.Web/ClientApp/src/main.tsx +++ b/SW.Bitween.Web/ClientApp/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider } from "react-router"; import { NotWiredError } from "./api/types"; +import { applyQueryDefaults } from "./api/queryKeys"; import { SessionProvider } from "./auth/SessionContext"; import { router } from "./router"; import "./index.css"; @@ -18,7 +19,13 @@ if (base !== "/" && !window.location.pathname.startsWith(base.replace(/\/$/, "") const queryClient = new QueryClient({ defaultOptions: { queries: { + // A floor, not the policy: per-entity windows are registered by applyQueryDefaults below and + // override this. It only governs a query whose key isn't in the catalog, where the safe + // answer is "refetch on mount, but don't do it twice in ten seconds". staleTime: 10_000, + // Kept long enough that going back to a page you were just on renders from cache instead of + // re-fetching. Was the 5-minute default, which is shorter than a train of thought. + gcTime: 30 * 60_000, refetchOnWindowFocus: false, // NotWiredError is permanent (the method will reject every time, with no // network round-trip) — retrying it just stalls "Loading…" states for the @@ -28,6 +35,8 @@ const queryClient = new QueryClient({ }, }); +applyQueryDefaults(queryClient); + createRoot(document.getElementById("root")!).render( diff --git a/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx index 80c5ad8..ad062c7 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/aggregations/AggregationsPage.tsx @@ -21,6 +21,7 @@ import { useWorkGroupNames, } from "../../components/config/shared"; import { formatDateTime, formatDurationMs, timeAgo, timeUntil } from "../../lib/dates"; +import { keys } from "../../api/queryKeys"; function AggregateNowButton({ job }: { job: SubscriptionRow }) { const queryClient = useQueryClient(); @@ -28,9 +29,7 @@ function AggregateNowButton({ job }: { job: SubscriptionRow }) { const aggregate = useMutation({ mutationFn: () => api.aggregateNow(job.id), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["subscription-rows"] }); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows-search"] }); - void queryClient.invalidateQueries({ queryKey: ["last-runs"] }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); }, }); @@ -103,7 +102,7 @@ export function AggregationsPage() { const canOperate = useSessionCan("subscriptions.operate"); const rows = useQuery({ - queryKey: ["subscription-rows-search", "Aggregation", q, inactive, offset], + queryKey: keys.subscriptions.rowsSearch({ type: "Aggregation", q, inactive, offset }), queryFn: () => api.searchSubscriptionRows({ search: q, type: "Aggregation", inactive, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, @@ -115,10 +114,10 @@ export function AggregationsPage() { const nameById = useMemo(() => new Map(setups.map((s) => [s.id, s.name])), [setups]); const workGroupNames = useWorkGroupNames(); const retryPolicyNames = useRetryPolicyNames(); - const lastRuns = useQuery({ queryKey: ["last-runs"], queryFn: () => api.listLastRuns() }).data ?? []; + const lastRuns = useQuery({ queryKey: keys.subscriptions.lastRuns, queryFn: () => api.listLastRuns() }).data ?? []; const lastRunById = useMemo(() => new Map(lastRuns.map((r) => [r.subscriptionId, r])), [lastRuns]); const health = - useQuery({ queryKey: ["schedule-health"], queryFn: () => api.listScheduleHealth() }).data ?? []; + useQuery({ queryKey: keys.subscriptions.scheduleHealth, queryFn: () => api.listScheduleHealth() }).data ?? []; const healthById = useMemo(() => new Map(health.map((h) => [h.subscriptionId, h])), [health]); const setParam = (key: string, value: string | null, resetOffset = true) => diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayNewPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayNewPage.tsx index 0efcfba..0e8c457 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayNewPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayNewPage.tsx @@ -6,6 +6,7 @@ import { Button, FormError } from "../../components/ui/basics"; import { Field, TextInput } from "../../components/ui/forms"; import { finishUrlName, suggestSlug, toUrlName } from "../../lib/identifiers"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; export function ApiGatewayNewPage() { const navigate = useNavigate(); @@ -17,7 +18,7 @@ export function ApiGatewayNewPage() { const create = useMutation({ mutationFn: () => api.createApiGateway({ name, urlName: finishUrlName(urlName) }), onSuccess: (gateway) => { - void queryClient.invalidateQueries({ queryKey: ["api-gateways"] }); + void queryClient.invalidateQueries({ queryKey: keys.apiGateways.all }); const base = `/api-gateways/${gateway.id}`; navigate(base); }, diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx index 0f04687..62b4bdd 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx @@ -14,6 +14,7 @@ import { MiniTable } from "../../components/ui/Table"; import { Pagination } from "../../components/ui/Pagination"; import { useWiredSubscriptionColumns } from "../../components/config/shared"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; const ATTACHMENTS_PAGE_SIZE = 10; @@ -27,7 +28,7 @@ export function ApiGatewayPage() { const wiredColumns = useWiredSubscriptionColumns((a) => a.subscriptionId); const gateway = useQuery({ - queryKey: ["api-gateway", gatewayId], + queryKey: keys.apiGateways.detail(gatewayId), queryFn: () => api.getApiGateway(gatewayId), retry: false, }); @@ -35,7 +36,7 @@ export function ApiGatewayPage() { const attachmentsQuery = searchParams.get("aq") ?? ""; const attachmentsOffset = searchParams.get("aoffset") ? Number(searchParams.get("aoffset")) : 0; const attachments = useQuery({ - queryKey: ["api-gateway-attachments-search", gatewayId, attachmentsQuery, attachmentsOffset], + queryKey: keys.apiGateways.attachments(gatewayId, { q: attachmentsQuery, offset: attachmentsOffset }), queryFn: () => api.searchGatewayAttachments(gatewayId, { search: attachmentsQuery, @@ -87,9 +88,8 @@ export function ApiGatewayPage() { inactive: gateway.data?.inactive ?? false, }), onSuccess: async () => { - // Await the detail refetch before re-syncing the draft (avoids stale-data race). - await queryClient.invalidateQueries({ queryKey: ["api-gateway", gatewayId] }); - void queryClient.invalidateQueries({ queryKey: ["api-gateways"] }); + // Awaited before the draft is re-synced, or the re-sync would seed from stale data. + await queryClient.invalidateQueries({ queryKey: keys.apiGateways.all }); setLoaded(false); }, }); @@ -274,9 +274,8 @@ export function ApiGatewayPage() { confirmLabel="Detach partner" onConfirm={async () => { await api.removeGatewayAttachment(gatewayId, removing.partnerId); - void queryClient.invalidateQueries({ queryKey: ["api-gateway", gatewayId] }); - void queryClient.invalidateQueries({ queryKey: ["api-gateway-attachments-search"] }); - void queryClient.invalidateQueries({ queryKey: ["subscriptions"] }); + void queryClient.invalidateQueries({ queryKey: keys.apiGateways.all }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); }} onClose={() => setRemoving(null)} /> @@ -297,8 +296,7 @@ export function ApiGatewayPage() { urlName: g.urlName, inactive: !g.inactive, }); - await queryClient.invalidateQueries({ queryKey: ["api-gateway", gatewayId] }); - void queryClient.invalidateQueries({ queryKey: ["api-gateways"] }); + await queryClient.invalidateQueries({ queryKey: keys.apiGateways.all }); }} onClose={() => setConfirmingActive(false)} /> @@ -317,8 +315,8 @@ export function ApiGatewayPage() { confirmLabel="Delete gateway" onConfirm={async () => { await api.deleteApiGateway(gatewayId); - void queryClient.invalidateQueries({ queryKey: ["api-gateways"] }); - void queryClient.invalidateQueries({ queryKey: ["subscriptions"] }); + void queryClient.invalidateQueries({ queryKey: keys.apiGateways.all }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); navigate("/api-gateways"); }} onClose={() => setDeleting(false)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx index 804d0be..ded2212 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewaysPage.tsx @@ -8,6 +8,7 @@ import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/bas import { Pagination } from "../../components/ui/Pagination"; import { Select } from "../../components/ui/forms"; import { Table } from "../../components/ui/Table"; +import { keys } from "../../api/queryKeys"; import { LinkListCell, WiredHealthBadge, @@ -36,7 +37,7 @@ export function ApiGatewaysPage() { const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : 0; const gateways = useQuery({ - queryKey: ["api-gateways-search", q, inactive, offset], + queryKey: keys.apiGateways.search({ q, inactive, offset }), queryFn: () => api.searchApiGateways({ search: q, inactive, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/AttachPartnerPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/AttachPartnerPage.tsx index d4dc912..e46db78 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/AttachPartnerPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/AttachPartnerPage.tsx @@ -6,6 +6,7 @@ import { Button, EmptyState, FormError, LoadingBlock } from "../../components/ui import { Field } from "../../components/ui/forms"; import { SubscriptionPicker, PartnerPicker } from "../../components/config/pickers"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; /** Local draft state with the patch-and-clear shape the form bodies already use. */ function useDraft(initial: T) { @@ -37,7 +38,7 @@ export function AttachPartnerPage() { const [searchParams, setSearchParams] = useSearchParams(); const gateway = useQuery({ - queryKey: ["api-gateway", gatewayId], + queryKey: keys.apiGateways.detail(gatewayId), queryFn: () => api.getApiGateway(gatewayId), retry: false, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/EditAttachmentPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/EditAttachmentPage.tsx index d67ed94..ede40e8 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/EditAttachmentPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/EditAttachmentPage.tsx @@ -5,6 +5,7 @@ import { api } from "../../api"; import { Button, EmptyState, FormError, LoadingBlock } from "../../components/ui/basics"; import { SubscriptionPicker } from "../../components/config/pickers"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; /** Local draft state with the patch-and-clear shape the form bodies already use. */ function useDraft(initial: T) { @@ -28,7 +29,7 @@ export function EditAttachmentPage() { const queryClient = useQueryClient(); const gateway = useQuery({ - queryKey: ["api-gateway", gatewayId], + queryKey: keys.apiGateways.detail(gatewayId), queryFn: () => api.getApiGateway(gatewayId), retry: false, }); @@ -50,9 +51,8 @@ export function EditAttachmentPage() { mutationFn: () => api.updateGatewayAttachment(gatewayId, { partnerId: pid, subscriptionId: draft.subscriptionId! }), onSuccess: () => { clear(); - void queryClient.invalidateQueries({ queryKey: ["api-gateway", gatewayId] }); - void queryClient.invalidateQueries({ queryKey: ["api-gateway-attachments-search"] }); - void queryClient.invalidateQueries({ queryKey: ["subscriptions"] }); + void queryClient.invalidateQueries({ queryKey: keys.apiGateways.all }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); navigate(`/api-gateways/${gatewayId}`); }, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx index fdaea86..37e94c8 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/NewGatewaySubscriptionPage.tsx @@ -16,6 +16,7 @@ import { adapterIncomplete, faceOf } from "../subscriptions/studio/faces"; import { ResponseFields } from "../subscriptions/studio/ResponseFields"; import type { Draft as StudioDraft } from "../subscriptions/studio/model"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; /** Local draft state with the patch-and-clear shape the form bodies already use. */ function useDraft(initial: T) { @@ -73,7 +74,7 @@ export function NewGatewaySubscriptionPage() { const [stage, setStage] = useState("delivery"); const gateway = useQuery({ - queryKey: ["api-gateway", gatewayId], + queryKey: keys.apiGateways.detail(gatewayId), queryFn: () => api.getApiGateway(gatewayId), retry: false, }); @@ -111,9 +112,7 @@ export function NewGatewaySubscriptionPage() { enabled: true, }), onSuccess: (created) => { - void queryClient.invalidateQueries({ queryKey: ["subscriptions"] }); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows"] }); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows-search"] }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); backToAttach({ picked: String(created.id) }); }, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/auth/Login.tsx b/SW.Bitween.Web/ClientApp/src/pages/auth/Login.tsx index d36258c..e3dea70 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/auth/Login.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/auth/Login.tsx @@ -7,6 +7,7 @@ import { homePath } from "../../nav"; import { Button, FormError } from "../../components/ui/basics"; import { Field, PasswordInput, TextInput } from "../../components/ui/forms"; import { AuthLayout } from "./AuthLayout"; +import { keys } from "../../api/queryKeys"; function MicrosoftMark() { return ( @@ -31,7 +32,7 @@ export function LoginPage() { const [busy, setBusy] = useState(false); // Microsoft sign-in only shows when the backend has MSAL configured. - const appConfig = useQuery({ queryKey: ["app-config"], queryFn: getAppConfig }); + const appConfig = useQuery({ queryKey: keys.appConfig, queryFn: getAppConfig }); const microsoftEnabled = Boolean(appConfig.data?.msalClientId); // The Login handler rejects email/password outright when this instance is Microsoft-only, so // offering the form would only ever produce a failed sign-in. diff --git a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayNewPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayNewPage.tsx index c18dcc2..0a0be2e 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayNewPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayNewPage.tsx @@ -6,6 +6,7 @@ import { Button, FormError } from "../../components/ui/basics"; import { Field, TextInput } from "../../components/ui/forms"; import { InfoTypePicker } from "../../components/config/pickers"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; /** Local draft state with the patch-and-clear shape the form body uses. */ function useDraft(initial: T) { @@ -35,7 +36,7 @@ export function BusGatewayNewPage() { mutationFn: () => api.createBusGateway({ name: draft.name, informationTypeId: draft.informationTypeId! }), onSuccess: (gateway) => { clear(); - void queryClient.invalidateQueries({ queryKey: ["bus-gateways"] }); + void queryClient.invalidateQueries({ queryKey: keys.busGateways.all }); const base = `/bus-gateways/${gateway.id}`; navigate(base); }, diff --git a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx index 292c28d..7c2ecfb 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx @@ -24,6 +24,7 @@ import { import { PartnerDialog } from "../../components/config/PartnerDialog"; import { RouteList, type Selection } from "./studio/RouteList"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; import { BUS_NODES, NEW_ROUTE, @@ -74,19 +75,19 @@ export function BusGatewayPage() { const [params, setParams] = useSearchParams(); const gateway = useQuery({ - queryKey: ["bus-gateway", gatewayId], + queryKey: keys.busGateways.detail(gatewayId), queryFn: () => api.getBusGateway(gatewayId), retry: false, }); // One list serves three needs: the gateway's own promoted properties, its bus // message name, and resolving which type carries a published response. const informationTypes = useQuery({ - queryKey: ["information-types"], + queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes(), }); // Every gateway, because a response on the bus wakes routes on all of them. - const allGateways = useQuery({ queryKey: ["bus-gateways"], queryFn: () => api.listBusGateways() }); - const partners = useQuery({ queryKey: ["partners"], queryFn: () => api.listPartners() }); + const allGateways = useQuery({ queryKey: keys.busGateways.list, queryFn: () => api.listBusGateways() }); + const partners = useQuery({ queryKey: keys.partners.list, queryFn: () => api.listPartners() }); const rowsById = useSubscriptionRowsById(); const allSubscriptions = useSubscriptionsCache(); const catalogs = { @@ -160,7 +161,7 @@ export function BusGatewayPage() { const id0 = routeEdit?.draft.subscriptionId ?? null; const q0 = useQuery({ - queryKey: ["subscription", id0], + queryKey: keys.subscriptions.detail(id0), queryFn: () => api.getSubscription(id0!), // The subscription being defined here has no server side to fetch yet. enabled: id0 !== null && id0 !== NEW_SUBSCRIPTION_ID, @@ -168,14 +169,14 @@ export function BusGatewayPage() { const d0 = useHopDraft(edit, id0, q0.data); const id1 = d0?.responseSubscriptionId ?? null; const q1 = useQuery({ - queryKey: ["subscription", id1], + queryKey: keys.subscriptions.detail(id1), queryFn: () => api.getSubscription(id1!), enabled: id1 !== null, }); const d1 = useHopDraft(edit, id1, q1.data); const id2 = d1?.responseSubscriptionId ?? null; const q2 = useQuery({ - queryKey: ["subscription", id2], + queryKey: keys.subscriptions.detail(id2), queryFn: () => api.getSubscription(id2!), enabled: id2 !== null, }); @@ -330,15 +331,13 @@ export function BusGatewayPage() { // Both awaited before the drafts are dropped: re-seeding from stale data // would leave the save bar up over changes that are already saved. const fresh = await queryClient.fetchQuery({ - queryKey: ["bus-gateway", gatewayId], + queryKey: keys.busGateways.detail(gatewayId), queryFn: () => api.getBusGateway(gatewayId), }); - if (edit && edit.subscriptionId !== NEW_SUBSCRIPTION_ID) - await queryClient.invalidateQueries({ queryKey: ["subscription", edit.subscriptionId] }); - void queryClient.invalidateQueries({ queryKey: ["bus-gateways"] }); - void queryClient.invalidateQueries({ queryKey: ["subscriptions"] }); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows"] }); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows-search"] }); + void queryClient.invalidateQueries({ queryKey: keys.busGateways.all }); + // Awaited before the drafts are dropped: re-seeding from stale data would leave the save bar + // up over changes that are already saved. Covers the edited route's own subscription too. + await queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); setRouteEdit(null); setEdit(null); setName(fresh.name); @@ -723,11 +722,11 @@ export function BusGatewayPage() { onConfirm={async () => { await api.removeBusRoute(gatewayId, removingRoute); const fresh = await queryClient.fetchQuery({ - queryKey: ["bus-gateway", gatewayId], + queryKey: keys.busGateways.detail(gatewayId), queryFn: () => api.getBusGateway(gatewayId), }); - void queryClient.invalidateQueries({ queryKey: ["bus-gateways"] }); - void queryClient.invalidateQueries({ queryKey: ["subscriptions"] }); + void queryClient.invalidateQueries({ queryKey: keys.busGateways.all }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); setRouteEdit(null); setEdit(null); setQuery({ route: fresh.routes[0] ? String(fresh.routes[0].id) : null, hop: null }); @@ -747,8 +746,7 @@ export function BusGatewayPage() { confirmLabel={g.inactive ? "Activate" : "Deactivate"} onConfirm={async () => { await api.updateBusGateway(gatewayId, { name: g.name, inactive: !g.inactive }); - await queryClient.invalidateQueries({ queryKey: ["bus-gateway", gatewayId] }); - void queryClient.invalidateQueries({ queryKey: ["bus-gateways"] }); + await queryClient.invalidateQueries({ queryKey: keys.busGateways.all }); }} onClose={() => setConfirmingActive(false)} /> @@ -766,8 +764,8 @@ export function BusGatewayPage() { confirmLabel="Delete gateway" onConfirm={async () => { await api.deleteBusGateway(gatewayId); - void queryClient.invalidateQueries({ queryKey: ["bus-gateways"] }); - void queryClient.invalidateQueries({ queryKey: ["subscriptions"] }); + void queryClient.invalidateQueries({ queryKey: keys.busGateways.all }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); navigate("/bus-gateways"); }} onClose={() => setDeletingGateway(false)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewaysPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewaysPage.tsx index 4dc442d..ef3987d 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewaysPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewaysPage.tsx @@ -16,6 +16,7 @@ import { useSubscriptionRowsById, } from "../../components/config/shared"; import { matchSummary } from "../../lib/match"; +import { keys } from "../../api/queryKeys"; /** * Bus gateways — messages picked off the bus. A gateway listens for one @@ -42,14 +43,14 @@ export function BusGatewaysPage() { const canSeeInfoTypes = useSessionCan("documents.view"); const gateways = useQuery({ - queryKey: ["bus-gateways-search", q, informationTypeId, inactive, offset], + queryKey: keys.busGateways.search({ q, informationTypeId, inactive, offset }), queryFn: () => api.searchBusGateways({ search: q, informationTypeId, inactive, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, }); const subscriptionsById = useSubscriptionRowsById(); const infoTypes = useQuery({ - queryKey: ["information-types"], + queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes(), enabled: canSeeInfoTypes, }).data ?? []; diff --git a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx index 67004a0..a6d02d0 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/studio/Inspector.tsx @@ -10,6 +10,7 @@ import { AdapterConfig } from "../../../components/config/AdapterConfig"; import { MatchExpressionEditor } from "../../../components/config/MatchExpressionEditor"; import { HealthBadge } from "../../../components/config/shared"; import { ResponseFields } from "../../subscriptions/studio/ResponseFields"; +import { keys } from "../../../api/queryKeys"; import { BUS_NODES, type BusNodeId, @@ -121,11 +122,10 @@ export function RouteBody({ onEditPartner: (partnerId: number) => void; onNewSubscription: () => void; }) { - const partners = useQuery({ queryKey: ["partners"], queryFn: () => api.listPartners() }); + const partners = useQuery({ queryKey: keys.partners.list, queryFn: () => api.listPartners() }); const subscriptions = useQuery({ - queryKey: ["subscriptions"], + queryKey: keys.subscriptions.cache, queryFn: () => api.listSubscriptions(), - staleTime: Infinity, }); const canCreatePartner = useSessionCan("partners.create"); const canCreateSubscription = useSessionCan("subscriptions.create"); @@ -224,11 +224,10 @@ export function SubscriptionBody({ autoFocusName?: boolean; }) { const workGroups = useQuery({ - queryKey: ["work-groups"], + queryKey: keys.workGroups.list, queryFn: () => api.listWorkGroups(), - staleTime: Infinity, }); - const retryPolicies = useQuery({ queryKey: ["retry-policies"], queryFn: () => api.listRetryPolicies() }); + const retryPolicies = useQuery({ queryKey: keys.retryPolicies.list, queryFn: () => api.listRetryPolicies() }); return (
diff --git a/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx index e15c125..2fa2c2c 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/dashboard/DashboardPage.tsx @@ -7,6 +7,7 @@ import { Badge, EmptyState, LoadingBlock } from "../../components/ui/basics"; import { Panel } from "../../components/ui/Panel"; import { timeAgo } from "../../lib/dates"; import { StatusBadge, XchangeId } from "../exchanges/shared"; +import { keys } from "../../api/queryKeys"; const CHART_HEIGHT = 140; @@ -51,7 +52,7 @@ function StatTile({ */ export function DashboardPage() { const { data, isLoading } = useQuery({ - queryKey: ["dashboard"], + queryKey: keys.dashboard, queryFn: () => api.getDashboard(), refetchInterval: 60_000, placeholderData: keepPreviousData, diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx index 5cb3e85..bf39ec6 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeDrawer.tsx @@ -9,6 +9,7 @@ import { ConfirmDialog } from "../../components/ui/overlays"; import { formatDateTime, duration, timeUntil } from "../../lib/dates"; import { useSubscriptionsCache } from "../../components/config/shared"; import { RetryDialog, journeyStages, type JourneyStage } from "./shared"; +import { keys } from "../../api/queryKeys"; const STAGE_TONES: Record = { done: { ring: "border-ok-100", badge: Done }, @@ -78,7 +79,7 @@ export function ExchangeDrawer({ x }: { x: ExchangeRow }) { isLoading: activeLoading, isError: activeErrored, } = useQuery({ - queryKey: ["exchange-document", activeKey], + queryKey: keys.exchanges.document(activeKey), queryFn: () => api.getExchangeDocument(activeKey!), enabled: activeKey !== null, }); @@ -87,7 +88,7 @@ export function ExchangeDrawer({ x }: { x: ExchangeRow }) { const [actionError, setActionError] = useState(null); const [startedId, setStartedId] = useState(null); - const invalidate = () => void queryClient.invalidateQueries({ queryKey: ["exchanges"] }); + const invalidate = () => void queryClient.invalidateQueries({ queryKey: keys.exchanges.all }); const retry = useMutation({ mutationFn: (reset: boolean) => api.retryExchange(x.id, { reset }), @@ -108,7 +109,7 @@ export function ExchangeDrawer({ x }: { x: ExchangeRow }) { onSuccess: () => { setStartedId("scheduled"); invalidate(); - void queryClient.invalidateQueries({ queryKey: ["scheduled-retries"] }); + void queryClient.invalidateQueries({ queryKey: keys.scheduledRetries.all }); }, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeNewPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeNewPage.tsx index 45d7278..d873121 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeNewPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangeNewPage.tsx @@ -7,6 +7,7 @@ import { Button, FormError } from "../../components/ui/basics"; import { Field } from "../../components/ui/forms"; import { SearchSelect } from "../../components/ui/SearchSelect"; import { useSubscriptionsCache } from "../../components/config/shared"; +import { keys } from "../../api/queryKeys"; /** * Manually inject a payload — useful for testing a pipeline without waiting @@ -29,7 +30,7 @@ export function ExchangeNewPage() { const subscriptions = useSubscriptionsCache().data ?? []; const infoTypes = - useQuery({ queryKey: ["information-types"], queryFn: () => api.listInformationTypes() }).data ?? []; + useQuery({ queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes() }).data ?? []; const create = useMutation({ mutationFn: () => diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx index bdadce4..7d6f76f 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx @@ -13,6 +13,7 @@ import { timeAgo, timeUntil, duration } from "../../lib/dates"; import { ExchangeDrawer } from "./ExchangeDrawer"; import { JourneyStrip, RetryDialog, STATUS_LABELS, StatusBadge } from "./shared"; import { PromotedProps } from "../../components/config/shared"; +import { keys } from "../../api/queryKeys"; const PAGE_SIZE = 25; const STATUSES: ExchangeStatus[] = ["processing", "success", "badResponse", "failed"]; @@ -53,16 +54,16 @@ export function ExchangesPage() { const queryClient = useQueryClient(); const { data, isLoading, dataUpdatedAt } = useQuery({ - queryKey: ["exchanges", searchParams.toString()], + queryKey: keys.exchanges.search(searchParams.toString()), queryFn: () => api.searchExchanges(query), refetchInterval: refreshMs || false, placeholderData: keepPreviousData, }); const subscriptions = useSubscriptionsCache().data ?? []; - const partners = useQuery({ queryKey: ["partners"], queryFn: () => api.listPartners() }).data ?? []; + const partners = useQuery({ queryKey: keys.partners.list, queryFn: () => api.listPartners() }).data ?? []; const infoTypes = - useQuery({ queryKey: ["information-types"], queryFn: () => api.listInformationTypes() }).data ?? []; + useQuery({ queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes() }).data ?? []; /** * Every promoted key any information type declares, with the types that declare it. @@ -122,7 +123,7 @@ export function ExchangesPage() { setBulkResult( `${retried} retr${retried === 1 ? "y" : "ies"} started${skipped > 0 ? `, ${skipped} skipped (auto-retry already scheduled)` : ""}.`, ); - void queryClient.invalidateQueries({ queryKey: ["exchanges"] }); + void queryClient.invalidateQueries({ queryKey: keys.exchanges.all }); }, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/flow/FlowPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/flow/FlowPage.tsx index 58b9bef..f659348 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/flow/FlowPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/flow/FlowPage.tsx @@ -9,6 +9,7 @@ import { EmptyState, LoadingBlock } from "../../components/ui/basics"; import { FlowLegend, FlowMap } from "./FlowMap"; import { buildFlowGraph } from "./model"; import { layoutFlow } from "./layout"; +import { keys } from "../../api/queryKeys"; /** * How data moves between gateways, on one surface. @@ -27,16 +28,16 @@ export function FlowPage() { const subscriptions = useSubscriptionsCache(); const informationTypes = useQuery({ - queryKey: ["information-types"], + queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes(), }); const apiGateways = useQuery({ - queryKey: ["api-gateways"], + queryKey: keys.apiGateways.list, queryFn: () => api.listApiGateways(), enabled: canSeeApi, }); const busGateways = useQuery({ - queryKey: ["bus-gateways"], + queryKey: keys.busGateways.list, queryFn: () => api.listBusGateways(), enabled: canSeeBus, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetPage.tsx index 1908b41..ec56524 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetPage.tsx @@ -10,6 +10,7 @@ import { KeyValueEditor, toRecord, toRows, type KvRow } from "../../components/u import { EditableTitle, Panel, UnsavedBar } from "../../components/ui/Panel"; import { MiniTable } from "../../components/ui/Table"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; import { SUBSCRIPTION_TYPE_LABELS, SubscriptionMiniList, @@ -23,7 +24,7 @@ export function GlobalValueSetPage() { const canEdit = useSessionCan("global-values.edit"); const set = useQuery({ - queryKey: ["value-set", id], + queryKey: keys.valueSets.detail(id), queryFn: () => api.getValueSet(id), retry: false, }); @@ -50,9 +51,8 @@ export function GlobalValueSetPage() { const save = useMutation({ mutationFn: () => api.updateValueSet(id, { name, values: toRecord(rows ?? []) }), onSuccess: async () => { - // Await the detail refetch before re-syncing the draft (avoids stale-data race). - await queryClient.invalidateQueries({ queryKey: ["value-set", id] }); - void queryClient.invalidateQueries({ queryKey: ["value-sets"] }); + // Awaited before the draft is re-synced, or the re-sync would seed from stale data. + await queryClient.invalidateQueries({ queryKey: keys.valueSets.all }); setLoaded(false); }, }); @@ -186,7 +186,7 @@ export function GlobalValueSetPage() { confirmLabel="Delete value set" onConfirm={async () => { await api.deleteValueSet(id); - void queryClient.invalidateQueries({ queryKey: ["value-sets"] }); + void queryClient.invalidateQueries({ queryKey: keys.valueSets.all }); navigate("/global-values"); }} onClose={() => setDeleting(false)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetsPage.tsx index d6f0fa7..b273b5f 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/global-values/GlobalValueSetsPage.tsx @@ -12,6 +12,7 @@ import { Dialog } from "../../components/ui/overlays"; import { Table } from "../../components/ui/Table"; import { UsedByCell, useSubscriptionsCache } from "../../components/config/shared"; import { suggestSlug } from "../../lib/identifiers"; +import { keys } from "../../api/queryKeys"; function CreateValueSetDialog({ onClose }: { onClose: () => void }) { const navigate = useNavigate(); @@ -23,7 +24,7 @@ function CreateValueSetDialog({ onClose }: { onClose: () => void }) { const create = useMutation({ mutationFn: () => api.createValueSet({ id: slug, name, values: {} }), onSuccess: (set) => { - void queryClient.invalidateQueries({ queryKey: ["value-sets"] }); + void queryClient.invalidateQueries({ queryKey: keys.valueSets.all }); navigate(`/global-values/${set.id}`); }, }); @@ -94,7 +95,7 @@ export function GlobalValueSetsPage() { const subscriptionIds = parseIds(searchParams.get("subscriptions")); const creating = searchParams.get("new") === "1"; - const sets = useQuery({ queryKey: ["value-sets"], queryFn: () => api.listValueSets() }); + const sets = useQuery({ queryKey: keys.valueSets.list, queryFn: () => api.listValueSets() }); const subscriptions = useSubscriptionsCache().data ?? []; const setParam = (key: string, value: string | null) => diff --git a/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypePage.tsx b/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypePage.tsx index 50f3aa9..8293446 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypePage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypePage.tsx @@ -10,6 +10,7 @@ import { CodeBadge, Panel, UnsavedBar } from "../../components/ui/Panel"; import { MiniTable } from "../../components/ui/Table"; import { ExchangesList, SetupList, TrailTable } from "../../components/config/shared"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; import { InformationTypeFields, informationTypeChanges, @@ -26,7 +27,7 @@ export function InformationTypePage() { const canEdit = useSessionCan("documents.edit"); const type = useQuery({ - queryKey: ["information-type", typeId], + queryKey: keys.informationTypes.detail(typeId), queryFn: () => api.getInformationType(typeId), retry: false, }); @@ -50,9 +51,8 @@ export function InformationTypePage() { const save = useMutation({ mutationFn: () => api.updateInformationType(typeId, informationTypeChanges(draft!)), onSuccess: async () => { - // Await the detail refetch before re-syncing the draft (avoids stale-data race). - await queryClient.invalidateQueries({ queryKey: ["information-type", typeId] }); - void queryClient.invalidateQueries({ queryKey: ["information-types"] }); + // Awaited before the draft is re-synced, or the re-sync would seed from stale data. + await queryClient.invalidateQueries({ queryKey: keys.informationTypes.all }); setLoaded(false); }, }); @@ -157,7 +157,7 @@ export function InformationTypePage() { confirmLabel="Delete information type" onConfirm={async () => { await api.deleteInformationType(typeId); - void queryClient.invalidateQueries({ queryKey: ["information-types"] }); + void queryClient.invalidateQueries({ queryKey: keys.informationTypes.all }); navigate("/information-types"); }} onClose={() => setDeleting(false)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypesPage.tsx index 2958e84..093c05f 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/information-types/InformationTypesPage.tsx @@ -13,6 +13,7 @@ import { CodeBadge } from "../../components/ui/Panel"; import { Pagination } from "../../components/ui/Pagination"; import { Table } from "../../components/ui/Table"; import { UsedByCell, useSubscriptionsCache } from "../../components/config/shared"; +import { keys } from "../../api/queryKeys"; const PAGE_SIZE = 25; @@ -57,13 +58,13 @@ export function InformationTypesPage() { const filtering = subscriptionIds.length > 0; const serverSearch = useQuery({ - queryKey: ["information-types-search", q, format, busEnabled, offset], + queryKey: keys.informationTypes.search({ q, format, busEnabled, offset }), queryFn: () => api.searchInformationTypes({ search: q, format, busEnabled, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, enabled: !filtering, }); const allTypes = useQuery({ - queryKey: ["information-types-all"], + queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes(), enabled: filtering, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifierPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifierPage.tsx index 754e316..394a2b8 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifierPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifierPage.tsx @@ -14,6 +14,7 @@ import { useAdapterCatalog } from "../../components/config/AdapterConfig"; import { useSubscriptionsCache } from "../../components/config/shared"; import { timeAgo } from "../../lib/dates"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; type Draft = Omit; @@ -72,7 +73,7 @@ export function NotifierPage() { const canEdit = useSessionCan("notifiers.edit"); const notifier = useQuery({ - queryKey: ["notifier", notifierId], + queryKey: keys.notifiers.detail(notifierId), queryFn: () => api.getNotifier(notifierId), retry: false, }); @@ -101,10 +102,8 @@ export function NotifierPage() { const save = useMutation({ mutationFn: () => api.updateNotifier(notifierId, draft!), onSuccess: async () => { - // Await the detail refetch before re-syncing the draft (avoids stale-data race). - await queryClient.invalidateQueries({ queryKey: ["notifier", notifierId] }); - void queryClient.invalidateQueries({ queryKey: ["notifiers"] }); - void queryClient.invalidateQueries({ queryKey: ["notifiers-search"] }); + // Awaited before the draft is re-synced, or the re-sync would seed from stale data. + await queryClient.invalidateQueries({ queryKey: keys.notifiers.all }); setLoaded(false); }, }); @@ -354,8 +353,7 @@ export function NotifierPage() { confirmLabel="Delete notifier" onConfirm={async () => { await api.deleteNotifier(notifierId); - void queryClient.invalidateQueries({ queryKey: ["notifiers"] }); - void queryClient.invalidateQueries({ queryKey: ["notifiers-search"] }); + void queryClient.invalidateQueries({ queryKey: keys.notifiers.all }); navigate("/notifiers"); }} onClose={() => setDeleting(false)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifiersPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifiersPage.tsx index d1da4fc..0b2c9e9 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifiersPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifiersPage.tsx @@ -11,6 +11,7 @@ import { Field, TextInput } from "../../components/ui/forms"; import { Dialog } from "../../components/ui/overlays"; import { Pagination } from "../../components/ui/Pagination"; import { Table } from "../../components/ui/Table"; +import { keys } from "../../api/queryKeys"; function CreateNotifierDialog({ onClose }: { onClose: () => void }) { const navigate = useNavigate(); @@ -20,8 +21,7 @@ function CreateNotifierDialog({ onClose }: { onClose: () => void }) { const create = useMutation({ mutationFn: () => api.createNotifier({ name }), onSuccess: (notifier) => { - void queryClient.invalidateQueries({ queryKey: ["notifiers"] }); - void queryClient.invalidateQueries({ queryKey: ["notifiers-search"] }); + void queryClient.invalidateQueries({ queryKey: keys.notifiers.all }); navigate(`/notifiers/${notifier.id}`); }, }); @@ -70,7 +70,7 @@ export function NotifiersPage() { const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : 0; const notifiers = useQuery({ - queryKey: ["notifiers-search", q, offset], + queryKey: keys.notifiers.search({ q, offset }), queryFn: () => api.searchNotifiers({ search: q, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/partners/PartnerPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/partners/PartnerPage.tsx index 70f4f7a..7ef6525 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/partners/PartnerPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/partners/PartnerPage.tsx @@ -10,6 +10,7 @@ import { EditableTitle, Panel, UnsavedBar } from "../../components/ui/Panel"; import { MiniTable } from "../../components/ui/Table"; import { ExchangesList, SetupList, usePartnerSubscriptions } from "../../components/config/shared"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; import { PartnerFields, partnerChanges, @@ -26,7 +27,7 @@ export function PartnerPage() { const canEdit = useSessionCan("partners.edit"); const partner = useQuery({ - queryKey: ["partner", partnerId], + queryKey: keys.partners.detail(partnerId), queryFn: () => api.getPartner(partnerId), retry: false, }); @@ -53,10 +54,9 @@ export function PartnerPage() { const save = useMutation({ mutationFn: () => api.updatePartner(partnerId, partnerChanges(draft!)), onSuccess: async () => { - // Await the detail refetch BEFORE re-syncing the draft, so the re-sync - // effect reads the freshly-saved server data (not the stale cache). - await queryClient.invalidateQueries({ queryKey: ["partner", partnerId] }); - void queryClient.invalidateQueries({ queryKey: ["partners"] }); + // Awaited BEFORE the draft is re-synced, so the re-sync effect reads the + // freshly-saved server data (not the stale cache). + await queryClient.invalidateQueries({ queryKey: keys.partners.all }); setLoaded(false); }, }); @@ -206,7 +206,7 @@ export function PartnerPage() { confirmLabel="Delete partner" onConfirm={async () => { await api.deletePartner(partnerId); - void queryClient.invalidateQueries({ queryKey: ["partners"] }); + void queryClient.invalidateQueries({ queryKey: keys.partners.all }); navigate("/partners"); }} onClose={() => setDeleting(false)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/partners/PartnersPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/partners/PartnersPage.tsx index 1e053ff..9ea7710 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/partners/PartnersPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/partners/PartnersPage.tsx @@ -11,6 +11,7 @@ import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/bas import { Pagination } from "../../components/ui/Pagination"; import { Table } from "../../components/ui/Table"; import { UsedByCell, useSubscriptionsCache, usePartnerSubscriptions } from "../../components/config/shared"; +import { keys } from "../../api/queryKeys"; const PAGE_SIZE = 25; @@ -55,13 +56,13 @@ export function PartnersPage() { const filtering = subscriptionIds.length > 0; const serverSearch = useQuery({ - queryKey: ["partners-search", q, offset], + queryKey: keys.partners.search({ q, offset }), queryFn: () => api.searchPartners({ search: q, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, enabled: !filtering, }); const allPartners = useQuery({ - queryKey: ["partners-all"], + queryKey: keys.partners.list, queryFn: () => api.listPartners(), enabled: filtering, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/queue-health/QueueHealthPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/queue-health/QueueHealthPage.tsx index 873d1e0..7255dfc 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/queue-health/QueueHealthPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/queue-health/QueueHealthPage.tsx @@ -8,6 +8,7 @@ import { Badge, EmptyState, LoadingBlock } from "../../components/ui/basics"; import { queueHealthTitle } from "../../components/config/shared"; import { Panel } from "../../components/ui/Panel"; import { timeAgo } from "../../lib/dates"; +import { keys } from "../../api/queryKeys"; const POLL_MS = 5_000; @@ -90,7 +91,7 @@ function StatTile({ label, value, sub }: { label: string; value: ReactNode; sub? */ export function QueueHealthPage() { const { data, isLoading, dataUpdatedAt } = useQuery({ - queryKey: ["queue-health"], + queryKey: keys.queueHealth, queryFn: () => api.getQueueHealth(), refetchInterval: POLL_MS, placeholderData: keepPreviousData, diff --git a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPoliciesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPoliciesPage.tsx index 956811a..7d8eb03 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPoliciesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPoliciesPage.tsx @@ -11,6 +11,7 @@ import { Dialog } from "../../components/ui/overlays"; import { Pagination } from "../../components/ui/Pagination"; import { Table } from "../../components/ui/Table"; import { UsedByCell, useSubscriptionsCache } from "../../components/config/shared"; +import { keys } from "../../api/queryKeys"; function CreateRetryPolicyDialog({ onClose }: { onClose: () => void }) { const navigate = useNavigate(); @@ -20,7 +21,7 @@ function CreateRetryPolicyDialog({ onClose }: { onClose: () => void }) { const create = useMutation({ mutationFn: () => api.createRetryPolicy({ name }), onSuccess: (policy) => { - void queryClient.invalidateQueries({ queryKey: ["retry-policies"] }); + void queryClient.invalidateQueries({ queryKey: keys.retryPolicies.all }); navigate(`/retry-policies/${policy.id}`); }, }); @@ -65,7 +66,7 @@ export function RetryPoliciesPage() { const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : 0; const policies = useQuery({ - queryKey: ["retry-policies-search", q, offset], + queryKey: keys.retryPolicies.search({ q, offset }), queryFn: () => api.searchRetryPolicies({ search: q, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx index 202ed57..34b9940 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/RetryPolicyPage.tsx @@ -13,6 +13,7 @@ import { AdapterConfig } from "../../components/config/AdapterConfig"; import { GroupDialog } from "./GroupDialog"; import { UsagePanel } from "./UsagePanel"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; const matcherSummary = (m: RetryMatcher): string => { switch (m.type) { @@ -226,7 +227,7 @@ export function RetryPolicyPage() { const canEdit = useSessionCan("retry-policies.edit"); const policy = useQuery({ - queryKey: ["retry-policy", policyId], + queryKey: keys.retryPolicies.detail(policyId), queryFn: () => api.getRetryPolicy(policyId), retry: false, }); @@ -268,11 +269,10 @@ export function RetryPolicyPage() { alertHandlerProperties: alertProps, }), onSuccess: async () => { - // Await the detail refetch before re-syncing the draft (avoids stale-data race). - await queryClient.invalidateQueries({ queryKey: ["retry-policy", policyId] }); - void queryClient.invalidateQueries({ queryKey: ["retry-policies"] }); + // Awaited before the draft is re-synced, or the re-sync would seed from stale data. + await queryClient.invalidateQueries({ queryKey: keys.retryPolicies.all }); // Editing a group can change which budgets exist, so the usage report is stale too. - void queryClient.invalidateQueries({ queryKey: ["retry-usage"] }); + void queryClient.invalidateQueries({ queryKey: keys.retryUsage.all }); setLoaded(false); }, }); @@ -497,7 +497,7 @@ export function RetryPolicyPage() { confirmLabel="Delete policy" onConfirm={async () => { await api.deleteRetryPolicy(policyId); - void queryClient.invalidateQueries({ queryKey: ["retry-policies"] }); + void queryClient.invalidateQueries({ queryKey: keys.retryPolicies.all }); navigate("/retry-policies"); }} onClose={() => setDeleting(false)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/UsagePanel.tsx b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/UsagePanel.tsx index ae8708d..aac1c0e 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/retry-policies/UsagePanel.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/retry-policies/UsagePanel.tsx @@ -8,6 +8,7 @@ import { ConfirmDialog, Dialog } from "../../components/ui/overlays"; import { Panel } from "../../components/ui/Panel"; import { formatDateTime, timeAgo } from "../../lib/dates"; import { AlertRouting } from "./AlertRouting"; +import { keys } from "../../api/queryKeys"; /** * What each retry budget of this policy has actually spent, and whether anyone was told when @@ -120,7 +121,7 @@ function AlertedCell({ row }: { row: RetryUsageRow }) { /** The failures a pair spent its budget on, fetched only once the row is opened. */ function Attempts({ policyId, row }: { policyId: number; row: RetryUsageRow }) { const q = useQuery({ - queryKey: ["retry-attempts", policyId, row.subscriptionId, row.groupId], + queryKey: keys.retryUsage.attempts(policyId, row.subscriptionId, row.groupId), queryFn: () => api.getRetryAttempts(policyId, { subscriptionId: row.subscriptionId, groupId: row.groupId }), }); @@ -209,7 +210,7 @@ function OverrideDialog({ const save = useMutation({ mutationFn: () => api.saveRetryAlertOverride(policyId, { ...value, subscriptionId: row.subscriptionId, groupId: row.groupId }), onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["retry-usage"] }); + await queryClient.invalidateQueries({ queryKey: keys.retryUsage.all }); onClose(); }, }); @@ -270,9 +271,9 @@ export function UsagePanel({ const [overriding, setOverriding] = useState(null); const [resetting, setResetting] = useState(null); - const usage = useQuery({ queryKey: ["retry-usage", policyId], queryFn: () => api.getRetryUsage(policyId) }); + const usage = useQuery({ queryKey: keys.retryUsage.forPolicy(policyId), queryFn: () => api.getRetryUsage(policyId) }); - const invalidate = () => queryClient.invalidateQueries({ queryKey: ["retry-usage"] }); + const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.retryUsage.all }); if (usage.isPending) return ; if (usage.isError) diff --git a/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx index 76d24af..0d2cba8 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/scheduled-jobs/ScheduledJobsPage.tsx @@ -20,6 +20,7 @@ import { useWorkGroupNames, } from "../../components/config/shared"; import { formatDateTime, formatDurationMs, timeAgo, timeUntil } from "../../lib/dates"; +import { keys } from "../../api/queryKeys"; function ReceiveNowButton({ job }: { job: SubscriptionRow }) { const queryClient = useQueryClient(); @@ -27,9 +28,7 @@ function ReceiveNowButton({ job }: { job: SubscriptionRow }) { const receive = useMutation({ mutationFn: () => api.receiveNow(job.id), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["subscription-rows"] }); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows-search"] }); - void queryClient.invalidateQueries({ queryKey: ["last-runs"] }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); }, }); @@ -103,7 +102,7 @@ export function ScheduledJobsPage() { const canSeeInfoTypes = useSessionCan("documents.view"); const rows = useQuery({ - queryKey: ["subscription-rows-search", "Receiving", q, inactive, offset], + queryKey: keys.subscriptions.rowsSearch({ type: "Receiving", q, inactive, offset }), queryFn: () => api.searchSubscriptionRows({ search: q, type: "Receiving", inactive, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, @@ -115,10 +114,10 @@ export function ScheduledJobsPage() { const workGroupNames = useWorkGroupNames(); const retryPolicyNames = useRetryPolicyNames(); // One request for the whole list rather than one per row. - const lastRuns = useQuery({ queryKey: ["last-runs"], queryFn: () => api.listLastRuns() }).data ?? []; + const lastRuns = useQuery({ queryKey: keys.subscriptions.lastRuns, queryFn: () => api.listLastRuns() }).data ?? []; const lastRunById = useMemo(() => new Map(lastRuns.map((r) => [r.subscriptionId, r])), [lastRuns]); const health = - useQuery({ queryKey: ["schedule-health"], queryFn: () => api.listScheduleHealth() }).data ?? []; + useQuery({ queryKey: keys.subscriptions.scheduleHealth, queryFn: () => api.listScheduleHealth() }).data ?? []; const healthById = useMemo(() => new Map(health.map((h) => [h.subscriptionId, h])), [health]); const setParam = (key: string, value: string | null, resetOffset = true) => diff --git a/SW.Bitween.Web/ClientApp/src/pages/scheduled-retries/ScheduledRetriesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/scheduled-retries/ScheduledRetriesPage.tsx index 1591d33..0442de5 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/scheduled-retries/ScheduledRetriesPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/scheduled-retries/ScheduledRetriesPage.tsx @@ -14,6 +14,7 @@ import { Table } from "../../components/ui/Table"; import { useSubscriptionsCache } from "../../components/config/shared"; import { formatDateTime, timeAgo, timeUntil } from "../../lib/dates"; import { PromotedProps } from "../../components/config/shared"; +import { keys } from "../../api/queryKeys"; const PAGE_SIZE = 25; @@ -37,7 +38,7 @@ export function ScheduledRetriesPage() { const { can } = useSession(); const { data, isLoading } = useQuery({ - queryKey: ["scheduled-retries", searchParams.toString()], + queryKey: keys.scheduledRetries.search(searchParams.toString()), queryFn: () => api.searchScheduledRetries(query), refetchInterval: 30_000, placeholderData: keepPreviousData, @@ -45,7 +46,7 @@ export function ScheduledRetriesPage() { const subscriptions = useSubscriptionsCache().data ?? []; const infoTypes = - useQuery({ queryKey: ["information-types"], queryFn: () => api.listInformationTypes() }).data ?? []; + useQuery({ queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes() }).data ?? []; const setParam = (key: string, value: string | null, resetOffset = true) => { const next = new URLSearchParams(searchParams); @@ -62,8 +63,8 @@ export function ScheduledRetriesPage() { const runNow = useMutation({ mutationFn: (id: string) => api.runScheduledRetryNow(id), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["scheduled-retries"] }); - void queryClient.invalidateQueries({ queryKey: ["exchanges"] }); + void queryClient.invalidateQueries({ queryKey: keys.scheduledRetries.all }); + void queryClient.invalidateQueries({ queryKey: keys.exchanges.all }); }, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/settings/SettingsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/settings/SettingsPage.tsx index 719bfbc..1b986ea 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/settings/SettingsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/settings/SettingsPage.tsx @@ -9,6 +9,7 @@ import { Badge, Button, LoadingBlock } from "../../components/ui/basics"; import { Checkbox, TextInput } from "../../components/ui/forms"; import { UnsavedBar } from "../../components/ui/Panel"; import { settingsDraft, useSettingsDraft } from "../../lib/settingsDraft"; +import { keys } from "../../api/queryKeys"; /** Sections, in the order the backend catalog lists them. */ const sectionsOf = (rows: SettingRow[]): string[] => [...new Set(rows.map((r) => r.section))]; @@ -226,7 +227,7 @@ function SettingRowEditor({ export function SettingsPage() { const canEdit = useSessionCan("settings.edit"); const queryClient = useQueryClient(); - const { data: rows, isLoading } = useQuery({ queryKey: ["settings"], queryFn: () => api.listSettings() }); + const { data: rows, isLoading } = useQuery({ queryKey: keys.settings.list, queryFn: () => api.listSettings() }); const draft = useSettingsDraft(); const [searchParams] = useSearchParams(); @@ -249,11 +250,11 @@ export function SettingsPage() { }, onSuccess: () => { settingsDraft.discardAll(); - void queryClient.invalidateQueries({ queryKey: ["settings"] }); + void queryClient.invalidateQueries({ queryKey: keys.settings.all }); // Branding everywhere reads the memoised config payload, so it has to be re-fetched // for a saved brand change to stick once the draft preview is dropped. resetAppConfig(); - void queryClient.invalidateQueries({ queryKey: ["appConfig"] }); + void queryClient.invalidateQueries({ queryKey: keys.appConfig }); }, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx index 81b21ac..541dca0 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx @@ -19,6 +19,7 @@ import { EntryPointsTable, Overview } from "./studio/Overview"; import { ResponseFields } from "./studio/ResponseFields"; import { draftOf, entryPointsOf, stageDirty, type Draft } from "./studio/model"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; export function SubscriptionPage() { const { id = "" } = useParams(); @@ -31,14 +32,14 @@ export function SubscriptionPage() { const [params, setParams] = useSearchParams(); const subscription = useQuery({ - queryKey: ["subscription", subscriptionId], + queryKey: keys.subscriptions.detail(subscriptionId), queryFn: () => api.getSubscription(subscriptionId), retry: false, }); const allSubscriptions = useSubscriptionsCache(); // promoted properties power the legacy message filter const infoType = useQuery({ - queryKey: ["information-type", subscription.data?.informationTypeId], + queryKey: keys.informationTypes.detail(subscription.data?.informationTypeId), queryFn: () => api.getInformationType(subscription.data!.informationTypeId), enabled: subscription.data?.type === "Internal", }); @@ -53,7 +54,7 @@ export function SubscriptionPage() { // Shares the scheduled-jobs page's cache entry. The only per-stage fault we // can honestly attribute — it comes from the scheduler's own trigger state. const scheduleHealth = useQuery({ - queryKey: ["schedule-health"], + queryKey: keys.subscriptions.scheduleHealth, queryFn: () => api.listScheduleHealth(), enabled: subscription.data?.type === "Receiving" || subscription.data?.type === "Aggregation", }); @@ -107,10 +108,8 @@ export function SubscriptionPage() { }, [params, draft, subscription.data, setParams]); const invalidate = () => { - const detail = queryClient.invalidateQueries({ queryKey: ["subscription", subscriptionId] }); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows"] }); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows-search"] }); - void queryClient.invalidateQueries({ queryKey: ["subscriptions"] }); + const detail = queryClient.invalidateQueries({ queryKey: keys.subscriptions.detail(subscriptionId) }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); return detail; }; @@ -132,8 +131,8 @@ export function SubscriptionPage() { mutationFn: () => api.aggregateNow(subscriptionId), onSuccess: async () => { await invalidate(); - void queryClient.invalidateQueries({ queryKey: ["subscription-runs", subscriptionId] }); - void queryClient.invalidateQueries({ queryKey: ["last-runs"] }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.runs(subscriptionId) }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.lastRuns }); }, }); @@ -141,8 +140,8 @@ export function SubscriptionPage() { mutationFn: () => api.receiveNow(subscriptionId), onSuccess: async () => { await invalidate(); - void queryClient.invalidateQueries({ queryKey: ["subscription-runs", subscriptionId] }); - void queryClient.invalidateQueries({ queryKey: ["last-runs"] }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.runs(subscriptionId) }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.lastRuns }); }, }); @@ -524,9 +523,7 @@ export function SubscriptionPage() { confirmLabel="Delete subscription" onConfirm={async () => { await api.deleteSubscription(subscriptionId); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows"] }); - void queryClient.invalidateQueries({ queryKey: ["subscription-rows-search"] }); - void queryClient.invalidateQueries({ queryKey: ["subscriptions"] }); + void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); navigate("/subscriptions"); }} onClose={() => setDeleting(false)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionsPage.tsx index 1209bb3..6dc2e12 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionsPage.tsx @@ -9,6 +9,7 @@ import { Pagination } from "../../components/ui/Pagination"; import { SearchSelect } from "../../components/ui/SearchSelect"; import { Select } from "../../components/ui/forms"; import { Table } from "../../components/ui/Table"; +import { keys } from "../../api/queryKeys"; import { HealthBadge, SUBSCRIPTION_TYPE_LABELS, @@ -61,7 +62,7 @@ export function SubscriptionsPage() { const canSeeInfoTypes = useSessionCan("documents.view"); const rows = useQuery({ - queryKey: ["subscription-rows-search", q, type, informationTypeId, partnerId, inactive, offset], + queryKey: keys.subscriptions.rowsSearch({ q, type, informationTypeId, partnerId, inactive, offset }), queryFn: () => api.searchSubscriptionRows({ search: q, @@ -75,8 +76,8 @@ export function SubscriptionsPage() { placeholderData: keepPreviousData, }); const gatewayPartners = useGatewayPartners(); - const infoTypes = useQuery({ queryKey: ["information-types"], queryFn: () => api.listInformationTypes() }).data ?? []; - const partners = useQuery({ queryKey: ["partners"], queryFn: () => api.listPartners() }).data ?? []; + const infoTypes = useQuery({ queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes() }).data ?? []; + const partners = useQuery({ queryKey: keys.partners.list, queryFn: () => api.listPartners() }).data ?? []; /** Its own partner (legacy types) plus any reached through a gateway. */ const partnersFor = (r: SubscriptionRow) => { diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/Overview.tsx b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/Overview.tsx index db43d7e..878f28e 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/Overview.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/Overview.tsx @@ -13,6 +13,7 @@ import { formatDate, formatDateTime, formatDurationMs, timeAgo, timeUntil } from import { ReceiveAttemptsPanel, type AttemptKind } from "./ReceiveAttemptsPanel"; import { RetryBudget } from "./RetryBudget"; import type { Draft, EntryPoint } from "./model"; +import { keys } from "../../../api/queryKeys"; /** Who can feed this subscription. Shared with the Trigger stage, which is the same question. */ export function EntryPointsTable({ rows, empty }: { rows: EntryPoint[]; empty: string }) { @@ -170,11 +171,10 @@ export function Overview({ scheduled: boolean; }) { const workGroups = useQuery({ - queryKey: ["work-groups"], + queryKey: keys.workGroups.list, queryFn: () => api.listWorkGroups(), - staleTime: Infinity, }); - const retryPolicies = useQuery({ queryKey: ["retry-policies"], queryFn: () => api.listRetryPolicies() }); + const retryPolicies = useQuery({ queryKey: keys.retryPolicies.list, queryFn: () => api.listRetryPolicies() }); // Both scheduled types keep their own attempt history and show it in one table // (ReceiveAttemptsPanel) instead of the scheduler's run history beside a separate exchange // list. The scheduler's history is Quartz vocabulary an operator has no reason to know, it @@ -184,13 +184,13 @@ export function Overview({ const aggregation = s.type === "Aggregation"; const attemptKind: AttemptKind | null = receiving ? "receiving" : aggregation ? "aggregation" : null; const runs = useQuery({ - queryKey: ["subscription-runs", s.id], + queryKey: keys.subscriptions.runs(s.id), queryFn: () => api.listSubscriptionRuns(s.id, 20), enabled: scheduled && attemptKind === null, }); // Just for the "Last run" fact above — ReceiveAttemptsPanel fetches its own page. const latestAttempt = useQuery({ - queryKey: ["receive-attempts", s.id, null, 0, 1], + queryKey: keys.subscriptions.receiveAttempts(s.id, { outcome: null, offset: 0, limit: 1 }), queryFn: () => api.searchReceiveAttempts(s.id, { outcome: null, offset: 0, limit: 1 }), enabled: attemptKind !== null, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/ReceiveAttemptsPanel.tsx b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/ReceiveAttemptsPanel.tsx index 2c0ddd3..a61f08c 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/ReceiveAttemptsPanel.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/ReceiveAttemptsPanel.tsx @@ -10,6 +10,7 @@ import { Table } from "../../../components/ui/Table"; import { PromotedProps } from "../../../components/config/shared"; import { StatusBadge } from "../../exchanges/shared"; import { formatDateTime, timeAgo } from "../../../lib/dates"; +import { keys } from "../../../api/queryKeys"; const PAGE_SIZE = 25; @@ -191,7 +192,7 @@ export function ReceiveAttemptsPanel({ const [offset, setOffset] = useState(0); const attempts = useQuery({ - queryKey: ["receive-attempts", subscriptionId, outcome, offset], + queryKey: keys.subscriptions.receiveAttempts(subscriptionId, { outcome, offset }), queryFn: () => api.searchReceiveAttempts(subscriptionId, { outcome, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/ResponseFields.tsx b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/ResponseFields.tsx index 6335da8..2e808e5 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/ResponseFields.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/ResponseFields.tsx @@ -8,6 +8,7 @@ import { Field } from "../../../components/ui/forms"; import { SearchSelect } from "../../../components/ui/SearchSelect"; import { InformationTypeDialog } from "../../../components/config/InformationTypeDialog"; import { busMessageNameProblem } from "../../../lib/busMessageName"; +import { keys } from "../../../api/queryKeys"; /** * The Response stage's body: what happens to whatever the delivery hands back. @@ -148,9 +149,8 @@ function BusMessageField({ onChange: (value: string | null) => void; }) { const informationTypes = useQuery({ - queryKey: ["information-types"], + queryKey: keys.informationTypes.list, queryFn: () => api.listInformationTypes(), - staleTime: Infinity, }); const canCreate = useSessionCan("documents.create"); const [creating, setCreating] = useState(false); diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/RetryBudget.tsx b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/RetryBudget.tsx index dd166c4..d8102fc 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/RetryBudget.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/studio/RetryBudget.tsx @@ -6,6 +6,7 @@ import { api } from "../../../api"; import { Button } from "../../../components/ui/basics"; import { ConfirmDialog } from "../../../components/ui/overlays"; import { timeAgo } from "../../../lib/dates"; +import { keys } from "../../../api/queryKeys"; /** * This subscription's own retry budgets, asked for from its side rather than its policy's. @@ -24,7 +25,7 @@ export function RetryBudget({ subscriptionId, canEdit }: { subscriptionId: numbe const [resetting, setResetting] = useState(false); const usage = useQuery({ - queryKey: ["retry-usage", "subscription", subscriptionId], + queryKey: keys.retryUsage.forSubscription(subscriptionId), queryFn: () => api.getSubscriptionRetryUsage(subscriptionId), }); @@ -92,7 +93,7 @@ export function RetryBudget({ subscriptionId, canEdit }: { subscriptionId: numbe onConfirm={async () => { // No group id: every group of this subscription, which is what the banner reports on. await api.resetSubscriptionRetryUsage(subscriptionId); - await queryClient.invalidateQueries({ queryKey: ["retry-usage"] }); + await queryClient.invalidateQueries({ queryKey: keys.retryUsage.all }); }} onClose={() => setResetting(false)} /> diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/AddMemberDialog.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/AddMemberDialog.tsx index 3d464e0..0365a59 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/team/AddMemberDialog.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/team/AddMemberDialog.tsx @@ -4,10 +4,11 @@ import { api, ApiRequestError } from "../../api"; import { Button, FormError } from "../../components/ui/basics"; import { Checkbox, Field, PasswordInput, TextInput } from "../../components/ui/forms"; import { Dialog } from "../../components/ui/overlays"; +import { keys } from "../../api/queryKeys"; export function AddMemberDialog({ onClose }: { onClose: () => void }) { const queryClient = useQueryClient(); - const roles = useQuery({ queryKey: ["roles"], queryFn: () => api.listRoles() }); + const roles = useQuery({ queryKey: keys.roles.list, queryFn: () => api.listRoles() }); const [displayName, setDisplayName] = useState(""); const [email, setEmail] = useState(""); @@ -17,8 +18,8 @@ export function AddMemberDialog({ onClose }: { onClose: () => void }) { const create = useMutation({ mutationFn: () => api.createUser({ displayName, email, password, roleIds }), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["users"] }); - void queryClient.invalidateQueries({ queryKey: ["roles"] }); + void queryClient.invalidateQueries({ queryKey: keys.users.all }); + void queryClient.invalidateQueries({ queryKey: keys.roles.all }); onClose(); }, }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx index 68a66e4..1a1b98a 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/team/MemberDrawer.tsx @@ -11,6 +11,7 @@ import { Checkbox, PasswordInput } from "../../components/ui/forms"; import { ConfirmDialog } from "../../components/ui/overlays"; import { formatDate, timeAgo, timeUntil } from "../../lib/dates"; import { statusBadge } from "./MembersTab"; +import { keys } from "../../api/queryKeys"; function Section({ title, children }: { title: string; children: ReactNode }) { return ( @@ -25,8 +26,8 @@ export function MemberDrawer({ userId, onClose }: { userId: string; onClose: () const { session, can } = useSession(); const queryClient = useQueryClient(); - const user = useQuery({ queryKey: ["user", userId], queryFn: () => api.getUser(userId), retry: false }); - const roles = useQuery({ queryKey: ["roles"], queryFn: () => api.listRoles() }); + const user = useQuery({ queryKey: keys.users.detail(userId), queryFn: () => api.getUser(userId), retry: false }); + const roles = useQuery({ queryKey: keys.roles.list, queryFn: () => api.listRoles() }); const [draftRoleIds, setDraftRoleIds] = useState(null); const [confirming, setConfirming] = useState<"remove" | null>(null); const [newPassword, setNewPassword] = useState(""); @@ -42,9 +43,8 @@ export function MemberDrawer({ userId, onClose }: { userId: string; onClose: () }, [onClose]); const invalidate = () => { - void queryClient.invalidateQueries({ queryKey: ["users"] }); - void queryClient.invalidateQueries({ queryKey: ["user", userId] }); - void queryClient.invalidateQueries({ queryKey: ["roles"] }); + void queryClient.invalidateQueries({ queryKey: keys.users.all }); + void queryClient.invalidateQueries({ queryKey: keys.roles.all }); }; const saveRoles = useMutation({ diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/MembersTab.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/MembersTab.tsx index d6e2083..60e5987 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/team/MembersTab.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/team/MembersTab.tsx @@ -9,6 +9,7 @@ import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/bas import { timeAgo } from "../../lib/dates"; import { AddMemberDialog } from "./AddMemberDialog"; import { MemberDrawer } from "./MemberDrawer"; +import { keys } from "../../api/queryKeys"; const STATUS_FILTERS: { value: string; label: string }[] = [ { value: "all", label: "All" }, @@ -33,8 +34,8 @@ export function MembersTab() { const status = searchParams.get("status") ?? "all"; const addOpen = searchParams.get("add") === "1"; - const users = useQuery({ queryKey: ["users"], queryFn: () => api.listUsers() }); - const roles = useQuery({ queryKey: ["roles"], queryFn: () => api.listRoles() }); + const users = useQuery({ queryKey: keys.users.list, queryFn: () => api.listUsers() }); + const roles = useQuery({ queryKey: keys.roles.list, queryFn: () => api.listRoles() }); const setParam = (key: string, value: string | null) => { setSearchParams( diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/RoleEditor.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/RoleEditor.tsx index 5580180..d9b1ee9 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/team/RoleEditor.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/team/RoleEditor.tsx @@ -17,6 +17,7 @@ import { Badge, Button, FormError, LoadingBlock } from "../../components/ui/basi import { Field, TextInput } from "../../components/ui/forms"; import { ConfirmDialog } from "../../components/ui/overlays"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; /** Live answer to "what would someone with this role actually see?" */ function AccessPreview({ permissions, total }: { permissions: Set; total: number }) { @@ -73,7 +74,7 @@ export function RoleEditor() { const areas = catalog.data ?? []; const source = useQuery({ - queryKey: ["role", sourceId], + queryKey: keys.roles.detail(sourceId), queryFn: () => api.getRole(sourceId!), enabled: sourceId !== null, retry: false, @@ -127,8 +128,7 @@ export function RoleEditor() { return isNew ? api.createRole(input) : api.updateRole(id!, input); }, onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["roles"] }); - void queryClient.invalidateQueries({ queryKey: ["role", id] }); + void queryClient.invalidateQueries({ queryKey: keys.roles.all }); navigate("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/team/roles"); }, }); @@ -355,7 +355,7 @@ export function RoleEditor() { confirmLabel="Delete role" onConfirm={async () => { await api.deleteRole(id!); - void queryClient.invalidateQueries({ queryKey: ["roles"] }); + void queryClient.invalidateQueries({ queryKey: keys.roles.all }); navigate("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/team/roles"); }} onClose={() => setConfirmingDelete(false)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/RolesTab.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/RolesTab.tsx index a5ca155..447b226 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/team/RolesTab.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/team/RolesTab.tsx @@ -5,10 +5,11 @@ import { api } from "../../api"; import { allKeysIn, usePermissionCatalog } from "../../api/permissions"; import { Can } from "../../auth/guards"; import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/basics"; +import { keys } from "../../api/queryKeys"; export function RolesTab() { const navigate = useNavigate(); - const roles = useQuery({ queryKey: ["roles"], queryFn: () => api.listRoles() }); + const roles = useQuery({ queryKey: keys.roles.list, queryFn: () => api.listRoles() }); const totalPermissions = allKeysIn(usePermissionCatalog().data ?? []).length; if (roles.isPending) return ; diff --git a/SW.Bitween.Web/ClientApp/src/pages/work-groups/LiveQueueStats.tsx b/SW.Bitween.Web/ClientApp/src/pages/work-groups/LiveQueueStats.tsx index edd0f65..57946e0 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/work-groups/LiveQueueStats.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/work-groups/LiveQueueStats.tsx @@ -3,6 +3,7 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { api } from "../../api"; import { Badge } from "../../components/ui/basics"; import { queueHealthTitle } from "../../components/config/shared"; +import { keys } from "../../api/queryKeys"; function LiveStat({ label, value, tone }: { label: string; value: ReactNode; tone?: "warn" | "danger" }) { return ( @@ -26,7 +27,7 @@ function LiveStat({ label, value, tone }: { label: string; value: ReactNode; ton */ export function LiveQueueStats({ groupId }: { groupId: number }) { const { data } = useQuery({ - queryKey: ["queue-health"], + queryKey: keys.queueHealth, queryFn: () => api.getQueueHealth(), refetchInterval: 5_000, placeholderData: keepPreviousData, diff --git a/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupPage.tsx index 1477fd5..20c2347 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupPage.tsx @@ -15,6 +15,7 @@ import { EditableTitle, Panel, UnsavedBar } from "../../components/ui/Panel"; import { SetupList } from "../../components/config/shared"; import { LiveQueueStats } from "./LiveQueueStats"; import { BackLink } from "../../components/ui/BackLink"; +import { keys } from "../../api/queryKeys"; /** * This group's slice of the live RabbitMQ picture — the same numbers the @@ -47,7 +48,7 @@ export function WorkGroupPage() { const canEdit = useSessionCan("workgroups.edit"); const group = useQuery({ - queryKey: ["work-group", groupId], + queryKey: keys.workGroups.detail(groupId), queryFn: () => api.getWorkGroup(groupId), retry: false, }); @@ -71,10 +72,8 @@ export function WorkGroupPage() { const save = useMutation({ mutationFn: () => api.updateWorkGroup(groupId, draft!), onSuccess: async () => { - // Await the detail refetch before re-syncing the draft (avoids stale-data race). - await queryClient.invalidateQueries({ queryKey: ["work-group", groupId] }); - void queryClient.invalidateQueries({ queryKey: ["work-groups"] }); - void queryClient.invalidateQueries({ queryKey: ["work-groups-search"] }); + // Awaited before the draft is re-synced, or the re-sync would seed from stale data. + await queryClient.invalidateQueries({ queryKey: keys.workGroups.all }); setLoaded(false); }, }); @@ -146,8 +145,7 @@ export function WorkGroupPage() { confirmLabel="Delete work group" onConfirm={async () => { await api.deleteWorkGroup(groupId); - void queryClient.invalidateQueries({ queryKey: ["work-groups"] }); - void queryClient.invalidateQueries({ queryKey: ["work-groups-search"] }); + void queryClient.invalidateQueries({ queryKey: keys.workGroups.all }); navigate("/work-groups"); }} onClose={() => setDeleting(false)} diff --git a/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupsPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupsPage.tsx index 2f4814d..9d0d3d2 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupsPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/work-groups/WorkGroupsPage.tsx @@ -10,6 +10,7 @@ import { Badge, Button, EmptyState, LoadingBlock } from "../../components/ui/bas import { Pagination } from "../../components/ui/Pagination"; import { Table, type Column } from "../../components/ui/Table"; import { UsedByCell, queueHealthTitle, useSubscriptionsCache } from "../../components/config/shared"; +import { keys } from "../../api/queryKeys"; /** * The live RabbitMQ numbers, as columns rather than a per-row drill-down. @@ -58,13 +59,13 @@ export function WorkGroupsPage() { const canMonitor = useSessionCan("monitoring.view"); const groups = useQuery({ - queryKey: ["work-groups-search", q, offset], + queryKey: keys.workGroups.search({ q, offset }), queryFn: () => api.searchWorkGroups({ search: q, offset, limit: PAGE_SIZE }), placeholderData: keepPreviousData, }); const subscriptions = useSubscriptionsCache().data ?? []; const live = useQuery({ - queryKey: ["queue-health"], + queryKey: keys.queueHealth, queryFn: () => api.getQueueHealth(), refetchInterval: 5_000, placeholderData: keepPreviousData, From 28c1857d84bc8a6a32d0c7423a11f2ccee7362b2 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 30 Aug 2026 11:23:25 +0300 Subject: [PATCH 4/8] test: bring the e2e suite back to green MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 43 pass; 16 were failing before, none of them for a reason that still existed. The UI had moved on and the specs hadn't: - Settings sections are links, not buttons (a section is a pasteable URL). - Information types and work groups are created in a dialog on the list page; the /new routes they drove are gone. - Scheduled jobs, API gateways and bus gateway routes are built from pickers and stage cards, not a Continue wizard. Bus routes are edited on the gateway's own canvas. - Setting a member's password now replaces the form with the password to copy, rather than clearing the field. - A member can no longer be created with no roles at all — the server refuses — so the roleless case is reached by removing their only role afterwards. - The exchanges spec pinned four exchange ids and two subscription names from a seed that no longer exists; it now works with whatever rows are there. Two assertions were wrong rather than stale, and are now narrower: a new work group legitimately has one consumer (declaring its queue makes this instance one), and adapter picks need a sync point before the form is read, or the test races the render. --- .../ClientApp/e2e/exchanges.spec.ts | 19 +-- SW.Bitween.Web/ClientApp/e2e/gateways.spec.ts | 114 +++++++++--------- .../ClientApp/e2e/information-types.spec.ts | 16 +-- .../e2e/permissions-enforcement.spec.ts | 11 +- SW.Bitween.Web/ClientApp/e2e/settings.spec.ts | 15 ++- .../ClientApp/e2e/subscriptions.spec.ts | 56 +++++---- .../ClientApp/e2e/team-members.spec.ts | 8 +- .../ClientApp/e2e/work-groups.spec.ts | 20 +-- 8 files changed, 147 insertions(+), 112 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts b/SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts index 2129929..802b6b3 100644 --- a/SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts @@ -14,14 +14,18 @@ test.beforeEach(async ({ page }) => { test("exchanges list, filter, retry, bulk retry, create", async ({ page }) => { test.setTimeout(45000); await page.goto("exchanges"); - await expect(page.getByRole("row", { name: /azureBlob test sub/ }).first()).toBeVisible({ timeout: 15000 }); + // Whatever the database holds: this used to pin to particular exchange ids and subscription + // names from a seed that no longer exists, which made it a test of the fixtures, not the page. + await expect(page.getByRole("row").nth(1)).toBeVisible({ timeout: 15000 }); await expect(page.getByText("undefined")).toHaveCount(0); // Avoid the background refetch racing with row selection below. await page.getByLabel("Refresh interval").selectOption("0"); // Filter down to failed exchanges only. await page.getByRole("button", { name: "Failed" }).click(); - const row = page.getByRole("row", { name: "36b3e2b1003048ff8dec1573b2f752c5" }); + // Pick by content, not position: the filter re-renders the table, and an index would race it + // and land on whichever row was showing before. + const row = page.getByRole("row").filter({ hasText: "Failed" }).first(); await expect(row).toBeVisible({ timeout: 10000 }); // Expand the row (click the chevron cell — other cells stop propagation) @@ -34,11 +38,10 @@ test("exchanges list, filter, retry, bulk retry, create", async ({ page }) => { // Bulk retry a couple of specific rows (not the whole page — each retry does // real file I/O against storage, so keep this fast and deterministic). await page.getByRole("button", { name: "All" }).click(); - await expect(page.getByRole("checkbox", { name: "Select 18dfd10c4b764b53aea339eedb98de18" })).toBeVisible({ - timeout: 10000, - }); - await page.getByRole("checkbox", { name: "Select 18dfd10c4b764b53aea339eedb98de18" }).check(); - await page.getByRole("checkbox", { name: "Select a1b088fffe9e4993ac75736576a826cb" }).check(); + const rowCheckbox = page.getByRole("checkbox", { name: /^Select \w/ }); + await expect(rowCheckbox.first()).toBeVisible({ timeout: 10000 }); + await rowCheckbox.nth(0).check(); + await rowCheckbox.nth(1).check(); await expect(page.getByText(/\d+ selected/)).toBeVisible(); await page.getByRole("button", { name: "Retry selected…" }).click(); await page.getByRole("dialog").getByRole("button", { name: "Retry" }).click(); @@ -47,7 +50,7 @@ test("exchanges list, filter, retry, bulk retry, create", async ({ page }) => { // Manually create an exchange addressed at a subscription. await page.goto("exchanges/new"); await page.getByRole("combobox", { name: "Pick a subscription…" }).click(); - await page.getByRole("option", { name: "s3 test sub" }).click(); + await page.getByRole("option").first().click(); // Dismiss the dropdown panel via an outside click (it sits above the panel's // anchor point, so it can't itself be covered) rather than Escape, which // doesn't close this Headless UI combobox instance. diff --git a/SW.Bitween.Web/ClientApp/e2e/gateways.spec.ts b/SW.Bitween.Web/ClientApp/e2e/gateways.spec.ts index 12b9617..e3f4006 100644 --- a/SW.Bitween.Web/ClientApp/e2e/gateways.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/gateways.spec.ts @@ -1,5 +1,8 @@ import { test, expect } from "@playwright/test"; +/** A seeded partner, used for the attachment this test makes and then removes. */ +const PARTNER = "Acme Retail"; + const ADMIN_EMAIL = "admin@Bitween.systems"; const ADMIN_PASSWORD = "Mtm@dmin!2"; @@ -22,49 +25,50 @@ test("API gateway: create, attach partner, create subscription detour, edit atta await expect(page).toHaveURL(/\/api-gateways\/\d+$/); await expect(page.getByRole("heading", { name })).toBeVisible(); - // Attach a partner, detouring to create the required GatewayApiCall subscription inline. + // Attach a partner, detouring to create the required GatewayApiCall subscription. The + // detour is a nested route off the attach page, and both pickers are comboboxes now — + // there is no wizard to "Continue" through. await page.getByRole("button", { name: "Attach partner" }).click(); await expect(page).toHaveURL(/\/api-gateways\/\d+\/attach$/); - await page.getByRole("button", { name: "acme" }).click(); - await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("combobox", { name: "Partner" }).click(); + await page.getByRole("option", { name: new RegExp(PARTNER) }).click(); + await expect(page.getByRole("combobox", { name: "Partner" })).toHaveValue(PARTNER); const subscriptionName = `Playwright GW Subscription ${Date.now()}`; - await page.getByRole("link", { name: "New subscription" }).click(); - await expect(page).toHaveURL(/\/subscriptions\/new\?type=GatewayApiCall/); - await page.fill("#ni-name", subscriptionName); - await page.getByRole("button", { name: "test doc" }).click(); - await page.getByLabel("handler adapter").click(); + await page.getByRole("button", { name: "New subscription" }).click(); + // The picked partner rides along as a query param through the detour. + await expect(page).toHaveURL(/\/api-gateways\/\d+\/attach\/new-subscription/); + await page.fill("#ngi-name", subscriptionName); + await page.getByRole("combobox", { name: "Information type" }).click(); + await page.getByRole("option", { name: /Shipment order/ }).click(); + await expect(page.getByRole("combobox", { name: "Information type" })).toHaveValue(/Shipment order/); + await page.getByRole("combobox", { name: "handler adapter" }).click(); await page.getByRole("option", { name: "NativeHttpHandler" }).click(); - await expect(page.getByRole("listbox")).toHaveCount(0, { timeout: 10000 }); await page.locator("#prop-Url").fill("https://example.com/sink"); + await expect(page.locator("#prop-Url")).toHaveValue("https://example.com/sink"); await page.getByRole("button", { name: "Create subscription" }).click(); - // This page itself renders a ReturnBanner with a "Continue" button before - // the mutation resolves (inherited from the detour link) — wait for the - // create to actually land on the new subscription's own page first, or the - // click races and hits that stale button instead. - await expect(page).toHaveURL(/\/subscriptions\/\d+\?/); - await page.getByRole("button", { name: "Continue" }).click(); - await expect(page).toHaveURL(/\/api-gateways\/\d+\/attach$/); - await expect(page.getByText(subscriptionName)).toBeVisible(); - await page.getByRole("button", { name: "Continue" }).click(); + // Back on the attach form with the new subscription already chosen. + await expect(page).toHaveURL(/\/api-gateways\/\d+\/attach(\?|$)/); + // It comes back selected in the picker, so it is the combobox's value, not page text. + await expect(page.getByRole("combobox", { name: "Subscription" })).toHaveValue(subscriptionName); await page.getByRole("button", { name: "Attach partner" }).click(); await expect(page).toHaveURL(/\/api-gateways\/\d+$/); - await expect(page.getByText("acme").first()).toBeVisible(); - await expect(page.getByText(subscriptionName)).toBeVisible(); + await expect(page.getByText(PARTNER).first()).toBeVisible(); + await expect(page.getByText(subscriptionName).first()).toBeVisible(); // Edit the attachment — exercises the remove-then-add path (backend's // updatepartner can't mutate a composite-key column in place). - await page.getByRole("button", { name: "Edit attachment for acme" }).click(); + await page.getByRole("button", { name: `Edit attachment for ${PARTNER}` }).click(); await expect(page).toHaveURL(/\/api-gateways\/\d+\/attachments\/\d+$/); await page.getByRole("button", { name: "Save" }).click(); await expect(page).toHaveURL(/\/api-gateways\/\d+$/); await expect(page.getByText(subscriptionName)).toBeVisible(); // Detach. - await page.getByRole("button", { name: "Detach acme" }).click(); + await page.getByRole("button", { name: `Detach ${PARTNER}` }).click(); await page .getByRole("dialog", { name: "Detach this partner?" }) .getByRole("button", { name: "Detach partner" }) @@ -79,9 +83,8 @@ test("API gateway: create, attach partner, create subscription detour, edit atta .getByRole("dialog", { name: "Delete this API gateway?" }) .getByRole("button", { name: "Delete gateway" }) .click(); - // ApiGatewayPage navigates to /api-gateways, which the router redirects to - // the unified subscriptions list. - await expect(page).toHaveURL(/\/subscriptions\?types=api-gateways$/); + // API gateways have their own list page now, which is where deleting one lands. + await expect(page).toHaveURL(/\/api-gateways$/); }); test("Bus gateway: create, add route with match expression, edit route, remove, delete", async ({ page }) => { @@ -89,51 +92,43 @@ test("Bus gateway: create, add route with match expression, edit route, remove, await page.goto("bus-gateways/new"); await page.fill("#nbg-name", name); - await page.getByRole("button", { name: "test-hh" }).click(); + // Bus-enabled types only, and this one is the one no seeded gateway already listens for. + await page.getByRole("combobox", { name: "Information type" }).click(); + await page.getByRole("option", { name: /Delivery proof/ }).click(); + await expect(page.getByRole("combobox", { name: "Information type" })).toHaveValue(/Delivery proof/); await page.getByRole("button", { name: "Create gateway" }).click(); await expect(page).toHaveURL(/\/bus-gateways\/\d+$/); await expect(page.getByRole("heading", { name })).toBeVisible(); - await page.getByRole("button", { name: "Add route" }).click(); - await expect(page).toHaveURL(/\/bus-gateways\/\d+\/add-route$/); + // Routes are built on the gateway's own canvas now — no separate add-route page, and no + // wizard: the route, its subscription and that subscription's delivery are all edited in + // place, and "Create route" saves the lot. + // An empty gateway offers both "Add a route" in the header and "Add the first route" + // in the empty canvas; either does the same thing. + await page.getByRole("button", { name: /^Add (a|the first) route/ }).first().click(); + await expect(page).toHaveURL(/\/bus-gateways\/\d+\?route=new/); - // Filter step — leave the match expression empty (null = matches everything). - await page.getByRole("button", { name: "Continue" }).click(); - // Partner step — no partner. - await page.getByRole("button", { name: "No partner" }).click(); - await page.getByRole("button", { name: "Continue" }).click(); - - // Subscription step — detour to create the required BusGateway subscription. + // No partner and no filter: an empty match expression means "matches everything". const subscriptionName = `Playwright Bus Subscription ${Date.now()}`; - await page.getByRole("link", { name: "New subscription" }).click(); - await expect(page).toHaveURL(/\/subscriptions\/new\?type=BusGateway/); - await page.fill("#ni-name", subscriptionName); - await page.getByLabel("handler adapter").click(); + await page.getByRole("button", { name: "New subscription" }).click(); + await page.fill("#bs-int-name", subscriptionName); + + // Its delivery is a node on the same canvas. + await page.getByRole("button", { name: /^Delivery/ }).click(); + await page.getByRole("combobox", { name: "handler adapter" }).click(); await page.getByRole("option", { name: "NativeHttpHandler" }).click(); - await expect(page.getByRole("listbox")).toHaveCount(0, { timeout: 10000 }); await page.locator("#prop-Url").fill("https://example.com/sink"); - await page.getByRole("button", { name: "Create subscription" }).click(); + await expect(page.locator("#prop-Url")).toHaveValue("https://example.com/sink"); - // Wait for the create to actually land (see the comment in the API gateway - // test above) before clicking the ReturnBanner's "Continue". - await expect(page).toHaveURL(/\/subscriptions\/\d+\?/); - await page.getByRole("button", { name: "Continue" }).click(); - await expect(page).toHaveURL(/\/bus-gateways\/\d+\/add-route$/); - await expect(page.getByText(subscriptionName)).toBeVisible(); - await page.getByRole("button", { name: "Continue" }).click(); - await page.getByRole("button", { name: "Add route" }).click(); - - await expect(page).toHaveURL(/\/bus-gateways\/\d+$/); - await expect(page.getByText(subscriptionName)).toBeVisible(); + await page.getByRole("button", { name: "Create route" }).click(); + await expect(page).toHaveURL(/\/bus-gateways\/\d+\?.*route=\d+/); - // Edit the route (no-op save exercises the round trip of a null match expression). - await page.getByRole("button", { name: /Edit route \d+/ }).click(); - await expect(page).toHaveURL(/\/bus-gateways\/\d+\/routes\/\d+$/); - await page.getByRole("button", { name: "Save" }).click(); - await expect(page).toHaveURL(/\/bus-gateways\/\d+$/); + // Reload to prove the route round-tripped, null match expression and all. + await page.reload(); + await expect(page.getByText(subscriptionName).first()).toBeVisible(); // Remove the route. - await page.getByRole("button", { name: /Remove route \d+/ }).click(); + await page.getByRole("button", { name: "Remove route" }).click(); await page .getByRole("dialog", { name: "Remove this route?" }) .getByRole("button", { name: "Remove route" }) @@ -146,5 +141,6 @@ test("Bus gateway: create, add route with match expression, edit route, remove, .getByRole("dialog", { name: "Delete this bus gateway?" }) .getByRole("button", { name: "Delete gateway" }) .click(); - await expect(page).toHaveURL(/\/subscriptions\?types=bus-gateways$/); + // Bus gateways have their own list page now, which is where deleting one lands. + await expect(page).toHaveURL(/\/bus-gateways$/); }); diff --git a/SW.Bitween.Web/ClientApp/e2e/information-types.spec.ts b/SW.Bitween.Web/ClientApp/e2e/information-types.spec.ts index 25814e1..4481463 100644 --- a/SW.Bitween.Web/ClientApp/e2e/information-types.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/information-types.spec.ts @@ -14,16 +14,16 @@ test.beforeEach(async ({ page }) => { test("information type Code is optional end to end", async ({ page }) => { const name = `Playwright No Code ${Date.now()}`; - await page.goto("information-types/new"); - await page.fill("#nit-name", name); + // Creating happens in a dialog on the list page, not on a page of its own. + await page.goto("information-types"); + await page.getByRole("button", { name: "New information type" }).click(); - // Expand the collapsed code/format section and clear the auto-suggested code. - await page.getByRole("button", { name: /^Code/ }).click(); - const codeInput = page.locator("#nit-code"); - await expect(codeInput).not.toHaveAttribute("required", ""); - await codeInput.fill(""); + const dialog = page.getByRole("dialog", { name: "New information type" }); + await dialog.getByRole("textbox", { name: "Name" }).fill(name); + // Code starts empty and stays optional — nothing to clear, and it doesn't block creating. + await expect(dialog.getByRole("textbox", { name: "Code" })).toHaveValue(""); - await page.getByRole("button", { name: "Create information type" }).click(); + await dialog.getByRole("button", { name: "Create information type" }).click(); // Should navigate straight to the detail page — no validation block on empty code. await expect(page).toHaveURL(/\/information-types\/\d+$/); diff --git a/SW.Bitween.Web/ClientApp/e2e/permissions-enforcement.spec.ts b/SW.Bitween.Web/ClientApp/e2e/permissions-enforcement.spec.ts index 675cb5e..d44ca12 100644 --- a/SW.Bitween.Web/ClientApp/e2e/permissions-enforcement.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/permissions-enforcement.spec.ts @@ -6,8 +6,10 @@ import { deleteRole, removeMember, signIn, + openMember, signInAsAdmin, signOut, + startsWith, } from "./helpers"; /** @@ -168,7 +170,14 @@ test("editing a role changes what its members can do, without them signing in ag test("a member with no roles at all sees nothing and can do nothing", async ({ page }) => { await signInAsAdmin(page); - const email = await addMember(page, { name: "No Roles", roles: [] }); + // A member cannot be created without a role — the server refuses an empty one — so the + // roleless state is reached the only way it can be: by taking their one role away after. + const email = await addMember(page, { name: "No Roles", roles: ["Viewer"] }); + await openMember(page, email); + const drawer = page.getByRole("dialog", { name: "Member details" }); + await drawer.getByRole("checkbox", { name: startsWith("Viewer") }).uncheck(); + await drawer.getByRole("button", { name: "Save roles" }).click(); + await expect(drawer.getByRole("button", { name: "Save roles" })).toHaveCount(0); await signOut(page); await signIn(page, email, FIRST_PASSWORD); diff --git a/SW.Bitween.Web/ClientApp/e2e/settings.spec.ts b/SW.Bitween.Web/ClientApp/e2e/settings.spec.ts index 73b8461..9179cd4 100644 --- a/SW.Bitween.Web/ClientApp/e2e/settings.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/settings.spec.ts @@ -10,12 +10,15 @@ const DEFAULT_CRON = "0 * * * * ?"; const brandColorVar = (page: Page) => page.evaluate(() => document.documentElement.style.getPropertyValue("--color-crimson-600").trim()); +/** Sections are real links — a section is a URL you can paste into a ticket — not buttons. */ +const sectionLink = (page: Page, name: string) => page.getByRole("link", { name }); + /** The hex box beside the colour swatch; `exact` keeps it apart from the picker itself. */ const hexInput = (page: Page) => page.getByRole("textbox", { name: "Primary color", exact: true }); async function openBrandSection(page: Page) { await page.goto("settings"); - await page.getByRole("button", { name: "Brand & theme" }).click(); + await sectionLink(page, "Brand & theme").click(); } test.beforeEach(async ({ page }) => { @@ -36,7 +39,7 @@ test("sections come from the backend catalog, with no restart-required rows", as "Security", "Brand & theme", ]) - await expect(page.getByRole("button", { name: section })).toBeVisible(); + await expect(sectionLink(page, section)).toBeVisible(); // Nothing carries a restart badge: a setting that couldn't take effect immediately is shown // as an environment value instead of being offered as an edit that needs a restart to land. @@ -45,7 +48,7 @@ test("sections come from the backend catalog, with no restart-required rows", as test("environment settings are shown but not offered as edits", async ({ page }) => { await page.goto("settings"); - await page.getByRole("button", { name: "Database" }).click(); + await sectionLink(page, "Database").click(); // A read-only row renders its value as text — there's no control carrying its label… await expect(page.getByText("Use Azure managed identity")).toBeVisible(); @@ -68,7 +71,7 @@ test("environment settings are shown but not offered as edits", async ({ page }) test("Microsoft-only sign-in is an editable setting, not an environment value", async ({ page }) => { await page.goto("settings"); - await page.getByRole("button", { name: "Single sign-on (Microsoft)" }).click(); + await sectionLink(page, "Single sign-on (Microsoft)").click(); // It applies per request — the Login handler and the config endpoint both read it live — so it // belongs in the catalog as an edit rather than a read-only environment row. @@ -80,7 +83,7 @@ test("Microsoft-only sign-in is an editable setting, not an environment value", test("the retry schedule is editable and rejects an invalid cron", async ({ page }) => { await page.goto("settings"); - await page.getByRole("button", { name: "Reliability & jobs" }).click(); + await sectionLink(page, "Reliability & jobs").click(); const cron = page.getByRole("textbox", { name: "Retry poll schedule", exact: true }); await expect(cron).toHaveValue(DEFAULT_CRON); @@ -137,7 +140,7 @@ test("a secret's value never reaches the browser", async ({ page }) => { }); await page.goto("settings"); - await page.getByRole("button", { name: "Adapters" }).click(); + await sectionLink(page, "Adapters").click(); // The local backend configures a Rebex key, so the row shows as set — masked, with the // adapter-config "Replace" affordance rather than the value itself. diff --git a/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts b/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts index 26c1818..1daa5d1 100644 --- a/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts @@ -15,28 +15,34 @@ test("scheduled job create, adapters, pause/resume, receive now, list, delete", const name = `Playwright Job ${Date.now()}`; await page.goto("scheduled-jobs/new"); - await page.fill("#sjw-name", name); - await page.getByRole("button", { name: "order" }).click(); - await page.getByRole("button", { name: "Continue" }).click(); + await page.fill("#nj-name", name); - // Source & schedule step — receiver adapter + its one required prop. - await page.getByLabel("receiver adapter").click(); + // The information type is a searchable picker, and the stages below it are cards that open + // in place — there is no wizard to "Continue" through any more. + await page.getByRole("combobox", { name: "Information type" }).click(); + await page.getByRole("option", { name: /Shipment order/ }).click(); + // Wait for the pick to land: without this the next click can run first and the form is still + // missing its information type, leaving "Create job" disabled. + await expect(page.getByRole("combobox", { name: "Information type" })).toHaveValue(/Shipment order/); + + // Source — open by default. Receiver adapter plus its one required prop. + await page.getByRole("combobox", { name: "receiver adapter" }).click(); await page.getByRole("option", { name: "NativeHttpReceiver" }).click(); - await page.keyboard.press("Escape"); // close the combobox popover, it doesn't auto-dismiss - await expect(page.getByLabel("receiver adapter")).toHaveValue("NativeHttpReceiver"); + await expect(page.getByRole("combobox", { name: "receiver adapter" })).toHaveValue("NativeHttpReceiver"); await page.locator("#prop-Url").fill("https://example.com/feed"); - await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.locator("#prop-Url")).toHaveValue("https://example.com/feed"); - // Pipeline step — handler adapter + its required prop (mapper stays "None"). - await page.getByLabel("handler adapter").click(); + // Delivery — handler adapter and its required prop (transformation stays "Passes through"). + // Only one stage is open at a time, so #prop-Url is unambiguous here. + await page.getByRole("button", { name: /^Delivery/ }).click(); + await page.getByRole("combobox", { name: "handler adapter" }).click(); await page.getByRole("option", { name: "NativeHttpHandler" }).click(); - await expect(page.getByLabel("handler adapter")).toHaveValue("NativeHttpHandler"); - await expect(page.getByRole("listbox")).toHaveCount(0, { timeout: 10000 }); + await expect(page.getByRole("combobox", { name: "handler adapter" })).toHaveValue("NativeHttpHandler"); await page.locator("#prop-Url").fill("https://example.com/sink"); - await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.locator("#prop-Url")).toHaveValue("https://example.com/sink"); - // Review step — "Enable immediately" is checked by default. - await page.getByRole("button", { name: "Create scheduled job" }).click(); + // "Enable immediately" is checked by default. + await page.getByRole("button", { name: "Create job" }).click(); await expect(page).toHaveURL(/\/subscriptions\/\d+$/); await expect(page.getByRole("heading", { name })).toBeVisible(); @@ -59,18 +65,26 @@ test("scheduled job create, adapters, pause/resume, receive now, list, delete", await expect(page.getByRole("dialog")).toHaveCount(0); await expect(page.getByText("Next run")).toBeVisible(); - // Reload to prove the adapter config truly persisted server-side. + // Reload to prove the adapter config truly persisted server-side. Each stage card + // summarises what it saved, so both the adapter and its property show without opening it. await page.reload(); - await expect(page.getByLabel("receiver adapter")).toHaveValue("NativeHttpReceiver"); - await expect(page.locator("#prop-Url").first()).toHaveValue("https://example.com/feed"); - await expect(page.getByLabel("handler adapter")).toHaveValue("NativeHttpHandler"); + const source = page.getByRole("button", { name: /^Source/ }); + await expect(source).toContainText("NativeHttpReceiver"); + await expect(source).toContainText("https://example.com/feed"); + const delivery = page.getByRole("button", { name: /^Delivery/ }); + await expect(delivery).toContainText("NativeHttpHandler"); + await expect(delivery).toContainText("https://example.com/sink"); - await page.goto("subscriptions?types=scheduled-jobs"); + // Narrow by type — the supported filter — so the row can't be paged out of sight. Deliberately + // not the search box: a multi-word term is encoded with "+" and the backend never decodes it, + // so searching any name containing a space returns nothing (pre-existing, see notes). + await page.goto("subscriptions?type=Receiving"); const row = page.getByRole("row", { name: new RegExp(name) }); await expect(row).toBeVisible({ timeout: 15000 }); await expect(row.getByText("undefined")).toHaveCount(0); - await row.getByRole("button", { name: `Open ${name}` }).click(); + // The whole row is the link on this table — there is no separate open button. + await row.click(); await expect(page).toHaveURL(/\/subscriptions\/\d+$/); await page.getByRole("button", { name: "Delete" }).click(); await page.getByRole("button", { name: "Delete subscription" }).click(); diff --git a/SW.Bitween.Web/ClientApp/e2e/team-members.spec.ts b/SW.Bitween.Web/ClientApp/e2e/team-members.spec.ts index c3d9343..7206c6c 100644 --- a/SW.Bitween.Web/ClientApp/e2e/team-members.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/team-members.spec.ts @@ -61,7 +61,9 @@ test("an administrator resets a member's password", async ({ page }) => { const drawer = page.getByRole("dialog", { name: "Member details" }); await drawer.getByLabel("New password").fill(ROTATED_PASSWORD); await drawer.getByRole("button", { name: "Set password" }).click(); - await expect(drawer.getByLabel("New password")).toHaveValue(""); + // The form is replaced by a confirmation carrying the new password to copy: Bitween sends + // no email, so this is the only place it is ever shown. + await expect(drawer.getByText(/Password set for/)).toBeVisible(); await signOut(page); await signIn(page, email, ROTATED_PASSWORD); @@ -79,7 +81,9 @@ test("the old password stops working after a reset", async ({ page }) => { const drawer = page.getByRole("dialog", { name: "Member details" }); await drawer.getByLabel("New password").fill(ROTATED_PASSWORD); await drawer.getByRole("button", { name: "Set password" }).click(); - await expect(drawer.getByLabel("New password")).toHaveValue(""); + // The form is replaced by a confirmation carrying the new password to copy: Bitween sends + // no email, so this is the only place it is ever shown. + await expect(drawer.getByText(/Password set for/)).toBeVisible(); await signOut(page); await page.fill("#login-email", email); diff --git a/SW.Bitween.Web/ClientApp/e2e/work-groups.spec.ts b/SW.Bitween.Web/ClientApp/e2e/work-groups.spec.ts index 793d714..1bab858 100644 --- a/SW.Bitween.Web/ClientApp/e2e/work-groups.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/work-groups.spec.ts @@ -14,13 +14,18 @@ test.beforeEach(async ({ page }) => { test("work group create, edit queue settings, list, delete", async ({ page }) => { const name = `Playwright Workgroup ${Date.now()}`; - await page.goto("work-groups/new"); - await page.fill("#nwg-name", name); - await page.getByRole("button", { name: "Create work group" }).click(); + // Creating happens in a dialog on the list page, not on a page of its own. + await page.goto("work-groups"); + await page.getByRole("button", { name: "New work group" }).click(); + + const dialog = page.getByRole("dialog", { name: "New work group" }); + // `exact` keeps this off "Bus message name", which also contains "Name". + await dialog.getByRole("textbox", { name: "Name", exact: true }).fill(name); + await dialog.getByRole("button", { name: "Create work group" }).click(); await expect(page).toHaveURL(/\/work-groups\/\d+$/); await expect(page.getByRole("heading", { name })).toBeVisible(); - // Defaults from the new-page form. + // Defaults carried over from the create dialog. await expect(page.locator("#wg-prefetch")).toHaveValue("10"); await expect(page.locator("#wg-priority")).toHaveValue("5"); @@ -36,9 +41,10 @@ test("work group create, edit queue settings, list, delete", async ({ page }) => await page.goto("work-groups"); const row = page.getByRole("row", { name: new RegExp(name) }); await expect(row).toBeVisible(); - // A brand-new group has no consumers and nothing assigned to it, so both of - // those render as an em dash. - await expect(row.getByText("—")).toHaveCount(2); + // Nothing is assigned to a brand-new group, so "Used by" is an em dash. Its consumer + // count is deliberately not asserted: declaring the group's queue makes the running + // instance a consumer of it straight away, so "Nodes" is legitimately 1, not blank. + await expect(row.getByRole("cell").nth(2)).toHaveText("—"); await row.getByRole("button", { name: `Open ${name}` }).click(); await expect(page).toHaveURL(/\/work-groups\/\d+$/); From 53815e113571421819809bb081db30d10954c8df Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 30 Aug 2026 11:42:03 +0300 Subject: [PATCH 5/8] fix: multi-word searches returned nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Searching "Order intake" found no rows; "Order" found it. Every search box over a Searchy endpoint was affected, and it failed silently — no error, just an empty table, which reads as "nothing matches". URLSearchParams form-encodes a space as "+", and the backend parses the query string with Uri.UnescapeDataString (SW.PrimitiveTypes' QueryStringParser), which decodes %XX but leaves "+" as a literal plus. So the filter looked for "Order+intake". Verified against the API: Name:4:Shipment%20order returns 1, Name:4:Shipment+order returns 0. Fixed on the client, where the query is built — the parser is in a shared package used by other products. Only filter= is affected; model-bound parameters go through ASP.NET's own parser, which reads "+" as a space correctly. Also hit the exchanges promoted-property and scheduled-retry exception filters, which take free text too: "After fix" went from 0 results to 9. Unit tests cover the encoding, and vitest now picks up suites outside the mapping port. --- .../api/http/__tests__/searchQuery.test.ts | 33 +++++++++++++++++++ .../ClientApp/src/api/http/exchanges.ts | 5 +-- .../ClientApp/src/api/http/searchQuery.ts | 18 +++++++++- SW.Bitween.Web/ClientApp/vitest.config.ts | 2 +- 4 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 SW.Bitween.Web/ClientApp/src/api/http/__tests__/searchQuery.test.ts diff --git a/SW.Bitween.Web/ClientApp/src/api/http/__tests__/searchQuery.test.ts b/SW.Bitween.Web/ClientApp/src/api/http/__tests__/searchQuery.test.ts new file mode 100644 index 0000000..71ac814 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/api/http/__tests__/searchQuery.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { SEARCHY_RULE, buildListQuery, searchyQueryString } from "../searchQuery"; + +/** + * Guards a bug that was invisible from the UI: every multi-word search silently returned + * nothing, because the space went out form-encoded as "+" and the backend's query-string + * parser (Uri.UnescapeDataString) leaves "+" as a literal plus. + */ +describe("searchy query strings", () => { + it("percent-encodes a space rather than form-encoding it", () => { + const qs = buildListQuery({ + filters: [["Name", SEARCHY_RULE.contains, "Order intake"]], + offset: 0, + limit: 25, + }); + + expect(qs).toContain("Order%20intake"); + expect(qs).not.toContain("+"); + }); + + it("leaves a literal plus escaped, so the replace can't corrupt a term", () => { + const params = new URLSearchParams(); + params.append("filter", "Name:4:a + b"); + + // "+" survives as %2B; only the space becomes %20. + expect(searchyQueryString(params)).toBe("filter=Name%3A4%3Aa%20%2B%20b"); + }); + + it("still pages the way every list endpoint expects", () => { + const qs = buildListQuery({ offset: 50, limit: 25 }); + expect(qs).toBe("page=2&size=25"); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts index 7cf934b..d8e335a 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/exchanges.ts @@ -9,6 +9,7 @@ import type { } from "../types"; import { partnerMethods } from "./partners"; import { get, post } from "./request"; +import { searchyQueryString } from "./searchQuery"; // ——— backend shapes (camelCase over the wire) ——— interface SearchyResponse { @@ -145,7 +146,7 @@ function buildExchangeQuery(query: ExchangeQuery): string { if (query.to) params.append("filter", `StartedOn:8:${query.to}`); params.set("page", String(Math.floor(query.offset / query.limit))); params.set("size", String(query.limit)); - return params.toString(); + return searchyQueryString(params); } function buildScheduledRetryQuery(query: ScheduledRetryQuery): string { @@ -157,7 +158,7 @@ function buildScheduledRetryQuery(query: ScheduledRetryQuery): string { if (query.to) params.append("filter", `On:8:${query.to}`); params.set("page", String(Math.floor(query.offset / query.limit))); params.set("size", String(query.limit)); - return params.toString(); + return searchyQueryString(params); } async function partnerNameMap(): Promise> { diff --git a/SW.Bitween.Web/ClientApp/src/api/http/searchQuery.ts b/SW.Bitween.Web/ClientApp/src/api/http/searchQuery.ts index ffeba36..30887a8 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/searchQuery.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/searchQuery.ts @@ -9,6 +9,22 @@ * unbounded" guard, so an omitted size silently returns zero rows despite a correct * total count. Always supplying both sidesteps that regardless of which handler runs. */ +/** + * Serializes a Searchy query string, percent-encoding spaces. + * + * `URLSearchParams.toString()` form-encodes a space as "+", but the backend parses this query + * string with `Uri.UnescapeDataString` (SW.PrimitiveTypes' QueryStringParser), which decodes + * %XX escapes and leaves "+" as a literal plus. So every multi-word term silently matched + * nothing: searching "Order intake" looked for "Order+intake". Only `filter=` is affected — + * endpoints whose parameters are model-bound go through ASP.NET's own parser, which reads "+" + * as a space correctly. + * + * Safe as a blanket replace: URLSearchParams already encodes a literal "+" as %2B, so any bare + * "+" left in the output is a space. + */ +export const searchyQueryString = (params: URLSearchParams): string => + params.toString().replace(/\+/g, "%20"); + export function buildListQuery(opts: { /** [Field, Rule, Value] triples — Rule 1 = EqualsTo, 4 = Contains. Skipped when Value is "". */ filters?: [string, number, string | number][]; @@ -24,7 +40,7 @@ export function buildListQuery(opts: { if (opts.sort) params.append("sort", `${opts.sort[0]}:${opts.sort[1]}`); params.set("page", String(Math.floor(opts.offset / opts.limit))); params.set("size", String(opts.limit)); - return params.toString(); + return searchyQueryString(params); } export const SEARCHY_RULE = { diff --git a/SW.Bitween.Web/ClientApp/vitest.config.ts b/SW.Bitween.Web/ClientApp/vitest.config.ts index 847ad8a..fd17082 100644 --- a/SW.Bitween.Web/ClientApp/vitest.config.ts +++ b/SW.Bitween.Web/ClientApp/vitest.config.ts @@ -7,6 +7,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, - include: ["src/lib/mapping/__tests__/**/*.test.ts"], + include: ["src/**/__tests__/**/*.test.ts"], }, }); From fa282943821b00881a0961fd7717b186f1ca85b3 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 30 Aug 2026 11:42:16 +0300 Subject: [PATCH 6/8] test: purge the suite's own leftovers, and stop clicking a moving target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit global-setup already repaired accounts, roles and settings after a failed run, but not the gateways, subscriptions, work groups and information types the tests create. Those accumulated — 36 stray subscriptions against 13 real ones — until a newly created row was pushed off the first page and the test looking for it failed for a reason of its own making. It now purges those too, gateways first so the subscriptions beneath them will delete. The scheduled-job spec opened Delivery straight after filling the source adapter's property. That form renders asynchronously and shifts the cards below it, so the click was racing a moving element. Collapsing the step first settles the layout. --- SW.Bitween.Web/ClientApp/e2e/global-setup.ts | 30 +++++++++++++++++++ .../ClientApp/e2e/subscriptions.spec.ts | 4 +++ 2 files changed, 34 insertions(+) diff --git a/SW.Bitween.Web/ClientApp/e2e/global-setup.ts b/SW.Bitween.Web/ClientApp/e2e/global-setup.ts index d89b59b..6e83499 100644 --- a/SW.Bitween.Web/ClientApp/e2e/global-setup.ts +++ b/SW.Bitween.Web/ClientApp/e2e/global-setup.ts @@ -18,6 +18,8 @@ const ADMINISTRATOR_ROLE_ID = 1; const TEST_EMAIL = /^pw-.*@example\.test$/; const TEST_ROLE = /^PW /; +/** Everything the suite creates is named this way, so it can be found again and removed. */ +const TEST_NAME = /^Playwright /; /** The only settings the suite writes to — see the reset below for why this is a list, not "all". */ const TEST_SETTINGS = ["Theme.PrimaryColor", "Theme.TabTitle", "Theme.CompanyName"]; @@ -81,5 +83,33 @@ export default async function purgeTestData() { for (const key of TEST_SETTINGS) await api.delete(`${API}/settings/${encodeURIComponent(key)}`, { headers: auth() }); + // The domain objects the suite creates outlive a failed run too, and they are not harmless + // either: enough of them push a newly created row off the first page of a list, and the tests + // that look for their own row then fail for a reason that has nothing to do with them. + // + // Order matters. A gateway holds attachments and routes that reference subscriptions, so the + // gateways go first or the subscriptions underneath them refuse to delete. + const purge = async (list: string, remove: (id: number) => Promise) => { + const res = await api.get(`${API}/${list}?size=500&limit=500`, { headers: auth() }); + if (!res.ok()) return; + const rows = ((await res.json()).result ?? []) as { id: number; name: string }[]; + for (const row of rows.filter((r) => TEST_NAME.test(r.name ?? ""))) { + // Best effort: something still referencing a row is not a reason to abandon the rest. + try { + await remove(row.id); + } catch { + /* leave it for the next run */ + } + } + }; + + await purge("apigateways", (id) => api.delete(`${API}/apigateways/${id}`, { headers: auth() })); + await purge("busgateways", (id) => api.delete(`${API}/busgateways/${id}`, { headers: auth() })); + await purge("subscriptions", (id) => api.delete(`${API}/subscriptions/${id}`, { headers: auth() })); + await purge("workgroups", (id) => + api.post(`${API}/workgroups/${id}/delete`, { headers: auth(), data: {} }), + ); + await purge("documents", (id) => api.delete(`${API}/documents/${id}`, { headers: auth() })); + await api.dispose(); } diff --git a/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts b/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts index 1daa5d1..443e15b 100644 --- a/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts @@ -32,6 +32,10 @@ test("scheduled job create, adapters, pause/resume, receive now, list, delete", await page.locator("#prop-Url").fill("https://example.com/feed"); await expect(page.locator("#prop-Url")).toHaveValue("https://example.com/feed"); + // Collapse Source before opening Delivery: its property form renders asynchronously and the + // cards below it keep moving while it does, so clicking straight through hits a moving target. + await page.getByRole("button", { name: "Close this step" }).click(); + // Delivery — handler adapter and its required prop (transformation stays "Passes through"). // Only one stage is open at a time, so #prop-Url is unambiguous here. await page.getByRole("button", { name: /^Delivery/ }).click(); From de6a4f1075093adaf8770433526195049670ffee Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 30 Aug 2026 12:10:02 +0300 Subject: [PATCH 7/8] test: bulk-retry two rows, not the whole page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Select all on this page" also starts with "Select", so the row-checkbox regex matched the header one. Its checked state is derived from every row, so a refetch landing mid-click read as a click that did nothing — and the retry it kicked off hit the entire page, leaving the backend doing file I/O for the rest of the run and timing out the later specs. --- SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts b/SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts index 802b6b3..d2bff95 100644 --- a/SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts @@ -38,7 +38,12 @@ test("exchanges list, filter, retry, bulk retry, create", async ({ page }) => { // Bulk retry a couple of specific rows (not the whole page — each retry does // real file I/O against storage, so keep this fast and deterministic). await page.getByRole("button", { name: "All" }).click(); - const rowCheckbox = page.getByRole("checkbox", { name: /^Select \w/ }); + // Row checkboxes only. "Select all on this page" also starts with "Select", and its checked + // state is derived from every row on the page — so a refetch landing mid-click (the filter + // above triggers one) flips it back and reads as a click that did nothing. A row checkbox is + // keyed by its own id and survives that. It also keeps the bulk retry to two rows, which is + // what this test says it wants: each retry is real file I/O. + const rowCheckbox = page.getByRole("checkbox", { name: /^Select (?!all\b)/ }); await expect(rowCheckbox.first()).toBeVisible({ timeout: 10000 }); await rowCheckbox.nth(0).check(); await rowCheckbox.nth(1).check(); From e894e2782a727f0510f1a80f416c469f22d6c346 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 30 Aug 2026 14:35:12 +0300 Subject: [PATCH 8/8] fix: read the saved gateway, not the one we already had MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchQuery honours staleTime, and the gateway detail key inherits the five minutes registered for bus gateways — so after saving a route it handed back the copy from before the save. The new route was missing from fresh.routes, and removing the last route left the page selecting the route it had just deleted. Both call sites now pass staleTime: 0. SubscriptionPage invalidated both subscriptions.all and subscriptions.detail; all is a prefix of detail, and invalidateQueries cancels by default, so the second call cancelled the refetch the first had started and the awaited promise belonged to the cancelled one. One call now. The bus gateway e2e test was passing on the broken behaviour: with a deleted route still selected the main panel never showed its empty state, so "No routes" matched one element instead of two. It now names the panel's heading, which is what proves the route isn't still selected. Raised by CodeRabbit on #273. --- SW.Bitween.Web/ClientApp/e2e/gateways.spec.ts | 5 ++++- .../src/pages/bus-gateways/BusGatewayPage.tsx | 8 ++++++++ .../src/pages/subscriptions/SubscriptionPage.tsx | 10 +++++----- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/e2e/gateways.spec.ts b/SW.Bitween.Web/ClientApp/e2e/gateways.spec.ts index e3f4006..bde39fd 100644 --- a/SW.Bitween.Web/ClientApp/e2e/gateways.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/gateways.spec.ts @@ -134,7 +134,10 @@ test("Bus gateway: create, add route with match expression, edit route, remove, .getByRole("button", { name: "Remove route" }) .click(); await expect(page.getByRole("dialog")).toHaveCount(0); - await expect(page.getByText("No routes")).toBeVisible(); + // The route list and the main panel both say this, so name the panel's heading: it shows only + // when no route is selected, which is the thing actually worth proving here — that removing + // the last route doesn't leave the page still pointing at it. + await expect(page.getByRole("heading", { name: /^No routes —/ })).toBeVisible(); await page.getByRole("button", { name: "Delete" }).click(); await page diff --git a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx index 7c2ecfb..acc5dac 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/bus-gateways/BusGatewayPage.tsx @@ -333,6 +333,11 @@ export function BusGatewayPage() { const fresh = await queryClient.fetchQuery({ queryKey: keys.busGateways.detail(gatewayId), queryFn: () => api.getBusGateway(gatewayId), + // This has to be the saved gateway, not the one we already had: fetchQuery honours + // staleTime, and this key inherits the five minutes registered for bus gateways, so + // without this it returns the copy from before the save — and the new route would be + // missing from fresh.routes below. + staleTime: 0, }); void queryClient.invalidateQueries({ queryKey: keys.busGateways.all }); // Awaited before the drafts are dropped: re-seeding from stale data would leave the save bar @@ -724,6 +729,9 @@ export function BusGatewayPage() { const fresh = await queryClient.fetchQuery({ queryKey: keys.busGateways.detail(gatewayId), queryFn: () => api.getBusGateway(gatewayId), + // As above: the cached copy still lists the route that was just removed, and + // fresh.routes[0] below would select it. + staleTime: 0, }); void queryClient.invalidateQueries({ queryKey: keys.busGateways.all }); void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); diff --git a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx index 541dca0..5b32ddd 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/subscriptions/SubscriptionPage.tsx @@ -107,11 +107,11 @@ export function SubscriptionPage() { return () => window.removeEventListener("keydown", onKey); }, [params, draft, subscription.data, setParams]); - const invalidate = () => { - const detail = queryClient.invalidateQueries({ queryKey: keys.subscriptions.detail(subscriptionId) }); - void queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); - return detail; - }; + // One call, because `all` is a prefix of `detail` and already covers it. Invalidating both + // cancelled the detail refetch the first call had just started (invalidateQueries cancels by + // default), so the promise awaited below belonged to the cancelled one and could resolve before + // the fresh data landed — the very race the await exists to prevent. + const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.subscriptions.all }); const save = useMutation({ mutationFn: () => api.updateSubscription(subscriptionId, draft!),