mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
feat(portal): adopt TanStack Query with a shared per-resource query layer (#7135)
## 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.
This commit is contained in:
@@ -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 (
|
||||
<ThemeProvider>
|
||||
<ThemedSuiProvider>
|
||||
{/* Scopes base.css to the portal so it doesn't restyle the host editor. */}
|
||||
<div className="portal-scope">
|
||||
{/* Tool registry is read by portal views (e.g. the policy setup
|
||||
wizard); mount it above the per-flavor provider split. */}
|
||||
<ToolRegistryProvider>
|
||||
<PortalAuthBoundary>
|
||||
<PortalProviders />
|
||||
</PortalAuthBoundary>
|
||||
</ToolRegistryProvider>
|
||||
</div>
|
||||
</ThemedSuiProvider>
|
||||
</ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<ThemedSuiProvider>
|
||||
{/* Scopes base.css to the portal so it doesn't restyle the host editor. */}
|
||||
<div className="portal-scope">
|
||||
{/* Tool registry is read by portal views (e.g. the policy setup
|
||||
wizard); mount it above the per-flavor provider split. */}
|
||||
<ToolRegistryProvider>
|
||||
<PortalAuthBoundary>
|
||||
<PortalProviders />
|
||||
</PortalAuthBoundary>
|
||||
</ToolRegistryProvider>
|
||||
</div>
|
||||
</ThemedSuiProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<PoliciesResponse> {
|
||||
const [wirePolicies, runs] = await Promise.all([
|
||||
apiClient.local.json<WirePolicy[]>("/api/v1/policies"),
|
||||
apiClient.local
|
||||
.json<PolicyRunView[]>("/api/v1/policies/runs")
|
||||
.catch(() => [] as PolicyRunView[]),
|
||||
]);
|
||||
/** GET /api/v1/policies — the flat stored-policy records. */
|
||||
export function fetchPoliciesList(): Promise<WirePolicy[]> {
|
||||
return apiClient.local.json<WirePolicy[]>("/api/v1/policies");
|
||||
}
|
||||
|
||||
/** GET /api/v1/policies/runs — best-effort (empty on a backend without runs). */
|
||||
export function fetchPolicyRuns(): Promise<PolicyRunView[]> {
|
||||
return apiClient.local
|
||||
.json<PolicyRunView[]>("/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 }
|
||||
|
||||
@@ -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<ProcessorFlow> {
|
||||
const [sourcesResp, wirePolicies, runs] = await Promise.all([
|
||||
fetchSources(),
|
||||
apiClient.local.json<WirePolicy[]>("/api/v1/policies"),
|
||||
apiClient.local
|
||||
.json<PolicyRunView[]>("/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,
|
||||
|
||||
@@ -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<EditorDeploymentResponse>(
|
||||
() => fetchEditorDeployment(tier),
|
||||
[tier],
|
||||
);
|
||||
const { data, loading } = useEditorDeployment(tier);
|
||||
|
||||
const view = useMemo(() => {
|
||||
if (!data) return null;
|
||||
|
||||
@@ -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<ProcessorFlowModel>(() => fetchProcessorFlow(), []);
|
||||
const fetched = useProcessorFlow();
|
||||
const data = dataOverride ?? fetched.data;
|
||||
const loading = dataOverride ? false : fetched.loading;
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Card padding="loose">
|
||||
<div className="portal-billing__fleet-row">
|
||||
|
||||
@@ -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<AuditLogResponse>(() => 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.
|
||||
|
||||
@@ -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<typeof baseRender>[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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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<AppConfigShape>(
|
||||
() => apiClient.local.json<AppConfigShape>("/api/v1/config/app-config"),
|
||||
[],
|
||||
);
|
||||
const query = useQuery({
|
||||
queryKey: qk.appConfig(),
|
||||
queryFn: () =>
|
||||
apiClient.local.json<AppConfigShape>("/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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<T>(query: UseQueryResult<T>): AsyncState<T> {
|
||||
return {
|
||||
data: query.data ?? null,
|
||||
loading: query.isPending,
|
||||
error: (query.error as Error | null) ?? null,
|
||||
};
|
||||
}
|
||||
@@ -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<DocumentsResponse> {
|
||||
return toAsyncState(
|
||||
useQuery({
|
||||
queryKey: qk.documents(tier),
|
||||
queryFn: () => fetchDocuments(tier),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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<FleetStats> {
|
||||
return toAsyncState(
|
||||
useQuery({
|
||||
queryKey: qk.fleetStats(),
|
||||
queryFn: ({ signal }) => fetchFleetStats(signal),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Base query: recent audit-log activity (tier-scoped). */
|
||||
export function useAuditLog(tier: Tier): AsyncState<AuditLogResponse> {
|
||||
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<EditorDeploymentResponse> {
|
||||
return toAsyncState(
|
||||
useQuery({
|
||||
queryKey: qk.editorDeployment(tier),
|
||||
queryFn: () => fetchEditorDeployment(tier),
|
||||
retry: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
/**
|
||||
* The portal's TanStack Query keys, in one place. Convention:
|
||||
* ["portal", <resource>, ...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;
|
||||
@@ -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<PipelinesOverviewResponse> {
|
||||
return toAsyncState(
|
||||
useQuery({ queryKey: qk.pipelines(), queryFn: fetchPipelines }),
|
||||
);
|
||||
}
|
||||
@@ -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<WirePolicy[]> {
|
||||
return toAsyncState(
|
||||
useQuery({ queryKey: qk.policiesList(), queryFn: fetchPoliciesList }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Base query: policy run history (GET /api/v1/policies/runs). */
|
||||
export function usePolicyRuns(): AsyncState<PolicyRunView[]> {
|
||||
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<PoliciesResponse> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<ProcessorFlow> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, number> = {};
|
||||
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(
|
||||
<QueryClientProvider client={client}>
|
||||
<HomeConsumers />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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(
|
||||
<QueryClientProvider client={client}>
|
||||
<HomeConsumers />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() => expect(counts["/policies"]).toBe(1));
|
||||
home.unmount();
|
||||
|
||||
// Navigate to "Policies" (same client, within staleTime) — cache hit.
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<PoliciesConsumer />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -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<SourcesResponse> {
|
||||
return toAsyncState(
|
||||
useQuery({ queryKey: qk.sources(), queryFn: fetchSources }),
|
||||
);
|
||||
}
|
||||
@@ -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<UsersResponse> {
|
||||
return toAsyncState(
|
||||
useQuery({
|
||||
queryKey: qk.usersRoster(tier),
|
||||
queryFn: () => usersBackend.fetchUsers(tier),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<TestQueryProvider>
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
</TestQueryProvider>
|
||||
);
|
||||
}
|
||||
@@ -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<DocumentsResponse>(() => fetchDocuments(tier), [tier]);
|
||||
const state = useDocuments(tier);
|
||||
const documents = state.data?.documents ?? [];
|
||||
|
||||
function exportCsv() {
|
||||
|
||||
@@ -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<EditorDeploymentResponse>(
|
||||
() => fetchEditorDeployment(tier),
|
||||
[tier],
|
||||
);
|
||||
const state = useEditorDeployment(tier);
|
||||
const { data } = state;
|
||||
const { isLoading } = useSectionFlags(state);
|
||||
|
||||
|
||||
@@ -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<typeof baseRender>[0],
|
||||
options?: Parameters<typeof baseRender>[1],
|
||||
) => baseRender(ui, { wrapper: MantineProvider, ...options });
|
||||
) => baseRender(ui, { wrapper: PortalTestProviders, ...options });
|
||||
|
||||
// Deterministic i18n: keys returned verbatim.
|
||||
vi.mock("react-i18next", () => ({
|
||||
|
||||
@@ -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<SourceView[]>(
|
||||
// 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<TriggerInfo[]>(
|
||||
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<SourceView[]>(
|
||||
() =>
|
||||
(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));
|
||||
|
||||
@@ -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<typeof baseRender>[0],
|
||||
options?: Parameters<typeof baseRender>[1],
|
||||
) => baseRender(ui, { wrapper: MantineProvider, ...options });
|
||||
) => baseRender(ui, { wrapper: PortalTestProviders, ...options });
|
||||
|
||||
// Deterministic i18n: keys returned verbatim.
|
||||
vi.mock("react-i18next", () => ({
|
||||
|
||||
@@ -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<PipelinesOverviewResponse>(() => fetchPipelines(), []);
|
||||
const state = usePipelines();
|
||||
const { data, loading } = state;
|
||||
const { isLoading } = useSectionFlags(state);
|
||||
|
||||
|
||||
@@ -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<PoliciesResponse>(() => 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.
|
||||
|
||||
@@ -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 }) => (
|
||||
<MantineProvider>
|
||||
<PortalTestProviders>
|
||||
<UIProvider>{children}</UIProvider>
|
||||
</MantineProvider>
|
||||
</PortalTestProviders>
|
||||
);
|
||||
|
||||
const render = (ui: Parameters<typeof baseRender>[0]) =>
|
||||
|
||||
@@ -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<Source | null>(
|
||||
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));
|
||||
|
||||
@@ -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<typeof baseRender>[0]) =>
|
||||
baseRender(ui, { wrapper: MantineProvider });
|
||||
baseRender(ui, { wrapper: PortalTestProviders });
|
||||
|
||||
// Deterministic i18n: keys returned verbatim.
|
||||
vi.mock("react-i18next", () => ({
|
||||
|
||||
@@ -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<SourcesResponse>(() => fetchSources(), []);
|
||||
const state = useSources();
|
||||
const { data, loading } = state;
|
||||
const { isLoading } = useSectionFlags(state);
|
||||
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
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(
|
||||
<MantineProvider>
|
||||
<QueryClientProvider client={client}>
|
||||
<MemoryRouter>
|
||||
<Users />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<MantineProvider>
|
||||
<PortalTestProviders>
|
||||
<MemoryRouter>
|
||||
<Users />
|
||||
</MemoryRouter>
|
||||
</MantineProvider>,
|
||||
</PortalTestProviders>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<UsersResponse>(
|
||||
() => usersBackend.fetchUsers(tier),
|
||||
[tier, refreshKey],
|
||||
);
|
||||
// Grants are ADMIN-only; skip the fetch entirely on flavors that can't manage them.
|
||||
const grantsState = useAsync<ResourceGrant[]>(
|
||||
() => (caps.manageGrants ? fetchGrants("PORTAL") : Promise.resolve([])),
|
||||
[tier, refreshKey],
|
||||
);
|
||||
const teamsState = useAsync<Team[]>(
|
||||
() => usersBackend.fetchTeams(),
|
||||
[tier, refreshKey],
|
||||
);
|
||||
const authState = useAsync<AdminAuthConfig>(
|
||||
() => usersBackend.fetchAuthConfig(),
|
||||
[],
|
||||
);
|
||||
const { usersState, grantsState, teamsState, authState, refresh } =
|
||||
useUsersData();
|
||||
|
||||
const [actionError, setActionError] = useState<string | null>(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));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<UsersResponse>;
|
||||
grantsState: AsyncState<ResourceGrant[]>;
|
||||
teamsState: AsyncState<Team[]>;
|
||||
authState: AsyncState<AdminAuthConfig>;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
// Grants are ADMIN-only; resolve empty on flavors that can't manage them.
|
||||
const fetchGrantsOrEmpty = (): Promise<ResourceGrant[]> =>
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<TeamDetailsDTO | null> {
|
||||
const teams = await apiClient.local.json<TeamDetailsDTO[]>("/api/v1/team/my");
|
||||
const fetchMy = () =>
|
||||
apiClient.local.json<TeamDetailsDTO[]>("/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) ??
|
||||
|
||||
Generated
+27
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user