diff --git a/src/app/(protected)/locations/_components/area-facet.tsx b/src/app/(protected)/locations/_components/area-facet.tsx new file mode 100644 index 00000000..2e41da3e --- /dev/null +++ b/src/app/(protected)/locations/_components/area-facet.tsx @@ -0,0 +1,22 @@ +import { Autocomplete, TextField } from "@mui/material"; +import { AREA_ZONES, type AreaZone } from "@/data/areas"; + +interface AreaFacetProps { + value: AreaZone[]; + onChange: (zones: AreaZone[]) => void; +} + +/** Multi-select area facet; an empty selection means no area filtering. */ +export function AreaFacet({ value, onChange }: AreaFacetProps) { + return ( + + multiple + size="small" + className="min-w-[200px]" + options={AREA_ZONES} + value={value} + onChange={(_event, zones) => onChange(zones)} + renderInput={(params) => } + /> + ); +} diff --git a/src/app/(protected)/locations/_components/location-delete-dialog.tsx b/src/app/(protected)/locations/_components/location-delete-dialog.tsx new file mode 100644 index 00000000..0b02c348 --- /dev/null +++ b/src/app/(protected)/locations/_components/location-delete-dialog.tsx @@ -0,0 +1,42 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from "@mui/material"; +import type { LocationListItem } from "@/domain/locations/types/location"; + +interface LocationDeleteDialogProps { + isPending: boolean; + onClose: () => void; + onConfirm: () => void; + target: LocationListItem | null; +} + +export function LocationDeleteDialog({ + isPending, + onClose, + onConfirm, + target, +}: LocationDeleteDialogProps) { + return ( + + Delete Location + + + Are you sure you want to delete "{target?.name}"? This action cannot be undone. + + + + + + + + ); +} diff --git a/src/app/(protected)/locations/_components/location-form-fields.tsx b/src/app/(protected)/locations/_components/location-form-fields.tsx new file mode 100644 index 00000000..c1f2fb1c --- /dev/null +++ b/src/app/(protected)/locations/_components/location-form-fields.tsx @@ -0,0 +1,159 @@ +import { Controller, type Control, type FieldErrors, type UseFormRegister } from "react-hook-form"; +import { + Autocomplete, + FormControl, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from "@mui/material"; +import type { AccountListItem } from "@/domain/accounts/types/account"; +import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; +import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location"; +import { StateSelect } from "./state-select"; + +interface SharedFieldsProps { + control: Control; + errors: FieldErrors; + register: UseFormRegister; +} + +interface LocationDetailsFieldsProps extends SharedFieldsProps { + accountOptions: AccountListItem[]; + isLoadingAccounts: boolean; + onAccountSearchChange: (value: string) => void; +} + +export function LocationDetailsFields({ + accountOptions, + control, + errors, + isLoadingAccounts, + onAccountSearchChange, + register, +}: LocationDetailsFieldsProps) { + return ( + + + Location Information + + + + ( + option.name || String(option.id)} + isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)} + value={accountOptions.find((account) => String(account.id) === field.value) ?? null} + onChange={(_event, option) => field.onChange(option ? String(option.id) : "")} + onInputChange={(_event, value, reason) => { + if (reason === "input") { + onAccountSearchChange(value); + } + }} + renderInput={(params) => ( + + )} + className="flex-1" + /> + )} + /> + + + + + + + + + ); +} + +export function LocationContactFields({ control, errors, register }: SharedFieldsProps) { + return ( + + + Contact Information + + + + + + + + ( + + Status + + + )} + /> + + + ); +} diff --git a/src/app/(protected)/locations/_components/location-form.tsx b/src/app/(protected)/locations/_components/location-form.tsx index 990fc75d..2e51731c 100644 --- a/src/app/(protected)/locations/_components/location-form.tsx +++ b/src/app/(protected)/locations/_components/location-form.tsx @@ -1,34 +1,20 @@ import { useEffect, useMemo, useState } from "react"; -import { Controller, useForm } from "react-hook-form"; +import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useNavigate, useParams } from "react-router"; -import { - Alert, - Autocomplete, - Box, - Button, - CircularProgress, - FormControl, - InputLabel, - MenuItem, - Paper, - Select, - Stack, - TextField, - Typography, -} from "@mui/material"; -import type { AccountListItem } from "@/domain/accounts/types/account"; +import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material"; import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list"; +import { normalizeStateCode } from "@/data/areas"; import { locationDefaultValues, locationSchema, type LocationFormValues, } from "@/domain/locations/schemas/location-schema"; -import { LOCATION_STATE_OPTIONS, LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location"; import { useCreateLocation } from "@/domain/locations/use-cases/use-create-location"; import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location"; import { useDebounce } from "@/hooks/use-debounce"; +import { LocationContactFields, LocationDetailsFields } from "./location-form-fields"; export default function LocationFormPage() { const { id } = useParams(); @@ -67,7 +53,7 @@ export default function LocationFormPage() { accountId: location.accountId != null ? String(location.accountId) : "", address: location.address ?? "", city: location.city ?? "", - state: (location.state as LocationFormValues["state"]) || "IL", + state: normalizeStateCode(location.state) ?? "", zipCode: location.zipCode ?? "", phone: location.phone ?? "", contact: location.contact ?? "", @@ -121,141 +107,15 @@ export default function LocationFormPage() { )} - - - Location Information - - - - ( - option.name || String(option.id)} - isOptionEqualToValue={(option, value) => - String(option.id) === String(value.id) - } - value={ - accountOptions.find((account) => String(account.id) === field.value) ?? null - } - onChange={(_event, option) => field.onChange(option ? String(option.id) : "")} - onInputChange={(_event, value, reason) => { - if (reason === "input") { - setAccountSearch(value); - } - }} - renderInput={(params) => ( - - )} - className="flex-1" - /> - )} - /> - - - - - ( - - State - - - )} - /> - - - - - - - Contact Information - - - - - - - - ( - - Status - - - )} - /> - - + + void; + onEdit: (row: LocationListItem) => void; + rows: LocationListItem[]; +} + +export function LocationsTable({ isLoading, onDelete, onEdit, rows }: LocationsTableProps) { + return ( + + + + + Location Name + Address + City + State + Phone + Contact + Status + + Actions + + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + No locations found + + + ) : ( + rows.map((row) => ( + + + + + {row.address || "—"} + {row.city || "—"} + + + + {row.phone || "—"} + {row.contact || "—"} + {row.status || "—"} + + + onEdit(row)}> + + + + + onDelete(row)} + > + + + + + + )) + )} + +
+
+ ); +} diff --git a/src/app/(protected)/locations/_components/state-area-cell.tsx b/src/app/(protected)/locations/_components/state-area-cell.tsx new file mode 100644 index 00000000..cf5d5e4f --- /dev/null +++ b/src/app/(protected)/locations/_components/state-area-cell.tsx @@ -0,0 +1,35 @@ +import { Stack, Typography } from "@mui/material"; +import { getAreaForState, normalizeStateCode } from "@/data/areas"; + +/** State abbreviation with the derived area as secondary text. */ +export function StateAreaCell({ state }: { state?: string | null }) { + const code = normalizeStateCode(state); + const area = code === null ? null : getAreaForState(code); + + if (code === null) { + return ( + + {state?.trim() || "—"} + + ); + } + + return ( + + + {code} + + {area !== null && ( + + {area} + + )} + + ); +} diff --git a/src/app/(protected)/locations/_components/state-select.tsx b/src/app/(protected)/locations/_components/state-select.tsx new file mode 100644 index 00000000..3348019a --- /dev/null +++ b/src/app/(protected)/locations/_components/state-select.tsx @@ -0,0 +1,49 @@ +import { Controller, type Control, type FieldError } from "react-hook-form"; +import { Autocomplete, TextField } from "@mui/material"; +import { + US_STATE_CODES, + formatStateOptionLabel, + getAreaForState, + type StateCode, +} from "@/data/areas"; +import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; + +interface StateSelectProps { + control: Control; + error?: FieldError; +} + +/** Searchable, non-free-text state picker with derived area helper text. */ +export function StateSelect({ control, error }: StateSelectProps) { + return ( + { + const selected: StateCode | null = + US_STATE_CODES.find((code) => code === field.value) ?? null; + const area = selected === null ? null : getAreaForState(selected); + const helperText = error?.message ?? (area === null ? undefined : `Area: ${area}`); + + return ( + + options={US_STATE_CODES} + value={selected} + onChange={(_event, option) => field.onChange(option ?? "")} + onBlur={field.onBlur} + getOptionLabel={formatStateOptionLabel} + renderInput={(params) => ( + + )} + /> + ); + }} + /> + ); +} diff --git a/src/app/(protected)/locations/index.tsx b/src/app/(protected)/locations/index.tsx index 097db2c8..79674137 100644 --- a/src/app/(protected)/locations/index.tsx +++ b/src/app/(protected)/locations/index.tsx @@ -1,53 +1,45 @@ import { useMemo, useState } from "react"; import { useNavigate } from "react-router"; -import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; -import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import { Alert, Box, Button, CircularProgress, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - IconButton, - Paper, Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, TablePagination, - TableRow, TextField, - Tooltip, Typography, } from "@mui/material"; import type { LocationListItem } from "@/domain/locations/types/location"; import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-location"; import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list"; import { useDebounce } from "@/hooks/use-debounce"; +import { expandAreasToStateCodes, type AreaZone } from "@/data/areas"; +import { AreaFacet } from "./_components/area-facet"; +import { LocationDeleteDialog } from "./_components/location-delete-dialog"; +import { LocationsTable } from "./_components/locations-table"; export default function LocationsListPage() { const navigate = useNavigate(); const [search, setSearch] = useState(""); const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(12); + const [selectedAreas, setSelectedAreas] = useState([]); const [deleteTarget, setDeleteTarget] = useState(null); const debouncedSearch = useDebounce(search, 300); const deleteLocation = useDeleteLocation(); + const stateCodes = useMemo(() => expandAreasToStateCodes(selectedAreas), [selectedAreas]); + const listParams = useMemo( () => ({ search: debouncedSearch, page: page + 1, pageSize, + states: stateCodes.length > 0 ? stateCodes : undefined, }), - [debouncedSearch, page, pageSize], + [debouncedSearch, page, pageSize, stateCodes], ); const { data, isLoading, isFetching, error } = useLocationsList(listParams); @@ -103,6 +95,13 @@ export default function LocationsListPage() { alignItems: "center", }} > + { + setSelectedAreas(zones); + setPage(0); + }} + /> )} - - - - - Location Name - Address - City - State - Phone - Contact - Status - - Actions - - - - - {isLoading ? ( - - - - - - ) : rows.length === 0 ? ( - - - - No locations found - - - - ) : ( - rows.map((row) => ( - - - - - {row.address || "—"} - {row.city || "—"} - {row.state || "—"} - {row.phone || "—"} - {row.contact || "—"} - {row.status || "—"} - - - navigate(`/locations/edit/${row.id}`)} - > - - - - - setDeleteTarget(row)} - > - - - - - - )) - )} - -
-
+ navigate(`/locations/edit/${row.id}`)} + onDelete={setDeleteTarget} + /> - setDeleteTarget(null)}> - Delete Location - - - Are you sure you want to delete "{deleteTarget?.name}"? This action cannot be - undone. - - - - - - - + setDeleteTarget(null)} + onConfirm={handleDeleteConfirm} + />
); } diff --git a/src/data/areas.ts b/src/data/areas.ts new file mode 100644 index 00000000..9fbda1b0 --- /dev/null +++ b/src/data/areas.ts @@ -0,0 +1,170 @@ +/** + * Canonical source of truth for US states and their Seahaven area zone + * mapping. Exactly the 50 states — no DC and no territories. + * Area is always derived from the state code and never persisted. + */ +export const AREA_ZONES = ["East", "Central", "West", "California"] as const; + +export type AreaZone = (typeof AREA_ZONES)[number]; + +export const AREA_ZONE_STATE_CODES = { + East: [ + "CT", + "DE", + "GA", + "MA", + "MD", + "ME", + "NC", + "NH", + "NJ", + "NY", + "PA", + "RI", + "SC", + "VA", + "VT", + "WV", + ], + Central: [ + "AL", + "AR", + "FL", + "IA", + "IL", + "IN", + "KS", + "KY", + "LA", + "MI", + "MN", + "MO", + "MS", + "ND", + "NE", + "OH", + "OK", + "SD", + "TN", + "WI", + ], + West: ["AK", "AZ", "CO", "HI", "ID", "MT", "NM", "NV", "OR", "TX", "UT", "WA", "WY"], + California: ["CA"], +} as const satisfies Record; + +export type StateCode = (typeof AREA_ZONE_STATE_CODES)[AreaZone][number]; + +/** All 50 state codes in canonical order: East, Central, West, California. */ +export const US_STATE_CODES = [ + ...AREA_ZONE_STATE_CODES.East, + ...AREA_ZONE_STATE_CODES.Central, + ...AREA_ZONE_STATE_CODES.West, + ...AREA_ZONE_STATE_CODES.California, +] as [StateCode, ...StateCode[]]; + +const STATE_NAMES = { + AL: "Alabama", + AK: "Alaska", + AZ: "Arizona", + AR: "Arkansas", + CA: "California", + CO: "Colorado", + CT: "Connecticut", + DE: "Delaware", + FL: "Florida", + GA: "Georgia", + HI: "Hawaii", + ID: "Idaho", + IL: "Illinois", + IN: "Indiana", + IA: "Iowa", + KS: "Kansas", + KY: "Kentucky", + LA: "Louisiana", + ME: "Maine", + MD: "Maryland", + MA: "Massachusetts", + MI: "Michigan", + MN: "Minnesota", + MS: "Mississippi", + MO: "Missouri", + MT: "Montana", + NE: "Nebraska", + NV: "Nevada", + NH: "New Hampshire", + NJ: "New Jersey", + NM: "New Mexico", + NY: "New York", + NC: "North Carolina", + ND: "North Dakota", + OH: "Ohio", + OK: "Oklahoma", + OR: "Oregon", + PA: "Pennsylvania", + RI: "Rhode Island", + SC: "South Carolina", + SD: "South Dakota", + TN: "Tennessee", + TX: "Texas", + UT: "Utah", + VT: "Vermont", + VA: "Virginia", + WA: "Washington", + WV: "West Virginia", + WI: "Wisconsin", + WY: "Wyoming", +} as const satisfies Record; + +export interface StateAreaEntry { + code: StateCode; + name: string; + zone: AreaZone; +} + +/** All 50 states with postal code, full name, and their single area zone. */ +export const STATE_AREA_ENTRIES: readonly StateAreaEntry[] = AREA_ZONES.flatMap((zone) => + AREA_ZONE_STATE_CODES[zone].map((code) => ({ code, name: STATE_NAMES[code], zone })), +); + +const STATE_CODE_SET = new Set(US_STATE_CODES); + +const STATE_NAME_TO_CODE = new Map( + STATE_AREA_ENTRIES.map((entry) => [entry.name.toLowerCase(), entry.code]), +); + +const STATE_ZONE_BY_CODE = Object.fromEntries( + STATE_AREA_ENTRIES.map((entry) => [entry.code, entry.zone]), +) as Record; + +/** Autocomplete option label: `Texas (TX)`. */ +export function formatStateOptionLabel(code: StateCode): string { + return `${STATE_NAMES[code]} (${code})`; +} + +/** + * Normalizes legacy API state values to the canonical two-letter code. + * Accepts codes in any case and full state names (any case); otherwise null. + */ +export function normalizeStateCode(value: string | null | undefined): StateCode | null { + const trimmed = value?.trim() ?? ""; + if (trimmed === "") { + return null; + } + const upper = trimmed.toUpperCase(); + if (STATE_CODE_SET.has(upper)) { + return upper as StateCode; + } + return STATE_NAME_TO_CODE.get(trimmed.toLowerCase()) ?? null; +} + +/** Derives the area zone for a state code or legacy full-name value. */ +export function getAreaForState(value: string | null | undefined): AreaZone | null { + const code = normalizeStateCode(value); + return code === null ? null : STATE_ZONE_BY_CODE[code]; +} + +/** Expands selected area zones to their state codes in canonical order. */ +export function expandAreasToStateCodes(areas: readonly AreaZone[]): StateCode[] { + const selected = new Set(areas); + return US_STATE_CODES.filter((code) => selected.has(STATE_ZONE_BY_CODE[code])); +} diff --git a/src/domain/locations/api/locations-api.ts b/src/domain/locations/api/locations-api.ts index 891bab5e..2f9f1376 100644 --- a/src/domain/locations/api/locations-api.ts +++ b/src/domain/locations/api/locations-api.ts @@ -15,11 +15,17 @@ import type { } from "@/domain/locations/types/location"; function buildSearchParams(params: LocationsListParams): Record { - return { + const searchParams: Record = { search: params.search ?? "", page: params.page ?? 1, pageSize: params.pageSize ?? 12, }; + + if (params.states && params.states.length > 0) { + searchParams.states = params.states.join(","); + } + + return searchParams; } export const locationsApi = { diff --git a/src/domain/locations/schemas/location-schema.ts b/src/domain/locations/schemas/location-schema.ts index b516ff8a..00fbbdbb 100644 --- a/src/domain/locations/schemas/location-schema.ts +++ b/src/domain/locations/schemas/location-schema.ts @@ -1,12 +1,15 @@ import { z } from "zod"; -import { LOCATION_STATE_OPTIONS, LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location"; +import { US_STATE_CODES } from "@/data/areas"; +import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location"; export const locationSchema = z.object({ name: z.string().min(1, "Name is required"), accountId: z.string().optional(), address: z.string().min(1, "Address is required"), city: z.string().min(1, "City is required"), - state: z.enum(LOCATION_STATE_OPTIONS, { message: "State is required" }), + state: z + .union([z.enum(US_STATE_CODES), z.literal("")]) + .refine((state): boolean => state !== "", { message: "State is required" }), zipCode: z.string().min(1, "Zip code is required"), phone: z.string().min(1, "Phone is required"), contact: z.string().min(1, "Contact person is required"), @@ -21,7 +24,7 @@ export const locationDefaultValues: LocationFormValues = { accountId: "", address: "", city: "", - state: "IL", + state: "", zipCode: "", phone: "", contact: "", diff --git a/src/domain/locations/types/location.ts b/src/domain/locations/types/location.ts index 8c9e3ac5..07ecbe2e 100644 --- a/src/domain/locations/types/location.ts +++ b/src/domain/locations/types/location.ts @@ -1,3 +1,5 @@ +import type { StateCode } from "@/data/areas"; + export interface LocationListItem { id: string | number; name: string; @@ -38,6 +40,8 @@ export interface LocationsListParams { search?: string; page?: number; pageSize?: number; + /** State codes to filter by (area expansion); omitted from the query when empty. */ + states?: StateCode[]; } export interface LocationsListResult { @@ -47,6 +51,4 @@ export interface LocationsListResult { pageSize: number; } -export const LOCATION_STATE_OPTIONS = ["IL", "IN", "MI", "OH", "WI"] as const; - export const LOCATION_STATUS_OPTIONS = ["Active", "Inactive", "Closed"] as const; diff --git a/src/test/app/(protected)/locations/location-form-state.test.tsx b/src/test/app/(protected)/locations/location-form-state.test.tsx new file mode 100644 index 00000000..23b716a3 --- /dev/null +++ b/src/test/app/(protected)/locations/location-form-state.test.tsx @@ -0,0 +1,103 @@ +import { screen } from "@testing-library/react"; +import { Route, Routes } from "react-router"; +import { describe, expect, it, vi } from "vitest"; +import LocationFormPage from "@/app/(protected)/locations/_components/location-form"; +import { renderWithProviders } from "@/test/test-utils"; + +const useLocationDetail = vi.fn(); +const useAccountsList = vi.fn(); +const useCreateLocation = vi.fn(); +const useUpdateLocation = vi.fn(); + +vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({ + useLocationDetail: (...args: unknown[]) => useLocationDetail(...args), +})); + +vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({ + useAccountsList: (...args: unknown[]) => useAccountsList(...args), +})); + +vi.mock("@/domain/locations/use-cases/use-create-location", () => ({ + useCreateLocation: (...args: unknown[]) => useCreateLocation(...args), +})); + +vi.mock("@/domain/locations/use-cases/use-update-location", () => ({ + useUpdateLocation: (...args: unknown[]) => useUpdateLocation(...args), +})); + +function mockHooks(location: Record | null) { + useLocationDetail.mockReturnValue({ + data: location, + isLoading: false, + error: null, + }); + useAccountsList.mockReturnValue({ + data: { items: [], totalCount: 0, page: 1, pageSize: 50 }, + isLoading: false, + }); + useCreateLocation.mockReturnValue({ mutate: vi.fn(), isPending: false }); + useUpdateLocation.mockReturnValue({ mutate: vi.fn(), isPending: false }); +} + +function renderEditPage(locationId: string) { + return renderWithProviders( + + } /> + , + { + route: `/locations/edit/${locationId}`, + routerProps: { initialEntries: [`/locations/edit/${locationId}`] }, + withAuth: false, + }, + ); +} + +describe("LocationFormPage state field", () => { + it("normalizes legacy API state values for edit", () => { + mockHooks({ + id: "1", + name: "Dallas DC", + address: "3811 Distribution Dr", + city: "Dallas", + state: "texas", + zipCode: "70068", + phone: "(421) 433-0032", + contact: "Jane", + contactEmail: "jane@example.com", + status: "Active", + accountId: "7", + }); + + renderEditPage("1"); + + const stateInput = screen.getByLabelText(/^State/); + expect(stateInput).toHaveValue("Texas (TX)"); + expect(screen.getByText("Area: West")).toBeInTheDocument(); + }); + + it("normalizes code values and keeps their derived area", () => { + mockHooks({ + id: "2", + state: "IL", + status: "Active", + }); + + renderEditPage("2"); + + expect(screen.getByLabelText(/^State/)).toHaveValue("Illinois (IL)"); + expect(screen.getByText("Area: Central")).toBeInTheDocument(); + }); + + it("starts empty in create mode with no implicit IL default", () => { + mockHooks(null); + + renderWithProviders(, { + route: "/locations/new", + routerProps: { initialEntries: ["/locations/new"] }, + withAuth: false, + }); + + expect(screen.getByLabelText(/^State/)).toHaveValue(""); + expect(screen.queryByText(/Area: /)).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/locations/locations-list-area.test.tsx b/src/test/app/(protected)/locations/locations-list-area.test.tsx new file mode 100644 index 00000000..fb45e28c --- /dev/null +++ b/src/test/app/(protected)/locations/locations-list-area.test.tsx @@ -0,0 +1,139 @@ +import { fireEvent, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import LocationsListPage from "@/app/(protected)/locations/index"; +import { expandAreasToStateCodes } from "@/data/areas"; +import { renderWithProviders } from "@/test/test-utils"; + +const useLocationsList = vi.fn(); +const useDeleteLocation = vi.fn(); + +vi.mock("@/domain/locations/use-cases/use-locations-list", () => ({ + useLocationsList: (...args: unknown[]) => useLocationsList(...args), +})); + +vi.mock("@/domain/locations/use-cases/use-delete-location", () => ({ + useDeleteLocation: (...args: unknown[]) => useDeleteLocation(...args), +})); + +vi.mock("@/hooks/use-debounce", () => ({ + useDebounce: (value: string) => value, +})); + +const rows = [ + { + id: 1, + name: "Dallas DC", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "70068", + phone: "(421) 433-0032", + contact: "Jane", + contactEmail: "jane@example.com", + status: "Active", + accountId: 7, + }, + { + id: 2, + name: "Chicago HQ", + address: "100 Main St", + city: "Chicago", + state: "Illinois", + zipCode: "60601", + phone: "(312) 555-0100", + contact: "Bob", + contactEmail: "bob@example.com", + status: "Active", + accountId: 8, + }, +]; + +function lastListParams() { + const calls = useLocationsList.mock.calls; + return calls[calls.length - 1][0] as Record; +} + +function renderListPage() { + useDeleteLocation.mockReturnValue({ mutate: vi.fn(), isPending: false }); + useLocationsList.mockReturnValue({ + data: { items: rows, totalCount: 30, page: 1, pageSize: 12 }, + isLoading: false, + isFetching: false, + error: null, + }); + + return renderWithProviders(, { route: "/locations", withAuth: false }); +} + +describe("LocationsListPage area wiring", () => { + it("shows the state abbreviation with the area as secondary text", () => { + renderListPage(); + + const table = screen.getByRole("table"); + expect(within(table).getByText("TX")).toBeInTheDocument(); + expect(within(table).getByText("West")).toBeInTheDocument(); + expect(within(table).getByText("IL")).toBeInTheDocument(); + expect(within(table).getByText("Central")).toBeInTheDocument(); + expect(within(table).queryByText("Illinois")).not.toBeInTheDocument(); + }); + + it("sends no states filter while the area facet is empty", () => { + renderListPage(); + + expect(lastListParams()).toEqual({ search: "", page: 1, pageSize: 12 }); + }); + + it("offers the four zones in canonical order", () => { + renderListPage(); + + fireEvent.mouseDown(screen.getByLabelText("Area")); + + const options = screen.getAllByRole("option").map((option) => option.textContent ?? ""); + expect(options).toEqual(["East", "Central", "West", "California"]); + }); + + it("expands selected areas to state codes and resets the page", () => { + renderListPage(); + + fireEvent.click(screen.getByRole("button", { name: "Go to next page" })); + expect(lastListParams()).toMatchObject({ page: 2 }); + + fireEvent.mouseDown(screen.getByLabelText("Area")); + fireEvent.click(screen.getByRole("option", { name: "East" })); + + expect(lastListParams()).toEqual({ + search: "", + page: 1, + pageSize: 12, + states: expandAreasToStateCodes(["East"]), + }); + expect(lastListParams().states).toHaveLength(16); + + fireEvent.mouseDown(screen.getByLabelText("Area")); + fireEvent.click(screen.getByRole("option", { name: "California" })); + + expect(lastListParams()).toEqual({ + search: "", + page: 1, + pageSize: 12, + states: expandAreasToStateCodes(["East", "California"]), + }); + }); + + it("preserves search filtering alongside the area facet", () => { + renderListPage(); + + fireEvent.mouseDown(screen.getByLabelText("Area")); + fireEvent.click(screen.getByRole("option", { name: "West" })); + fireEvent.change(screen.getByPlaceholderText("Search locations..."), { + target: { value: "dal" }, + }); + + expect(lastListParams()).toEqual({ + search: "dal", + page: 1, + pageSize: 12, + states: expandAreasToStateCodes(["West"]), + }); + }); +}); diff --git a/src/test/app/(protected)/locations/state-select.test.tsx b/src/test/app/(protected)/locations/state-select.test.tsx new file mode 100644 index 00000000..e1bc87d5 --- /dev/null +++ b/src/test/app/(protected)/locations/state-select.test.tsx @@ -0,0 +1,137 @@ +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { StateSelect } from "@/app/(protected)/locations/_components/state-select"; +import { + locationDefaultValues, + locationSchema, + type LocationFormValues, +} from "@/domain/locations/schemas/location-schema"; +import { renderWithProviders } from "@/test/test-utils"; + +function StateSelectHarness({ + onSubmit = vi.fn(), +}: { + onSubmit?: (values: LocationFormValues) => void; +}) { + const { + control, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(locationSchema), + defaultValues: { + ...locationDefaultValues, + name: "Dallas DC", + address: "3811 Distribution Dr", + city: "Dallas", + zipCode: "70068", + phone: "(421) 433-0032", + contact: "Jane", + contactEmail: "jane@example.com", + status: "Active", + }, + }); + + return ( +
+ + + + ); +} + +function openOptions() { + const input = screen.getByRole("combobox"); + input.focus(); + fireEvent.mouseDown(input); + return input; +} + +describe("StateSelect", () => { + it("starts empty with no implicit default state", () => { + renderWithProviders(, { withAuth: false }); + + expect(screen.getByRole("combobox")).toHaveValue(""); + expect(screen.queryByText("Area: ")).not.toBeInTheDocument(); + }); + + it("shows searchable Name (CODE) options and no free-text entries", async () => { + renderWithProviders(, { withAuth: false }); + const input = openOptions(); + + const options = await screen.findAllByRole("option"); + expect(options).toHaveLength(50); + expect(screen.getByRole("option", { name: "Texas (TX)" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "California (CA)" })).toBeInTheDocument(); + + fireEvent.change(input, { target: { value: "mass" } }); + await screen.findAllByRole("option"); + expect(screen.getByRole("option", { name: "Massachusetts (MA)" })).toBeInTheDocument(); + expect(screen.getAllByRole("option")).toHaveLength(1); + + fireEvent.change(input, { target: { value: "Republic of Texas" } }); + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + }); + + it("selects by search and shows the Area helper immediately", async () => { + const onSubmit = vi.fn(); + renderWithProviders(, { withAuth: false }); + const input = openOptions(); + + fireEvent.change(input, { target: { value: "texas" } }); + fireEvent.click(await screen.findByRole("option", { name: "Texas (TX)" })); + + expect(input).toHaveValue("Texas (TX)"); + expect(screen.getByText("Area: West")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ state: "TX" }), + expect.anything(), + ); + }); + }); + + it("is keyboard accessible (arrow + enter selection)", async () => { + const onSubmit = vi.fn(); + renderWithProviders(, { withAuth: false }); + const input = screen.getByRole("combobox"); + + input.focus(); + fireEvent.change(input, { target: { value: "ohio" } }); + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(input).toHaveValue("Ohio (OH)"); + expect(screen.getByText("Area: Central")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ state: "OH" }), + expect.anything(), + ); + }); + }); + + it("requires a state and rejects free text on submit", async () => { + const onSubmit = vi.fn(); + renderWithProviders(, { withAuth: false }); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + expect(await screen.findByText("State is required")).toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); + + const input = screen.getByRole("combobox"); + input.focus(); + fireEvent.change(input, { target: { value: "NotAState" } }); + fireEvent.keyDown(input, { key: "Enter" }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await screen.findByText("State is required"); + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/data/areas.test.ts b/src/test/data/areas.test.ts new file mode 100644 index 00000000..1c37f6bc --- /dev/null +++ b/src/test/data/areas.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; +import { + AREA_ZONE_STATE_CODES, + AREA_ZONES, + STATE_AREA_ENTRIES, + US_STATE_CODES, + expandAreasToStateCodes, + formatStateOptionLabel, + getAreaForState, + normalizeStateCode, +} from "@/data/areas"; + +const EAST = [ + "CT", + "DE", + "GA", + "MA", + "MD", + "ME", + "NC", + "NH", + "NJ", + "NY", + "PA", + "RI", + "SC", + "VA", + "VT", + "WV", +]; +const CENTRAL = [ + "AL", + "AR", + "FL", + "IA", + "IL", + "IN", + "KS", + "KY", + "LA", + "MI", + "MN", + "MO", + "MS", + "ND", + "NE", + "OH", + "OK", + "SD", + "TN", + "WI", +]; +const WEST = ["AK", "AZ", "CO", "HI", "ID", "MT", "NM", "NV", "OR", "TX", "UT", "WA", "WY"]; + +describe("state area catalog", () => { + it("covers exactly the 50 states with unique codes and names", () => { + expect(STATE_AREA_ENTRIES).toHaveLength(50); + expect(US_STATE_CODES).toHaveLength(50); + expect(new Set(US_STATE_CODES).size).toBe(50); + expect(new Set(STATE_AREA_ENTRIES.map((entry) => entry.name)).size).toBe(50); + }); + + it("excludes DC and territories", () => { + expect(US_STATE_CODES).not.toContain("DC"); + expect(US_STATE_CODES).not.toContain("PR"); + expect(US_STATE_CODES).not.toContain("GU"); + expect(US_STATE_CODES).not.toContain("VI"); + expect(US_STATE_CODES).not.toContain("AS"); + expect(US_STATE_CODES).not.toContain("MP"); + }); + + it("maps every state to exactly one confirmed zone with counts", () => { + expect([...AREA_ZONE_STATE_CODES.East]).toEqual(EAST); + expect([...AREA_ZONE_STATE_CODES.Central]).toEqual(CENTRAL); + expect([...AREA_ZONE_STATE_CODES.West]).toEqual(WEST); + expect([...AREA_ZONE_STATE_CODES.California]).toEqual(["CA"]); + + expect(EAST).toHaveLength(16); + expect(CENTRAL).toHaveLength(20); + expect(WEST).toHaveLength(13); + + for (const entry of STATE_AREA_ENTRIES) { + expect(AREA_ZONES).toContain(entry.zone); + expect(entry.code).toMatch(/^[A-Z]{2}$/); + expect(entry.name.length).toBeGreaterThan(0); + } + }); + + it("keeps the canonical zone order", () => { + expect([...AREA_ZONES]).toEqual(["East", "Central", "West", "California"]); + expect(US_STATE_CODES[0]).toBe("CT"); + expect(US_STATE_CODES[49]).toBe("CA"); + }); +}); + +describe("formatStateOptionLabel", () => { + it("renders Name (CODE)", () => { + expect(formatStateOptionLabel("TX")).toBe("Texas (TX)"); + expect(formatStateOptionLabel("CA")).toBe("California (CA)"); + }); +}); + +describe("normalizeStateCode", () => { + it("accepts canonical codes in any case", () => { + expect(normalizeStateCode("TX")).toBe("TX"); + expect(normalizeStateCode("tx")).toBe("TX"); + expect(normalizeStateCode("Mo")).toBe("MO"); + }); + + it("normalizes legacy full-name and lowercase values", () => { + expect(normalizeStateCode("Texas")).toBe("TX"); + expect(normalizeStateCode("texas")).toBe("TX"); + expect(normalizeStateCode(" Illinois ")).toBe("IL"); + expect(normalizeStateCode("NORTH CAROLINA")).toBe("NC"); + expect(normalizeStateCode("California")).toBe("CA"); + }); + + it("returns null for empty, unknown, and non-state values", () => { + expect(normalizeStateCode("")).toBeNull(); + expect(normalizeStateCode(" ")).toBeNull(); + expect(normalizeStateCode(null)).toBeNull(); + expect(normalizeStateCode(undefined)).toBeNull(); + expect(normalizeStateCode("ZZ")).toBeNull(); + expect(normalizeStateCode("DC")).toBeNull(); + expect(normalizeStateCode("Puerto Rico")).toBeNull(); + }); +}); + +describe("getAreaForState", () => { + it("derives the confirmed zone per state", () => { + expect(getAreaForState("CT")).toBe("East"); + expect(getAreaForState("IL")).toBe("Central"); + expect(getAreaForState("TX")).toBe("West"); + expect(getAreaForState("CA")).toBe("California"); + }); + + it("derives the zone from legacy full-name values", () => { + expect(getAreaForState("illinois")).toBe("Central"); + expect(getAreaForState("Texas")).toBe("West"); + }); + + it("returns null for unknown states", () => { + expect(getAreaForState("XX")).toBeNull(); + expect(getAreaForState("")).toBeNull(); + expect(getAreaForState(null)).toBeNull(); + }); +}); + +describe("expandAreasToStateCodes", () => { + it("returns an empty list for an empty selection", () => { + expect(expandAreasToStateCodes([])).toEqual([]); + }); + + it("expands a single zone in canonical code order", () => { + expect(expandAreasToStateCodes(["East"])).toEqual(EAST); + expect(expandAreasToStateCodes(["California"])).toEqual(["CA"]); + expect(expandAreasToStateCodes(["West"])).toEqual(WEST); + }); + + it("expands multiple zones in canonical zone order and dedupes", () => { + expect(expandAreasToStateCodes(["West", "California"])).toEqual([...WEST, "CA"]); + expect(expandAreasToStateCodes(["California", "West", "West"])).toEqual([...WEST, "CA"]); + expect(expandAreasToStateCodes([...AREA_ZONES])).toEqual(US_STATE_CODES); + }); +}); diff --git a/src/test/domain/locations/api/locations-list-api.test.ts b/src/test/domain/locations/api/locations-list-api.test.ts new file mode 100644 index 00000000..d14bffb9 --- /dev/null +++ b/src/test/domain/locations/api/locations-list-api.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { API_PATHS } from "@/api/api-paths"; + +const apiGet = vi.fn(); + +vi.mock("@/api/api", () => ({ + apiGet: (...args: unknown[]) => apiGet(...args), + apiPost: vi.fn(), + apiPut: vi.fn(), + apiDelete: vi.fn(), +})); + +import { expandAreasToStateCodes } from "@/data/areas"; +import { locationsApi } from "@/domain/locations/api/locations-api"; + +describe("locationsApi.getList states query param", () => { + beforeEach(() => { + apiGet.mockReset(); + apiGet.mockResolvedValue([]); + }); + + it("joins state codes into a comma-separated param", async () => { + await locationsApi.getList({ states: ["TX", "MO"] }); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.location.getList, { + searchParams: { search: "", page: 1, pageSize: 12, states: "TX,MO" }, + }); + }); + + it("omits the states param when no states are provided", async () => { + await locationsApi.getList({}); + + const [, config] = apiGet.mock.calls[0]; + expect(config.searchParams).toEqual({ search: "", page: 1, pageSize: 12 }); + expect("states" in config.searchParams).toBe(false); + }); + + it("omits the states param when the states list is empty", async () => { + await locationsApi.getList({ states: [] }); + + const [, config] = apiGet.mock.calls[0]; + expect(config.searchParams).toEqual({ search: "", page: 1, pageSize: 12 }); + expect("states" in config.searchParams).toBe(false); + }); + + it("preserves the canonical area expansion order in the joined value", async () => { + await locationsApi.getList({ states: expandAreasToStateCodes(["West", "California"]) }); + + const [, config] = apiGet.mock.calls[0]; + expect(config.searchParams.states).toBe("AK,AZ,CO,HI,ID,MT,NM,NV,OR,TX,UT,WA,WY,CA"); + }); + + it("keeps existing search and pagination params", async () => { + await locationsApi.getList({ search: "dallas", page: 2, pageSize: 24, states: ["TX"] }); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.location.getList, { + searchParams: { search: "dallas", page: 2, pageSize: 24, states: "TX" }, + }); + }); +});