mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 08:09:00 +00:00
fix(query): scope broadcast channel per account and clear on auth
Isolate TanStack Query broadcast by userId, dispose and clear on logout/401, and extract vendor filter drawers for governance.
This commit is contained in:
parent
c8a8522499
commit
e4370591ef
11 changed files with 505 additions and 275 deletions
|
|
@ -25,17 +25,23 @@ Use TanStack's own experimental broadcast client,
|
||||||
wired up in a single dedicated setup module,
|
wired up in a single dedicated setup module,
|
||||||
`src/lib/query/setup-query-broadcast.ts`. The module:
|
`src/lib/query/setup-query-broadcast.ts`. The module:
|
||||||
|
|
||||||
- Wraps `broadcastQueryClient({ queryClient, broadcastChannel })`, keyed to a
|
- Exposes `startQueryBroadcast(queryClient, userId)` and
|
||||||
single named channel (`seahaven-admin-query`).
|
`stopQueryBroadcast(queryClient)`.
|
||||||
- No-ops outside the browser (SSR/build) and under Vitest (`import.meta.env.MODE
|
- Keys `broadcastQueryClient` to an **account-scoped** channel
|
||||||
=== "test"`), so it never runs in unit tests or node-based tooling.
|
(`seahaven-admin-query:${userId}`), so tabs belonging to different accounts
|
||||||
- Is invoked once from `src/lib/query/query-client.ts` against the app's
|
on the same origin do not share cache traffic.
|
||||||
singleton `QueryClient`, so every tab that loads the SPA subscribes to the
|
- Disposes the prior subscription (the unsubscribe returned by
|
||||||
same channel automatically — no per-feature wiring required.
|
`broadcastQueryClient`) and clears the `QueryClient` when the authenticated
|
||||||
|
user changes or the session ends.
|
||||||
|
- No-ops the BroadcastChannel outside the browser (SSR/build) and under Vitest
|
||||||
|
(`import.meta.env.MODE === "test"`).
|
||||||
|
- Is started after authentication (`AuthProvider` session restore + login
|
||||||
|
success) and stopped on logout and HTTP 401 session clear — not at
|
||||||
|
`QueryClient` module load.
|
||||||
|
|
||||||
This piggybacks on the query cache we already have (no parallel store), uses
|
This piggybacks on the query cache we already have (no parallel store), uses
|
||||||
the library that owns the `QueryClient` we already depend on, and requires
|
the library that owns the `QueryClient` we already depend on, and keeps the
|
||||||
close to zero application code (~20 lines) to adopt.
|
integration isolated to auth/session boundaries.
|
||||||
|
|
||||||
### Alternatives considered
|
### Alternatives considered
|
||||||
|
|
||||||
|
|
@ -44,6 +50,9 @@ close to zero application code (~20 lines) to adopt.
|
||||||
serialization/versioning of query keys and mutation results, and keeping
|
serialization/versioning of query keys and mutation results, and keeping
|
||||||
every future mutation hook wired to broadcast. More code to own and more
|
every future mutation hook wired to broadcast. More code to own and more
|
||||||
surface area for subtle cache-desync bugs.
|
surface area for subtle cache-desync bugs.
|
||||||
|
- **Constant channel from module load** — simplest wiring, but shares one
|
||||||
|
channel across all sessions on the origin; after logout/login or multi-account
|
||||||
|
use, cached work-order/vendor data can leak into the next session. Rejected.
|
||||||
- **No cross-tab sync** — simplest option, but leaves the stale-tab UX problem
|
- **No cross-tab sync** — simplest option, but leaves the stale-tab UX problem
|
||||||
unresolved; users would need to manually refresh or wait for
|
unresolved; users would need to manually refresh or wait for
|
||||||
`refetchOnWindowFocus`/`staleTime` to catch up, which is not reliable enough
|
`refetchOnWindowFocus`/`staleTime` to catch up, which is not reliable enough
|
||||||
|
|
@ -56,26 +65,27 @@ close to zero application code (~20 lines) to adopt.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
- **Positive**: sibling tabs reflect mutations (status changes, dispatch
|
- **Positive**: sibling tabs for the same authenticated account reflect
|
||||||
actions, comments, patches) without a manual refresh; the integration is
|
mutations (status changes, dispatch actions, comments, patches) without a
|
||||||
isolated to one setup file and does not touch domain/query-key code.
|
manual refresh; logout/401 tear down the broadcaster and clear sensitive
|
||||||
|
cache so the next account cannot inherit prior data.
|
||||||
- **Risk — package lifecycle**: the dependency is explicitly "experimental" in
|
- **Risk — package lifecycle**: the dependency is explicitly "experimental" in
|
||||||
the TanStack ecosystem; its API may change or be deprecated between minor
|
the TanStack ecosystem; its API may change or be deprecated between minor
|
||||||
versions. `@tanstack/query-broadcast-client-experimental`,
|
versions. `@tanstack/query-broadcast-client-experimental`,
|
||||||
`@tanstack/react-query`, and `@tanstack/react-query-devtools` are pinned to
|
`@tanstack/react-query`, and `@tanstack/react-query-devtools` are pinned to
|
||||||
the **exact same version** in `package.json` (no `^` range). All three
|
the **exact same version** in `package.json` (no `^` range). All three
|
||||||
resolve to the identical `@tanstack/query-core` version at that pin, which
|
resolve to the identical `@tanstack/query-core` version at that pin, which
|
||||||
keeps the `QueryClient` type used by `setupQueryBroadcast` structurally
|
keeps the `QueryClient` type used by the broadcast helpers structurally
|
||||||
identical to the one constructed in `query-client.ts` — a caret range lets
|
identical to the one constructed in `query-client.ts` — a caret range lets
|
||||||
npm resolve the broadcast client and React Query against two different
|
npm resolve the broadcast client and React Query against two different
|
||||||
`query-core` versions independently, which breaks `QueryClient` type
|
`query-core` versions independently, which breaks `QueryClient` type
|
||||||
identity (TS2322) even though both packages build fine in isolation. Bump
|
identity (TS2322) even though both packages build fine in isolation. Bump
|
||||||
all three together and re-evaluate on every TanStack Query upgrade.
|
all three together and re-evaluate on every TanStack Query upgrade.
|
||||||
- **Risk — same-origin only**: `BroadcastChannel` only syncs tabs on the same
|
- **Risk — same-origin only**: `BroadcastChannel` only syncs tabs on the same
|
||||||
origin; it does not sync across different users/sessions or devices. This is
|
origin; it does not sync across different users/sessions or devices. Account
|
||||||
acceptable for the current requirement (single user, multiple tabs).
|
scoping further limits sync to tabs of the same `userId`.
|
||||||
- **Rollback plan**: remove the `@tanstack/query-broadcast-client-experimental`
|
- **Rollback plan**: remove the `@tanstack/query-broadcast-client-experimental`
|
||||||
dependency from `package.json` and delete the call to `setupQueryBroadcast`
|
dependency from `package.json`, delete `setup-query-broadcast.ts`, and remove
|
||||||
in `src/lib/query/query-client.ts` (and the `setup-query-broadcast.ts` module
|
the `startQueryBroadcast` / `stopQueryBroadcast` call sites in auth and
|
||||||
itself). No other code depends on it, so rollback is a single, isolated
|
`api.ts`. No other code depends on it, so rollback is isolated with no data
|
||||||
change with no data migration.
|
migration.
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,13 @@ import { env } from "@/lib/env";
|
||||||
import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url";
|
import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url";
|
||||||
import { API_PATHS } from "@/api/api-paths";
|
import { API_PATHS } from "@/api/api-paths";
|
||||||
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
|
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
|
||||||
import { queryKeys } from "@/infra/query-key/query-key";
|
|
||||||
import queryClient from "@/lib/query/query-client";
|
import queryClient from "@/lib/query/query-client";
|
||||||
|
import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast";
|
||||||
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
|
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
|
||||||
|
|
||||||
function clearSessionAndRedirectToLogin(): void {
|
function clearSessionAndRedirectToLogin(): void {
|
||||||
clearAuth();
|
clearAuth();
|
||||||
queryClient.setQueryData(queryKeys.auth.session(), null);
|
stopQueryBroadcast(queryClient);
|
||||||
|
|
||||||
if (typeof window !== "undefined" && window.location.pathname !== "/login") {
|
if (typeof window !== "undefined" && window.location.pathname !== "/login") {
|
||||||
window.location.replace("/login");
|
window.location.replace("/login");
|
||||||
|
|
|
||||||
248
src/app/(protected)/vendors/_components/vendor-filters-drawer-fields.tsx
vendored
Normal file
248
src/app/(protected)/vendors/_components/vendor-filters-drawer-fields.tsx
vendored
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
|
||||||
|
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
|
||||||
|
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
|
||||||
|
import TagOutlinedIcon from "@mui/icons-material/TagOutlined";
|
||||||
|
import { Autocomplete, Button, Checkbox, Chip, Stack, TextField, Typography } from "@mui/material";
|
||||||
|
import type {
|
||||||
|
VendorFacetCompany,
|
||||||
|
VendorFacetJobBucket,
|
||||||
|
VendorFacetLocation,
|
||||||
|
} from "@/domain/vendors/types/vendor";
|
||||||
|
import type { VendorFilters } from "./vendor-filters";
|
||||||
|
import { FilterSection } from "./vendor-filters-drawer-section";
|
||||||
|
|
||||||
|
interface VendorFiltersDrawerFieldsProps {
|
||||||
|
draft: VendorFilters;
|
||||||
|
setDraft: Dispatch<SetStateAction<VendorFilters>>;
|
||||||
|
options: {
|
||||||
|
companies: VendorFacetCompany[];
|
||||||
|
trades: string[];
|
||||||
|
locations: VendorFacetLocation[];
|
||||||
|
jobBuckets: VendorFacetJobBucket[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompanyFilterFields({
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
|
companyOptions,
|
||||||
|
}: {
|
||||||
|
draft: VendorFilters;
|
||||||
|
setDraft: Dispatch<SetStateAction<VendorFilters>>;
|
||||||
|
companyOptions: VendorFacetCompany[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FilterSection title="Company" icon={<BusinessOutlinedIcon />}>
|
||||||
|
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
Select vendor companies
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
companies:
|
||||||
|
current.companies.length === companyOptions.length
|
||||||
|
? []
|
||||||
|
: companyOptions.map((company) => company.name),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{draft.companies.length === companyOptions.length && companyOptions.length > 0
|
||||||
|
? "Clear all"
|
||||||
|
: "Select all"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
size="small"
|
||||||
|
options={companyOptions}
|
||||||
|
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
|
||||||
|
isOptionEqualToValue={(option, value) =>
|
||||||
|
typeof option === "string" || typeof value === "string"
|
||||||
|
? option === value
|
||||||
|
: option.name === value.name
|
||||||
|
}
|
||||||
|
value={companyOptions.filter((option) => draft.companies.includes(option.name))}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
const next = (value as (string | VendorFacetCompany)[]).map((entry) =>
|
||||||
|
typeof entry === "string" ? entry : entry.name,
|
||||||
|
);
|
||||||
|
setDraft((current) => ({ ...current, companies: next }));
|
||||||
|
}}
|
||||||
|
renderOption={(props, option, { selected }) => {
|
||||||
|
const { key, ...optionProps } = props;
|
||||||
|
return (
|
||||||
|
<li key={key} {...optionProps}>
|
||||||
|
<Checkbox checked={selected} size="small" sx={{ mr: 1 }} />
|
||||||
|
{option.name}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderValue={(value, getItemProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const label = typeof option === "string" ? option : option.name;
|
||||||
|
const tagProps = getItemProps({ index });
|
||||||
|
const { key, ...chipProps } = tagProps;
|
||||||
|
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={draft.companies.length === 0 ? "All companies" : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FilterSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TradeFilterFields({
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
|
tradeOptions,
|
||||||
|
}: {
|
||||||
|
draft: VendorFilters;
|
||||||
|
setDraft: Dispatch<SetStateAction<VendorFilters>>;
|
||||||
|
tradeOptions: string[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FilterSection title="Trade" icon={<BuildOutlinedIcon />}>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
size="small"
|
||||||
|
options={tradeOptions}
|
||||||
|
value={draft.trades}
|
||||||
|
onChange={(_event, value) =>
|
||||||
|
setDraft((current) => ({ ...current, trades: value as string[] }))
|
||||||
|
}
|
||||||
|
renderValue={(value, getItemProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const tagProps = getItemProps({ index });
|
||||||
|
const { key, ...chipProps } = tagProps;
|
||||||
|
return <Chip key={key} size="small" label={option} {...chipProps} />;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} placeholder={draft.trades.length === 0 ? "All trades" : ""} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FilterSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocationFilterFields({
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
|
locationOptions,
|
||||||
|
}: {
|
||||||
|
draft: VendorFilters;
|
||||||
|
setDraft: Dispatch<SetStateAction<VendorFilters>>;
|
||||||
|
locationOptions: VendorFacetLocation[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FilterSection title="Location" icon={<LocationOnOutlinedIcon />}>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
size="small"
|
||||||
|
options={locationOptions}
|
||||||
|
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
||||||
|
isOptionEqualToValue={(option, value) =>
|
||||||
|
typeof option === "string" || typeof value === "string"
|
||||||
|
? option === value
|
||||||
|
: option.label === value.label
|
||||||
|
}
|
||||||
|
value={locationOptions.filter((option) => draft.locations.includes(option.label))}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
const next = (value as (string | VendorFacetLocation)[]).map((entry) =>
|
||||||
|
typeof entry === "string" ? entry : entry.label,
|
||||||
|
);
|
||||||
|
setDraft((current) => ({ ...current, locations: next }));
|
||||||
|
}}
|
||||||
|
renderValue={(value, getItemProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const label = typeof option === "string" ? option : option.label;
|
||||||
|
const tagProps = getItemProps({ index });
|
||||||
|
const { key, ...chipProps } = tagProps;
|
||||||
|
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={draft.locations.length === 0 ? "All locations" : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FilterSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function JobBucketFilterFields({
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
|
jobBucketOptions,
|
||||||
|
}: {
|
||||||
|
draft: VendorFilters;
|
||||||
|
setDraft: Dispatch<SetStateAction<VendorFilters>>;
|
||||||
|
jobBucketOptions: VendorFacetJobBucket[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FilterSection title="Total Jobs" icon={<TagOutlinedIcon />}>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
size="small"
|
||||||
|
options={jobBucketOptions}
|
||||||
|
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
||||||
|
isOptionEqualToValue={(option, value) =>
|
||||||
|
typeof option === "string" || typeof value === "string"
|
||||||
|
? option === value
|
||||||
|
: option.id === value.id
|
||||||
|
}
|
||||||
|
value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
const next = (value as (string | VendorFacetJobBucket)[]).map((entry) =>
|
||||||
|
typeof entry === "string" ? entry : entry.id,
|
||||||
|
);
|
||||||
|
setDraft((current) => ({ ...current, jobBuckets: next }));
|
||||||
|
}}
|
||||||
|
renderValue={(value, getItemProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const label = typeof option === "string" ? option : option.label;
|
||||||
|
const tagProps = getItemProps({ index });
|
||||||
|
const { key, ...chipProps } = tagProps;
|
||||||
|
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={draft.jobBuckets.length === 0 ? "All job counts" : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FilterSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorFiltersDrawerFields({
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
|
options,
|
||||||
|
}: VendorFiltersDrawerFieldsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CompanyFilterFields draft={draft} setDraft={setDraft} companyOptions={options.companies} />
|
||||||
|
<TradeFilterFields draft={draft} setDraft={setDraft} tradeOptions={options.trades} />
|
||||||
|
<LocationFilterFields draft={draft} setDraft={setDraft} locationOptions={options.locations} />
|
||||||
|
<JobBucketFilterFields
|
||||||
|
draft={draft}
|
||||||
|
setDraft={setDraft}
|
||||||
|
jobBucketOptions={options.jobBuckets}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/app/(protected)/vendors/_components/vendor-filters-drawer-section.tsx
vendored
Normal file
36
src/app/(protected)/vendors/_components/vendor-filters-drawer-section.tsx
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionDetails,
|
||||||
|
AccordionSummary,
|
||||||
|
Box,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
|
||||||
|
export function FilterSection({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Accordion disableGutters elevation={0} square sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${title}-filters-content`}>
|
||||||
|
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
||||||
|
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
|
||||||
|
<Typography variant="subtitle1" className="font-semibold">
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails id={`${title}-filters-content`} sx={{ pt: 0, pb: 2.5 }}>
|
||||||
|
{children}
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,32 +1,9 @@
|
||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
|
|
||||||
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
|
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
import { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material";
|
||||||
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
|
import type { VendorFacets } from "@/domain/vendors/types/vendor";
|
||||||
import TagOutlinedIcon from "@mui/icons-material/TagOutlined";
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionDetails,
|
|
||||||
AccordionSummary,
|
|
||||||
Autocomplete,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
Chip,
|
|
||||||
Drawer,
|
|
||||||
IconButton,
|
|
||||||
Stack,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import type {
|
|
||||||
VendorFacets,
|
|
||||||
VendorFacetCompany,
|
|
||||||
VendorFacetJobBucket,
|
|
||||||
VendorFacetLocation,
|
|
||||||
} from "@/domain/vendors/types/vendor";
|
|
||||||
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
|
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
|
||||||
|
import { VendorFiltersDrawerFields } from "./vendor-filters-drawer-fields";
|
||||||
|
|
||||||
interface VendorFiltersDrawerProps {
|
interface VendorFiltersDrawerProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -38,32 +15,6 @@ interface VendorFiltersDrawerProps {
|
||||||
|
|
||||||
const DRAWER_WIDTH = 360;
|
const DRAWER_WIDTH = 360;
|
||||||
|
|
||||||
function FilterSection({
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
icon: ReactNode;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Accordion disableGutters elevation={0} square sx={{ borderBottom: 1, borderColor: "divider" }}>
|
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${title}-filters-content`}>
|
|
||||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
|
|
||||||
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
|
|
||||||
<Typography variant="subtitle1" className="font-semibold">
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails id={`${title}-filters-content`} sx={{ pt: 0, pb: 2.5 }}>
|
|
||||||
{children}
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VendorFiltersDrawer({
|
export function VendorFiltersDrawer({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -125,7 +76,7 @@ export function VendorFiltersDrawer({
|
||||||
<Typography id="vendor-filters-drawer-title" variant="h6" component="h2">
|
<Typography id="vendor-filters-drawer-title" variant="h6" component="h2">
|
||||||
Filters
|
Filters
|
||||||
</Typography>
|
</Typography>
|
||||||
{activeCount > 0 && <Chip size="small" color="primary" label={activeCount} />}
|
{Boolean(activeCount > 0) && <Chip size="small" color="primary" label={activeCount} />}
|
||||||
</Stack>
|
</Stack>
|
||||||
<IconButton aria-label="Close filters" onClick={onClose} size="small">
|
<IconButton aria-label="Close filters" onClick={onClose} size="small">
|
||||||
<CloseIcon fontSize="small" />
|
<CloseIcon fontSize="small" />
|
||||||
|
|
@ -133,165 +84,16 @@ export function VendorFiltersDrawer({
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Box className="flex-1 overflow-y-auto" sx={{ px: 2.5 }}>
|
<Box className="flex-1 overflow-y-auto" sx={{ px: 2.5 }}>
|
||||||
<FilterSection title="Company" icon={<BusinessOutlinedIcon />}>
|
<VendorFiltersDrawerFields
|
||||||
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
draft={draft}
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
setDraft={setDraft}
|
||||||
Select vendor companies
|
options={{
|
||||||
</Typography>
|
companies: companyOptions,
|
||||||
<Button
|
trades: tradeOptions,
|
||||||
size="small"
|
locations: locationOptions,
|
||||||
onClick={() =>
|
jobBuckets: jobBucketOptions,
|
||||||
setDraft((current) => ({
|
}}
|
||||||
...current,
|
/>
|
||||||
companies:
|
|
||||||
current.companies.length === companyOptions.length
|
|
||||||
? []
|
|
||||||
: companyOptions.map((company) => company.name),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{draft.companies.length === companyOptions.length && companyOptions.length > 0
|
|
||||||
? "Clear all"
|
|
||||||
: "Select all"}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
<Autocomplete
|
|
||||||
multiple
|
|
||||||
size="small"
|
|
||||||
options={companyOptions}
|
|
||||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
|
|
||||||
isOptionEqualToValue={(option, value) =>
|
|
||||||
typeof option === "string" || typeof value === "string"
|
|
||||||
? option === value
|
|
||||||
: option.name === value.name
|
|
||||||
}
|
|
||||||
value={companyOptions.filter((option) => draft.companies.includes(option.name))}
|
|
||||||
onChange={(_event, value) => {
|
|
||||||
const next = (value as (string | VendorFacetCompany)[]).map((entry) =>
|
|
||||||
typeof entry === "string" ? entry : entry.name,
|
|
||||||
);
|
|
||||||
setDraft((current) => ({ ...current, companies: next }));
|
|
||||||
}}
|
|
||||||
renderOption={(props, option, { selected }) => {
|
|
||||||
const { key, ...optionProps } = props;
|
|
||||||
return (
|
|
||||||
<li key={key} {...optionProps}>
|
|
||||||
<Checkbox checked={selected} size="small" sx={{ mr: 1 }} />
|
|
||||||
{option.name}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
renderValue={(value, getItemProps) =>
|
|
||||||
value.map((option, index) => {
|
|
||||||
const label = typeof option === "string" ? option : option.name;
|
|
||||||
const tagProps = getItemProps({ index });
|
|
||||||
const { key, ...chipProps } = tagProps;
|
|
||||||
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
placeholder={draft.companies.length === 0 ? "All companies" : ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FilterSection>
|
|
||||||
|
|
||||||
<FilterSection title="Trade" icon={<BuildOutlinedIcon />}>
|
|
||||||
<Autocomplete
|
|
||||||
multiple
|
|
||||||
size="small"
|
|
||||||
options={tradeOptions}
|
|
||||||
value={draft.trades}
|
|
||||||
onChange={(_event, value) =>
|
|
||||||
setDraft((current) => ({ ...current, trades: value as string[] }))
|
|
||||||
}
|
|
||||||
renderValue={(value, getItemProps) =>
|
|
||||||
value.map((option, index) => {
|
|
||||||
const tagProps = getItemProps({ index });
|
|
||||||
const { key, ...chipProps } = tagProps;
|
|
||||||
return <Chip key={key} size="small" label={option} {...chipProps} />;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
placeholder={draft.trades.length === 0 ? "All trades" : ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FilterSection>
|
|
||||||
|
|
||||||
<FilterSection title="Location" icon={<LocationOnOutlinedIcon />}>
|
|
||||||
<Autocomplete
|
|
||||||
multiple
|
|
||||||
size="small"
|
|
||||||
options={locationOptions}
|
|
||||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
|
||||||
isOptionEqualToValue={(option, value) =>
|
|
||||||
typeof option === "string" || typeof value === "string"
|
|
||||||
? option === value
|
|
||||||
: option.label === value.label
|
|
||||||
}
|
|
||||||
value={locationOptions.filter((option) => draft.locations.includes(option.label))}
|
|
||||||
onChange={(_event, value) => {
|
|
||||||
const next = (value as (string | VendorFacetLocation)[]).map((entry) =>
|
|
||||||
typeof entry === "string" ? entry : entry.label,
|
|
||||||
);
|
|
||||||
setDraft((current) => ({ ...current, locations: next }));
|
|
||||||
}}
|
|
||||||
renderValue={(value, getItemProps) =>
|
|
||||||
value.map((option, index) => {
|
|
||||||
const label = typeof option === "string" ? option : option.label;
|
|
||||||
const tagProps = getItemProps({ index });
|
|
||||||
const { key, ...chipProps } = tagProps;
|
|
||||||
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
placeholder={draft.locations.length === 0 ? "All locations" : ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FilterSection>
|
|
||||||
|
|
||||||
<FilterSection title="Total Jobs" icon={<TagOutlinedIcon />}>
|
|
||||||
<Autocomplete
|
|
||||||
multiple
|
|
||||||
size="small"
|
|
||||||
options={jobBucketOptions}
|
|
||||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
|
|
||||||
isOptionEqualToValue={(option, value) =>
|
|
||||||
typeof option === "string" || typeof value === "string"
|
|
||||||
? option === value
|
|
||||||
: option.id === value.id
|
|
||||||
}
|
|
||||||
value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))}
|
|
||||||
onChange={(_event, value) => {
|
|
||||||
const next = (value as (string | VendorFacetJobBucket)[]).map((entry) =>
|
|
||||||
typeof entry === "string" ? entry : entry.id,
|
|
||||||
);
|
|
||||||
setDraft((current) => ({ ...current, jobBuckets: next }));
|
|
||||||
}}
|
|
||||||
renderValue={(value, getItemProps) =>
|
|
||||||
value.map((option, index) => {
|
|
||||||
const label = typeof option === "string" ? option : option.label;
|
|
||||||
const tagProps = getItemProps({ index });
|
|
||||||
const { key, ...chipProps } = tagProps;
|
|
||||||
return <Chip key={key} size="small" label={label} {...chipProps} />;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
placeholder={draft.jobBuckets.length === 0 ? "All job counts" : ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FilterSection>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Stack
|
<Stack
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,37 @@ function PageShell({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RosterLoadErrorState({ onBack }: { onBack: () => void }) {
|
||||||
|
return (
|
||||||
|
<PageShell title="Vendor company" subtitle="Edit the company and its technicians.">
|
||||||
|
<Alert severity="error">Failed to load vendor company. Try again.</Alert>
|
||||||
|
<Stack direction="row" sx={{ justifyContent: "flex-end" }}>
|
||||||
|
<Button variant="outlined" onClick={onBack}>
|
||||||
|
Back to vendors
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rosterPageCopy(isEdit: boolean): { title: string; subtitle: string } {
|
||||||
|
if (isEdit) {
|
||||||
|
return {
|
||||||
|
title: "Edit Vendor Company",
|
||||||
|
subtitle: "Update company details and technicians in one save.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: "Add Vendor Company",
|
||||||
|
subtitle: "Register a company and add zero, one, or multiple technicians.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitLabel(isSaving: boolean, isEdit: boolean): string {
|
||||||
|
if (isSaving) return "Saving...";
|
||||||
|
return isEdit ? "Save" : "Create";
|
||||||
|
}
|
||||||
|
|
||||||
export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPageProps) {
|
export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPageProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isEdit = vendorId !== undefined || companyId !== undefined;
|
const isEdit = vendorId !== undefined || companyId !== undefined;
|
||||||
|
|
@ -94,26 +125,14 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEdit && form.isError) {
|
if (isEdit && form.isError) {
|
||||||
return (
|
return <RosterLoadErrorState onBack={() => navigate("/vendors")} />;
|
||||||
<PageShell title="Vendor company" subtitle="Edit the company and its technicians.">
|
|
||||||
<Alert severity="error">Failed to load vendor company. Try again.</Alert>
|
|
||||||
<Stack direction="row" sx={{ justifyContent: "flex-end" }}>
|
|
||||||
<Button variant="outlined" onClick={() => navigate("/vendors")}>
|
|
||||||
Back to vendors
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</PageShell>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEdit && !form.roster) {
|
if (isEdit && !form.roster) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = isEdit ? "Edit Vendor Company" : "Add Vendor Company";
|
const { title, subtitle } = rosterPageCopy(isEdit);
|
||||||
const subtitle = isEdit
|
|
||||||
? "Update company details and technicians in one save."
|
|
||||||
: "Register a company and add zero, one, or multiple technicians.";
|
|
||||||
const companySelectionProps = isEdit
|
const companySelectionProps = isEdit
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
|
|
@ -122,6 +141,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
|
||||||
onSelectCompany: form.selectCompany,
|
onSelectCompany: form.selectCompany,
|
||||||
onClearSelectedCompany: form.clearSelectedCompany,
|
onClearSelectedCompany: form.clearSelectedCompany,
|
||||||
};
|
};
|
||||||
|
const showPortalToken = Boolean(isEdit && vendorId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell title={title} subtitle={subtitle}>
|
<PageShell title={title} subtitle={subtitle}>
|
||||||
|
|
@ -143,7 +163,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
|
||||||
{...companySelectionProps}
|
{...companySelectionProps}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isEdit && vendorId !== undefined && vendorId !== "" && (
|
{showPortalToken && vendorId !== undefined && (
|
||||||
<VendorPortalTokenPanel vendorId={vendorId} />
|
<VendorPortalTokenPanel vendorId={vendorId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -156,7 +176,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" variant="contained" disabled={form.isSaving || !form.isValid}>
|
<Button type="submit" variant="contained" disabled={form.isSaving || !form.isValid}>
|
||||||
{form.isSaving ? "Saving..." : isEdit ? "Save" : "Create"}
|
{submitLabel(form.isSaving, isEdit)}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { toast } from "react-toastify";
|
||||||
import * as authApi from "@/domain/auth/api/auth-api";
|
import * as authApi from "@/domain/auth/api/auth-api";
|
||||||
import type { AuthUser, LoginCredentials } from "@/domain/auth/types/auth-user";
|
import type { AuthUser, LoginCredentials } from "@/domain/auth/types/auth-user";
|
||||||
import { queryKeys } from "@/infra/query-key/query-key";
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
import { startQueryBroadcast, stopQueryBroadcast } from "@/lib/query/setup-query-broadcast";
|
||||||
|
|
||||||
export function useSession(): UseQueryResult<AuthUser | null, Error> {
|
export function useSession(): UseQueryResult<AuthUser | null, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|
@ -28,7 +29,9 @@ export function useLogin(): UseMutationResult<AuthUser, Error, LoginCredentials>
|
||||||
mutationFn: (credentials: LoginCredentials) => authApi.login(credentials),
|
mutationFn: (credentials: LoginCredentials) => authApi.login(credentials),
|
||||||
meta: { suppressErrorToast: true },
|
meta: { suppressErrorToast: true },
|
||||||
onSuccess: (user) => {
|
onSuccess: (user) => {
|
||||||
|
stopQueryBroadcast(queryClient);
|
||||||
queryClient.setQueryData(queryKeys.auth.session(), user);
|
queryClient.setQueryData(queryKeys.auth.session(), user);
|
||||||
|
startQueryBroadcast(queryClient, user.id);
|
||||||
toast.success("Login successful!");
|
toast.success("Login successful!");
|
||||||
navigate("/");
|
navigate("/");
|
||||||
},
|
},
|
||||||
|
|
@ -42,13 +45,12 @@ export function useLogout(): UseMutationResult<void, Error, void> {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: authApi.logout,
|
mutationFn: authApi.logout,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.setQueryData(queryKeys.auth.session(), null);
|
stopQueryBroadcast(queryClient);
|
||||||
queryClient.removeQueries({ queryKey: queryKeys.auth.all });
|
|
||||||
toast.info("You have been logged out");
|
toast.info("You have been logged out");
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
queryClient.setQueryData(queryKeys.auth.session(), null);
|
stopQueryBroadcast(queryClient);
|
||||||
toast.error(error.message || "Logout failed");
|
toast.error(error.message || "Logout failed");
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { setupQueryBroadcast } from "@/lib/query/setup-query-broadcast";
|
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
queryCache: new QueryCache({
|
queryCache: new QueryCache({
|
||||||
|
|
@ -31,6 +30,4 @@ export const queryClient = new QueryClient({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setupQueryBroadcast(queryClient);
|
|
||||||
|
|
||||||
export default queryClient;
|
export default queryClient;
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,60 @@
|
||||||
import { broadcastQueryClient } from "@tanstack/query-broadcast-client-experimental";
|
import { broadcastQueryClient } from "@tanstack/query-broadcast-client-experimental";
|
||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
/** Same-origin channel name for cross-tab QueryClient sync. */
|
/** Prefix for same-origin BroadcastChannel names (suffixed with authenticated user id). */
|
||||||
export const QUERY_BROADCAST_CHANNEL = "seahaven-admin-query";
|
export const QUERY_BROADCAST_CHANNEL_PREFIX = "seahaven-admin-query";
|
||||||
|
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
let activeUserId: string | number | null = null;
|
||||||
|
|
||||||
|
/** Builds the account-scoped channel name for cross-tab QueryClient sync. */
|
||||||
|
export function queryBroadcastChannelForUser(userId: string | number): string {
|
||||||
|
return `${QUERY_BROADCAST_CHANNEL_PREFIX}:${String(userId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeBroadcast(): void {
|
||||||
|
unsubscribe?.();
|
||||||
|
unsubscribe = null;
|
||||||
|
activeUserId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUseBroadcast(): boolean {
|
||||||
|
return typeof window !== "undefined" && import.meta.env.MODE !== "test";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Syncs QueryClient cache (invalidations + data) across browser tabs via BroadcastChannel.
|
* Starts (or switches) account-scoped QueryClient broadcast.
|
||||||
* No-op outside the browser and during Vitest runs.
|
* Disposes any prior subscription; clears the cache when the authenticated user changes.
|
||||||
|
* No-op for the BroadcastChannel outside the browser and during Vitest runs.
|
||||||
*/
|
*/
|
||||||
export function setupQueryBroadcast(queryClient: QueryClient): void {
|
export function startQueryBroadcast(queryClient: QueryClient, userId: string | number): void {
|
||||||
if (typeof window === "undefined") return;
|
if (activeUserId === userId) {
|
||||||
if (import.meta.env.MODE === "test") return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
broadcastQueryClient({
|
const previousUserId = activeUserId;
|
||||||
|
disposeBroadcast();
|
||||||
|
|
||||||
|
if (previousUserId != null && previousUserId !== userId) {
|
||||||
|
queryClient.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
activeUserId = userId;
|
||||||
|
|
||||||
|
if (!canUseBroadcast()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe = broadcastQueryClient({
|
||||||
queryClient,
|
queryClient,
|
||||||
broadcastChannel: QUERY_BROADCAST_CHANNEL,
|
broadcastChannel: queryBroadcastChannelForUser(userId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tears down the broadcaster and clears the entire QueryClient cache (logout / 401).
|
||||||
|
*/
|
||||||
|
export function stopQueryBroadcast(queryClient: QueryClient): void {
|
||||||
|
disposeBroadcast();
|
||||||
|
queryClient.clear();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,26 @@
|
||||||
import { useMemo, type ReactNode } from "react";
|
import { useEffect, useMemo, type ReactNode } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useChangePassword } from "@/domain/auth/use-cases/use-change-password";
|
import { useChangePassword } from "@/domain/auth/use-cases/use-change-password";
|
||||||
import { useLogin, useLogout, useSession } from "@/domain/auth/use-cases/use-login";
|
import { useLogin, useLogout, useSession } from "@/domain/auth/use-cases/use-login";
|
||||||
|
import { startQueryBroadcast } from "@/lib/query/setup-query-broadcast";
|
||||||
import { AuthContext, type AuthContextValue } from "@/providers/auth-context";
|
import { AuthContext, type AuthContextValue } from "@/providers/auth-context";
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const sessionQuery = useSession();
|
const sessionQuery = useSession();
|
||||||
const loginMutation = useLogin();
|
const loginMutation = useLogin();
|
||||||
const logoutMutation = useLogout();
|
const logoutMutation = useLogout();
|
||||||
const changePasswordMutation = useChangePassword();
|
const changePasswordMutation = useChangePassword();
|
||||||
|
|
||||||
|
const userId = sessionQuery.data?.id;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (userId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startQueryBroadcast(queryClient, userId);
|
||||||
|
}, [queryClient, userId]);
|
||||||
|
|
||||||
const value = useMemo<AuthContextValue>(
|
const value = useMemo<AuthContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
user: sessionQuery.data,
|
user: sessionQuery.data,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
const disposeA = vi.fn();
|
||||||
|
const disposeB = vi.fn();
|
||||||
const broadcastQueryClient = vi.fn();
|
const broadcastQueryClient = vi.fn();
|
||||||
|
|
||||||
vi.mock("@tanstack/query-broadcast-client-experimental", () => ({
|
vi.mock("@tanstack/query-broadcast-client-experimental", () => ({
|
||||||
broadcastQueryClient: (...args: unknown[]) => broadcastQueryClient(...args),
|
broadcastQueryClient: (...args: unknown[]) => broadcastQueryClient(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("setupQueryBroadcast", () => {
|
describe("query broadcast account isolation", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
broadcastQueryClient.mockClear();
|
broadcastQueryClient.mockReset();
|
||||||
|
disposeA.mockReset();
|
||||||
|
disposeB.mockReset();
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -19,29 +23,87 @@ describe("setupQueryBroadcast", () => {
|
||||||
|
|
||||||
it("does not call broadcastQueryClient when MODE is test", async () => {
|
it("does not call broadcastQueryClient when MODE is test", async () => {
|
||||||
vi.stubEnv("MODE", "test");
|
vi.stubEnv("MODE", "test");
|
||||||
const { setupQueryBroadcast } = await import("@/lib/query/setup-query-broadcast");
|
vi.resetModules();
|
||||||
|
|
||||||
|
const { startQueryBroadcast } = await import("@/lib/query/setup-query-broadcast");
|
||||||
const client = new QueryClient();
|
const client = new QueryClient();
|
||||||
|
|
||||||
setupQueryBroadcast(client);
|
startQueryBroadcast(client, "user-a");
|
||||||
|
|
||||||
expect(broadcastQueryClient).not.toHaveBeenCalled();
|
expect(broadcastQueryClient).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls broadcastQueryClient with the app channel when MODE is not test", async () => {
|
it("scopes the BroadcastChannel to the authenticated user id", async () => {
|
||||||
vi.stubEnv("MODE", "development");
|
vi.stubEnv("MODE", "development");
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
broadcastQueryClient.mockReturnValue(disposeA);
|
||||||
|
|
||||||
const { setupQueryBroadcast, QUERY_BROADCAST_CHANNEL } =
|
const { startQueryBroadcast, queryBroadcastChannelForUser, QUERY_BROADCAST_CHANNEL_PREFIX } =
|
||||||
await import("@/lib/query/setup-query-broadcast");
|
await import("@/lib/query/setup-query-broadcast");
|
||||||
const client = new QueryClient();
|
const client = new QueryClient();
|
||||||
|
|
||||||
setupQueryBroadcast(client);
|
startQueryBroadcast(client, 42);
|
||||||
|
|
||||||
|
expect(QUERY_BROADCAST_CHANNEL_PREFIX).toBe("seahaven-admin-query");
|
||||||
|
expect(queryBroadcastChannelForUser(42)).toBe("seahaven-admin-query:42");
|
||||||
expect(broadcastQueryClient).toHaveBeenCalledTimes(1);
|
expect(broadcastQueryClient).toHaveBeenCalledTimes(1);
|
||||||
expect(broadcastQueryClient).toHaveBeenCalledWith({
|
expect(broadcastQueryClient).toHaveBeenCalledWith({
|
||||||
queryClient: client,
|
queryClient: client,
|
||||||
broadcastChannel: QUERY_BROADCAST_CHANNEL,
|
broadcastChannel: "seahaven-admin-query:42",
|
||||||
});
|
});
|
||||||
expect(QUERY_BROADCAST_CHANNEL).toBe("seahaven-admin-query");
|
});
|
||||||
|
|
||||||
|
it("disposes the prior broadcaster, clears cache, and switches channel across accounts", async () => {
|
||||||
|
vi.stubEnv("MODE", "development");
|
||||||
|
vi.resetModules();
|
||||||
|
broadcastQueryClient.mockReturnValueOnce(disposeA).mockReturnValueOnce(disposeB);
|
||||||
|
|
||||||
|
const { startQueryBroadcast, stopQueryBroadcast } =
|
||||||
|
await import("@/lib/query/setup-query-broadcast");
|
||||||
|
const client = new QueryClient();
|
||||||
|
const clearSpy = vi.spyOn(client, "clear");
|
||||||
|
|
||||||
|
client.setQueryData(["work-orders", "sensitive"], { from: "user-a" });
|
||||||
|
|
||||||
|
startQueryBroadcast(client, "user-a");
|
||||||
|
expect(broadcastQueryClient).toHaveBeenCalledWith({
|
||||||
|
queryClient: client,
|
||||||
|
broadcastChannel: "seahaven-admin-query:user-a",
|
||||||
|
});
|
||||||
|
|
||||||
|
startQueryBroadcast(client, "user-b");
|
||||||
|
|
||||||
|
expect(disposeA).toHaveBeenCalledTimes(1);
|
||||||
|
expect(clearSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(client.getQueryData(["work-orders", "sensitive"])).toBeUndefined();
|
||||||
|
expect(broadcastQueryClient).toHaveBeenCalledTimes(2);
|
||||||
|
expect(broadcastQueryClient).toHaveBeenLastCalledWith({
|
||||||
|
queryClient: client,
|
||||||
|
broadcastChannel: "seahaven-admin-query:user-b",
|
||||||
|
});
|
||||||
|
|
||||||
|
client.setQueryData(["vendors", "roster"], { from: "user-b" });
|
||||||
|
stopQueryBroadcast(client);
|
||||||
|
|
||||||
|
expect(disposeB).toHaveBeenCalledTimes(1);
|
||||||
|
expect(clearSpy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(client.getQueryData(["vendors", "roster"])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when start is called again for the same user", async () => {
|
||||||
|
vi.stubEnv("MODE", "development");
|
||||||
|
vi.resetModules();
|
||||||
|
broadcastQueryClient.mockReturnValue(disposeA);
|
||||||
|
|
||||||
|
const { startQueryBroadcast } = await import("@/lib/query/setup-query-broadcast");
|
||||||
|
const client = new QueryClient();
|
||||||
|
const clearSpy = vi.spyOn(client, "clear");
|
||||||
|
|
||||||
|
startQueryBroadcast(client, "user-a");
|
||||||
|
startQueryBroadcast(client, "user-a");
|
||||||
|
|
||||||
|
expect(broadcastQueryClient).toHaveBeenCalledTimes(1);
|
||||||
|
expect(disposeA).not.toHaveBeenCalled();
|
||||||
|
expect(clearSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue