frontend phase 3: geography — addresses, map-pin picker & nurse coverage areas

Three domain services (mirroring the patients/nurse template): services/geography
(cached province→city→district lookups; Infinity staleTime + shared geographyKeys),
services/addresses (address book CRUD + set-primary; single-primary invariant), and
services/serviceAreas (coverage add/remove; areaExists dup-guard, districtId=null = whole city).

Four tested composites in src/components/geography: CascadingRegionSelect (drives the
cascade queries), AddressMapPicker (map-pin stand-in emitting real lat/lng), AddressForm,
AddressCard. Screens: customer address book (/addresses, reached from the profile hub) and
nurse coverage editor (/nurse/coverage, new sidebar tab, inline duplicate block + 409).

Adds geo/address/coverage i18n namespaces (both locales), location/delete/coverage icons,
ADDRESSES/NURSE_COVERAGE routes. Consumes the b4 geography-addresses contract; filed REQ-008
(accept the map pin on create/update) and REQ-009 (provinceId on CustomerAddressDto) for gaps.

Gate: npm run check + npm run test:ci (129, +17) + npm run build all green. A 5-dimension
adversarial review fixed 3 findings (map-marker RTL transform, page_size→pageSize pagination
casing, coverage districts-scope dead-end on district-less cities).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-05 14:50:41 +03:30
parent 1c266523bc
commit b8934f531d
56 changed files with 2627 additions and 3 deletions
@@ -37,6 +37,9 @@ import ArchiveIcon from '@mui/icons-material/Inventory2Outlined';
import BankIcon from '@mui/icons-material/AccountBalanceOutlined';
import CameraIcon from '@mui/icons-material/PhotoCameraOutlined';
import WarningIcon from '@mui/icons-material/WarningAmberOutlined';
import LocationIcon from '@mui/icons-material/LocationOnOutlined';
import DeleteIcon from '@mui/icons-material/DeleteOutlined';
import CoverageIcon from '@mui/icons-material/MapOutlined';
/**
* List of all available Icon names
@@ -89,4 +92,7 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
bank: BankIcon,
camera: CameraIcon,
warning: WarningIcon,
location: LocationIcon,
delete: DeleteIcon,
coverage: CoverageIcon,
};
@@ -0,0 +1,59 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '../../theme';
import AddressCard, { type AddressCardProps } from './AddressCard';
const BASE: AddressCardProps = {
title: 'Home',
regionLabel: 'Tehran · District 1',
addressLine: 'No. 5, Vali St',
isPrimary: false,
primaryLabel: 'Primary',
onEdit: jest.fn(),
onDelete: jest.fn(),
onSetPrimary: jest.fn(),
editLabel: 'Edit',
deleteLabel: 'Delete',
setPrimaryLabel: 'Set as primary',
};
function renderCard(overrides: Partial<AddressCardProps> = {}) {
const props = { ...BASE, ...overrides };
render(
<ThemeProvider>
<AddressCard {...props} />
</ThemeProvider>,
);
return props;
}
describe('<AddressCard/> component', () => {
it('renders the title, region label and street line', () => {
renderCard();
expect(screen.getByText('Home')).toBeInTheDocument();
expect(screen.getByText('Tehran · District 1')).toBeInTheDocument();
expect(screen.getByText('No. 5, Vali St')).toBeInTheDocument();
});
it('shows the primary badge and hides set-primary on the primary card', () => {
renderCard({ isPrimary: true });
expect(screen.getByText('Primary')).toBeInTheDocument();
expect(screen.queryByText('Set as primary')).not.toBeInTheDocument();
});
it('offers set-primary only on a non-primary card and fires it', async () => {
const user = userEvent.setup();
const props = renderCard({ isPrimary: false });
await user.click(screen.getByText('Set as primary'));
expect(props.onSetPrimary).toHaveBeenCalledTimes(1);
});
it('calls onEdit and onDelete from the action buttons', async () => {
const user = userEvent.setup();
const props = renderCard();
await user.click(screen.getByLabelText('Edit'));
await user.click(screen.getByLabelText('Delete'));
expect(props.onEdit).toHaveBeenCalledTimes(1);
expect(props.onDelete).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,88 @@
'use client';
import { FunctionComponent } from 'react';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { AppButton, AppIconButton } from '@/components/common';
import StatusChip from '@/components/StatusChip';
export interface AddressCardProps {
title: string;
/** Localised "city · district" (or "city · whole city") label, computed by the caller. */
regionLabel: string;
addressLine?: string | null;
isPrimary: boolean;
primaryLabel: string;
onEdit: () => void;
onDelete: () => void;
onSetPrimary: () => void;
editLabel: string;
deleteLabel: string;
setPrimaryLabel: string;
/** Disables the set-primary action while its mutation is in flight. */
settingPrimary?: boolean;
}
/**
* Address summary card for the address book — title + a primary badge (the f0 `StatusChip`),
* the region label and street line, with edit / delete / set-as-primary actions. Presentational:
* all display text is translated by the caller. The set-primary action shows only on non-primary
* cards so the UI never presents two primaries.
* @component AddressCard
*/
const AddressCard: FunctionComponent<AddressCardProps> = ({
title,
regionLabel,
addressLine,
isPrimary,
primaryLabel,
onEdit,
onDelete,
onSetPrimary,
editLabel,
deleteLabel,
setPrimaryLabel,
settingPrimary = false,
}) => (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack direction="row" sx={{ alignItems: 'flex-start', gap: 1 }}>
<Stack sx={{ flexGrow: 1, gap: 0.75, minWidth: 0 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{isPrimary ? <StatusChip status="verified" label={primaryLabel} /> : null}
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{regionLabel}
</Typography>
{addressLine ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{addressLine}
</Typography>
) : null}
{!isPrimary ? (
<AppButton
variant="text"
color="primary"
disabled={settingPrimary}
onClick={onSetPrimary}
sx={{ m: 0, mt: 0.25, alignSelf: 'flex-start' }}
>
{setPrimaryLabel}
</AppButton>
) : null}
</Stack>
<Stack direction="row" sx={{ flexShrink: 0 }}>
<AppIconButton icon="edit" title={editLabel} aria-label={editLabel} size="small" onClick={onEdit} />
<AppIconButton icon="delete" title={deleteLabel} aria-label={deleteLabel} size="small" onClick={onDelete} />
</Stack>
</Stack>
</Paper>
);
export default AddressCard;
@@ -0,0 +1,72 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
const PROVINCES = [{ id: 1, nameFa: 'تهران', nameEn: 'Tehran', sortOrder: 0 }];
const CITIES = [{ id: 101, provinceId: 1, nameFa: 'تهران', nameEn: 'Tehran', sortOrder: 0 }];
const DISTRICTS = [{ id: 1001, cityId: 101, nameFa: 'منطقه ۱', nameEn: 'District 1', sortOrder: 0 }];
jest.mock('@/services/geography', () => ({
useProvinces: () => ({ data: PROVINCES, isLoading: false, isSuccess: true }),
useCities: (provinceId: number | null) => ({ data: provinceId ? CITIES : [], isLoading: false, isSuccess: provinceId != null }),
useDistricts: (cityId: number | null) => ({ data: cityId ? DISTRICTS : [], isLoading: false, isSuccess: cityId != null }),
}));
import AddressForm from './AddressForm';
describe('<AddressForm/> component', () => {
it('blocks submit and flags city + pin + required text when empty', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(
<ThemeProvider>
<AddressForm onSubmit={onSubmit} />
</ThemeProvider>,
);
await user.click(screen.getByRole('button', { name: 'save' }));
expect(onSubmit).not.toHaveBeenCalled();
expect(screen.getByText('title_required')).toBeInTheDocument();
expect(screen.getByText('city_required')).toBeInTheDocument();
expect(screen.getByText('line_required')).toBeInTheDocument();
expect(screen.getByText('map_required')).toBeInTheDocument();
});
it('submits the mapped address input from an edit prefill', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(
<ThemeProvider>
<AddressForm
initial={{
title: 'Home',
provinceId: 1,
cityId: 101,
districtId: 1001,
addressLine: 'No. 5, Vali St',
latitude: 35.7,
longitude: 51.4,
isPrimary: false,
}}
onSubmit={onSubmit}
/>
</ThemeProvider>,
);
await user.click(screen.getByRole('button', { name: 'save' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
title: 'Home',
provinceId: 1,
cityId: 101,
districtId: 1001,
addressLine: 'No. 5, Vali St',
latitude: 35.7,
longitude: 51.4,
isPrimary: false,
});
});
});
@@ -0,0 +1,164 @@
'use client';
import { FunctionComponent, useState } from 'react';
import { useTranslations } from 'next-intl';
import FormControlLabel from '@mui/material/FormControlLabel';
import Stack from '@mui/material/Stack';
import Switch from '@mui/material/Switch';
import TextField from '@mui/material/TextField';
import { AppButton } from '@/components/common';
import { cityCentroid } from '@/services/geography/constants';
import type { CreateAddressInput, LatLng } from '@/services/addresses/types';
import CascadingRegionSelect, { type CascadingRegionValue } from './CascadingRegionSelect';
import AddressMapPicker from './AddressMapPicker';
/** Prefill for edit (or empty for add). `provinceId` is needed to prefill the cascade. */
export interface AddressFormInitial {
title?: string;
provinceId?: number | null;
cityId?: number | null;
districtId?: number | null;
addressLine?: string | null;
latitude?: number | null;
longitude?: number | null;
isPrimary?: boolean;
}
export interface AddressFormProps {
initial?: AddressFormInitial;
submitting?: boolean;
onSubmit: (input: CreateAddressInput) => void;
onCancel?: () => void;
}
// Only prefill the region when we have the province — a city id without its province can't drive
// the (per-province) city query, so the cascade starts fresh instead of showing a dead value.
function initialRegion(initial?: AddressFormInitial): CascadingRegionValue {
if (initial?.provinceId == null) return { provinceId: null, cityId: null, districtId: null };
return { provinceId: initial.provinceId, cityId: initial.cityId ?? null, districtId: initial.districtId ?? null };
}
function initialPin(initial?: AddressFormInitial): LatLng | null {
return initial?.latitude != null && initial?.longitude != null
? { latitude: initial.latitude, longitude: initial.longitude }
: null;
}
/**
* The add/edit address form body — the cascading region dropdowns + the map-pin picker + a
* title and street address + a "set as primary" toggle. Validation: **city required**, **pin
* required** (surfaced inline), title + street required, **district optional**. Emits a
* `CreateAddressInput` carrying the picked coordinates. Reused for create and edit.
* @component AddressForm
*/
const AddressForm: FunctionComponent<AddressFormProps> = ({ initial, submitting = false, onSubmit, onCancel }) => {
const t = useTranslations('address');
const tc = useTranslations('common');
const [title, setTitle] = useState(initial?.title ?? '');
const [region, setRegion] = useState<CascadingRegionValue>(() => initialRegion(initial));
const [addressLine, setAddressLine] = useState(initial?.addressLine ?? '');
const [pin, setPin] = useState<LatLng | null>(() => initialPin(initial));
const [isPrimary, setIsPrimary] = useState(initial?.isPrimary ?? false);
const [titleError, setTitleError] = useState(false);
const [cityError, setCityError] = useState(false);
const [lineError, setLineError] = useState(false);
const [pinError, setPinError] = useState(false);
const handleSubmit = () => {
const titleInvalid = title.trim().length === 0;
const cityInvalid = region.cityId == null;
const lineInvalid = addressLine.trim().length === 0;
const pinInvalid = pin == null;
setTitleError(titleInvalid);
setCityError(cityInvalid);
setLineError(lineInvalid);
setPinError(pinInvalid);
if (titleInvalid || cityInvalid || lineInvalid || pinInvalid) return;
onSubmit({
title: title.trim(),
provinceId: region.provinceId as number,
cityId: region.cityId as number,
districtId: region.districtId,
addressLine: addressLine.trim(),
latitude: pin!.latitude,
longitude: pin!.longitude,
isPrimary,
});
};
return (
<Stack sx={{ gap: 2.5 }}>
<TextField
label={t('title_label')}
placeholder={t('title_placeholder')}
value={title}
onChange={(event) => {
setTitle(event.target.value);
if (titleError) setTitleError(false);
}}
error={titleError}
helperText={titleError ? t('title_required') : undefined}
fullWidth
/>
<CascadingRegionSelect
value={region}
onChange={(next) => {
setRegion(next);
if (cityError && next.cityId != null) setCityError(false);
}}
cityError={cityError}
cityErrorText={t('city_required')}
/>
<AddressMapPicker
value={pin}
onChange={(next) => {
setPin(next);
if (pinError) setPinError(false);
}}
center={cityCentroid(region.cityId)}
helperText={t('map_hint')}
latLabel={t('map_lat')}
lngLabel={t('map_lng')}
error={pinError}
errorText={t('map_required')}
/>
<TextField
label={t('line_label')}
value={addressLine}
onChange={(event) => {
setAddressLine(event.target.value);
if (lineError) setLineError(false);
}}
error={lineError}
helperText={lineError ? t('line_required') : t('line_hint')}
multiline
minRows={2}
fullWidth
/>
<FormControlLabel
control={<Switch checked={isPrimary} onChange={(event) => setIsPrimary(event.target.checked)} />}
label={t('set_primary_toggle')}
/>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
{onCancel ? (
<AppButton variant="text" onClick={onCancel} disabled={submitting} sx={{ m: 0 }}>
{tc('cancel')}
</AppButton>
) : null}
<AppButton color="primary" variant="contained" onClick={handleSubmit} disabled={submitting} sx={{ m: 0 }}>
{submitting ? tc('saving') : tc('save')}
</AppButton>
</Stack>
</Stack>
);
};
export default AddressForm;
@@ -0,0 +1,43 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import AddressMapPicker from './AddressMapPicker';
import type { LatLng } from '@/services/addresses/types';
function renderPicker(value: LatLng | null, center?: LatLng) {
const onChange = jest.fn();
const utils = render(
<ThemeProvider>
<AddressMapPicker
value={value}
onChange={onChange}
center={center}
helperText="Drop a pin"
latLabel="Latitude"
lngLabel="Longitude"
/>
</ThemeProvider>,
);
return { ...utils, onChange };
}
describe('<AddressMapPicker/> component', () => {
it('prompts to drop a pin when no coordinate is set', () => {
renderPicker(null);
expect(screen.getAllByText('Drop a pin').length).toBeGreaterThan(0);
});
it('emits real coordinates around the centre when the canvas is tapped', () => {
const center = { latitude: 35.6892, longitude: 51.389 };
const { onChange } = renderPicker(null, center);
fireEvent.click(screen.getByRole('application'), { clientX: 10, clientY: 10 });
// jsdom reports a 0×0 rect, so the tap resolves to the viewport centre = the city centroid.
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(center);
});
it('shows the coordinate readout once a pin is placed', () => {
renderPicker({ latitude: 35.6892, longitude: 51.389 });
expect(screen.getByText(/35\.68920/)).toBeInTheDocument();
expect(screen.getByText(/51\.38900/)).toBeInTheDocument();
});
});
@@ -0,0 +1,157 @@
'use client';
import { FunctionComponent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import AppIcon from '@/components/common/AppIcon';
import { IRAN_CENTROID } from '@/services/geography/constants';
import type { LatLng } from '@/services/addresses/types';
export interface AddressMapPickerProps {
value: LatLng | null;
onChange: (value: LatLng) => void;
/** Centre of the picker's viewport — the chosen city's centroid, or the Iran centroid. */
center?: LatLng;
helperText: string;
latLabel: string;
lngLabel: string;
error?: boolean;
errorText?: string;
}
// Half-degree span each side of the centre (~±6 km) — enough precision for the later EVV check
// while keeping the whole stand-in viewport around one city.
const SPAN = 0.06;
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
const round6 = (n: number) => Math.round(n * 1e6) / 1e6;
/**
* Lightweight **map-pin picker stand-in** — a draggable/tappable marker panel that emits real
* `{ latitude, longitude }`. It is NOT a real map (no Neshan/Google tiles), only a bounded
* canvas mapping the pointer position to coordinates around the chosen city's centroid, behind a
* small component boundary so a real map drops in later without touching the address form. The
* picked coordinates are what the create/update request sends; the pin only refines coordinates —
* the bookable geography is still the region dropdown choice.
* @component AddressMapPicker
*/
const AddressMapPicker: FunctionComponent<AddressMapPickerProps> = ({
value,
onChange,
center = IRAN_CENTROID,
helperText,
latLabel,
lngLabel,
error = false,
errorText,
}) => {
const mapRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState(false);
// Fraction [0,1] across the canvas → coordinates. x: west→east (left→right); y: north→south (top→bottom).
const fractionToLatLng = (fx: number, fy: number): LatLng => ({
latitude: round6(center.latitude + SPAN - clamp01(fy) * 2 * SPAN),
longitude: round6(center.longitude - SPAN + clamp01(fx) * 2 * SPAN),
});
// Inverse — coordinates → percent offsets for the marker, plus the centering transform. All applied
// via inline `style` (not `sx`) so the RTL stylis plugin can't mirror them: it flips physical `left`
// AND inverts the X of `transform: translate(...)`, either of which would offset the pin from its
// click point on the default (fa/RTL) locale. Inline style bypasses the emotion cache entirely.
const markerStyle = (v: LatLng) => ({
left: `${clamp01((v.longitude - (center.longitude - SPAN)) / (2 * SPAN)) * 100}%`,
top: `${clamp01((center.latitude + SPAN - v.latitude) / (2 * SPAN)) * 100}%`,
transform: 'translate(-50%, -100%)',
});
const place = (clientX: number, clientY: number) => {
const rect = mapRef.current?.getBoundingClientRect();
// jsdom / zero-size layouts report a 0×0 rect — fall back to the centre so a tap still resolves.
const fx = rect && rect.width ? (clientX - rect.left) / rect.width : 0.5;
const fy = rect && rect.height ? (clientY - rect.top) / rect.height : 0.5;
onChange(fractionToLatLng(fx, fy));
};
// A tap places via `click` (reliable everywhere); a drag places via pointer moves while held.
const handleClick = (event: ReactMouseEvent<HTMLDivElement>) => place(event.clientX, event.clientY);
const handlePointerDown = () => setDragging(true);
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
if (dragging) place(event.clientX, event.clientY);
};
const stopDragging = () => setDragging(false);
return (
<Stack sx={{ gap: 1 }}>
<Box
ref={mapRef}
dir="ltr"
role="application"
aria-label={helperText}
onClick={handleClick}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={stopDragging}
onPointerLeave={stopDragging}
sx={{
position: 'relative',
height: 220,
borderRadius: 2,
border: '1px solid',
borderColor: error ? 'var(--bal-error)' : 'divider',
cursor: 'crosshair',
overflow: 'hidden',
touchAction: 'none',
bgcolor: 'var(--bal-primary-soft)',
backgroundImage:
'linear-gradient(var(--bal-divider) 1px, transparent 1px), linear-gradient(90deg, var(--bal-divider) 1px, transparent 1px)',
backgroundSize: '28px 28px',
}}
>
{value ? (
<Box sx={{ position: 'absolute', inset: 0 }}>
<Box
style={markerStyle(value)}
sx={{
position: 'absolute',
color: 'var(--bal-primary)',
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.35))',
pointerEvents: 'none',
}}
>
<AppIcon icon="location" size={32} color="var(--bal-primary)" />
</Box>
</Box>
) : (
<Stack sx={{ position: 'absolute', inset: 0, alignItems: 'center', justifyContent: 'center', gap: 0.5, pointerEvents: 'none' }}>
<AppIcon icon="location" size={28} color="var(--bal-text-secondary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{helperText}
</Typography>
</Stack>
)}
</Box>
{error && errorText ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{errorText}
</Typography>
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{helperText}
</Typography>
)}
{value ? (
<Stack direction="row" dir="ltr" sx={{ gap: 2, alignSelf: 'flex-start' }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{latLabel}: {value.latitude.toFixed(5)}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{lngLabel}: {value.longitude.toFixed(5)}
</Typography>
</Stack>
) : null}
</Stack>
);
};
export default AddressMapPicker;
@@ -0,0 +1,72 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
const PROVINCES = [
{ id: 1, nameFa: 'تهران', nameEn: 'Tehran', sortOrder: 0 },
{ id: 4, nameFa: 'فارس', nameEn: 'Fars', sortOrder: 3 },
];
const CITIES = [{ id: 101, provinceId: 1, nameFa: 'تهران', nameEn: 'Tehran', sortOrder: 0 }];
const DISTRICTS = [{ id: 1001, cityId: 101, nameFa: 'منطقه ۱', nameEn: 'District 1', sortOrder: 0 }];
// Mock the aggressively-cached geography hooks so the cascade renders deterministically:
// city 101 has districts; any other city is whole-city-only (fetched-but-empty).
jest.mock('@/services/geography', () => ({
useProvinces: () => ({ data: PROVINCES, isLoading: false, isSuccess: true }),
useCities: (provinceId: number | null) => ({ data: provinceId ? CITIES : [], isLoading: false, isSuccess: provinceId != null }),
useDistricts: (cityId: number | null) => ({
data: cityId === 101 ? DISTRICTS : [],
isLoading: false,
isSuccess: cityId != null,
}),
}));
import CascadingRegionSelect, { type CascadingRegionValue } from './CascadingRegionSelect';
const NONE: CascadingRegionValue = { provinceId: null, cityId: null, districtId: null };
function renderSelect(value: CascadingRegionValue) {
const onChange = jest.fn();
const utils = render(
<ThemeProvider>
<CascadingRegionSelect value={value} onChange={onChange} />
</ThemeProvider>,
);
return { ...utils, onChange };
}
describe('<CascadingRegionSelect/> component', () => {
it('lists provinces and blocks the city level until a province is chosen', () => {
renderSelect(NONE);
expect(screen.getByRole('combobox', { name: 'province' })).toBeInTheDocument();
// City is not selectable yet — its helper prompts to pick a province first.
expect(screen.getByText('city_needs_province')).toBeInTheDocument();
});
it('resets the city/district when the province changes', async () => {
const user = userEvent.setup();
const { onChange } = renderSelect({ provinceId: 1, cityId: 101, districtId: 1001 });
await user.click(screen.getByRole('combobox', { name: 'province' }));
await user.click(screen.getByRole('option', { name: 'Fars' }));
expect(onChange).toHaveBeenCalledWith({ provinceId: 4, cityId: null, districtId: null });
});
it('offers the whole-city option plus districts for a city that has them', async () => {
const user = userEvent.setup();
const { onChange } = renderSelect({ provinceId: 1, cityId: 101, districtId: null });
await user.click(screen.getByRole('combobox', { name: 'district' }));
expect(screen.getByRole('option', { name: 'whole_city' })).toBeInTheDocument();
await user.click(screen.getByRole('option', { name: 'District 1' }));
expect(onChange).toHaveBeenCalledWith({ provinceId: 1, cityId: 101, districtId: 1001 });
});
it('surfaces the whole-city affordance for a city with no districts', () => {
renderSelect({ provinceId: 1, cityId: 201, districtId: null });
expect(screen.getByText('no_districts')).toBeInTheDocument();
});
});
@@ -0,0 +1,164 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import CircularProgress from '@mui/material/CircularProgress';
import InputAdornment from '@mui/material/InputAdornment';
import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import { useProvinces, useCities, useDistricts } from '@/services/geography';
import { pickRegionName } from '@/services/geography/names';
/** The region a caller reads back — a `null` id means "not chosen yet"; a `null` district also
* doubles as the deliberate "whole city" choice once a city is set. */
export interface CascadingRegionValue {
provinceId: number | null;
cityId: number | null;
districtId: number | null;
}
export interface CascadingRegionSelectProps {
value: CascadingRegionValue;
onChange: (next: CascadingRegionValue) => void;
/** Render the district level. Off for a whole-city-only context (e.g. the coverage "whole city" scope). */
includeDistrict?: boolean;
cityError?: boolean;
cityErrorText?: string;
districtError?: boolean;
districtErrorText?: string;
disabled?: boolean;
}
const toId = (raw: string): number | null => (raw === '' ? null : Number(raw));
/**
* Province → city → district cascading dropdowns, driving the aggressively-cached geography
* queries itself so both the address form and the coverage editor drop it in with only a
* `value`/`onChange`. Each level enables only once its parent is chosen and resets its children
* on change; inactive regions never arrive (the server filters them), and a city with no
* districts surfaces the **whole-city** affordance rather than an error. **City is required;
* district is optional** — leaving district empty is a real choice, never an error.
* @component CascadingRegionSelect
*/
const CascadingRegionSelect: FunctionComponent<CascadingRegionSelectProps> = ({
value,
onChange,
includeDistrict = true,
cityError = false,
cityErrorText,
districtError = false,
districtErrorText,
disabled = false,
}) => {
const t = useTranslations('geo');
const locale = useLocale();
const provincesQuery = useProvinces();
const citiesQuery = useCities(value.provinceId);
const districtsQuery = useDistricts(value.cityId);
const provinces = provincesQuery.data ?? [];
const cities = citiesQuery.data ?? [];
const districts = districtsQuery.data ?? [];
const hasProvince = value.provinceId != null;
const hasCity = value.cityId != null;
// Distinguish "not fetched yet" from "fetched and genuinely empty" (whole-city-only city).
const cityHasNoDistricts = hasCity && districtsQuery.isSuccess && districts.length === 0;
// Only bind a value the loaded options actually contain — an id whose options are still
// fetching (e.g. an edit prefill) would otherwise trip MUI's out-of-range Select warning.
const provinceValue = provinces.some((province) => province.id === value.provinceId) ? value.provinceId : '';
const cityValue = cities.some((city) => city.id === value.cityId) ? value.cityId : '';
const districtValue = districts.some((district) => district.id === value.districtId) ? value.districtId : '';
const loadingAdornment = (loading: boolean) =>
loading
? {
endAdornment: (
<InputAdornment position="end">
<CircularProgress size={16} />
</InputAdornment>
),
}
: undefined;
const handleProvince = (raw: string) =>
onChange({ provinceId: toId(raw), cityId: null, districtId: null });
const handleCity = (raw: string) =>
onChange({ provinceId: value.provinceId, cityId: toId(raw), districtId: null });
const handleDistrict = (raw: string) =>
onChange({ provinceId: value.provinceId, cityId: value.cityId, districtId: toId(raw) });
return (
<Stack sx={{ gap: 2 }}>
<TextField
select
label={t('province')}
value={provinceValue}
onChange={(event) => handleProvince(event.target.value)}
disabled={disabled || provincesQuery.isLoading}
slotProps={{ input: loadingAdornment(provincesQuery.isLoading) }}
fullWidth
>
{provinces.map((province) => (
<MenuItem key={province.id} value={province.id}>
{pickRegionName(province, locale)}
</MenuItem>
))}
</TextField>
<TextField
select
label={t('city')}
value={cityValue}
onChange={(event) => handleCity(event.target.value)}
disabled={disabled || !hasProvince || citiesQuery.isLoading}
error={cityError}
helperText={cityError ? cityErrorText : !hasProvince ? t('city_needs_province') : undefined}
slotProps={{ input: loadingAdornment(citiesQuery.isLoading) }}
fullWidth
>
{cities.map((city) => (
<MenuItem key={city.id} value={city.id}>
{pickRegionName(city, locale)}
</MenuItem>
))}
</TextField>
{includeDistrict ? (
<TextField
select
label={t('district')}
value={districtValue}
onChange={(event) => handleDistrict(event.target.value)}
disabled={disabled || !hasCity || districtsQuery.isLoading || cityHasNoDistricts}
error={districtError}
helperText={
districtError
? districtErrorText
: !hasCity
? t('district_needs_city')
: cityHasNoDistricts
? t('no_districts')
: t('district_optional')
}
slotProps={{ input: loadingAdornment(districtsQuery.isLoading) }}
fullWidth
>
{/* The empty option is the explicit "whole city" choice — district is optional. */}
<MenuItem value="">{t('whole_city')}</MenuItem>
{districts.map((district) => (
<MenuItem key={district.id} value={district.id}>
{pickRegionName(district, locale)}
</MenuItem>
))}
</TextField>
) : null}
</Stack>
);
};
export default CascadingRegionSelect;
+10
View File
@@ -0,0 +1,10 @@
import CascadingRegionSelect from './CascadingRegionSelect';
import AddressMapPicker from './AddressMapPicker';
import AddressForm from './AddressForm';
import AddressCard from './AddressCard';
export { CascadingRegionSelect, AddressMapPicker, AddressForm, AddressCard };
export type { CascadingRegionSelectProps, CascadingRegionValue } from './CascadingRegionSelect';
export type { AddressMapPickerProps } from './AddressMapPicker';
export type { AddressFormProps, AddressFormInitial } from './AddressForm';
export type { AddressCardProps } from './AddressCard';