mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-01 09:43:11 +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,
|
||||
`src/lib/query/setup-query-broadcast.ts`. The module:
|
||||
|
||||
- Wraps `broadcastQueryClient({ queryClient, broadcastChannel })`, keyed to a
|
||||
single named channel (`seahaven-admin-query`).
|
||||
- No-ops outside the browser (SSR/build) and under Vitest (`import.meta.env.MODE
|
||||
=== "test"`), so it never runs in unit tests or node-based tooling.
|
||||
- Is invoked once from `src/lib/query/query-client.ts` against the app's
|
||||
singleton `QueryClient`, so every tab that loads the SPA subscribes to the
|
||||
same channel automatically — no per-feature wiring required.
|
||||
- Exposes `startQueryBroadcast(queryClient, userId)` and
|
||||
`stopQueryBroadcast(queryClient)`.
|
||||
- Keys `broadcastQueryClient` to an **account-scoped** channel
|
||||
(`seahaven-admin-query:${userId}`), so tabs belonging to different accounts
|
||||
on the same origin do not share cache traffic.
|
||||
- Disposes the prior subscription (the unsubscribe returned by
|
||||
`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
|
||||
the library that owns the `QueryClient` we already depend on, and requires
|
||||
close to zero application code (~20 lines) to adopt.
|
||||
the library that owns the `QueryClient` we already depend on, and keeps the
|
||||
integration isolated to auth/session boundaries.
|
||||
|
||||
### 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
|
||||
every future mutation hook wired to broadcast. More code to own and more
|
||||
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
|
||||
unresolved; users would need to manually refresh or wait for
|
||||
`refetchOnWindowFocus`/`staleTime` to catch up, which is not reliable enough
|
||||
|
|
@ -56,26 +65,27 @@ close to zero application code (~20 lines) to adopt.
|
|||
|
||||
## Consequences
|
||||
|
||||
- **Positive**: sibling tabs reflect mutations (status changes, dispatch
|
||||
actions, comments, patches) without a manual refresh; the integration is
|
||||
isolated to one setup file and does not touch domain/query-key code.
|
||||
- **Positive**: sibling tabs for the same authenticated account reflect
|
||||
mutations (status changes, dispatch actions, comments, patches) without a
|
||||
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
|
||||
the TanStack ecosystem; its API may change or be deprecated between minor
|
||||
versions. `@tanstack/query-broadcast-client-experimental`,
|
||||
`@tanstack/react-query`, and `@tanstack/react-query-devtools` are pinned to
|
||||
the **exact same version** in `package.json` (no `^` range). All three
|
||||
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
|
||||
npm resolve the broadcast client and React Query against two different
|
||||
`query-core` versions independently, which breaks `QueryClient` type
|
||||
identity (TS2322) even though both packages build fine in isolation. Bump
|
||||
all three together and re-evaluate on every TanStack Query upgrade.
|
||||
- **Risk — same-origin only**: `BroadcastChannel` only syncs tabs on the same
|
||||
origin; it does not sync across different users/sessions or devices. This is
|
||||
acceptable for the current requirement (single user, multiple tabs).
|
||||
origin; it does not sync across different users/sessions or devices. Account
|
||||
scoping further limits sync to tabs of the same `userId`.
|
||||
- **Rollback plan**: remove the `@tanstack/query-broadcast-client-experimental`
|
||||
dependency from `package.json` and delete the call to `setupQueryBroadcast`
|
||||
in `src/lib/query/query-client.ts` (and the `setup-query-broadcast.ts` module
|
||||
itself). No other code depends on it, so rollback is a single, isolated
|
||||
change with no data migration.
|
||||
dependency from `package.json`, delete `setup-query-broadcast.ts`, and remove
|
||||
the `startQueryBroadcast` / `stopQueryBroadcast` call sites in auth and
|
||||
`api.ts`. No other code depends on it, so rollback is isolated with no data
|
||||
migration.
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ import { env } from "@/lib/env";
|
|||
import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
import queryClient from "@/lib/query/query-client";
|
||||
import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast";
|
||||
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
|
||||
|
||||
function clearSessionAndRedirectToLogin(): void {
|
||||
clearAuth();
|
||||
queryClient.setQueryData(queryKeys.auth.session(), null);
|
||||
stopQueryBroadcast(queryClient);
|
||||
|
||||
if (typeof window !== "undefined" && window.location.pathname !== "/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 BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
|
||||
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
|
||||
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 { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material";
|
||||
import type { VendorFacets } from "@/domain/vendors/types/vendor";
|
||||
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
|
||||
import { VendorFiltersDrawerFields } from "./vendor-filters-drawer-fields";
|
||||
|
||||
interface VendorFiltersDrawerProps {
|
||||
open: boolean;
|
||||
|
|
@ -38,32 +15,6 @@ interface VendorFiltersDrawerProps {
|
|||
|
||||
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({
|
||||
open,
|
||||
onClose,
|
||||
|
|
@ -125,7 +76,7 @@ export function VendorFiltersDrawer({
|
|||
<Typography id="vendor-filters-drawer-title" variant="h6" component="h2">
|
||||
Filters
|
||||
</Typography>
|
||||
{activeCount > 0 && <Chip size="small" color="primary" label={activeCount} />}
|
||||
{Boolean(activeCount > 0) && <Chip size="small" color="primary" label={activeCount} />}
|
||||
</Stack>
|
||||
<IconButton aria-label="Close filters" onClick={onClose} size="small">
|
||||
<CloseIcon fontSize="small" />
|
||||
|
|
@ -133,165 +84,16 @@ export function VendorFiltersDrawer({
|
|||
</Stack>
|
||||
|
||||
<Box className="flex-1 overflow-y-auto" sx={{ px: 2.5 }}>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<VendorFiltersDrawerFields
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
options={{
|
||||
companies: companyOptions,
|
||||
trades: tradeOptions,
|
||||
locations: locationOptions,
|
||||
jobBuckets: jobBucketOptions,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<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) {
|
||||
const navigate = useNavigate();
|
||||
const isEdit = vendorId !== undefined || companyId !== undefined;
|
||||
|
|
@ -94,26 +125,14 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
|
|||
}
|
||||
|
||||
if (isEdit && form.isError) {
|
||||
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={() => navigate("/vendors")}>
|
||||
Back to vendors
|
||||
</Button>
|
||||
</Stack>
|
||||
</PageShell>
|
||||
);
|
||||
return <RosterLoadErrorState onBack={() => navigate("/vendors")} />;
|
||||
}
|
||||
|
||||
if (isEdit && !form.roster) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = isEdit ? "Edit Vendor Company" : "Add Vendor Company";
|
||||
const subtitle = isEdit
|
||||
? "Update company details and technicians in one save."
|
||||
: "Register a company and add zero, one, or multiple technicians.";
|
||||
const { title, subtitle } = rosterPageCopy(isEdit);
|
||||
const companySelectionProps = isEdit
|
||||
? {}
|
||||
: {
|
||||
|
|
@ -122,6 +141,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
|
|||
onSelectCompany: form.selectCompany,
|
||||
onClearSelectedCompany: form.clearSelectedCompany,
|
||||
};
|
||||
const showPortalToken = Boolean(isEdit && vendorId);
|
||||
|
||||
return (
|
||||
<PageShell title={title} subtitle={subtitle}>
|
||||
|
|
@ -143,7 +163,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
|
|||
{...companySelectionProps}
|
||||
/>
|
||||
|
||||
{isEdit && vendorId !== undefined && vendorId !== "" && (
|
||||
{showPortalToken && vendorId !== undefined && (
|
||||
<VendorPortalTokenPanel vendorId={vendorId} />
|
||||
)}
|
||||
|
||||
|
|
@ -156,7 +176,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa
|
|||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="contained" disabled={form.isSaving || !form.isValid}>
|
||||
{form.isSaving ? "Saving..." : isEdit ? "Save" : "Create"}
|
||||
{submitLabel(form.isSaving, isEdit)}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { toast } from "react-toastify";
|
|||
import * as authApi from "@/domain/auth/api/auth-api";
|
||||
import type { AuthUser, LoginCredentials } from "@/domain/auth/types/auth-user";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
import { startQueryBroadcast, stopQueryBroadcast } from "@/lib/query/setup-query-broadcast";
|
||||
|
||||
export function useSession(): UseQueryResult<AuthUser | null, Error> {
|
||||
return useQuery({
|
||||
|
|
@ -28,7 +29,9 @@ export function useLogin(): UseMutationResult<AuthUser, Error, LoginCredentials>
|
|||
mutationFn: (credentials: LoginCredentials) => authApi.login(credentials),
|
||||
meta: { suppressErrorToast: true },
|
||||
onSuccess: (user) => {
|
||||
stopQueryBroadcast(queryClient);
|
||||
queryClient.setQueryData(queryKeys.auth.session(), user);
|
||||
startQueryBroadcast(queryClient, user.id);
|
||||
toast.success("Login successful!");
|
||||
navigate("/");
|
||||
},
|
||||
|
|
@ -42,13 +45,12 @@ export function useLogout(): UseMutationResult<void, Error, void> {
|
|||
return useMutation({
|
||||
mutationFn: authApi.logout,
|
||||
onSuccess: () => {
|
||||
queryClient.setQueryData(queryKeys.auth.session(), null);
|
||||
queryClient.removeQueries({ queryKey: queryKeys.auth.all });
|
||||
stopQueryBroadcast(queryClient);
|
||||
toast.info("You have been logged out");
|
||||
navigate("/login");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
queryClient.setQueryData(queryKeys.auth.session(), null);
|
||||
stopQueryBroadcast(queryClient);
|
||||
toast.error(error.message || "Logout failed");
|
||||
navigate("/login");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "react-toastify";
|
||||
import { setupQueryBroadcast } from "@/lib/query/setup-query-broadcast";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
|
|
@ -31,6 +30,4 @@ export const queryClient = new QueryClient({
|
|||
},
|
||||
});
|
||||
|
||||
setupQueryBroadcast(queryClient);
|
||||
|
||||
export default queryClient;
|
||||
|
|
|
|||
|
|
@ -1,19 +1,60 @@
|
|||
import { broadcastQueryClient } from "@tanstack/query-broadcast-client-experimental";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
/** Same-origin channel name for cross-tab QueryClient sync. */
|
||||
export const QUERY_BROADCAST_CHANNEL = "seahaven-admin-query";
|
||||
/** Prefix for same-origin BroadcastChannel names (suffixed with authenticated user id). */
|
||||
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.
|
||||
* No-op outside the browser and during Vitest runs.
|
||||
* Starts (or switches) account-scoped QueryClient broadcast.
|
||||
* 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 {
|
||||
if (typeof window === "undefined") return;
|
||||
if (import.meta.env.MODE === "test") return;
|
||||
export function startQueryBroadcast(queryClient: QueryClient, userId: string | number): void {
|
||||
if (activeUserId === userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
broadcastQueryClient({
|
||||
const previousUserId = activeUserId;
|
||||
disposeBroadcast();
|
||||
|
||||
if (previousUserId != null && previousUserId !== userId) {
|
||||
queryClient.clear();
|
||||
}
|
||||
|
||||
activeUserId = userId;
|
||||
|
||||
if (!canUseBroadcast()) {
|
||||
return;
|
||||
}
|
||||
|
||||
unsubscribe = broadcastQueryClient({
|
||||
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 { 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";
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const sessionQuery = useSession();
|
||||
const loginMutation = useLogin();
|
||||
const logoutMutation = useLogout();
|
||||
const changePasswordMutation = useChangePassword();
|
||||
|
||||
const userId = sessionQuery.data?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (userId == null) {
|
||||
return;
|
||||
}
|
||||
startQueryBroadcast(queryClient, userId);
|
||||
}, [queryClient, userId]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
user: sessionQuery.data,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
const disposeA = vi.fn();
|
||||
const disposeB = vi.fn();
|
||||
const broadcastQueryClient = vi.fn();
|
||||
|
||||
vi.mock("@tanstack/query-broadcast-client-experimental", () => ({
|
||||
broadcastQueryClient: (...args: unknown[]) => broadcastQueryClient(...args),
|
||||
}));
|
||||
|
||||
describe("setupQueryBroadcast", () => {
|
||||
describe("query broadcast account isolation", () => {
|
||||
beforeEach(() => {
|
||||
broadcastQueryClient.mockClear();
|
||||
broadcastQueryClient.mockReset();
|
||||
disposeA.mockReset();
|
||||
disposeB.mockReset();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
|
|
@ -19,29 +23,87 @@ describe("setupQueryBroadcast", () => {
|
|||
|
||||
it("does not call broadcastQueryClient when MODE is test", async () => {
|
||||
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();
|
||||
|
||||
setupQueryBroadcast(client);
|
||||
startQueryBroadcast(client, "user-a");
|
||||
|
||||
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.resetModules();
|
||||
broadcastQueryClient.mockReturnValue(disposeA);
|
||||
|
||||
const { setupQueryBroadcast, QUERY_BROADCAST_CHANNEL } =
|
||||
const { startQueryBroadcast, queryBroadcastChannelForUser, QUERY_BROADCAST_CHANNEL_PREFIX } =
|
||||
await import("@/lib/query/setup-query-broadcast");
|
||||
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).toHaveBeenCalledWith({
|
||||
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