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:
@@ -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;
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user