From 54bf32485f2c600e384e9acc844290c0d4157228 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:53:16 +0000 Subject: [PATCH] feat(portal): adopt TanStack Query with a shared per-resource query layer (#7135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why The processor/portal loads slowly because every view fetches its data on mount with no client-side cache — navigating away and back refetches everything, and shared data (policies, sources, roster, fleet stats) is fetched repeatedly. This adopts **TanStack Query** so the portal caches, dedupes, and revalidates instead. Follows the Users-page proof-of-concept (kept as the reference A/B example behind a dev flag); DevTools before/after confirmed revisiting a cached view now costs zero network calls. ## What **Shared per-resource query layer** (`portal/queries/`) — the mechanism for both in-view and cross-view sharing: - `keys.ts` (flavor-agnostic queryKey factory), `adapters.ts` (`toAsyncState` → the existing `AsyncState` shape, so view bodies barely change) - One **base hook per endpoint**; **derived hooks** (`usePoliciesOverview`, `useProcessorFlow`, `useOnboardingProgress`) compose them - The bundle functions (`fetchPolicies`, `fetchProcessorFlow`, `useOnboardingProgress`) are decomposed into base queries — otherwise the caches wouldn't dedupe against each other **Migrated:** Documents, Policies, Pipelines, Sources + all of Home's fetching cards. Mutations use `invalidateQueries` (Policies' `version` bump removed; Source/Pipeline builders invalidate-then-navigate; ConnectionsTab + S3 picker share one cache). **SaaS `/team/my` collapse:** `resolveTeam()` reads through the shared cache (`ensureQueryData`), so roster + teams resolve it once (2→1), with a direct-fetch fallback when no provider is mounted. `QueryClientProvider` is mounted once at the portal root (`PortalApp`), above the router, so the cache survives navigation. ## Impact on duplicate fetches - **In-view:** Home `/policies` ×3, `/policies/runs` ×3, `/sources` ×2, `/v1/editor/deployment` ×2 → **1× each** per mount - **Cross-view:** Policies / Sources / Users / EditorAdmin / Infrastructure reuse Home's warmed cache within `staleTime` (no refetch on navigation) - **SaaS Users:** `/team/my` 2× → **1×** ## Testing - Portal typecheck + SaaS typecheck, ESLint (`--max-warnings=0`), Prettier — all green - **224 portal tests pass** (existing component tests wrapped in a shared `QueryClient` test provider) - New: `queries/sharing.test.tsx` (in-view: 3 consumers → 1 fetch each; cross-view: remount → 0 refetch) and a `/team/my` collapse assertion in `UsersReactQuery.test.tsx` ## Notes for reviewers - Keys are intentionally flavor-agnostic (local vs SaaS routing lives inside the api fns), so one key addresses whichever backend the flavor build resolves. - `staleTime` 30s / `gcTime` 5m defaults; tier-dependent resources key on tier. - Users view keeps its dev flag/legacy path deliberately as the documented reference. --- frontend/editor/src/portal/PortalApp.tsx | 37 ++--- frontend/editor/src/portal/api/policies.ts | 32 +++-- .../editor/src/portal/api/processorFlow.ts | 23 ++-- .../portal/components/EditorStatusCard.tsx | 13 +- .../src/portal/components/ProcessorFlow.tsx | 9 +- .../components/billing/FreePdfEditorsCard.tsx | 5 +- .../components/infrastructure/AuditTab.tsx | 7 +- .../policies/PolicySetupWizard.test.tsx | 4 +- .../components/policies/PolicySetupWizard.tsx | 4 +- .../components/policies/storyFixtures.ts | 2 +- .../src/portal/hooks/useAiEngineEnabled.ts | 16 ++- .../src/portal/hooks/useOnboardingProgress.ts | 37 +++-- .../src/portal/mocks/handlers/policies.ts | 2 +- .../editor/src/portal/queries/adapters.ts | 16 +++ .../editor/src/portal/queries/documents.ts | 16 +++ .../src/portal/queries/infrastructure.ts | 49 +++++++ frontend/editor/src/portal/queries/keys.ts | 33 +++++ .../editor/src/portal/queries/pipelines.ts | 15 +++ .../editor/src/portal/queries/policies.ts | 42 ++++++ .../src/portal/queries/processorFlow.ts | 30 +++++ .../src/portal/queries/sharing.test.tsx | 109 +++++++++++++++ frontend/editor/src/portal/queries/sources.ts | 13 ++ frontend/editor/src/portal/queries/users.ts | 22 +++ frontend/editor/src/portal/queryClient.ts | 35 +++++ .../src/portal/test/TestQueryProvider.tsx | 29 ++++ .../editor/src/portal/views/Documents.tsx | 6 +- .../editor/src/portal/views/EditorAdmin.tsx | 12 +- .../src/portal/views/PipelineBuilder.test.tsx | 4 +- .../src/portal/views/PipelineBuilder.tsx | 37 +++-- .../src/portal/views/Pipelines.test.tsx | 4 +- .../editor/src/portal/views/Pipelines.tsx | 11 +- frontend/editor/src/portal/views/Policies.tsx | 18 ++- .../src/portal/views/SourceBuilder.test.tsx | 9 +- .../editor/src/portal/views/SourceBuilder.tsx | 10 ++ .../editor/src/portal/views/Sources.test.tsx | 4 +- frontend/editor/src/portal/views/Sources.tsx | 11 +- .../src/portal/views/Users.caching.test.tsx | 126 ++++++++++++++++++ .../src/portal/views/Users.saas.test.tsx | 6 +- frontend/editor/src/portal/views/Users.tsx | 37 ++--- .../src/portal/views/teamMyCache.saas.test.ts | 87 ++++++++++++ frontend/editor/src/portal/views/usersData.ts | 63 +++++++++ .../editor/src/saas/portal/usersBackend.ts | 15 ++- frontend/package-lock.json | 27 ++++ frontend/package.json | 1 + 44 files changed, 908 insertions(+), 180 deletions(-) create mode 100644 frontend/editor/src/portal/queries/adapters.ts create mode 100644 frontend/editor/src/portal/queries/documents.ts create mode 100644 frontend/editor/src/portal/queries/infrastructure.ts create mode 100644 frontend/editor/src/portal/queries/keys.ts create mode 100644 frontend/editor/src/portal/queries/pipelines.ts create mode 100644 frontend/editor/src/portal/queries/policies.ts create mode 100644 frontend/editor/src/portal/queries/processorFlow.ts create mode 100644 frontend/editor/src/portal/queries/sharing.test.tsx create mode 100644 frontend/editor/src/portal/queries/sources.ts create mode 100644 frontend/editor/src/portal/queries/users.ts create mode 100644 frontend/editor/src/portal/queryClient.ts create mode 100644 frontend/editor/src/portal/test/TestQueryProvider.tsx create mode 100644 frontend/editor/src/portal/views/Users.caching.test.tsx create mode 100644 frontend/editor/src/portal/views/teamMyCache.saas.test.ts create mode 100644 frontend/editor/src/portal/views/usersData.ts diff --git a/frontend/editor/src/portal/PortalApp.tsx b/frontend/editor/src/portal/PortalApp.tsx index 1aa7da3535..27fc5652cb 100644 --- a/frontend/editor/src/portal/PortalApp.tsx +++ b/frontend/editor/src/portal/PortalApp.tsx @@ -1,9 +1,11 @@ -import { type ReactNode } from "react"; +import { useState, type ReactNode } from "react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { PortalAuthBoundary } from "@portal/auth/PortalAuthBoundary"; import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext"; import { SuiProvider } from "@portal/theme/SuiProvider"; import { PortalProviders } from "@portal/PortalProviders"; import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { createPortalQueryClient } from "@portal/queryClient"; // Reset + typography, scoped to .portal-scope below. import "@portal/theme/base.css"; @@ -28,20 +30,25 @@ function ThemedSuiProvider({ children }: { children: ReactNode }) { * self-hosted mounts the account-link layer, SaaS does not. */ export function PortalApp() { + // One client for the portal's lifetime. Sits above the router so its cache + // survives view navigation. Cheap and inert when no query hooks are mounted. + const [queryClient] = useState(createPortalQueryClient); return ( - - - {/* Scopes base.css to the portal so it doesn't restyle the host editor. */} -
- {/* Tool registry is read by portal views (e.g. the policy setup - wizard); mount it above the per-flavor provider split. */} - - - - - -
-
-
+ + + + {/* Scopes base.css to the portal so it doesn't restyle the host editor. */} +
+ {/* Tool registry is read by portal views (e.g. the policy setup + wizard); mount it above the per-flavor provider split. */} + + + + + +
+
+
+
); } diff --git a/frontend/editor/src/portal/api/policies.ts b/frontend/editor/src/portal/api/policies.ts index fb3788dc76..6c122a3bc4 100644 --- a/frontend/editor/src/portal/api/policies.ts +++ b/frontend/editor/src/portal/api/policies.ts @@ -4,8 +4,8 @@ * The portal calls the real Stirling policy API (`/api/v1/policies`); * Storybook and tests intercept the same calls with MSW handlers. * - * `fetchPolicies()` assembles the decorated catalogue client-side from the - * backend's flat `WirePolicy[]` + `PolicyRunView[]`, mirroring the same + * The flat `WirePolicy[]` + `PolicyRunView[]` responses are assembled into the + * decorated catalogue client-side by `assemblePolicies()`, mirroring the same * approach the editor uses for its own catalogue view. */ @@ -455,15 +455,27 @@ function decoratePolicy( }; } -/** GET /api/v1/policies + GET /api/v1/policies/runs → assembled catalogue. */ -export async function fetchPolicies(): Promise { - const [wirePolicies, runs] = await Promise.all([ - apiClient.local.json("/api/v1/policies"), - apiClient.local - .json("/api/v1/policies/runs") - .catch(() => [] as PolicyRunView[]), - ]); +/** GET /api/v1/policies — the flat stored-policy records. */ +export function fetchPoliciesList(): Promise { + return apiClient.local.json("/api/v1/policies"); +} +/** GET /api/v1/policies/runs — best-effort (empty on a backend without runs). */ +export function fetchPolicyRuns(): Promise { + return apiClient.local + .json("/api/v1/policies/runs") + .catch(() => [] as PolicyRunView[]); +} + +/** + * Pure assembly of the decorated catalogue from the two raw responses. Split + * out so the React Query layer can fetch the list + runs as separate shared + * cache entries (deduped across Home + Policies) and assemble client-side. + */ +export function assemblePolicies( + wirePolicies: WirePolicy[], + runs: PolicyRunView[], +): PoliciesResponse { const decodedByCategory = new Map< string, { decoded: PolicyDecodedState; isDefault: boolean } diff --git a/frontend/editor/src/portal/api/processorFlow.ts b/frontend/editor/src/portal/api/processorFlow.ts index 35adc9c89f..1ab2f72c62 100644 --- a/frontend/editor/src/portal/api/processorFlow.ts +++ b/frontend/editor/src/portal/api/processorFlow.ts @@ -1,8 +1,7 @@ /** Assembles the home visualiser's sources → policies → outcomes from the real * sources/policies/runs APIs. Counts are real; the flow motion is illustrative. */ -import { apiClient } from "@portal/api/http"; -import { fetchSources } from "@portal/api/sources"; +import type { SourcesResponse } from "@portal/api/sources"; import { POLICY_CATEGORIES } from "@portal/api/policies"; import { fromWirePolicy } from "@app/policies/codec"; import type { PolicyRunView, WirePolicy } from "@app/policies/types"; @@ -123,16 +122,16 @@ function buildOutcomes(runs: PolicyRunView[]): FlowOutcome[] { ]; } -/** Assemble the full flow model from the three live portal surfaces. */ -export async function fetchProcessorFlow(): Promise { - const [sourcesResp, wirePolicies, runs] = await Promise.all([ - fetchSources(), - apiClient.local.json("/api/v1/policies"), - apiClient.local - .json("/api/v1/policies/runs") - .catch(() => [] as PolicyRunView[]), - ]); - +/** + * Pure assembly of the flow model from the three raw responses. Split out so + * the React Query layer composes it from the shared sources/policies/runs + * cache entries instead of re-fetching them (see useProcessorFlow). + */ +export function assembleProcessorFlow( + sourcesResp: SourcesResponse, + wirePolicies: WirePolicy[], + runs: PolicyRunView[], +): ProcessorFlow { const sources: FlowSource[] = sourcesResp.sources.map((s) => ({ id: s.id, name: s.name, diff --git a/frontend/editor/src/portal/components/EditorStatusCard.tsx b/frontend/editor/src/portal/components/EditorStatusCard.tsx index 1734d677c3..9726cc1009 100644 --- a/frontend/editor/src/portal/components/EditorStatusCard.tsx +++ b/frontend/editor/src/portal/components/EditorStatusCard.tsx @@ -4,12 +4,8 @@ import { useTranslation } from "react-i18next"; import { Button, Skeleton } from "@app/ui"; import { useTier } from "@portal/contexts/TierContext"; import { useView } from "@portal/contexts/ViewContext"; -import { useAsync } from "@portal/hooks/useAsync"; -import { - fetchEditorDeployment, - type EditorDeploymentResponse, - type EditorInstance, -} from "@portal/api/editorDeploy"; +import { useEditorDeployment } from "@portal/queries/infrastructure"; +import { type EditorInstance } from "@portal/api/editorDeploy"; import { DownloadIcon, ExternalLinkIcon, @@ -76,10 +72,7 @@ export function EditorStatusCard({ footer, hideChips }: EditorStatusCardProps) { const { tier } = useTier(); const { setActiveView } = useView(); const [installOpen, setInstallOpen] = useState(false); - const { data, loading } = useAsync( - () => fetchEditorDeployment(tier), - [tier], - ); + const { data, loading } = useEditorDeployment(tier); const view = useMemo(() => { if (!data) return null; diff --git a/frontend/editor/src/portal/components/ProcessorFlow.tsx b/frontend/editor/src/portal/components/ProcessorFlow.tsx index af9e318ba4..547ec21ca7 100644 --- a/frontend/editor/src/portal/components/ProcessorFlow.tsx +++ b/frontend/editor/src/portal/components/ProcessorFlow.tsx @@ -7,11 +7,8 @@ import { VIEW_PATHS, toPortalPath, } from "@portal/contexts/ViewContext"; -import { useAsync } from "@portal/hooks/useAsync"; -import { - fetchProcessorFlow, - type ProcessorFlow as ProcessorFlowModel, -} from "@portal/api/processorFlow"; +import { useProcessorFlow } from "@portal/queries/processorFlow"; +import { type ProcessorFlow as ProcessorFlowModel } from "@portal/api/processorFlow"; import { DEV_KEEP_FLOWING, DEV_SYNTH_RATE, @@ -37,7 +34,7 @@ export function ProcessorFlow({ dataOverride }: ProcessorFlowProps = {}) { const { t } = useTranslation(); const { setActiveView } = useView(); const navigate = useNavigate(); - const fetched = useAsync(() => fetchProcessorFlow(), []); + const fetched = useProcessorFlow(); const data = dataOverride ?? fetched.data; const loading = dataOverride ? false : fetched.loading; diff --git a/frontend/editor/src/portal/components/billing/FreePdfEditorsCard.tsx b/frontend/editor/src/portal/components/billing/FreePdfEditorsCard.tsx index 76fa5a9109..25ef05ec9e 100644 --- a/frontend/editor/src/portal/components/billing/FreePdfEditorsCard.tsx +++ b/frontend/editor/src/portal/components/billing/FreePdfEditorsCard.tsx @@ -3,8 +3,7 @@ import { useTranslation } from "react-i18next"; import { Button, Card, MetricCard, MetricStrip } from "@app/ui"; import GroupsIcon from "@mui/icons-material/GroupsRounded"; import PersonAddIcon from "@mui/icons-material/PersonAddAltRounded"; -import { useAsync } from "@portal/hooks/useAsync"; -import { fetchFleetStats } from "@portal/api/fleetStats"; +import { useFleetStats } from "@portal/queries/infrastructure"; /** * "Free PDF Editors" team-fleet card. Editors-deployed / active-this-month / @@ -22,7 +21,7 @@ function fmtMetric(value: number | null | undefined, loading: boolean): string { export function FreePdfEditorsCard() { const navigate = useNavigate(); const { t } = useTranslation(); - const { data, loading } = useAsync((signal) => fetchFleetStats(signal), []); + const { data, loading } = useFleetStats(); return (
diff --git a/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx b/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx index b27d57995e..a415abf107 100644 --- a/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx +++ b/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx @@ -12,13 +12,12 @@ import { type TableColumn, } from "@app/ui"; import { useTier } from "@portal/contexts/TierContext"; -import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; +import { useSectionFlags } from "@portal/hooks/useAsync"; +import { useAuditLog } from "@portal/queries/infrastructure"; import { HttpError } from "@portal/api/http"; import { - fetchAuditLog, type AuditCategory, type AuditEvent, - type AuditLogResponse, } from "@portal/api/infrastructure"; import { AuditExportModal } from "@portal/components/infrastructure/AuditExportModal"; import { SectionHeader } from "@portal/components/infrastructure/SectionHeader"; @@ -108,7 +107,7 @@ export function AuditTab() { }, ]; - const state = useAsync(() => fetchAuditLog(tier), [tier]); + const state = useAuditLog(tier); const { data, error } = state; const { isLoading, isEmpty } = useSectionFlags(state); // Backend returns 403 for scoped-out callers; show an access message, not an empty state. diff --git a/frontend/editor/src/portal/components/policies/PolicySetupWizard.test.tsx b/frontend/editor/src/portal/components/policies/PolicySetupWizard.test.tsx index 503811eeda..da253f9fcc 100644 --- a/frontend/editor/src/portal/components/policies/PolicySetupWizard.test.tsx +++ b/frontend/editor/src/portal/components/policies/PolicySetupWizard.test.tsx @@ -5,7 +5,7 @@ import { screen, waitFor, } from "@testing-library/react"; -import { MantineProvider } from "@mantine/core"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard"; import { POLICY_CATEGORIES, @@ -17,7 +17,7 @@ import { } from "@portal/api/policies"; const render = (ui: Parameters[0]) => - baseRender(ui, { wrapper: MantineProvider }); + baseRender(ui, { wrapper: PortalTestProviders }); // Deterministic i18n: return the fallback when given, else the key. initReactI18next is stubbed // because the import graph pulls core/i18n.ts, which registers it as a plugin. diff --git a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx index e56065ce48..39ef8246f9 100644 --- a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx +++ b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx @@ -31,7 +31,7 @@ import { type PolicyToolId, type PolicyToolStep, } from "@app/policies/operations"; -import { fetchSources } from "@portal/api/sources"; +import { useSources } from "@portal/queries/sources"; import { fetchIntegrations } from "@portal/api/integrations"; import { errorMessage } from "@portal/api/http"; import { useAsync } from "@portal/hooks/useAsync"; @@ -271,7 +271,7 @@ function PolicySetupWizardBody({ policy?.state.sources ?? ["editor"], ); - const sourcesAsync = useAsync(() => fetchSources(), []); + const sourcesAsync = useSources(); const availableSources = useMemo(() => { const backendSources = (sourcesAsync.data?.sources ?? []).filter( (s) => s.status !== "disabled", diff --git a/frontend/editor/src/portal/components/policies/storyFixtures.ts b/frontend/editor/src/portal/components/policies/storyFixtures.ts index 3cb061b9a7..0e92ade641 100644 --- a/frontend/editor/src/portal/components/policies/storyFixtures.ts +++ b/frontend/editor/src/portal/components/policies/storyFixtures.ts @@ -15,7 +15,7 @@ import { seedPolicies, seedPolicyRuns } from "@portal/mocks/policies"; export { POLICY_CATEGORIES, POLICY_CONFIG }; -/** A decorated, active policy for a category, mirroring fetchPolicies() assembly. */ +/** A decorated, active policy for a category, mirroring assemblePolicies(). */ export function decorateForStory(categoryId: string): DecoratedPolicy { const category = POLICY_CATEGORIES.find((c) => c.id === categoryId)!; const config = POLICY_CONFIG[categoryId]; diff --git a/frontend/editor/src/portal/hooks/useAiEngineEnabled.ts b/frontend/editor/src/portal/hooks/useAiEngineEnabled.ts index 9fe76a19fa..ea6d14a0c1 100644 --- a/frontend/editor/src/portal/hooks/useAiEngineEnabled.ts +++ b/frontend/editor/src/portal/hooks/useAiEngineEnabled.ts @@ -2,8 +2,9 @@ // setup: the card always shows but can't be enabled until the engine is on. // `loading` lets callers hold the decision rather than flash a locked card. +import { useQuery } from "@tanstack/react-query"; import { apiClient } from "@portal/api/http"; -import { useAsync } from "@portal/hooks/useAsync"; +import { qk } from "@portal/queries/keys"; interface AppConfigShape { aiEngineEnabled?: boolean; @@ -15,12 +16,13 @@ export interface AiEngineState { } export function useAiEngineEnabled(): AiEngineState { - const state = useAsync( - () => apiClient.local.json("/api/v1/config/app-config"), - [], - ); + const query = useQuery({ + queryKey: qk.appConfig(), + queryFn: () => + apiClient.local.json("/api/v1/config/app-config"), + }); return { - enabled: Boolean(state.data?.aiEngineEnabled), - loading: state.loading && state.data === null, + enabled: Boolean(query.data?.aiEngineEnabled), + loading: query.isPending, }; } diff --git a/frontend/editor/src/portal/hooks/useOnboardingProgress.ts b/frontend/editor/src/portal/hooks/useOnboardingProgress.ts index c46fa18fa0..0aacd87be7 100644 --- a/frontend/editor/src/portal/hooks/useOnboardingProgress.ts +++ b/frontend/editor/src/portal/hooks/useOnboardingProgress.ts @@ -1,18 +1,17 @@ import { useTier } from "@portal/contexts/TierContext"; -import { useAsync } from "@portal/hooks/useAsync"; import { useEditorInstalled } from "@portal/hooks/useEditorInstalled"; -import { fetchEditorDeployment } from "@portal/api/editorDeploy"; -import { fetchPolicies } from "@portal/api/policies"; -import { fetchUsers } from "@portal/api/users"; +import { useEditorDeployment } from "@portal/queries/infrastructure"; +import { usePoliciesOverview } from "@portal/queries/policies"; +import { useUsersRoster } from "@portal/queries/users"; /** - * Getting-started completion, derived live from the org's real state. Drives - * both the per-step checks on the home hero's setup steps and the collapse to - * the deployed-status header once every step is done. + * Getting-started completion, derived live from the org's real state, composed + * from the shared editor-deployment / policies / users queries. * - * Each fetch is independently best-effort: an endpoint that isn't served yet - * (e.g. editor-deployment on a bare backend) simply leaves its step incomplete - * rather than breaking the card. + * Best-effort per source: a step reads `data ?? fallback` and query errors are + * never folded into `loading`, so an endpoint that isn't served yet (e.g. + * editor-deployment on a bare backend) leaves its step incomplete instead of + * breaking the card. */ export interface OnboardingProgress { loading: boolean; @@ -31,17 +30,13 @@ export interface OnboardingProgress { export function useOnboardingProgress(): OnboardingProgress { const { tier } = useTier(); const editorInstalled = useEditorInstalled(); - const { data, loading } = useAsync( - () => - Promise.all([ - fetchEditorDeployment(tier).catch(() => null), - fetchPolicies().catch(() => null), - fetchUsers(tier).catch(() => null), - ]), - [tier], - ); + const deployQuery = useEditorDeployment(tier); + const policiesQuery = usePoliciesOverview(); + const usersQuery = useUsersRoster(tier); - const [deploy, policies, users] = data ?? [null, null, null]; + const deploy = deployQuery.data; + const policies = policiesQuery.data; + const users = usersQuery.data; const deployed = (deploy?.instances.length ?? 0) > 0; // Authoritative signal is a deployed instance; the user's own download/Done @@ -56,7 +51,7 @@ export function useOnboardingProgress(): OnboardingProgress { const inviteDone = (users?.summary.totalMembers ?? 0) > 1; return { - loading, + loading: deployQuery.loading || policiesQuery.loading || usersQuery.loading, deployed, editorDone, policiesDone, diff --git a/frontend/editor/src/portal/mocks/handlers/policies.ts b/frontend/editor/src/portal/mocks/handlers/policies.ts index 16bc0933e6..1bfc435d4c 100644 --- a/frontend/editor/src/portal/mocks/handlers/policies.ts +++ b/frontend/editor/src/portal/mocks/handlers/policies.ts @@ -14,7 +14,7 @@ import type { PolicyRunView, WirePolicy } from "@app/policies/types"; * - DELETE /api/v1/policies/:id → 204 * * The decorated catalogue (summary, category grouping, stats) is assembled - * client-side in api/policies.ts#fetchPolicies(), mirroring the real backend. + * client-side in api/policies.ts#assemblePolicies(), mirroring the real backend. */ let store: WirePolicy[] = seedPolicies(); diff --git a/frontend/editor/src/portal/queries/adapters.ts b/frontend/editor/src/portal/queries/adapters.ts new file mode 100644 index 0000000000..083e396bad --- /dev/null +++ b/frontend/editor/src/portal/queries/adapters.ts @@ -0,0 +1,16 @@ +import type { UseQueryResult } from "@tanstack/react-query"; +import type { AsyncState } from "@portal/hooks/useAsync"; + +/** + * Adapt a query result to the {@link AsyncState} shape the views already use + * (data/loading/error + useSectionFlags), so a hook swaps in with no render + * changes. `isPending` is false once data is cached, so a remount renders from + * cache instead of flashing a skeleton. + */ +export function toAsyncState(query: UseQueryResult): AsyncState { + return { + data: query.data ?? null, + loading: query.isPending, + error: (query.error as Error | null) ?? null, + }; +} diff --git a/frontend/editor/src/portal/queries/documents.ts b/frontend/editor/src/portal/queries/documents.ts new file mode 100644 index 0000000000..ae1ec14cee --- /dev/null +++ b/frontend/editor/src/portal/queries/documents.ts @@ -0,0 +1,16 @@ +import { useQuery } from "@tanstack/react-query"; +import { qk } from "@portal/queries/keys"; +import { toAsyncState } from "@portal/queries/adapters"; +import type { AsyncState } from "@portal/hooks/useAsync"; +import { fetchDocuments, type DocumentsResponse } from "@portal/api/documents"; +import type { Tier } from "@portal/contexts/TierContext"; + +/** Base query: the documents review queue (tier-scoped). */ +export function useDocuments(tier: Tier): AsyncState { + return toAsyncState( + useQuery({ + queryKey: qk.documents(tier), + queryFn: () => fetchDocuments(tier), + }), + ); +} diff --git a/frontend/editor/src/portal/queries/infrastructure.ts b/frontend/editor/src/portal/queries/infrastructure.ts new file mode 100644 index 0000000000..40e0e829cd --- /dev/null +++ b/frontend/editor/src/portal/queries/infrastructure.ts @@ -0,0 +1,49 @@ +import { useQuery } from "@tanstack/react-query"; +import { qk } from "@portal/queries/keys"; +import { toAsyncState } from "@portal/queries/adapters"; +import type { AsyncState } from "@portal/hooks/useAsync"; +import { fetchFleetStats, type FleetStats } from "@portal/api/fleetStats"; +import { + fetchAuditLog, + type AuditLogResponse, +} from "@portal/api/infrastructure"; +import { + fetchEditorDeployment, + type EditorDeploymentResponse, +} from "@portal/api/editorDeploy"; +import type { Tier } from "@portal/contexts/TierContext"; + +/** Base query: fleet processing stats (GET /api/v1/usage/fleet-stats). */ +export function useFleetStats(): AsyncState { + return toAsyncState( + useQuery({ + queryKey: qk.fleetStats(), + queryFn: ({ signal }) => fetchFleetStats(signal), + }), + ); +} + +/** Base query: recent audit-log activity (tier-scoped). */ +export function useAuditLog(tier: Tier): AsyncState { + return toAsyncState( + useQuery({ + queryKey: qk.auditLog(tier), + queryFn: () => fetchAuditLog(tier), + }), + ); +} + +/** Base query: editor deployment health (tier-scoped). Shared by Home's hero / + * status card, EditorAdmin, and onboarding. Best-effort — callers tolerate a + * 404 on a bare backend, so no retry. */ +export function useEditorDeployment( + tier: Tier, +): AsyncState { + return toAsyncState( + useQuery({ + queryKey: qk.editorDeployment(tier), + queryFn: () => fetchEditorDeployment(tier), + retry: false, + }), + ); +} diff --git a/frontend/editor/src/portal/queries/keys.ts b/frontend/editor/src/portal/queries/keys.ts new file mode 100644 index 0000000000..d2c3fc601a --- /dev/null +++ b/frontend/editor/src/portal/queries/keys.ts @@ -0,0 +1,33 @@ +import type { Tier } from "@portal/contexts/TierContext"; + +/** + * The portal's TanStack Query keys, in one place. Convention: + * ["portal", , ...params]. + * + * Keep keys flavor-agnostic — self-hosted-vs-SaaS routing lives in the api + * functions, not the key, so one key addresses whichever backend the build + * resolves. Include tier only for resources whose response varies by tier. + */ +export const qk = { + // Tier-independent + policiesList: () => ["portal", "policies", "list"] as const, + policyRuns: () => ["portal", "policies", "runs"] as const, + sources: () => ["portal", "sources"] as const, + pipelines: () => ["portal", "pipelines"] as const, + fleetStats: () => ["portal", "fleetStats"] as const, + appConfig: () => ["portal", "appConfig"] as const, + + // Tier-dependent + documents: (tier: Tier) => ["portal", "documents", tier] as const, + auditLog: (tier: Tier) => ["portal", "auditLog", tier] as const, + editorDeployment: (tier: Tier) => + ["portal", "editorDeployment", tier] as const, + + // Users cluster (consumed by usersData.ts + Home onboarding) + usersRoster: (tier: Tier) => ["portal", "users", "roster", tier] as const, + usersGrants: (tier: Tier) => ["portal", "users", "grants", tier] as const, + usersTeams: (tier: Tier) => ["portal", "users", "teams", tier] as const, + usersAuthConfig: () => ["portal", "users", "authConfig"] as const, + /** SaaS-only shared team directory (/api/v1/team/my) — see the /team/my collapse. */ + teamMy: () => ["portal", "team", "my"] as const, +} as const; diff --git a/frontend/editor/src/portal/queries/pipelines.ts b/frontend/editor/src/portal/queries/pipelines.ts new file mode 100644 index 0000000000..58f67c0b4e --- /dev/null +++ b/frontend/editor/src/portal/queries/pipelines.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; +import { qk } from "@portal/queries/keys"; +import { toAsyncState } from "@portal/queries/adapters"; +import type { AsyncState } from "@portal/hooks/useAsync"; +import { + fetchPipelines, + type PipelinesOverviewResponse, +} from "@portal/api/pipelines"; + +/** Base query: the pipelines overview (GET /api/v1/policies/overview). */ +export function usePipelines(): AsyncState { + return toAsyncState( + useQuery({ queryKey: qk.pipelines(), queryFn: fetchPipelines }), + ); +} diff --git a/frontend/editor/src/portal/queries/policies.ts b/frontend/editor/src/portal/queries/policies.ts new file mode 100644 index 0000000000..51a8c0ff55 --- /dev/null +++ b/frontend/editor/src/portal/queries/policies.ts @@ -0,0 +1,42 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { qk } from "@portal/queries/keys"; +import { toAsyncState } from "@portal/queries/adapters"; +import type { AsyncState } from "@portal/hooks/useAsync"; +import { + assemblePolicies, + fetchPoliciesList, + fetchPolicyRuns, + type PoliciesResponse, + type PolicyRunView, + type WirePolicy, +} from "@portal/api/policies"; + +/** Base query: the flat stored-policy records (GET /api/v1/policies). */ +export function usePoliciesList(): AsyncState { + return toAsyncState( + useQuery({ queryKey: qk.policiesList(), queryFn: fetchPoliciesList }), + ); +} + +/** Base query: policy run history (GET /api/v1/policies/runs). */ +export function usePolicyRuns(): AsyncState { + return toAsyncState( + useQuery({ queryKey: qk.policyRuns(), queryFn: fetchPolicyRuns }), + ); +} + +/** + * The decorated catalogue Policies and Home both render, composed from the two + * shared base queries so /policies and /policies/runs are fetched once across + * all consumers. + */ +export function usePoliciesOverview(): AsyncState { + const list = usePoliciesList(); + const runs = usePolicyRuns(); + const data = useMemo( + () => (list.data ? assemblePolicies(list.data, runs.data ?? []) : null), + [list.data, runs.data], + ); + return { data, loading: list.loading, error: list.error }; +} diff --git a/frontend/editor/src/portal/queries/processorFlow.ts b/frontend/editor/src/portal/queries/processorFlow.ts new file mode 100644 index 0000000000..5ac441436f --- /dev/null +++ b/frontend/editor/src/portal/queries/processorFlow.ts @@ -0,0 +1,30 @@ +import { useMemo } from "react"; +import { usePoliciesList, usePolicyRuns } from "@portal/queries/policies"; +import { useSources } from "@portal/queries/sources"; +import type { AsyncState } from "@portal/hooks/useAsync"; +import { + assembleProcessorFlow, + type ProcessorFlow, +} from "@portal/api/processorFlow"; + +/** + * Home's sources → policies → outcomes visualiser, composed from the shared + * base queries so it reuses Home's cache entries instead of refetching them. + */ +export function useProcessorFlow(): AsyncState { + const sources = useSources(); + const list = usePoliciesList(); + const runs = usePolicyRuns(); + const data = useMemo( + () => + sources.data && list.data + ? assembleProcessorFlow(sources.data, list.data, runs.data ?? []) + : null, + [sources.data, list.data, runs.data], + ); + return { + data, + loading: sources.loading || list.loading, + error: sources.error ?? list.error, + }; +} diff --git a/frontend/editor/src/portal/queries/sharing.test.tsx b/frontend/editor/src/portal/queries/sharing.test.tsx new file mode 100644 index 0000000000..5cfb9d1629 --- /dev/null +++ b/frontend/editor/src/portal/queries/sharing.test.tsx @@ -0,0 +1,109 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { render, waitFor } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { setupServer } from "msw/node"; +import { http, HttpResponse } from "msw"; +import { createPortalQueryClient } from "@portal/queryClient"; +import { usePoliciesOverview } from "@portal/queries/policies"; +import { useProcessorFlow } from "@portal/queries/processorFlow"; + +/** + * Proves the two sharing properties the migration is for, at the hook level: + * - in-view: several consumers on one screen (Home renders the policies + * overview AND the processor flow, which both need /policies + /runs) + * trigger ONE fetch of each endpoint, not one per consumer. + * - cross-view: navigating to another screen that needs the same data + * (unmount + remount within staleTime) serves it from cache — no refetch. + */ + +// Keep apiClient.local's transport hermetic (no real token / Supabase at import). +vi.mock("@app/auth", () => ({ + getStoredToken: () => null, + clearStoredToken: vi.fn(), +})); +vi.mock("@app/auth/supabase/supabaseClient", () => ({ + getSupabaseClient: () => null, + configureSupabase: vi.fn(), +})); + +const counts: Record = {}; +const server = setupServer( + http.get("*/api/v1/policies", () => { + counts["/policies"] = (counts["/policies"] ?? 0) + 1; + return HttpResponse.json([]); + }), + http.get("*/api/v1/policies/runs", () => { + counts["/policies/runs"] = (counts["/policies/runs"] ?? 0) + 1; + return HttpResponse.json([]); + }), + http.get("*/api/v1/sources", () => { + counts["/sources"] = (counts["/sources"] ?? 0) + 1; + return HttpResponse.json({ sources: [] }); + }), +); + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); +beforeEach(() => { + for (const k of Object.keys(counts)) delete counts[k]; +}); + +// Two Home consumers of the same base queries, rendered together. +function HomeConsumers() { + usePoliciesOverview(); // e.g. onboarding progress + useProcessorFlow(); // the processor visualiser (also needs /sources) + return null; +} +// A Policies-view-style consumer of the same policies caches. +function PoliciesConsumer() { + usePoliciesOverview(); + return null; +} + +describe("portal query sharing", () => { + it("in-view: multiple consumers of the same endpoints fetch each once", async () => { + const client = createPortalQueryClient(); + render( + + + , + ); + await waitFor(() => expect(counts["/policies"]).toBe(1)); + // Two consumers both needed /policies + /runs; /sources came from the flow. + expect(counts["/policies"]).toBe(1); + expect(counts["/policies/runs"]).toBe(1); + expect(counts["/sources"]).toBe(1); + }); + + it("cross-view: a later screen reusing the data refetches nothing", async () => { + const client = createPortalQueryClient(); + const home = render( + + + , + ); + await waitFor(() => expect(counts["/policies"]).toBe(1)); + home.unmount(); + + // Navigate to "Policies" (same client, within staleTime) — cache hit. + render( + + + , + ); + // Give any (unwanted) refetch a chance to fire, then assert it didn't. + await new Promise((r) => setTimeout(r, 50)); + expect(counts["/policies"]).toBe(1); + expect(counts["/policies/runs"]).toBe(1); + }); +}); diff --git a/frontend/editor/src/portal/queries/sources.ts b/frontend/editor/src/portal/queries/sources.ts new file mode 100644 index 0000000000..4d0c363fd9 --- /dev/null +++ b/frontend/editor/src/portal/queries/sources.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import { qk } from "@portal/queries/keys"; +import { toAsyncState } from "@portal/queries/adapters"; +import type { AsyncState } from "@portal/hooks/useAsync"; +import { fetchSources, type SourcesResponse } from "@portal/api/sources"; + +/** Base query: configured sources (GET /api/v1/sources). Shared by Sources, + * Home's ProcessorFlow, the pipeline/policy builders' source pickers. */ +export function useSources(): AsyncState { + return toAsyncState( + useQuery({ queryKey: qk.sources(), queryFn: fetchSources }), + ); +} diff --git a/frontend/editor/src/portal/queries/users.ts b/frontend/editor/src/portal/queries/users.ts new file mode 100644 index 0000000000..8f314d7fc0 --- /dev/null +++ b/frontend/editor/src/portal/queries/users.ts @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; +import { qk } from "@portal/queries/keys"; +import { toAsyncState } from "@portal/queries/adapters"; +import type { AsyncState } from "@portal/hooks/useAsync"; +import { usersBackend } from "@app/portal/usersBackend"; +import type { UsersResponse } from "@portal/api/users"; +import type { Tier } from "@portal/contexts/TierContext"; + +/** + * Base query: the org roster (flavor-resolved via usersBackend). Keyed the same + * as the Users view's roster query (qk.usersRoster), so Home's onboarding read + * and the Users page share one cache entry — the roster is fetched once across + * both. Used by useOnboardingProgress. + */ +export function useUsersRoster(tier: Tier): AsyncState { + return toAsyncState( + useQuery({ + queryKey: qk.usersRoster(tier), + queryFn: () => usersBackend.fetchUsers(tier), + }), + ); +} diff --git a/frontend/editor/src/portal/queryClient.ts b/frontend/editor/src/portal/queryClient.ts new file mode 100644 index 0000000000..85bd449b61 --- /dev/null +++ b/frontend/editor/src/portal/queryClient.ts @@ -0,0 +1,35 @@ +import { QueryClient } from "@tanstack/react-query"; + +/** + * The portal's TanStack Query client, mounted once at the portal root + * (PortalApp) so its cache lives above the router — data survives navigating + * away and back. staleTime 30s: a return visit within 30s serves cache with no + * network call, then revalidates in the background. Focus refetch is off — admin + * screens don't need polling. + */ +let current: QueryClient | null = null; + +export function createPortalQueryClient(): QueryClient { + current = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + gcTime: 5 * 60_000, + retry: 1, + refetchOnWindowFocus: false, + }, + }, + }); + return current; +} + +/** + * The client created by {@link createPortalQueryClient}, or null if none has + * been mounted yet. Lets a non-hook module (the SaaS usersBackend's resolveTeam) + * read/populate the shared cache via ensureQueryData when the portal is mounted, + * while still working — via a direct fetch — when it isn't (e.g. a unit test + * that exercises the adapter without the provider). + */ +export function tryGetPortalQueryClient(): QueryClient | null { + return current; +} diff --git a/frontend/editor/src/portal/test/TestQueryProvider.tsx b/frontend/editor/src/portal/test/TestQueryProvider.tsx new file mode 100644 index 0000000000..d843d816d3 --- /dev/null +++ b/frontend/editor/src/portal/test/TestQueryProvider.tsx @@ -0,0 +1,29 @@ +import { useState, type ReactNode } from "react"; +import { MantineProvider } from "@mantine/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +/** + * Wraps a portal component under test in a fresh QueryClient (retries off for + * deterministic tests). Needed by any test that renders a component using the + * shared query hooks (portal/queries/*). Mirror of the QueryClientProvider the + * app mounts at PortalApp. + */ +export function TestQueryProvider({ children }: { children: ReactNode }) { + const [client] = useState( + () => new QueryClient({ defaultOptions: { queries: { retry: false } } }), + ); + return {children}; +} + +/** + * Combined provider for portal component tests: QueryClient + Mantine. Drop-in + * replacement for a bare `MantineProvider` test wrapper once a component (or a + * child) uses the shared query hooks. + */ +export function PortalTestProviders({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/frontend/editor/src/portal/views/Documents.tsx b/frontend/editor/src/portal/views/Documents.tsx index c083071bcb..8854d4db7f 100644 --- a/frontend/editor/src/portal/views/Documents.tsx +++ b/frontend/editor/src/portal/views/Documents.tsx @@ -2,11 +2,9 @@ import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { Button } from "@app/ui"; import { useTier } from "@portal/contexts/TierContext"; -import { useAsync } from "@portal/hooks/useAsync"; +import { useDocuments } from "@portal/queries/documents"; import { - fetchDocuments, DOCUMENT_STATUS_LABEL, - type DocumentsResponse, type ReviewDocument, } from "@portal/api/documents"; import { ReviewQueue } from "@portal/components/documents/ReviewQueue"; @@ -66,7 +64,7 @@ function toCsv(docs: ReviewDocument[], t: TFunction): string { export function Documents() { const { t } = useTranslation(); const { tier } = useTier(); - const state = useAsync(() => fetchDocuments(tier), [tier]); + const state = useDocuments(tier); const documents = state.data?.documents ?? []; function exportCsv() { diff --git a/frontend/editor/src/portal/views/EditorAdmin.tsx b/frontend/editor/src/portal/views/EditorAdmin.tsx index e60efceea5..bd202a2bd3 100644 --- a/frontend/editor/src/portal/views/EditorAdmin.tsx +++ b/frontend/editor/src/portal/views/EditorAdmin.tsx @@ -2,11 +2,8 @@ import { useTranslation } from "react-i18next"; import { Skeleton } from "@app/ui"; import { useTier } from "@portal/contexts/TierContext"; import { useView } from "@portal/contexts/ViewContext"; -import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; -import { - fetchEditorDeployment, - type EditorDeploymentResponse, -} from "@portal/api/editorDeploy"; +import { useSectionFlags } from "@portal/hooks/useAsync"; +import { useEditorDeployment } from "@portal/queries/infrastructure"; import { DeploymentSummaryStrip } from "@portal/components/editor-admin/DeploymentSummaryStrip"; import { DeploymentTargets } from "@portal/components/editor-admin/DeploymentTargets"; import { PairingPanel } from "@portal/components/editor-admin/PairingPanel"; @@ -35,10 +32,7 @@ export function EditorAdmin() { const { t } = useTranslation(); const { tier } = useTier(); const { setActiveView } = useView(); - const state = useAsync( - () => fetchEditorDeployment(tier), - [tier], - ); + const state = useEditorDeployment(tier); const { data } = state; const { isLoading } = useSectionFlags(state); diff --git a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx index 6f40b82764..2f7300422f 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx @@ -5,7 +5,7 @@ import { screen, waitFor, } from "@testing-library/react"; -import { MantineProvider } from "@mantine/core"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import type { Policy, TriggerOutcome } from "@portal/api/pipelines"; import type { ToolRegistryCatalog } from "@app/contexts/ToolRegistryContext"; @@ -15,7 +15,7 @@ import { PipelineBuilder } from "@portal/views/PipelineBuilder"; const render = ( ui: Parameters[0], options?: Parameters[1], -) => baseRender(ui, { wrapper: MantineProvider, ...options }); +) => baseRender(ui, { wrapper: PortalTestProviders, ...options }); // Deterministic i18n: keys returned verbatim. vi.mock("react-i18next", () => ({ diff --git a/frontend/editor/src/portal/views/PipelineBuilder.tsx b/frontend/editor/src/portal/views/PipelineBuilder.tsx index 66c5748e85..fc23ac1516 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.tsx @@ -51,9 +51,12 @@ import { import { clearProcessedHistory } from "@portal/api/policies"; import { availableOutputModes } from "@portal/components/pipelines/outputModes"; import { S3ConnectionPicker } from "@portal/components/sources/S3ConnectionPicker"; -import { fetchSources, type SourceView } from "@portal/api/sources"; +import { type SourceView } from "@portal/api/sources"; +import { useSources } from "@portal/queries/sources"; import { EDITOR_SOURCE_TYPE } from "@portal/components/sources/sourceTypes"; import { useAsync } from "@portal/hooks/useAsync"; +import { useQueryClient } from "@tanstack/react-query"; +import { qk } from "@portal/queries/keys"; import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { humanizeOperation } from "@portal/components/pipelines/pipelineOperations"; import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings"; @@ -154,6 +157,16 @@ function parseOutput(output: OutputSpec | undefined): { export function PipelineBuilder() { const { t } = useTranslation(); const navigate = useNavigate(); + const queryClient = useQueryClient(); + // Pipelines are stored as policies, so a save/delete must invalidate both the + // pipelines overview and the policies caches (Policies page + Home) before + // navigating back to the list. + const invalidatePipelines = () => + Promise.all([ + queryClient.invalidateQueries({ queryKey: qk.pipelines() }), + queryClient.invalidateQueries({ queryKey: qk.policiesList() }), + queryClient.invalidateQueries({ queryKey: qk.policyRuns() }), + ]); const { id } = useParams(); const isEdit = Boolean(id); const { allTools } = useToolRegistry(); @@ -166,20 +179,20 @@ export function PipelineBuilder() { async () => (id ? await fetchPipeline(id) : null), [id], ); - const sourcesState = useAsync( - // The editor is a built-in, client-driven source (it runs on editor upload, not as a pipeline - // input), so it's excluded from the sources a pipeline can pull from. - async () => - (await fetchSources()).sources.filter( - (source) => source.type !== EDITOR_SOURCE_TYPE, - ), - [], - ); + const sourcesState = useSources(); const triggersState = useAsync( async () => await fetchTriggers(), [], ); - const availableSources = sourcesState.data ?? []; + // The editor is a built-in, client-driven source (it runs on editor upload, + // not as a pipeline input), so it's excluded from a pipeline's inputs. + const availableSources = useMemo( + () => + (sourcesState.data?.sources ?? []).filter( + (source) => source.type !== EDITOR_SOURCE_TYPE, + ), + [sourcesState.data], + ); const triggers = useMemo( () => triggersState.data ?? [], [triggersState.data], @@ -450,6 +463,7 @@ export function PipelineBuilder() { }; try { await savePipeline(policy); + await invalidatePipelines(); navigate(destination); } catch (e) { setError(errorMessage(e)); @@ -560,6 +574,7 @@ export function PipelineBuilder() { setDeleting(true); try { await deletePipeline(id); + await invalidatePipelines(); close(); } catch (e) { setError(errorMessage(e)); diff --git a/frontend/editor/src/portal/views/Pipelines.test.tsx b/frontend/editor/src/portal/views/Pipelines.test.tsx index ff54881c72..655abf62cb 100644 --- a/frontend/editor/src/portal/views/Pipelines.test.tsx +++ b/frontend/editor/src/portal/views/Pipelines.test.tsx @@ -4,7 +4,7 @@ import { render as baseRender, screen, } from "@testing-library/react"; -import { MantineProvider } from "@mantine/core"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import type { PipelinesOverviewResponse } from "@portal/api/pipelines"; import { Pipelines } from "@portal/views/Pipelines"; @@ -12,7 +12,7 @@ import { Pipelines } from "@portal/views/Pipelines"; const render = ( ui: Parameters[0], options?: Parameters[1], -) => baseRender(ui, { wrapper: MantineProvider, ...options }); +) => baseRender(ui, { wrapper: PortalTestProviders, ...options }); // Deterministic i18n: keys returned verbatim. vi.mock("react-i18next", () => ({ diff --git a/frontend/editor/src/portal/views/Pipelines.tsx b/frontend/editor/src/portal/views/Pipelines.tsx index 4e9893dd03..b0321cfee1 100644 --- a/frontend/editor/src/portal/views/Pipelines.tsx +++ b/frontend/editor/src/portal/views/Pipelines.tsx @@ -2,12 +2,9 @@ import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; import { Button, EmptyState, Skeleton } from "@app/ui"; -import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; -import { - fetchPipelines, - type PipelinesOverviewResponse, - type PipelineView, -} from "@portal/api/pipelines"; +import { useSectionFlags } from "@portal/hooks/useAsync"; +import { usePipelines } from "@portal/queries/pipelines"; +import { type PipelineView } from "@portal/api/pipelines"; import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { PipelinesIcon } from "@portal/components/icons"; import { KpiStrip } from "@portal/components/pipelines/KpiStrip"; @@ -17,7 +14,7 @@ import "@portal/views/Pipelines.css"; export function Pipelines() { const { t } = useTranslation(); const navigate = useNavigate(); - const state = useAsync(() => fetchPipelines(), []); + const state = usePipelines(); const { data, loading } = state; const { isLoading } = useSectionFlags(state); diff --git a/frontend/editor/src/portal/views/Policies.tsx b/frontend/editor/src/portal/views/Policies.tsx index 2de9122e0e..5bdcd19a8f 100644 --- a/frontend/editor/src/portal/views/Policies.tsx +++ b/frontend/editor/src/portal/views/Policies.tsx @@ -1,22 +1,23 @@ import { useCallback, useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Banner, Button, Skeleton } from "@app/ui"; import { errorMessage } from "@portal/api/http"; -import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; +import { useSectionFlags } from "@portal/hooks/useAsync"; import { buildWireFromSetup, buildWireFromState, clearProcessedHistory, deletePolicy, - fetchPolicies, savePolicy, POLICY_CATEGORIES, POLICY_CONFIG, type CatalogueEntry, - type PoliciesResponse, type PolicySetupResult, } from "@portal/api/policies"; +import { usePoliciesOverview } from "@portal/queries/policies"; +import { qk } from "@portal/queries/keys"; import { CatalogueSummary } from "@portal/components/policies/CatalogueSummary"; import { PolicyCatalogueTable } from "@portal/components/policies/PolicyCatalogueTable"; import { PolicyDetailPanel } from "@portal/components/policies/PolicyDetailPanel"; @@ -26,8 +27,8 @@ import "@portal/views/Policies.css"; export function Policies() { const { t } = useTranslation(); - const [version, setVersion] = useState(0); - const state = useAsync(() => fetchPolicies(), [version]); + const queryClient = useQueryClient(); + const state = usePoliciesOverview(); const { data, loading, error: fetchError } = state; const { isLoading } = useSectionFlags(state); @@ -63,7 +64,12 @@ export function Policies() { ); const catalogue = data?.catalogue ?? []; - const refetch = useCallback(() => setVersion((v) => v + 1), []); + // Invalidate the shared policies caches; because ProcessorFlow and onboarding + // read the SAME entries, this also live-refreshes Home. + const refetch = useCallback(() => { + queryClient.invalidateQueries({ queryKey: qk.policiesList() }); + queryClient.invalidateQueries({ queryKey: qk.policyRuns() }); + }, [queryClient]); // The catalogue cards are always shown (they're the "configure a policy" CTAs), // but the summary strip is pure stat boxes: hide it until at least one policy // is configured so a fresh workspace doesn't show a row of zeros. diff --git a/frontend/editor/src/portal/views/SourceBuilder.test.tsx b/frontend/editor/src/portal/views/SourceBuilder.test.tsx index 77186de7d8..fab14ff1ae 100644 --- a/frontend/editor/src/portal/views/SourceBuilder.test.tsx +++ b/frontend/editor/src/portal/views/SourceBuilder.test.tsx @@ -5,17 +5,18 @@ import { screen, waitFor, } from "@testing-library/react"; -import { MantineProvider } from "@mantine/core"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import type { ReactNode } from "react"; import { SourceBuilder } from "@portal/views/SourceBuilder"; import { UIProvider } from "@portal/contexts/UIContext"; -// SourceBuilder reads useUI() to open settings, so wrap in its provider. +// SourceBuilder reads useUI() (open settings) and useQueryClient (list +// invalidation), so provide the query client + Mantine + the UI context. const Providers = ({ children }: { children: ReactNode }) => ( - + {children} - + ); const render = (ui: Parameters[0]) => diff --git a/frontend/editor/src/portal/views/SourceBuilder.tsx b/frontend/editor/src/portal/views/SourceBuilder.tsx index 4d6ff1af14..c38eb9f48d 100644 --- a/frontend/editor/src/portal/views/SourceBuilder.tsx +++ b/frontend/editor/src/portal/views/SourceBuilder.tsx @@ -23,6 +23,8 @@ import { } from "@portal/api/sources"; import { useUI } from "@portal/contexts/UIContext"; import { useAsync } from "@portal/hooks/useAsync"; +import { useQueryClient } from "@tanstack/react-query"; +import { qk } from "@portal/queries/keys"; import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { creatableSourceTypes } from "@portal/components/sources/creatableSourceTypes"; import { @@ -71,11 +73,17 @@ function optionsFor( export function SourceBuilder() { const { t } = useTranslation(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const { openSettings } = useUI(); const { id } = useParams(); const isEdit = Boolean(id); const listPath = toPortalPath(VIEW_PATHS.sources); + // The list is a shared cache entry (Sources view + Home's ProcessorFlow), so + // a create/delete here must invalidate it before we navigate back to it. + const invalidateSources = () => + queryClient.invalidateQueries({ queryKey: qk.sources() }); + const sourceState = useAsync( async () => (id ? await fetchSource(id) : null), [id], @@ -152,6 +160,7 @@ export function SourceBuilder() { options, enabled, }); + await invalidateSources(); if (!isEdit && type.type === WEBHOOK_SOURCE_TYPE) { const webhookId = String(saved.options?.webhookId ?? ""); const secret = String(saved.options?.signingSecret ?? ""); @@ -177,6 +186,7 @@ export function SourceBuilder() { setDeleting(true); try { await deleteSource(id); + await invalidateSources(); navigate(listPath); } catch (e) { setError(errorMessage(e)); diff --git a/frontend/editor/src/portal/views/Sources.test.tsx b/frontend/editor/src/portal/views/Sources.test.tsx index e3b42356c0..f4a52b5665 100644 --- a/frontend/editor/src/portal/views/Sources.test.tsx +++ b/frontend/editor/src/portal/views/Sources.test.tsx @@ -4,13 +4,13 @@ import { render as baseRender, screen, } from "@testing-library/react"; -import { MantineProvider } from "@mantine/core"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import type { SourcesResponse } from "@portal/api/sources"; import { Sources } from "@portal/views/Sources"; const render = (ui: Parameters[0]) => - baseRender(ui, { wrapper: MantineProvider }); + baseRender(ui, { wrapper: PortalTestProviders }); // Deterministic i18n: keys returned verbatim. vi.mock("react-i18next", () => ({ diff --git a/frontend/editor/src/portal/views/Sources.tsx b/frontend/editor/src/portal/views/Sources.tsx index 385a0ab121..6013e00d79 100644 --- a/frontend/editor/src/portal/views/Sources.tsx +++ b/frontend/editor/src/portal/views/Sources.tsx @@ -2,13 +2,10 @@ import { useTranslation } from "react-i18next"; import { useNavigate, useSearchParams } from "react-router-dom"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; import { Button, EmptyState, Skeleton, Tabs } from "@app/ui"; -import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; +import { useSectionFlags } from "@portal/hooks/useAsync"; +import { useSources } from "@portal/queries/sources"; import { SourcesIcon } from "@portal/components/icons"; -import { - fetchSources, - type SourcesResponse, - type SourceView, -} from "@portal/api/sources"; +import { type SourceView } from "@portal/api/sources"; import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { KpiStrip } from "@portal/components/sources/KpiStrip"; import { SourcesTable } from "@portal/components/sources/SourcesTable"; @@ -24,7 +21,7 @@ export function Sources() { const activeTab: SourcesTab = searchParams.get("tab") === "connections" ? "connections" : "sources"; - const state = useAsync(() => fetchSources(), []); + const state = useSources(); const { data, loading } = state; const { isLoading } = useSectionFlags(state); diff --git a/frontend/editor/src/portal/views/Users.caching.test.tsx b/frontend/editor/src/portal/views/Users.caching.test.tsx new file mode 100644 index 0000000000..a43cd8a835 --- /dev/null +++ b/frontend/editor/src/portal/views/Users.caching.test.tsx @@ -0,0 +1,126 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { render, screen, type RenderResult } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { MemoryRouter } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { setupServer } from "msw/node"; +import { + teamSaasHandlers, + resetTeamSaasStore, +} from "@portal/mocks/handlers/teamSaas"; +import { createPortalQueryClient } from "@portal/queryClient"; +import { qk } from "@portal/queries/keys"; + +/** + * The migration's payoff, as assertions: revisiting the Users view serves the + * roster from cache with no refetch, and the SaaS roster + teams queries share + * one /team/my resolve. Same SaaS mocks as Users.saas.test.tsx. + */ + +vi.mock("@app/auth", () => ({ + getStoredToken: () => null, + clearStoredToken: vi.fn(), +})); +vi.mock("@app/auth/supabase/supabaseClient", () => ({ + getSupabaseClient: () => null, + configureSupabase: vi.fn(), +})); +vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() })); + +vi.mock("@app/portal/usersCapabilities", async () => ({ + usersCapabilities: (await import("../../saas/portal/usersCapabilities")) + .usersCapabilities, +})); +vi.mock("@app/portal/usersBackend", async () => ({ + usersBackend: (await import("../../saas/portal/usersBackend")).usersBackend, +})); + +vi.mock("@portal/contexts/TierContext", () => ({ + useTier: () => ({ tier: "pro" }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string, opts?: Record) => { + const base = fallback ?? key; + return opts + ? base.replace(/\{\{(\w+)\}\}/g, (_, k) => String(opts[k] ?? "")) + : base; + }, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +import { Users } from "@portal/views/Users"; + +const server = setupServer(...teamSaasHandlers); + +let rosterFetches = 0; +let teamMyFetches = 0; +server.events.on("request:start", ({ request }) => { + const { pathname } = new URL(request.url); + if (pathname.endsWith("/members")) rosterFetches += 1; + if (pathname.endsWith("/team/my")) teamMyFetches += 1; +}); + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => { + server.events.removeAllListeners(); + server.close(); +}); +beforeEach(() => { + resetTeamSaasStore(); + rosterFetches = 0; + teamMyFetches = 0; +}); + +function renderUsers(client: QueryClient): RenderResult { + return render( + + + + + + + , + ); +} + +describe("Users view caching", () => { + it("serves the roster from cache on remount (no refetch)", async () => { + // One client across both mounts — the real app keeps it at the portal root, + // above the router, for exactly this reason. + const client = createPortalQueryClient(); + + const first = renderUsers(client); + expect(await screen.findByText("leader@acme.com")).toBeInTheDocument(); + expect(rosterFetches).toBe(1); + first.unmount(); + + // Remount (navigate away + back) within staleTime → straight from cache. + renderUsers(client); + expect(await screen.findByText("leader@acme.com")).toBeInTheDocument(); + expect(rosterFetches).toBe(1); + }); + + it("collapses the SaaS /team/my call to one per mount", async () => { + const client = createPortalQueryClient(); + renderUsers(client); + await screen.findByText("leader@acme.com"); + + // Roster + teams both resolve the team but share the cached qk.teamMy() + // entry, so /team/my is hit once, not twice. + expect(teamMyFetches).toBe(1); + expect(client.getQueryData(qk.teamMy())).toBeDefined(); + }); +}); diff --git a/frontend/editor/src/portal/views/Users.saas.test.tsx b/frontend/editor/src/portal/views/Users.saas.test.tsx index 7cc49c91dd..3b63171c5d 100644 --- a/frontend/editor/src/portal/views/Users.saas.test.tsx +++ b/frontend/editor/src/portal/views/Users.saas.test.tsx @@ -15,7 +15,7 @@ import { waitFor, within, } from "@testing-library/react"; -import { MantineProvider } from "@mantine/core"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; import { MemoryRouter } from "react-router-dom"; import { setupServer } from "msw/node"; import { @@ -78,11 +78,11 @@ beforeEach(() => resetTeamSaasStore()); function renderUsers() { return render( - + - , + , ); } diff --git a/frontend/editor/src/portal/views/Users.tsx b/frontend/editor/src/portal/views/Users.tsx index e8a287e6f7..ad76894b06 100644 --- a/frontend/editor/src/portal/views/Users.tsx +++ b/frontend/editor/src/portal/views/Users.tsx @@ -2,28 +2,23 @@ import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Button, EmptyState, Skeleton } from "@app/ui"; -import { useTier } from "@portal/contexts/TierContext"; -import { useAsync } from "@portal/hooks/useAsync"; import { changeMemberRole, disableMemberMfa, setMemberSuspended, unlockMember, - type AdminAuthConfig, type Member, type PendingInvitation, type PortalAccessState, type RoleId, - type UsersResponse, } from "@portal/api/users"; import { usersBackend } from "@app/portal/usersBackend"; import { createGrant, - fetchGrants, revokeGrant, type ResourceGrant, } from "@portal/api/access"; -import { deleteTeam as apiDeleteTeam, type Team } from "@portal/api/teams"; +import { deleteTeam as apiDeleteTeam } from "@portal/api/teams"; import { errorMessage } from "@portal/api/http"; import { usersCapabilities as caps } from "@app/portal/usersCapabilities"; import { UsersDirectory } from "@portal/components/users/UsersDirectory"; @@ -35,6 +30,7 @@ import { MoveToTeamModal } from "@portal/components/users/MoveToTeamModal"; import { RenameTeamModal } from "@portal/components/users/RenameTeamModal"; import { ConfirmModal } from "@portal/components/users/ConfirmModal"; import type { TeamGroup } from "@portal/components/users/directory"; +import { useUsersData } from "@portal/views/usersData"; interface Confirm { title: string; @@ -44,28 +40,14 @@ interface Confirm { action: () => Promise; } +/** + * Users page: the org roster, teams, and portal-access management. Mutation + * handlers call `refresh` to invalidate the shared caches (see useUsersData). + */ export function Users() { const { t } = useTranslation(); - const { tier } = useTier(); - const [refreshKey, setRefreshKey] = useState(0); - - const usersState = useAsync( - () => usersBackend.fetchUsers(tier), - [tier, refreshKey], - ); - // Grants are ADMIN-only; skip the fetch entirely on flavors that can't manage them. - const grantsState = useAsync( - () => (caps.manageGrants ? fetchGrants("PORTAL") : Promise.resolve([])), - [tier, refreshKey], - ); - const teamsState = useAsync( - () => usersBackend.fetchTeams(), - [tier, refreshKey], - ); - const authState = useAsync( - () => usersBackend.fetchAuthConfig(), - [], - ); + const { usersState, grantsState, teamsState, authState, refresh } = + useUsersData(); const [actionError, setActionError] = useState(null); const [inviteOpen, setInviteOpen] = useState(false); @@ -151,9 +133,8 @@ export function Users() { .catch((error) => setActionError(errorMessage(error))) // Refetch on success AND failure: a multi-step mutation (e.g. changeMemberRole) // has no rollback, so a mid-sequence failure must resync the roster to real state. - .finally(() => setRefreshKey((k) => k + 1)); + .finally(() => refresh()); } - const refresh = () => setRefreshKey((k) => k + 1); function changeRole(member: Member, role: RoleId) { run(() => changeMemberRole(member, role)); diff --git a/frontend/editor/src/portal/views/teamMyCache.saas.test.ts b/frontend/editor/src/portal/views/teamMyCache.saas.test.ts new file mode 100644 index 0000000000..f18c70dd0a --- /dev/null +++ b/frontend/editor/src/portal/views/teamMyCache.saas.test.ts @@ -0,0 +1,87 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { setupServer } from "msw/node"; +import { http, HttpResponse } from "msw"; +import { createPortalQueryClient } from "@portal/queryClient"; +import { qk } from "@portal/queries/keys"; +import { usersBackend } from "@app/portal/usersBackend"; + +/** + * The SaaS team resolution (/team/my) is read through the shared query cache so + * fetchUsers + fetchTeams dedupe to one request per mount. Regression guard for + * the bug where a rename/remove "did nothing": the cache must also honour + * invalidation, so a mutation's refresh() forces a re-resolve instead of + * serving the stale team. (Uses fetchQuery, not ensureQueryData — the latter + * returns cached data even when invalidated.) + */ + +// Keep apiClient.local's transport hermetic (no real token at import). +vi.mock("@app/auth", () => ({ + getStoredToken: () => null, + clearStoredToken: vi.fn(), +})); +vi.mock("@app/auth/supabase/supabaseClient", () => ({ + getSupabaseClient: () => null, + configureSupabase: vi.fn(), +})); +// The portal test project's @app points at proprietary; resolve the flavor seam +// to the real SaaS backend (same approach as Users.saas.test). +vi.mock("@app/portal/usersBackend", async () => ({ + usersBackend: (await import("../../saas/portal/usersBackend")).usersBackend, +})); + +let teamMyFetches = 0; +let teamName = "Old name"; +const server = setupServer( + http.get("*/api/v1/team/my", () => { + teamMyFetches += 1; + return HttpResponse.json([ + { + teamId: 1, + name: teamName, + teamType: "STANDARD", + isPersonal: false, + memberCount: 2, + seatCount: 5, + seatsUsed: 2, + maxSeats: 5, + isLeader: true, + }, + ]); + }), +); + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); +beforeEach(() => { + teamMyFetches = 0; + teamName = "Old name"; +}); + +describe("SaaS /team/my resolution cache", () => { + it("dedupes within staleTime but re-resolves after invalidation", async () => { + const client = createPortalQueryClient(); + + // Two resolves within staleTime → one network call (the collapse). + expect((await usersBackend.fetchTeams())[0]?.name).toBe("Old name"); + expect((await usersBackend.fetchTeams())[0]?.name).toBe("Old name"); + expect(teamMyFetches).toBe(1); + + // Server-side rename + what a Users mutation's refresh() does. + teamName = "New name"; + await client.invalidateQueries({ queryKey: qk.teamMy() }); + + // Must refetch, not serve the stale cached team (the reported bug). + expect((await usersBackend.fetchTeams())[0]?.name).toBe("New name"); + expect(teamMyFetches).toBe(2); + }); +}); diff --git a/frontend/editor/src/portal/views/usersData.ts b/frontend/editor/src/portal/views/usersData.ts new file mode 100644 index 0000000000..d6e965d714 --- /dev/null +++ b/frontend/editor/src/portal/views/usersData.ts @@ -0,0 +1,63 @@ +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTier } from "@portal/contexts/TierContext"; +import type { AsyncState } from "@portal/hooks/useAsync"; +import { toAsyncState } from "@portal/queries/adapters"; +import { qk } from "@portal/queries/keys"; +import { usersBackend } from "@app/portal/usersBackend"; +import { fetchGrants, type ResourceGrant } from "@portal/api/access"; +import { usersCapabilities as caps } from "@app/portal/usersCapabilities"; +import type { AdminAuthConfig, UsersResponse } from "@portal/api/users"; +import type { Team } from "@portal/api/teams"; + +/** The four resources the Users page renders, plus a post-mutation refresh. */ +export interface UsersData { + usersState: AsyncState; + grantsState: AsyncState; + teamsState: AsyncState; + authState: AsyncState; + refresh: () => void; +} + +// Grants are ADMIN-only; resolve empty on flavors that can't manage them. +const fetchGrantsOrEmpty = (): Promise => + caps.manageGrants ? fetchGrants("PORTAL") : Promise.resolve([]); + +export function useUsersData(): UsersData { + const { tier } = useTier(); + const queryClient = useQueryClient(); + + const usersQuery = useQuery({ + queryKey: qk.usersRoster(tier), + queryFn: () => usersBackend.fetchUsers(tier), + }); + const grantsQuery = useQuery({ + queryKey: qk.usersGrants(tier), + queryFn: fetchGrantsOrEmpty, + }); + const teamsQuery = useQuery({ + queryKey: qk.usersTeams(tier), + queryFn: () => usersBackend.fetchTeams(), + }); + const authQuery = useQuery({ + queryKey: qk.usersAuthConfig(), + queryFn: () => usersBackend.fetchAuthConfig(), + }); + + // Auth config is deliberately not invalidated (it never changes via these + // mutations); teamMy is the shared SaaS entry roster + teams both derive from. + const refresh = useCallback(() => { + queryClient.invalidateQueries({ queryKey: qk.usersRoster(tier) }); + queryClient.invalidateQueries({ queryKey: qk.usersGrants(tier) }); + queryClient.invalidateQueries({ queryKey: qk.usersTeams(tier) }); + queryClient.invalidateQueries({ queryKey: qk.teamMy() }); + }, [queryClient, tier]); + + return { + usersState: toAsyncState(usersQuery), + grantsState: toAsyncState(grantsQuery), + teamsState: toAsyncState(teamsQuery), + authState: toAsyncState(authQuery), + refresh, + }; +} diff --git a/frontend/editor/src/saas/portal/usersBackend.ts b/frontend/editor/src/saas/portal/usersBackend.ts index 9239810814..a4fbc03dfb 100644 --- a/frontend/editor/src/saas/portal/usersBackend.ts +++ b/frontend/editor/src/saas/portal/usersBackend.ts @@ -1,5 +1,7 @@ import type { UsersBackend } from "@portal/api/usersBackend"; import { apiClient } from "@portal/api/http"; +import { tryGetPortalQueryClient } from "@portal/queryClient"; +import { qk } from "@portal/queries/keys"; import { ROLES, type AdminAuthConfig, @@ -76,7 +78,18 @@ function isExpired(iso: string | undefined): boolean { * no teams at all. */ async function resolveTeam(): Promise { - const teams = await apiClient.local.json("/api/v1/team/my"); + const fetchMy = () => + apiClient.local.json("/api/v1/team/my"); + // fetchUsers and fetchTeams both resolve the team; share one /team/my via the + // query cache. fetchQuery (not ensureQueryData) so the shared entry honours + // both staleTime — two callers in one mount dedupe to a single request — AND + // invalidation: refresh() invalidates qk.teamMy(), so the next resolve after a + // rename/remove refetches instead of returning the stale team. Falls back to a + // direct fetch when no portal client is mounted (e.g. a unit test). + const client = tryGetPortalQueryClient(); + const teams = client + ? await client.fetchQuery({ queryKey: qk.teamMy(), queryFn: fetchMy }) + : await fetchMy(); if (!teams || teams.length === 0) return null; return ( teams.find((t) => t.isLeader && !t.isPersonal) ?? diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2ddece68a0..f467cd4e47 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -50,6 +50,7 @@ "@stripe/stripe-js": "^7.9.0", "@supabase/supabase-js": "^2.47.13", "@tailwindcss/postcss": "^4.1.13", + "@tanstack/react-query": "^5.101.4", "@tanstack/react-virtual": "^3.13.12", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "2.7.0", @@ -4525,6 +4526,32 @@ "tailwindcss": "4.2.2" } }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-virtual": { "version": "3.13.23", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz", diff --git a/frontend/package.json b/frontend/package.json index e460f37e40..06059a3962 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,6 +47,7 @@ "@stripe/stripe-js": "^7.9.0", "@supabase/supabase-js": "^2.47.13", "@tailwindcss/postcss": "^4.1.13", + "@tanstack/react-query": "^5.101.4", "@tanstack/react-virtual": "^3.13.12", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "2.7.0",