diff --git a/src/api/calls/__tests__/saveCall.test.ts b/src/api/calls/__tests__/saveCall.test.ts new file mode 100644 index 00000000..a9574280 --- /dev/null +++ b/src/api/calls/__tests__/saveCall.test.ts @@ -0,0 +1,72 @@ +import { createCall, updateCall } from '../calls'; + +jest.mock('../../common/client', () => { + const post = jest.fn(); + const put = jest.fn(); + return { + createApiEndpoint: jest.fn(() => ({ get: jest.fn(), post, put, delete: jest.fn() })), + __mockPost: post, + __mockPut: put, + }; +}); + +jest.mock('../../common/cached-client', () => ({ + createCachedApiEndpoint: jest.fn(() => ({ get: jest.fn() })), +})); + +jest.mock('@/lib/cache/cache-manager', () => ({ + cacheManager: { remove: jest.fn() }, +})); + +jest.mock('@/lib/logging', () => ({ + logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +const { __mockPost: mockPost, __mockPut: mockPut } = jest.requireMock('../../common/client') as { __mockPost: jest.Mock; __mockPut: jest.Mock }; + +const base = { name: 'Structure fire', nature: 'Smoke showing', priority: 1, type: 'Fire' }; + +describe('call save payloads', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPost.mockResolvedValue({ data: {} }); + mockPut.mockResolvedValue({ data: {} }); + }); + + describe('createCall', () => { + it('sends the reporter details and call identifiers', async () => { + await createCall({ ...base, contactName: 'Jo Caller', contactInfo: '555-0100', externalId: 'CAD-1', incidentId: 'INC-7', referenceId: 'R-9' }); + + expect(mockPost).toHaveBeenCalledWith( + expect.objectContaining({ ContactName: 'Jo Caller', ContactInfo: '555-0100', ExternalId: 'CAD-1', IncidentId: 'INC-7', ReferenceId: 'R-9' }) + ); + }); + + it('sends the scheduled dispatch time only when one is set', async () => { + await createCall({ ...base, dispatchOnUtc: '2026-10-09T14:30:00.000Z' }); + expect(mockPost).toHaveBeenLastCalledWith(expect.objectContaining({ DispatchOnUtc: '2026-10-09T14:30:00.000Z' })); + + await createCall({ ...base }); + expect(mockPost.mock.calls[1][0]).not.toHaveProperty('DispatchOnUtc'); + + await createCall({ ...base, dispatchOnUtc: '' }); + expect(mockPost.mock.calls[2][0]).not.toHaveProperty('DispatchOnUtc'); + }); + }); + + describe('updateCall', () => { + it('sends the call identifiers, blank meaning "keep what is stored"', async () => { + await updateCall({ ...base, callId: '42', externalId: 'CAD-1' }); + + expect(mockPut).toHaveBeenCalledWith(expect.objectContaining({ Id: '42', ExternalId: 'CAD-1', IncidentId: '', ReferenceId: '' })); + }); + + it('sends the scheduled dispatch time only when one is set, so leaving it out keeps the schedule', async () => { + await updateCall({ ...base, callId: '42', dispatchOnUtc: '2026-10-09T14:30:00.000Z' }); + expect(mockPut).toHaveBeenLastCalledWith(expect.objectContaining({ DispatchOnUtc: '2026-10-09T14:30:00.000Z' })); + + await updateCall({ ...base, callId: '42' }); + expect(mockPut.mock.calls[1][0]).not.toHaveProperty('DispatchOnUtc'); + }); + }); +}); diff --git a/src/api/calls/calls.ts b/src/api/calls/calls.ts index a81ffbf7..b77372c5 100644 --- a/src/api/calls/calls.ts +++ b/src/api/calls/calls.ts @@ -49,6 +49,15 @@ export interface CreateCallRequest { type?: string; contactName?: string; contactInfo?: string; + /** External (CAD) call id. On update, blank keeps the stored value. */ + externalId?: string; + incidentId?: string; + referenceId?: string; + /** + * Scheduled dispatch time as an ISO 8601 UTC instant. Sent only when set: on create a past time dispatches at once + * (ignored for a pending call); on update, leaving it out keeps the schedule — the API has no way to clear it. + */ + dispatchOnUtc?: string; /** Primary Contact (premises/customer record) to link; the contact must belong to the department. */ contactId?: string | null; /** Additional Contacts to link. On update, supplying either list replaces the existing links; omitting both leaves them alone. */ @@ -75,6 +84,15 @@ export interface UpdateCallRequest { type?: string; contactName?: string; contactInfo?: string; + /** External (CAD) call id. On update, blank keeps the stored value. */ + externalId?: string; + incidentId?: string; + referenceId?: string; + /** + * Scheduled dispatch time as an ISO 8601 UTC instant. Sent only when set: on create a past time dispatches at once + * (ignored for a pending call); on update, leaving it out keeps the schedule — the API has no way to clear it. + */ + dispatchOnUtc?: string; /** Primary Contact (premises/customer record) to link; the contact must belong to the department. */ contactId?: string | null; /** Additional Contacts to link. On update, supplying either list replaces the existing links; omitting both leaves them alone. */ @@ -139,6 +157,10 @@ export const createCall = async (callData: CreateCallRequest) => { Type: callData.type || '', ContactName: callData.contactName || '', ContactInfo: callData.contactInfo || '', + ExternalId: callData.externalId || '', + IncidentId: callData.incidentId || '', + ReferenceId: callData.referenceId || '', + ...(callData.dispatchOnUtc ? { DispatchOnUtc: callData.dispatchOnUtc } : {}), ...(callData.contactId !== undefined ? { ContactId: callData.contactId || '' } : {}), ...(callData.additionalContactIds !== undefined ? { AdditionalContactIds: callData.additionalContactIds } : {}), What3Words: callData.what3words || '', @@ -174,6 +196,10 @@ export const updateCall = async (callData: UpdateCallRequest) => { Type: callData.type || '', ContactName: callData.contactName || '', ContactInfo: callData.contactInfo || '', + ExternalId: callData.externalId || '', + IncidentId: callData.incidentId || '', + ReferenceId: callData.referenceId || '', + ...(callData.dispatchOnUtc ? { DispatchOnUtc: callData.dispatchOnUtc } : {}), ...(callData.contactId !== undefined ? { ContactId: callData.contactId || '' } : {}), ...(callData.additionalContactIds !== undefined ? { AdditionalContactIds: callData.additionalContactIds } : {}), What3Words: callData.what3words || '', diff --git a/src/app/call/[id]/edit.tsx b/src/app/call/[id]/edit.tsx index 61f5fb5f..2af917c8 100644 --- a/src/app/call/[id]/edit.tsx +++ b/src/app/call/[id]/edit.tsx @@ -1,8 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import axios from 'axios'; import { router, Stack, useLocalSearchParams } from 'expo-router'; -import { ChevronDownIcon, PlusIcon, SearchIcon } from 'lucide-react-native'; -import { useColorScheme } from 'nativewind'; import React, { useEffect, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -10,25 +7,41 @@ import { ScrollView, View } from 'react-native'; import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; import * as z from 'zod'; +import { CallAddressSelectionSheet, CallLocationSearchField } from '@/components/calls/call-location-fields'; import { DestinationPoiSelector } from '@/components/calls/destination-poi-selector'; import { DispatchSelectionModal } from '@/components/calls/dispatch-selection-modal'; +import { DateTimeField } from '@/components/common/date-time-field'; import { HeaderBackButton } from '@/components/common/header-back-button'; import { Loading } from '@/components/common/loading'; import FullScreenLocationPicker from '@/components/maps/full-screen-location-picker'; import LocationPicker from '@/components/maps/location-picker'; -import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; import { Box } from '@/components/ui/box'; import { Button, ButtonSpinner, ButtonText } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { FormControl, FormControlError, FormControlLabel, FormControlLabelText } from '@/components/ui/form-control'; import { Input, InputField } from '@/components/ui/input'; +import { ChevronDownIcon } from '@/components/ui/lucide-icons'; import { Select, SelectBackdrop, SelectContent, SelectIcon, SelectInput, SelectItem, SelectPortal, SelectTrigger } from '@/components/ui/select'; import { Text } from '@/components/ui/text'; import { Textarea, TextareaInput } from '@/components/ui/textarea'; -import { useToast } from '@/components/ui/toast'; import { useAnalytics } from '@/hooks/use-analytics'; +import { type CallLocation, useCallLocationSearch } from '@/hooks/use-call-location-search'; +import { useNewCallFieldPolicy } from '@/hooks/use-new-call-field-policy'; +import { useToast } from '@/hooks/use-toast'; +import { + CALL_FIELD_LABEL_KEYS, + CALL_IDENTIFIER_FIELDS, + DISPATCH_ON_MIN_LEAD_MINUTES, + formatCallFieldLabels, + getEditCallFieldValues, + getEnforcedMissingCallFields, + getMissingRequiredCallFields, + getScheduledDispatchPrefill, + isDispatchOnTooSoon, + isPendingCallState, +} from '@/lib/call-field-policy'; import { logger } from '@/lib/logging'; -import { useCoreStore } from '@/stores/app/core-store'; +import { NewCallFieldKeys } from '@/models/v4/calls/newCallFieldPolicyResultData'; import { useCallDetailStore } from '@/stores/calls/detail-store'; import { useCallsStore } from '@/stores/calls/store'; import { type DispatchSelection } from '@/stores/dispatch/store'; @@ -49,6 +62,10 @@ const formSchema = z.object({ type: z.string().min(1, 'Type is required'), contactName: z.string().optional(), contactInfo: z.string().optional(), + externalId: z.string().optional(), + incidentId: z.string().optional(), + referenceId: z.string().optional(), + dispatchOn: z.string().optional(), dispatchSelection: z.object({ everyone: z.boolean(), users: z.array(z.string()), @@ -60,26 +77,17 @@ const formSchema = z.object({ type FormValues = z.infer; -interface GeocodingResult { - place_id: string; - formatted_address: string; - geometry: { - location: { - lat: number; - lng: number; - }; - }; -} - -interface GeocodingResponse { - results: GeocodingResult[]; - status: string; -} +const EMPTY_DISPATCH: DispatchSelection = { + everyone: false, + users: [], + groups: [], + roles: [], + units: [], +}; export default function EditCall() { const { t } = useTranslation(); const { trackEvent } = useAnalytics(); - const { colorScheme } = useColorScheme(); const { id } = useLocalSearchParams(); const callId = Array.isArray(id) ? id[0] : id; const callPriorities = useCallsStore((state) => state.callPriorities); @@ -94,28 +102,21 @@ export default function EditCall() { const callDetailLoading = useCallDetailStore((state) => state.isLoading); const callDetailError = useCallDetailStore((state) => state.error); const fetchCallDetail = useCallDetailStore((state) => state.fetchCallDetail); - const config = useCoreStore((state) => state.config); const toast = useToast(); const [showLocationPicker, setShowLocationPicker] = useState(false); const [showDispatchModal, setShowDispatchModal] = useState(false); - const [showAddressSelection, setShowAddressSelection] = useState(false); - const [isGeocodingAddress, setIsGeocodingAddress] = useState(false); - const [isGeocodingPlusCode, setIsGeocodingPlusCode] = useState(false); - const [isGeocodingCoordinates, setIsGeocodingCoordinates] = useState(false); - const [isGeocodingWhat3Words, setIsGeocodingWhat3Words] = useState(false); - const [addressResults, setAddressResults] = useState([]); - const [dispatchSelection, setDispatchSelection] = useState({ - everyone: false, - users: [], - groups: [], - roles: [], - units: [], - }); - const [selectedLocation, setSelectedLocation] = useState<{ - latitude: number; - longitude: number; - address?: string; - } | null>(null); + const [dispatchSelection, setDispatchSelection] = useState(EMPTY_DISPATCH); + const [selectedLocation, setSelectedLocation] = useState(null); + + // The department's call field policy applies to edits too: the same fields are hidden, and the call + // must still have every required field once the edit is saved. The server enforces it as well. + const fieldPolicy = useNewCallFieldPolicy(); + + // The scheduled dispatch time the form was seeded with ('' when the call has no upcoming schedule). Left as it + // was, the save does not send it, so an upcoming schedule is never re-validated against the 15-minute rule just + // because something else changed. While there is one the picker offers no "clear": EditCall cannot remove a + // schedule, so clearing would look like it worked while the stored time stayed. + const [initialDispatchOn, setInitialDispatchOn] = useState(''); const { control, @@ -140,13 +141,11 @@ export default function EditCall() { type: '', contactName: '', contactInfo: '', - dispatchSelection: { - everyone: false, - users: [], - groups: [], - roles: [], - units: [], - }, + externalId: '', + incidentId: '', + referenceId: '', + dispatchOn: '', + dispatchSelection: EMPTY_DISPATCH, }, }); @@ -191,13 +190,18 @@ export default function EditCall() { setDispatchSelection(initialDispatch); + // Only a schedule that has not gone out yet is editable; a past time is when the call was sent. + const dispatchOnPrefill = getScheduledDispatchPrefill(call.DispatchedOnUtc); + setInitialDispatchOn(dispatchOnPrefill); + reset({ name: call.Name || '', nature: call.Nature || '', note: call.Note || '', address: call.Address || '', coordinates: call.Geolocation || '', - what3words: '', + what3words: call.What3Words || '', + // A plus code is only a way to find a location; the call does not store one. plusCode: '', latitude: call.Latitude ? parseFloat(call.Latitude) : undefined, longitude: call.Longitude ? parseFloat(call.Longitude) : undefined, @@ -206,6 +210,10 @@ export default function EditCall() { type: type?.Name || '', contactName: call.ContactName || '', contactInfo: call.ContactInfo || '', + externalId: call.ExternalId || '', + incidentId: call.IncidentId || '', + referenceId: call.ReferenceId || '', + dispatchOn: dispatchOnPrefill, dispatchSelection: initialDispatch, }); @@ -234,7 +242,31 @@ export default function EditCall() { } }, [trackEvent, call]); + const showNote = fieldPolicy.isVisible(NewCallFieldKeys.Note); + const showAddress = fieldPolicy.isVisible(NewCallFieldKeys.Address); + const showGeolocation = fieldPolicy.isVisible(NewCallFieldKeys.Geolocation); + const showWhat3Words = fieldPolicy.isVisible(NewCallFieldKeys.What3Words); + const showPlusCode = fieldPolicy.isVisible(NewCallFieldKeys.PlusCode); + const showDestinationPoi = fieldPolicy.isVisible(NewCallFieldKeys.DestinationPoi); + const showContactName = fieldPolicy.isVisible(NewCallFieldKeys.ContactName); + const showContactInfo = fieldPolicy.isVisible(NewCallFieldKeys.ContactInfo); + const showDispatchList = fieldPolicy.isVisible(NewCallFieldKeys.DispatchList); + const showDispatchOn = fieldPolicy.isVisible(NewCallFieldKeys.DispatchOn); + const visibleIdentifierFields = CALL_IDENTIFIER_FIELDS.filter((field) => fieldPolicy.isVisible(field.key)); + const showLocationCard = showAddress || showGeolocation || showWhat3Words || showPlusCode || showDestinationPoi; + const onSubmit = async (data: FormValues) => { + if (!call) { + return; + } + + // The policy arrives asynchronously and reads as "nothing hidden, nothing required" until it + // lands, so a save in that window could skip a required field or overwrite a hidden one. + if (!fieldPolicy.isLoaded) { + toast.error(t('calls.field_policy_loading')); + return; + } + try { // If we have latitude and longitude, add them to the data if (selectedLocation?.latitude && selectedLocation?.longitude) { @@ -242,10 +274,57 @@ export default function EditCall() { data.longitude = selectedLocation.longitude; } + // Checked against the call as the save will leave it (EditCall keeps the stored value for a blank + // input), the same way the server checks it. A pending call has not been dispatched yet, so its + // dispatch list is not required; the dispatch time is never asked for on an edit. + const missingFields = getEnforcedMissingCallFields( + fieldPolicy.missingRequired( + getEditCallFieldValues( + { + note: data.note, + address: data.address, + latitude: data.latitude, + longitude: data.longitude, + what3words: data.what3words, + plusCode: data.plusCode, + contactName: data.contactName, + contactInfo: data.contactInfo, + externalId: data.externalId, + incidentId: data.incidentId, + referenceId: data.referenceId, + destinationPoiId: data.destinationPoiId, + dispatchSelection, + }, + call + ) + ), + { isPending: isPendingCallState(call.State), isEdit: true } + ); + + if (missingFields.length > 0) { + toast.error(t('calls.required_fields_missing', { fields: formatCallFieldLabels(missingFields, (labelKey) => t(labelKey)) })); + return; + } + + // A new or moved schedule must leave the dispatcher time to change their mind (the web form's rule). An + // unchanged or cleared one is not sent: the API keeps the stored schedule and has no way to clear it. + const dispatchOnChanged = showDispatchOn && !!data.dispatchOn && data.dispatchOn !== initialDispatchOn; + + if (dispatchOnChanged && isDispatchOnTooSoon(data.dispatchOn!)) { + toast.error(t('calls.dispatch_on_too_soon', { minutes: DISPATCH_ON_MIN_LEAD_MINUTES })); + return; + } + const priority = callPriorities.find((p) => p.Name === data.priority); const type = callTypes.find((t) => t.Name === data.type); - // Update the call using the store + // A field the department hides was never on this form, so the save must leave it as stored. For + // EditCall that means a blank text value and no location point (the server keeps the stored one, or + // geocodes a changed address). A hidden destination and dispatch list are sent as loaded rather than + // left out: the current server ignores both when hidden, but an older one reads a missing destination + // as "clear it" and a blank dispatch list as "page everyone", so this is safe in any deploy order. + const dispatch = data.dispatchSelection; + await useCallDetailStore.getState().updateCall({ callId: callId!, name: data.name, @@ -253,63 +332,60 @@ export default function EditCall() { priority: priority?.Id || 0, // The API matches the call type by its text, not its id. type: type?.Name || '', - note: data.note, - address: data.address, - latitude: data.latitude, - longitude: data.longitude, + note: showNote ? data.note : '', + address: showAddress ? data.address : '', + latitude: showGeolocation ? data.latitude : undefined, + longitude: showGeolocation ? data.longitude : undefined, destinationPoiId: data.destinationPoiId ? Number(data.destinationPoiId) : null, - what3words: data.what3words, - plusCode: data.plusCode, - contactName: data.contactName, - contactInfo: data.contactInfo, - dispatchUsers: data.dispatchSelection?.users, - dispatchGroups: data.dispatchSelection?.groups, - dispatchRoles: data.dispatchSelection?.roles, - dispatchUnits: data.dispatchSelection?.units, - dispatchEveryone: data.dispatchSelection?.everyone, + what3words: showWhat3Words ? data.what3words : '', + plusCode: showPlusCode ? data.plusCode : '', + contactName: showContactName ? data.contactName : '', + contactInfo: showContactInfo ? data.contactInfo : '', + externalId: fieldPolicy.isVisible(NewCallFieldKeys.ExternalId) ? data.externalId : '', + incidentId: fieldPolicy.isVisible(NewCallFieldKeys.IncidentId) ? data.incidentId : '', + referenceId: fieldPolicy.isVisible(NewCallFieldKeys.ReferenceId) ? data.referenceId : '', + dispatchOnUtc: dispatchOnChanged ? data.dispatchOn : undefined, + dispatchUsers: dispatch?.users, + dispatchGroups: dispatch?.groups, + dispatchRoles: dispatch?.roles, + dispatchUnits: dispatch?.units, + dispatchEveryone: dispatch?.everyone, }); - // Show success toast - toast.show({ - placement: 'top', - render: () => { - return ( - - {t('call_detail.update_call_success')} - - ); - }, - }); + toast.success(t('call_detail.update_call_success')); // Navigate back to call detail router.back(); } catch (error) { logger.error({ message: 'Error updating call', context: { error } }); - // Show error toast - toast.show({ - placement: 'top', - render: () => { - return ( - - {t('call_detail.update_call_error')} - - ); - }, - }); + // The server enforces the full policy, including fields this form has no input for; name them + // the way the form does rather than showing a generic failure. + const serverMissingFields = getMissingRequiredCallFields(error); + + if (serverMissingFields) { + toast.error(t('calls.required_fields_missing', { fields: formatCallFieldLabels(serverMissingFields, (labelKey) => t(labelKey)) })); + return; + } + + toast.error(t('call_detail.update_call_error')); } }; - const handleLocationSelected = (location: { latitude: number; longitude: number; address?: string }) => { + const handleLocationSelected = (location: CallLocation) => { setSelectedLocation(location); setValue('latitude', location.latitude); setValue('longitude', location.longitude); if (location.address) { setValue('address', location.address); } + setValue('coordinates', `${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}`); setShowLocationPicker(false); }; + // Address, coordinates, what3words and plus code lookups — shared with the new-call screen. + const locationSearch = useCallLocationSearch(handleLocationSelected); + const handleDispatchSelection = (selection: DispatchSelection) => { setDispatchSelection(selection); setValue('dispatchSelection', selection); @@ -330,111 +406,6 @@ export default function EditCall() { return `${totalSelected} ${t('calls.selected')}`; }; - // Address search functionality (same as New Call) - const handleAddressSearch = async (address: string) => { - if (!address.trim()) { - toast.show({ - placement: 'top', - render: () => { - return ( - - {t('calls.address_required')} - - ); - }, - }); - return; - } - - setIsGeocodingAddress(true); - try { - const apiKey = config?.GoogleMapsKey; - - if (!apiKey) { - throw new Error('Google Maps API key not configured'); - } - - const response = await axios.get(`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${apiKey}`); - - if (response.data.status === 'OK' && response.data.results.length > 0) { - const results = response.data.results; - - if (results.length === 1) { - const result = results[0]; - const newLocation = { - latitude: result.geometry.location.lat, - longitude: result.geometry.location.lng, - address: result.formatted_address, - }; - - handleLocationSelected(newLocation); - - toast.show({ - placement: 'top', - render: () => { - return ( - - {t('calls.address_found')} - - ); - }, - }); - } else { - setAddressResults(results); - setShowAddressSelection(true); - } - } else { - toast.show({ - placement: 'top', - render: () => { - return ( - - {t('calls.address_not_found')} - - ); - }, - }); - } - } catch (error) { - logger.error({ message: 'Error geocoding address', context: { error } }); - - toast.show({ - placement: 'top', - render: () => { - return ( - - {t('calls.geocoding_error')} - - ); - }, - }); - } finally { - setIsGeocodingAddress(false); - } - }; - - const handleAddressSelected = (result: GeocodingResult) => { - const newLocation = { - latitude: result.geometry.location.lat, - longitude: result.geometry.location.lng, - address: result.formatted_address, - }; - - handleLocationSelected(newLocation); - setShowAddressSelection(false); - - toast.show({ - placement: 'top', - render: () => { - return ( - - {t('calls.address_found')} - - ); - }, - }); - }; - if (callDetailLoading || callDataLoading) { return ( <> @@ -488,7 +459,7 @@ export default function EditCall() { {t('calls.edit_call_description')} - + {t('calls.name')} @@ -510,7 +481,7 @@ export default function EditCall() { - + {t('calls.nature')} @@ -532,7 +503,7 @@ export default function EditCall() { - + {t('calls.priority')} @@ -565,7 +536,7 @@ export default function EditCall() { - + {t('calls.type')} @@ -597,121 +568,238 @@ export default function EditCall() { - - - - {t('calls.note')} - - ( - - )} - /> - - - - - {t('calls.call_location')} + {showNote ? ( + + + + {t('calls.note')} + + ( + + )} + /> + + + ) : null} + + {showLocationCard ? ( + + {t('calls.call_location')} + + {/* Address Field */} + {showAddress ? ( + ( + + )} + /> + ) : null} - {/* Address Field */} - - - {t('calls.address')} - - ( - - - - - - - - - )} - /> - + {/* GPS Coordinates Field */} + {showGeolocation ? ( + ( + + )} + /> + ) : null} - {/* Map Preview */} - - {selectedLocation ? ( - - ) : ( - - )} - - - ( - onChange(poiId != null ? poiId.toString() : '')} + {/* what3words Field */} + {showWhat3Words ? ( + ( + + )} /> - )} - /> - + ) : null} - - - - {t('calls.contact_name')} - - ( - - - - )} - /> - - + {/* Plus Code Field — a lookup aid only (never stored), so never required. */} + {showPlusCode ? ( + ( + + )} + /> + ) : null} - - - - {t('calls.contact_info')} - - ( - - - - )} - /> - - + {/* Map Preview — the map is how a geolocation gets picked, so it follows that field. */} + {showGeolocation ? ( + + {selectedLocation ? ( + + ) : ( + + )} + + ) : null} - - {t('calls.dispatch_to')} - - + {showDestinationPoi ? ( + ( + onChange(poiId != null ? poiId.toString() : '')} + isRequired={fieldPolicy.isRequired(NewCallFieldKeys.DestinationPoi)} + /> + )} + /> + ) : null} + + ) : null} + + {showContactName ? ( + + + + {t('calls.contact_name')} + + ( + + + + )} + /> + + + ) : null} + + {showContactInfo ? ( + + + + {t('calls.contact_info')} + + ( + + + + )} + /> + + + ) : null} + + {visibleIdentifierFields.length > 0 ? ( + + {visibleIdentifierFields.map((field, index) => ( + + + {t(CALL_FIELD_LABEL_KEYS[field.key])} + + ( + + + + )} + /> + + ))} + + ) : null} + + {showDispatchOn ? ( + + {/* Never required on an edit, so never marked. */} + + + {t('calls.dispatch_on')} + + ( + + )} + /> + + + ) : null} + + {showDispatchList ? ( + + + {t('calls.dispatch_to')} + {fieldPolicy.isRequired(NewCallFieldKeys.DispatchList) && !isPendingCallState(call.State) ? '*' : ''} + + + + ) : null} - @@ -746,20 +834,7 @@ export default function EditCall() { setShowDispatchModal(false)} onConfirm={handleDispatchSelection} initialSelection={dispatchSelection} /> {/* Address selection bottom sheet */} - setShowAddressSelection(false)} isLoading={false}> - - {t('calls.select_address')} - - {addressResults.map((result, index) => ( - - ))} - - - + ); } diff --git a/src/app/call/__tests__/edit.test.tsx b/src/app/call/__tests__/edit.test.tsx new file mode 100644 index 00000000..bfd060a3 --- /dev/null +++ b/src/app/call/__tests__/edit.test.tsx @@ -0,0 +1,460 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react-native'; +import { AxiosError, AxiosHeaders } from 'axios'; +import React from 'react'; + +import { getNewCallFieldPolicy } from '@/api/calls/newCallFieldPolicy'; + +import EditCall from '../[id]/edit'; + +// --- state the mocked stores read -------------------------------------------------------------- + +const mockUpdateCall = jest.fn(); +const mockToast = { show: jest.fn(), success: jest.fn(), error: jest.fn(), warning: jest.fn(), info: jest.fn() }; + +const BASE_CALL = { + CallId: '42', + Name: 'Structure fire', + Nature: 'Smoke showing', + Note: 'Stored note', + Address: '1 Main St', + Geolocation: '40.1,-70.2', + Latitude: '40.1', + Longitude: '-70.2', + What3Words: '', + ContactName: '', + ContactInfo: 'Stored info', + ExternalId: 'CAD-1', + IncidentId: '', + ReferenceId: 'R-9', + DestinationPoiId: 7, + Priority: 1, + Type: 'Fire', + State: 0, +}; + +const mockDetailState: any = { + call: BASE_CALL, + callExtraData: { Dispatches: [{ Id: 'u1', Type: 'Personnel' }] }, + isLoading: false, + error: null, + fetchCallDetail: jest.fn(), + updateCall: (...args: unknown[]) => mockUpdateCall(...args), +}; + +// Stable references: the form re-seeds itself whenever these change. +const mockCallsState = { + callPriorities: [{ Id: 1, Name: 'High' }], + callTypes: [{ Id: '1', Name: 'Fire' }], + destinationPois: [], + poiTypes: [], + isLoading: false, + error: null, + fetchCallFormData: jest.fn(), +}; + +// --- module mocks ------------------------------------------------------------------------------ + +jest.mock('@/api/calls/newCallFieldPolicy', () => ({ getNewCallFieldPolicy: jest.fn() })); + +jest.mock('@/stores/calls/detail-store', () => { + const useCallDetailStore: any = (selector: (state: any) => unknown) => selector(mockDetailState); + useCallDetailStore.getState = () => mockDetailState; + return { useCallDetailStore }; +}); + +jest.mock('@/stores/calls/store', () => ({ + useCallsStore: (selector: (state: any) => unknown) => selector(mockCallsState), +})); + +jest.mock('@/hooks/use-toast', () => ({ useToast: () => mockToast })); +jest.mock('@/hooks/use-analytics', () => ({ useAnalytics: () => ({ trackEvent: jest.fn() }) })); +jest.mock('@/lib/logging', () => ({ logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() } })); + +jest.mock('@/hooks/use-call-location-search', () => ({ + useCallLocationSearch: () => ({ + isGeocodingAddress: false, + isGeocodingCoordinates: false, + isGeocodingWhat3Words: false, + isGeocodingPlusCode: false, + addressResults: [], + isAddressSelectionOpen: false, + closeAddressSelection: jest.fn(), + selectAddressResult: jest.fn(), + searchAddress: jest.fn(), + searchCoordinates: jest.fn(), + searchWhat3Words: jest.fn(), + searchPlusCode: jest.fn(), + }), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { fields?: string }) => (options?.fields !== undefined ? `${key}|${options.fields}` : key), + }), +})); + +jest.mock('expo-router', () => ({ + router: { back: jest.fn(), push: jest.fn() }, + Stack: { Screen: () => null }, + useLocalSearchParams: () => ({ id: '42' }), +})); + +jest.mock('@/components/common/header-back-button', () => ({ HeaderBackButton: () => null })); +jest.mock('@/components/common/loading', () => ({ Loading: () => null })); +jest.mock('@/components/maps/full-screen-location-picker', () => ({ __esModule: true, default: () => null })); +jest.mock('@/components/maps/location-picker', () => ({ __esModule: true, default: () => null })); +jest.mock('@/components/calls/dispatch-selection-modal', () => ({ DispatchSelectionModal: () => null })); +jest.mock('@/components/common/date-time-field', () => ({ + DateTimeField: ({ value, onChange, testID, clearable = true }: any) => { + const { TextInput } = require('react-native'); + return ; + }, +})); +jest.mock('@/components/ui/lucide-icons', () => ({ ChevronDownIcon: () => null })); + +jest.mock('@/components/calls/destination-poi-selector', () => ({ + DestinationPoiSelector: ({ isRequired }: { isRequired?: boolean }) => { + const { Text } = require('react-native'); + return {isRequired ? 'destination*' : 'destination'}; + }, +})); + +jest.mock('@/components/calls/call-location-fields', () => ({ + CallAddressSelectionSheet: () => null, + CallLocationSearchField: ({ label, isRequired, testIDPrefix, value, onChangeText }: any) => { + const { TextInput, View, Text } = require('react-native'); + return ( + + {label} + + + ); + }, +})); + +jest.mock('@/components/ui/form-control', () => { + const { View, Text } = require('react-native'); + return { + FormControl: ({ children, isRequired }: any) => {children}, + FormControlLabel: ({ children }: any) => {children}, + FormControlLabelText: ({ children }: any) => {children}, + FormControlError: ({ children }: any) => {children}, + }; +}); + +jest.mock('@/components/ui/input', () => { + const { View, TextInput } = require('react-native'); + return { + Input: ({ children }: any) => {children}, + InputField: ({ testID, placeholder, value, onChangeText }: any) => , + }; +}); + +jest.mock('@/components/ui/textarea', () => { + const { View, TextInput } = require('react-native'); + return { + Textarea: ({ children }: any) => {children}, + TextareaInput: ({ testID, placeholder, value, onChangeText }: any) => , + }; +}); + +jest.mock('@/components/ui/select', () => { + const { View } = require('react-native'); + const Pass = ({ children }: any) => {children}; + return { Select: Pass, SelectBackdrop: () => null, SelectContent: Pass, SelectIcon: () => null, SelectInput: () => null, SelectItem: () => null, SelectPortal: Pass, SelectTrigger: Pass }; +}); + +jest.mock('@/components/ui/button', () => { + const { Pressable, Text } = require('react-native'); + return { + Button: ({ children, onPress, isDisabled, disabled, testID }: any) => ( + + {children} + + ), + ButtonText: ({ children }: any) => {children}, + ButtonSpinner: () => null, + }; +}); + +jest.mock('@/components/ui/box', () => { + const { View } = require('react-native'); + return { Box: ({ children }: any) => {children} }; +}); + +jest.mock('@/components/ui/card', () => { + const { View } = require('react-native'); + return { Card: ({ children }: any) => {children} }; +}); + +jest.mock('@/components/ui/text', () => { + const { Text } = require('react-native'); + return { Text: ({ children }: any) => {children} }; +}); + +// --- helpers ----------------------------------------------------------------------------------- + +const mockedGetPolicy = getNewCallFieldPolicy as jest.Mock; + +const renderWithPolicy = async (rules: { Key: string; Visible: boolean; Required: boolean }[]) => { + mockedGetPolicy.mockResolvedValue({ Rules: rules }); + render(); + // The save button stays disabled until the policy lands. + await waitFor(() => expect(screen.getByTestId('save-call-button')).not.toBeDisabled()); +}; + +const save = () => fireEvent.press(screen.getByTestId('save-call-button')); + +const isMarkedRequired = (label: string) => screen.getAllByTestId('required-field').some((field) => within(field).queryByText(label) !== null); + +const inMinutes = (minutes: number) => new Date(Date.now() + minutes * 60 * 1000).toISOString(); + +describe('EditCall field policy', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDetailState.call = BASE_CALL; + mockDetailState.callExtraData = { Dispatches: [{ Id: 'u1', Type: 'Personnel' }] }; + mockUpdateCall.mockResolvedValue(undefined); + }); + + it('offers every field the new-call form offers when nothing is hidden', async () => { + await renderWithPolicy([]); + + expect(screen.getByTestId('note-input')).toBeTruthy(); + expect(screen.getByTestId('address-input')).toBeTruthy(); + expect(screen.getByTestId('coordinates-input')).toBeTruthy(); + expect(screen.getByTestId('what3words-input')).toBeTruthy(); + expect(screen.getByTestId('plus-code-input')).toBeTruthy(); + expect(screen.getByTestId('destination-poi-selector')).toBeTruthy(); + expect(screen.getByTestId('contact-name-input')).toBeTruthy(); + expect(screen.getByTestId('contact-info-input')).toBeTruthy(); + expect(screen.getByTestId('external-id-input')).toHaveProp('value', 'CAD-1'); + expect(screen.getByTestId('incident-id-input')).toBeTruthy(); + expect(screen.getByTestId('reference-id-input')).toHaveProp('value', 'R-9'); + expect(screen.getByTestId('dispatch-selection-button')).toBeTruthy(); + }); + + it('hides the fields the department turned off and leaves them as stored on save', async () => { + await renderWithPolicy( + ['note', 'geolocation', 'what3words', 'pluscode', 'destinationPoi', 'contactName', 'contactInfo', 'externalId', 'referenceId', 'dispatchList'].map((Key) => ({ + Key, + Visible: false, + Required: false, + })) + ); + + expect(screen.queryByTestId('note-input')).toBeNull(); + expect(screen.queryByTestId('coordinates-input')).toBeNull(); + expect(screen.queryByTestId('what3words-input')).toBeNull(); + expect(screen.queryByTestId('plus-code-input')).toBeNull(); + expect(screen.queryByTestId('destination-poi-selector')).toBeNull(); + expect(screen.queryByTestId('contact-name-input')).toBeNull(); + expect(screen.queryByTestId('contact-info-input')).toBeNull(); + expect(screen.queryByTestId('dispatch-selection-button')).toBeNull(); + expect(screen.queryByTestId('external-id-input')).toBeNull(); + expect(screen.queryByTestId('reference-id-input')).toBeNull(); + // Still visible. + expect(screen.getByTestId('address-input')).toBeTruthy(); + expect(screen.getByTestId('incident-id-input')).toBeTruthy(); + + save(); + + await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1)); + expect(mockUpdateCall).toHaveBeenCalledWith( + expect.objectContaining({ + callId: '42', + address: '1 Main St', + // Blank text keeps the stored value on EditCall. + note: '', + what3words: '', + plusCode: '', + contactName: '', + contactInfo: '', + externalId: '', + referenceId: '', + // No point: the stored location stays. + latitude: undefined, + longitude: undefined, + // Sent as loaded, never left out or blank: an older server would clear a missing destination + // and page everyone for a blank dispatch list. + destinationPoiId: 7, + dispatchEveryone: false, + dispatchUsers: ['u1'], + dispatchGroups: [], + dispatchRoles: [], + dispatchUnits: [], + }) + ); + }); + + it('sends the visible fields as the form holds them', async () => { + await renderWithPolicy([]); + + fireEvent.changeText(screen.getByTestId('contact-name-input'), 'Jo Caller'); + save(); + + await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1)); + expect(mockUpdateCall).toHaveBeenCalledWith( + expect.objectContaining({ + note: 'Stored note', + contactName: 'Jo Caller', + contactInfo: 'Stored info', + externalId: 'CAD-1', + incidentId: '', + referenceId: 'R-9', + latitude: 40.1, + longitude: -70.2, + destinationPoiId: 7, + dispatchUsers: ['u1'], + }) + ); + }); + + it('marks required fields and refuses to save while one is blank both on the form and on the call', async () => { + await renderWithPolicy([ + { Key: 'contactName', Visible: true, Required: true }, + { Key: 'note', Visible: true, Required: true }, + ]); + + expect(isMarkedRequired('calls.contact_name')).toBe(true); + expect(isMarkedRequired('calls.note')).toBe(true); + expect(isMarkedRequired('calls.contact_info')).toBe(false); + + // Clearing the note does not make it missing: EditCall keeps the stored note for a blank input. + fireEvent.changeText(screen.getByTestId('note-input'), ''); + save(); + + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('calls.required_fields_missing|calls.contact_name')); + expect(mockUpdateCall).not.toHaveBeenCalled(); + + fireEvent.changeText(screen.getByTestId('contact-name-input'), 'Jo Caller'); + save(); + + await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1)); + }); + + it('requires an identifier the call does not have yet, and saves once it is filled in', async () => { + await renderWithPolicy([{ Key: 'incidentId', Visible: true, Required: true }]); + + expect(isMarkedRequired('calls.incident_id')).toBe(true); + + save(); + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('calls.required_fields_missing|calls.incident_id')); + expect(mockUpdateCall).not.toHaveBeenCalled(); + + fireEvent.changeText(screen.getByTestId('incident-id-input'), 'INC-77'); + save(); + + await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledWith(expect.objectContaining({ incidentId: 'INC-77' }))); + }); + + describe('scheduled dispatch time', () => { + it('is never required or marked on an edit', async () => { + await renderWithPolicy([{ Key: 'dispatchOn', Visible: true, Required: true }]); + + expect(screen.getByTestId('dispatch-on-field')).toHaveProp('value', ''); + expect(screen.getByTestId('dispatch-on-field')).toHaveProp('accessibilityHint', 'clearable'); + expect(isMarkedRequired('calls.dispatch_on')).toBe(false); + + save(); + + await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1)); + expect(mockUpdateCall.mock.calls[0][0].dispatchOnUtc).toBeUndefined(); + }); + + it('starts from a schedule that has not gone out yet, read as UTC without a zone, and does not resend it unchanged', async () => { + // Five minutes out: inside the 15-minute rule, which must not block a save that never touched it. + const upcoming = inMinutes(5); + mockDetailState.call = { ...BASE_CALL, DispatchedOnUtc: upcoming.replace(/Z$/, '') }; + await renderWithPolicy([]); + + expect(screen.getByTestId('dispatch-on-field')).toHaveProp('value', upcoming); + // EditCall cannot remove a schedule, so the picker must not pretend it can. + expect(screen.getByTestId('dispatch-on-field')).toHaveProp('accessibilityHint', 'not-clearable'); + + save(); + + await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1)); + expect(mockUpdateCall.mock.calls[0][0].dispatchOnUtc).toBeUndefined(); + }); + + it('leaves the field empty for a call that was already sent', async () => { + mockDetailState.call = { ...BASE_CALL, DispatchedOnUtc: inMinutes(-30) }; + await renderWithPolicy([]); + + expect(screen.getByTestId('dispatch-on-field')).toHaveProp('value', ''); + }); + + it('refuses a new time less than 15 minutes ahead', async () => { + await renderWithPolicy([]); + + fireEvent.changeText(screen.getByTestId('dispatch-on-field'), inMinutes(10)); + save(); + + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('calls.dispatch_on_too_soon')); + expect(mockUpdateCall).not.toHaveBeenCalled(); + }); + + it('sends a new valid time as DispatchOnUtc', async () => { + mockDetailState.call = { ...BASE_CALL, DispatchedOnUtc: inMinutes(120) }; + await renderWithPolicy([]); + + const moved = inMinutes(45); + fireEvent.changeText(screen.getByTestId('dispatch-on-field'), moved); + save(); + + await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledWith(expect.objectContaining({ dispatchOnUtc: moved }))); + }); + + it('is not shown or sent when the department hides it', async () => { + mockDetailState.call = { ...BASE_CALL, DispatchedOnUtc: inMinutes(120) }; + await renderWithPolicy([{ Key: 'dispatchOn', Visible: false, Required: false }]); + + expect(screen.queryByTestId('dispatch-on-field')).toBeNull(); + save(); + + await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1)); + expect(mockUpdateCall.mock.calls[0][0].dispatchOnUtc).toBeUndefined(); + }); + }); + + it('requires a dispatch list on an active call', async () => { + mockDetailState.callExtraData = { Dispatches: [] }; + await renderWithPolicy([{ Key: 'dispatchList', Visible: true, Required: true }]); + + save(); + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('calls.required_fields_missing|calls.dispatch_to')); + expect(mockUpdateCall).not.toHaveBeenCalled(); + }); + + it('does not require the dispatch list of a pending call', async () => { + mockDetailState.call = { ...BASE_CALL, State: 8 }; + mockDetailState.callExtraData = { Dispatches: [] }; + await renderWithPolicy([{ Key: 'dispatchList', Visible: true, Required: true }]); + + save(); + await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1)); + expect(mockToast.error).not.toHaveBeenCalled(); + }); + + it('names the fields the server says are missing, by their labels', async () => { + const config = { headers: new AxiosHeaders() }; + mockUpdateCall.mockRejectedValue( + new AxiosError('Request failed', 'ERR_BAD_REQUEST', config, {}, { status: 400, statusText: '', data: 'Required call fields are missing: incidentId, what3words', headers: {}, config }) + ); + await renderWithPolicy([]); + + save(); + + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('calls.required_fields_missing|calls.incident_id, calls.what3words')); + }); + + it('shows the generic error for any other failure', async () => { + mockUpdateCall.mockRejectedValue(new Error('offline')); + await renderWithPolicy([]); + + save(); + + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('call_detail.update_call_error')); + }); +}); diff --git a/src/app/call/new/__tests__/field-policy.test.tsx b/src/app/call/new/__tests__/field-policy.test.tsx new file mode 100644 index 00000000..ad2d4e88 --- /dev/null +++ b/src/app/call/new/__tests__/field-policy.test.tsx @@ -0,0 +1,361 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react-native'; +import { AxiosError, AxiosHeaders } from 'axios'; +import React from 'react'; + +import { createCall } from '@/api/calls/calls'; +import { getNewCallFieldPolicy } from '@/api/calls/newCallFieldPolicy'; + +import NewCall from '../index'; + +const mockToast = { show: jest.fn(), success: jest.fn(), error: jest.fn(), warning: jest.fn(), info: jest.fn() }; +let mockPickedLocation = { latitude: 0, longitude: 0 }; + +// Stable references: the screen re-renders from these on every store read. +const mockCallsState = { + callPriorities: [{ Id: 1, Name: 'High' }], + callTypes: [{ Id: '1', Name: 'Fire' }], + destinationPois: [], + poiTypes: [], + isLoading: false, + error: null, + fetchCallFormData: jest.fn(), +}; + +jest.mock('@/api/calls/calls', () => ({ createCall: jest.fn() })); +jest.mock('@/api/calls/newCallFieldPolicy', () => ({ getNewCallFieldPolicy: jest.fn() })); +jest.mock('@/stores/calls/store', () => ({ + useCallsStore: (selector: (state: any) => unknown) => selector(mockCallsState), +})); +jest.mock('@/hooks/use-toast', () => ({ useToast: () => mockToast })); +jest.mock('@/hooks/use-analytics', () => ({ useAnalytics: () => ({ trackEvent: jest.fn() }) })); +jest.mock('@/lib/logging', () => ({ logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() } })); + +jest.mock('@/hooks/use-call-location-search', () => ({ + useCallLocationSearch: () => ({ + isGeocodingAddress: false, + isGeocodingCoordinates: false, + isGeocodingWhat3Words: false, + isGeocodingPlusCode: false, + addressResults: [], + isAddressSelectionOpen: false, + closeAddressSelection: jest.fn(), + selectAddressResult: jest.fn(), + searchAddress: jest.fn(), + searchCoordinates: jest.fn(), + searchWhat3Words: jest.fn(), + searchPlusCode: jest.fn(), + }), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { fields?: string }) => (options?.fields !== undefined ? `${key}|${options.fields}` : key), + }), +})); + +jest.mock('expo-router', () => ({ + router: { back: jest.fn(), push: jest.fn() }, + Stack: { Screen: () => null }, +})); + +jest.mock('@/components/ui/focus-aware-status-bar', () => ({ FocusAwareStatusBar: () => null })); +jest.mock('@/components/common/header-back-button', () => ({ HeaderBackButton: () => null })); +jest.mock('@/components/common/loading', () => ({ Loading: () => null })); +jest.mock('@/components/maps/location-picker', () => ({ __esModule: true, default: () => null })); +jest.mock('@/components/maps/full-screen-location-picker', () => ({ + __esModule: true, + default: ({ onLocationSelected }: any) => { + const { Pressable, Text } = require('react-native'); + return ( + onLocationSelected(mockPickedLocation)}> + pick + + ); + }, +})); +jest.mock('@/components/calls/dispatch-selection-modal', () => ({ DispatchSelectionModal: () => null })); +jest.mock('@/components/common/date-time-field', () => ({ + DateTimeField: ({ value, onChange, testID }: any) => { + const { TextInput } = require('react-native'); + return ; + }, +})); +jest.mock('@/components/ui/lucide-icons', () => ({ ChevronDownIcon: () => null, PlusIcon: () => null })); + +jest.mock('@/components/calls/destination-poi-selector', () => ({ + DestinationPoiSelector: ({ isRequired }: { isRequired?: boolean }) => { + const { Text } = require('react-native'); + return {isRequired ? 'destination*' : 'destination'}; + }, +})); + +jest.mock('@/components/calls/call-location-fields', () => ({ + CallAddressSelectionSheet: () => null, + CallLocationSearchField: ({ label, isRequired, testIDPrefix, value, onChangeText }: any) => { + const { TextInput, View, Text } = require('react-native'); + return ( + + {label} + + + ); + }, +})); + +jest.mock('@/components/ui/form-control', () => { + const { View, Text } = require('react-native'); + return { + FormControl: ({ children, isRequired }: any) => {children}, + FormControlLabel: ({ children }: any) => {children}, + FormControlLabelText: ({ children }: any) => {children}, + FormControlError: ({ children }: any) => {children}, + }; +}); + +jest.mock('@/components/ui/input', () => { + const { View, TextInput } = require('react-native'); + return { + Input: ({ children }: any) => {children}, + InputField: ({ testID, placeholder, value, onChangeText }: any) => , + }; +}); + +jest.mock('@/components/ui/textarea', () => { + const { View, TextInput } = require('react-native'); + return { + Textarea: ({ children }: any) => {children}, + TextareaInput: ({ testID, placeholder, value, onChangeText }: any) => , + }; +}); + +jest.mock('@/components/ui/select', () => { + const { View, TextInput } = require('react-native'); + const Pass = ({ children }: any) => {children}; + return { + Select: ({ children, onValueChange, selectedValue }: any) => ( + + {children} + + + ), + SelectBackdrop: () => null, + SelectContent: Pass, + SelectIcon: () => null, + SelectInput: () => null, + SelectItem: () => null, + SelectPortal: Pass, + SelectTrigger: Pass, + }; +}); + +jest.mock('@/components/ui/button', () => { + const { Pressable, Text } = require('react-native'); + return { + Button: ({ children, onPress, isDisabled, disabled, testID }: any) => ( + + {children} + + ), + ButtonText: ({ children }: any) => {children}, + ButtonSpinner: () => null, + }; +}); + +jest.mock('@/components/ui/box', () => { + const { View } = require('react-native'); + return { Box: ({ children }: any) => {children} }; +}); + +jest.mock('@/components/ui/card', () => { + const { View } = require('react-native'); + return { Card: ({ children }: any) => {children} }; +}); + +jest.mock('@/components/ui/text', () => { + const { Text } = require('react-native'); + return { Text: ({ children }: any) => {children} }; +}); + +const mockedGetPolicy = getNewCallFieldPolicy as jest.Mock; +const mockedCreateCall = createCall as jest.Mock; + +const renderWithPolicy = async (rules: { Key: string; Visible: boolean; Required: boolean }[]) => { + mockedGetPolicy.mockResolvedValue({ Rules: rules }); + render(); + await waitFor(() => expect(screen.getByTestId('create-call-button')).not.toBeDisabled()); +}; + +const fillBuiltIns = () => { + fireEvent.changeText(screen.getByTestId('calls.name_placeholder'), 'Structure fire'); + fireEvent.changeText(screen.getByTestId('calls.nature_placeholder'), 'Smoke showing'); + const [priority, type] = screen.getAllByTestId('select'); + fireEvent.changeText(priority, 'High'); + fireEvent.changeText(type, 'Fire'); +}; + +const create = () => fireEvent.press(screen.getByTestId('create-call-button')); + +const inMinutes = (minutes: number) => new Date(Date.now() + minutes * 60 * 1000).toISOString(); + +const isMarkedRequired = (label: string) => screen.getAllByTestId('required-field').some((field) => within(field).queryByText(label) !== null); + +describe('NewCall field policy', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPickedLocation = { latitude: 0, longitude: 0 }; + mockedCreateCall.mockResolvedValue({}); + }); + + it('hides the fields the department turned off', async () => { + await renderWithPolicy(['note', 'address', 'what3words', 'destinationPoi', 'contactInfo', 'dispatchList'].map((Key) => ({ Key, Visible: false, Required: false }))); + + expect(screen.queryByTestId('calls.note_placeholder')).toBeNull(); + expect(screen.queryByTestId('address-input')).toBeNull(); + expect(screen.queryByTestId('what3words-input')).toBeNull(); + expect(screen.queryByTestId('destination-poi-selector')).toBeNull(); + expect(screen.queryByTestId('calls.contact_info_placeholder')).toBeNull(); + expect(screen.queryByText('calls.dispatch_to')).toBeNull(); + expect(screen.getByTestId('coordinates-input')).toBeTruthy(); + expect(screen.getByTestId('plus-code-input')).toBeTruthy(); + expect(screen.getByTestId('calls.contact_name_placeholder')).toBeTruthy(); + }); + + it('marks required fields, names the blank ones and sends the reporter details once filled', async () => { + await renderWithPolicy([ + { Key: 'contactName', Visible: true, Required: true }, + { Key: 'contactInfo', Visible: true, Required: true }, + { Key: 'destinationPoi', Visible: true, Required: true }, + ]); + + expect(isMarkedRequired('calls.contact_name')).toBe(true); + expect(isMarkedRequired('calls.contact_info')).toBe(true); + expect(isMarkedRequired('calls.name')).toBe(true); + expect(isMarkedRequired('calls.note')).toBe(false); + expect(screen.getByText('destination*')).toBeTruthy(); + + fillBuiltIns(); + fireEvent.changeText(screen.getByTestId('calls.contact_name_placeholder'), 'Jo Caller'); + create(); + + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('calls.required_fields_missing|calls.contact_info, calls.destination_poi')); + expect(mockedCreateCall).not.toHaveBeenCalled(); + }); + + it('sends the reporter name and contact details it collects', async () => { + await renderWithPolicy([]); + + fillBuiltIns(); + fireEvent.changeText(screen.getByTestId('calls.contact_name_placeholder'), 'Jo Caller'); + fireEvent.changeText(screen.getByTestId('calls.contact_info_placeholder'), '555-0100'); + create(); + + await waitFor(() => expect(mockedCreateCall).toHaveBeenCalledTimes(1)); + expect(mockedCreateCall).toHaveBeenCalledWith(expect.objectContaining({ contactName: 'Jo Caller', contactInfo: '555-0100', priority: 1, type: 'Fire' })); + }); + + it('offers the call identifiers, enforces a required one and sends them', async () => { + await renderWithPolicy([ + { Key: 'referenceId', Visible: false, Required: false }, + { Key: 'incidentId', Visible: true, Required: true }, + ]); + + expect(screen.getByTestId('external-id-input')).toBeTruthy(); + expect(screen.queryByTestId('reference-id-input')).toBeNull(); + expect(isMarkedRequired('calls.incident_id')).toBe(true); + expect(isMarkedRequired('call_detail.external_id')).toBe(false); + + fillBuiltIns(); + create(); + + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('calls.required_fields_missing|calls.incident_id')); + expect(mockedCreateCall).not.toHaveBeenCalled(); + + fireEvent.changeText(screen.getByTestId('external-id-input'), 'CAD-1'); + fireEvent.changeText(screen.getByTestId('incident-id-input'), 'INC-77'); + create(); + + await waitFor(() => expect(mockedCreateCall).toHaveBeenCalledTimes(1)); + expect(mockedCreateCall).toHaveBeenCalledWith(expect.objectContaining({ externalId: 'CAD-1', incidentId: 'INC-77', referenceId: '' })); + }); + + it('requires a scheduled dispatch time when the department does', async () => { + await renderWithPolicy([{ Key: 'dispatchOn', Visible: true, Required: true }]); + + expect(isMarkedRequired('calls.dispatch_on')).toBe(true); + + fillBuiltIns(); + create(); + + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('calls.required_fields_missing|calls.dispatch_on')); + expect(mockedCreateCall).not.toHaveBeenCalled(); + }); + + it('refuses a dispatch time less than 15 minutes ahead', async () => { + await renderWithPolicy([]); + fillBuiltIns(); + + fireEvent.changeText(screen.getByTestId('dispatch-on-field'), inMinutes(10)); + create(); + + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('calls.dispatch_on_too_soon')); + expect(mockedCreateCall).not.toHaveBeenCalled(); + }); + + it('sends a valid dispatch time as DispatchOnUtc, and none when it is left empty', async () => { + await renderWithPolicy([]); + fillBuiltIns(); + + create(); + await waitFor(() => expect(mockedCreateCall).toHaveBeenCalledTimes(1)); + expect(mockedCreateCall.mock.calls[0][0].dispatchOnUtc).toBeUndefined(); + + const scheduled = inMinutes(60); + fireEvent.changeText(screen.getByTestId('dispatch-on-field'), scheduled); + create(); + + await waitFor(() => expect(mockedCreateCall).toHaveBeenCalledTimes(2)); + expect(mockedCreateCall.mock.calls[1][0]).toEqual(expect.objectContaining({ dispatchOnUtc: scheduled })); + }); + + it('hides the dispatch time when the department does', async () => { + await renderWithPolicy([{ Key: 'dispatchOn', Visible: false, Required: false }]); + + expect(screen.queryByTestId('dispatch-on-field')).toBeNull(); + }); + + it('does not take 0,0 as a location when one is required', async () => { + await renderWithPolicy([{ Key: 'geolocation', Visible: true, Required: true }]); + fillBuiltIns(); + + fireEvent.press(screen.getByText('calls.select_location')); + fireEvent.press(screen.getByTestId('full-screen-picker')); + create(); + + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('calls.required_fields_missing|calls.coordinates')); + expect(mockedCreateCall).not.toHaveBeenCalled(); + }); + + it('accepts a real location when one is required', async () => { + mockPickedLocation = { latitude: 0, longitude: 32.5 }; + await renderWithPolicy([{ Key: 'geolocation', Visible: true, Required: true }]); + fillBuiltIns(); + + fireEvent.press(screen.getByText('calls.select_location')); + fireEvent.press(screen.getByTestId('full-screen-picker')); + create(); + + await waitFor(() => expect(mockedCreateCall).toHaveBeenCalledTimes(1)); + expect(mockedCreateCall).toHaveBeenCalledWith(expect.objectContaining({ latitude: 0, longitude: 32.5 })); + }); + + it('names the fields the server says are missing, by their labels', async () => { + const config = { headers: new AxiosHeaders() }; + mockedCreateCall.mockRejectedValue(new AxiosError('Request failed', 'ERR_BAD_REQUEST', config, {}, { status: 400, statusText: '', data: 'Required call fields are missing: referenceId', headers: {}, config })); + await renderWithPolicy([]); + fillBuiltIns(); + + create(); + + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('calls.required_fields_missing|call_detail.reference_id')); + }); +}); diff --git a/src/app/call/new/index.tsx b/src/app/call/new/index.tsx index 7abe358c..c8acca61 100644 --- a/src/app/call/new/index.tsx +++ b/src/app/call/new/index.tsx @@ -1,8 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import axios from 'axios'; -import * as Location from 'expo-location'; import { router, Stack } from 'expo-router'; -import { useColorScheme } from 'nativewind'; import React, { useEffect, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -12,56 +9,43 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import * as z from 'zod'; import { createCall } from '@/api/calls/calls'; +import { CallAddressSelectionSheet, CallLocationSearchField } from '@/components/calls/call-location-fields'; import { DestinationPoiSelector } from '@/components/calls/destination-poi-selector'; import { DispatchSelectionModal } from '@/components/calls/dispatch-selection-modal'; +import { DateTimeField } from '@/components/common/date-time-field'; import { HeaderBackButton } from '@/components/common/header-back-button'; import { Loading } from '@/components/common/loading'; import FullScreenLocationPicker from '@/components/maps/full-screen-location-picker'; import LocationPicker from '@/components/maps/location-picker'; -import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; import { Box } from '@/components/ui/box'; import { Button, ButtonSpinner, ButtonText } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { FormControl, FormControlError, FormControlLabel, FormControlLabelText } from '@/components/ui/form-control'; import { Input, InputField } from '@/components/ui/input'; -import { ChevronDownIcon, PlusIcon, SearchIcon } from '@/components/ui/lucide-icons'; +import { ChevronDownIcon, PlusIcon } from '@/components/ui/lucide-icons'; import { Select, SelectBackdrop, SelectContent, SelectIcon, SelectInput, SelectItem, SelectPortal, SelectTrigger } from '@/components/ui/select'; import { Text } from '@/components/ui/text'; import { Textarea, TextareaInput } from '@/components/ui/textarea'; import { useAnalytics } from '@/hooks/use-analytics'; +import { useCallLocationSearch } from '@/hooks/use-call-location-search'; import { useNewCallFieldPolicy } from '@/hooks/use-new-call-field-policy'; import { useToast } from '@/hooks/use-toast'; +import { + CALL_FIELD_LABEL_KEYS, + CALL_IDENTIFIER_FIELDS, + DISPATCH_ON_MIN_LEAD_MINUTES, + formatCallFieldLabels, + getEnforcedMissingCallFields, + getMissingRequiredCallFields, + getNewCallFieldValues, + isDispatchOnTooSoon, +} from '@/lib/call-field-policy'; import { logger } from '@/lib/logging'; -import { type NewCallFieldKey, NewCallFieldKeys } from '@/models/v4/calls/newCallFieldPolicyResultData'; -import { useCoreStore } from '@/stores/app/core-store'; +import { NewCallFieldKeys } from '@/models/v4/calls/newCallFieldPolicyResultData'; import { useCallsStore } from '@/stores/calls/store'; import { type DispatchSelection } from '@/stores/dispatch/store'; -// The policy speaks in stable wire keys; a dispatcher told to fill in 'contactName' is being shown -// the protocol rather than their own form. Map each key back to the label this screen already puts -// on the field. Only the fields this screen renders appear here — anything else falls back to the -// raw key, which at least names something, rather than being dropped from the message. -const NEW_CALL_FIELD_LABEL_KEYS: Partial> = { - [NewCallFieldKeys.Address]: 'calls.address', - [NewCallFieldKeys.Geolocation]: 'calls.coordinates', - [NewCallFieldKeys.What3Words]: 'calls.what3words', - [NewCallFieldKeys.PlusCode]: 'calls.plus_code', - [NewCallFieldKeys.Note]: 'calls.note', - [NewCallFieldKeys.ContactName]: 'calls.contact_name', - [NewCallFieldKeys.ContactInfo]: 'calls.contact_info', - [NewCallFieldKeys.DestinationPoi]: 'calls.destination', - [NewCallFieldKeys.DispatchList]: 'calls.dispatch_to', -}; - -// The policy's key set is shared with the web app, which offers fields this screen does not have: -// external/incident/reference ids, protocols, linked call, indoor location and a scheduled dispatch -// time. A rule marking one of those required must not be enforced here — there is no input to -// satisfy it, so every submit would fail with a field the dispatcher cannot fill. The server -// enforces the full policy on save and rejects with a reason. The supported set is the set this -// screen renders, which is exactly the set it can label. -const SUPPORTED_NEW_CALL_FIELDS = new Set(Object.keys(NEW_CALL_FIELD_LABEL_KEYS) as NewCallFieldKey[]); - // Define the form schema using zod const formSchema = z.object({ name: z.string().min(1, { message: 'Name is required' }), @@ -78,6 +62,10 @@ const formSchema = z.object({ type: z.string().min(1, { message: 'Type is required' }), contactName: z.string().optional(), contactInfo: z.string().optional(), + externalId: z.string().optional(), + incidentId: z.string().optional(), + referenceId: z.string().optional(), + dispatchOn: z.string().optional(), dispatchSelection: z .object({ everyone: z.boolean(), @@ -91,49 +79,8 @@ const formSchema = z.object({ type FormValues = z.infer; -// Google Maps Geocoding API response types -interface GeocodingResult { - formatted_address: string; - geometry: { - location: { - lat: number; - lng: number; - }; - }; - place_id: string; -} - -interface GeocodingResponse { - results: GeocodingResult[]; - status: string; -} - -// what3words API response types -interface What3WordsResponse { - country: string; - square: { - southwest: { - lng: number; - lat: number; - }; - northeast: { - lng: number; - lat: number; - }; - }; - nearestPlace: string; - coordinates: { - lng: number; - lat: number; - }; - words: string; - language: string; - map: string; -} - export default function NewCall() { const { t } = useTranslation(); - const { colorScheme } = useColorScheme(); const insets = useSafeAreaInsets(); const callPriorities = useCallsStore((state) => state.callPriorities); const callTypes = useCallsStore((state) => state.callTypes); @@ -142,17 +89,10 @@ export default function NewCall() { const isLoading = useCallsStore((state) => state.isLoading); const error = useCallsStore((state) => state.error); const fetchCallFormData = useCallsStore((state) => state.fetchCallFormData); - const config = useCoreStore((state) => state.config); const { trackEvent } = useAnalytics(); const toast = useToast(); const [showLocationPicker, setShowLocationPicker] = useState(false); const [showDispatchModal, setShowDispatchModal] = useState(false); - const [showAddressSelection, setShowAddressSelection] = useState(false); - const [isGeocodingAddress, setIsGeocodingAddress] = useState(false); - const [isGeocodingPlusCode, setIsGeocodingPlusCode] = useState(false); - const [isGeocodingCoordinates, setIsGeocodingCoordinates] = useState(false); - const [isGeocodingWhat3Words, setIsGeocodingWhat3Words] = useState(false); - const [addressResults, setAddressResults] = useState([]); const [dispatchSelection, setDispatchSelection] = useState({ everyone: false, users: [], @@ -192,6 +132,10 @@ export default function NewCall() { type: '', contactName: '', contactInfo: '', + externalId: '', + incidentId: '', + referenceId: '', + dispatchOn: '', dispatchSelection: { everyone: false, users: [], @@ -224,36 +168,39 @@ export default function NewCall() { return; } - // A location on the equator or the prime meridian has a zero coordinate, which is a real - // place, not a blank field — test that both are finite rather than truthy. - const hasGeolocation = Number.isFinite(data.latitude) && Number.isFinite(data.longitude); - // The department may require fields beyond the built-in mandatory four. Enforced here for a // clear message, and again on the server so an old build cannot slip an incomplete call past. - // Only the fields this screen renders are enforced — see SUPPORTED_NEW_CALL_FIELDS. - const missingFields = fieldPolicy - .missingRequired({ - [NewCallFieldKeys.Address]: data.address, - [NewCallFieldKeys.Geolocation]: hasGeolocation ? `${data.latitude},${data.longitude}` : '', - [NewCallFieldKeys.What3Words]: data.what3words, - [NewCallFieldKeys.PlusCode]: data.plusCode, - [NewCallFieldKeys.Note]: data.note, - [NewCallFieldKeys.ContactName]: data.contactName, - [NewCallFieldKeys.ContactInfo]: data.contactInfo, - [NewCallFieldKeys.DestinationPoi]: data.destinationPoiId, - [NewCallFieldKeys.DispatchList]: - dispatchSelection.everyone || dispatchSelection.units.length > 0 || dispatchSelection.users.length > 0 || dispatchSelection.groups.length > 0 || dispatchSelection.roles.length > 0, - }) - .filter((key) => SUPPORTED_NEW_CALL_FIELDS.has(key)); + // Checked against what createCall will send (the location as the same "lat,lon" point rule the + // server applies). Only the fields this screen renders are enforced — see CALL_FORM_FIELDS. + const missingFields = getEnforcedMissingCallFields( + fieldPolicy.missingRequired( + getNewCallFieldValues({ + note: data.note, + address: data.address, + latitude: data.latitude, + longitude: data.longitude, + what3words: data.what3words, + plusCode: data.plusCode, + contactName: data.contactName, + contactInfo: data.contactInfo, + externalId: data.externalId, + incidentId: data.incidentId, + referenceId: data.referenceId, + dispatchOn: data.dispatchOn, + destinationPoiId: data.destinationPoiId, + dispatchSelection, + }) + ) + ); if (missingFields.length > 0) { - const missingLabels = missingFields.map((key) => { - const labelKey = NEW_CALL_FIELD_LABEL_KEYS[key]; - - return labelKey ? t(labelKey) : key; - }); + toast.error(t('calls.required_fields_missing', { fields: formatCallFieldLabels(missingFields, (labelKey) => t(labelKey)) })); + return; + } - toast.error(t('calls.required_fields_missing', { fields: missingLabels.join(', ') })); + // A scheduled dispatch has to leave the dispatcher time to change their mind — the web form's rule. + if (data.dispatchOn && isDispatchOnTooSoon(data.dispatchOn)) { + toast.error(t('calls.dispatch_on_too_soon', { minutes: DISPATCH_ON_MIN_LEAD_MINUTES })); return; } @@ -290,6 +237,15 @@ export default function NewCall() { destinationPoiId: data.destinationPoiId ? Number(data.destinationPoiId) : null, what3words: data.what3words, plusCode: data.plusCode, + // The reporter's name and contact details were collected but never sent, so a call saved + // without them and a department requiring them was refused by the server for no visible reason. + contactName: data.contactName, + contactInfo: data.contactInfo, + externalId: data.externalId, + incidentId: data.incidentId, + referenceId: data.referenceId, + // Only when one is set: no time means "dispatch now". + dispatchOnUtc: data.dispatchOn || undefined, dispatchUsers: data.dispatchSelection?.users, dispatchGroups: data.dispatchSelection?.groups, dispatchRoles: data.dispatchSelection?.roles, @@ -305,6 +261,15 @@ export default function NewCall() { } catch (error) { logger.error({ message: 'Error creating call', context: { error } }); + // The server enforces the full policy, including fields this screen has no input for; name + // them the way the form does rather than showing a generic failure. + const serverMissingFields = getMissingRequiredCallFields(error); + + if (serverMissingFields) { + toast.error(t('calls.required_fields_missing', { fields: formatCallFieldLabels(serverMissingFields, (labelKey) => t(labelKey)) })); + return; + } + // Show error toast toast.error(t('calls.create_error')); } @@ -327,6 +292,9 @@ export default function NewCall() { setValue('coordinates', `${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}`); }; + // Address, coordinates, what3words and plus code lookups — shared with the edit-call screen. + const locationSearch = useCallLocationSearch(handleLocationSelected); + // Handle dispatch selection const handleDispatchSelection = (selection: DispatchSelection) => { setDispatchSelection(selection); @@ -348,306 +316,6 @@ export default function NewCall() { return `${count} ${t('calls.selected')}`; }; - /** - * Handles address search using Google Maps Geocoding API - * - * Features: - * - Validates empty/null address input and shows error toast - * - Uses Google Maps API key from CoreStore configuration - * - Handles single result: automatically selects location - * - Handles multiple results: shows bottom sheet for user selection - * - Handles API errors gracefully with user-friendly messages - * - URL encodes addresses properly for special characters - * - Shows loading state during API call - * - * @param address - The address string to geocode - */ - const handleAddressSearch = async (address: string) => { - if (!address.trim()) { - toast.warning(t('calls.address_required')); - return; - } - - setIsGeocodingAddress(true); - try { - // Get Google Maps API key from CoreStore config - const apiKey = config?.GoogleMapsKey; - - if (!apiKey) { - throw new Error('Google Maps API key not configured'); - } - - // Make request to Google Maps Geocoding API - const response = await axios.get(`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${apiKey}`); - - if (response.data.status === 'OK' && response.data.results.length > 0) { - const results = response.data.results; - - if (results.length === 1) { - // Single result - use it directly - const result = results[0]; - const newLocation = { - latitude: result.geometry.location.lat, - longitude: result.geometry.location.lng, - address: result.formatted_address, - }; - - // Update the selected location and form values - handleLocationSelected(newLocation); - - // Show success toast - toast.success(t('calls.address_found')); - } else { - // Multiple results - show selection bottom sheet - setAddressResults(results); - setShowAddressSelection(true); - } - } else { - // Show error toast for no results - toast.error(t('calls.address_not_found')); - } - } catch (error) { - logger.error({ message: 'Error geocoding address', context: { error } }); - - // Show error toast - toast.error(t('calls.geocoding_error')); - } finally { - setIsGeocodingAddress(false); - } - }; - - // Handle address selection from bottom sheet - const handleAddressSelected = (result: GeocodingResult) => { - const newLocation = { - latitude: result.geometry.location.lat, - longitude: result.geometry.location.lng, - address: result.formatted_address, - }; - - // Update the selected location and form values - handleLocationSelected(newLocation); - setShowAddressSelection(false); - - // Show success toast - toast.success(t('calls.address_found')); - }; - - /** - * Handles what3words search using what3words API - * - * Features: - * - Validates empty/null what3words input and shows error toast - * - Uses what3words API key from CoreStore configuration - * - Handles API errors gracefully with user-friendly messages - * - Shows loading state during API call - * - Updates coordinates and address fields in form - * - Validates what3words format (3 words separated by dots) - * - * @param what3words - The what3words string to geocode (e.g., "filled.count.soap") - */ - const handleWhat3WordsSearch = async (what3words: string) => { - if (!what3words.trim()) { - toast.warning(t('calls.what3words_required')); - return; - } - - // Validate what3words format - should be 3 words separated by dots - const w3wRegex = /^[a-z]+\.[a-z]+\.[a-z]+$/; - if (!w3wRegex.test(what3words.trim().toLowerCase())) { - toast.warning(t('calls.what3words_invalid_format')); - return; - } - - setIsGeocodingWhat3Words(true); - try { - // Get what3words API key from CoreStore config - const apiKey = config?.W3WKey; - - if (!apiKey) { - throw new Error('what3words API key not configured'); - } - - // Make request to what3words API - const response = await axios.get(`https://api.what3words.com/v3/convert-to-coordinates?words=${encodeURIComponent(what3words)}&key=${apiKey}`); - - if (response.data.coordinates) { - const newLocation = { - latitude: response.data.coordinates.lat, - longitude: response.data.coordinates.lng, - address: response.data.nearestPlace, - }; - - // Update the selected location and form values - handleLocationSelected(newLocation); - - // Show success toast - toast.success(t('calls.what3words_found')); - } else { - // Show error toast for no results - toast.error(t('calls.what3words_not_found')); - } - } catch (error) { - logger.error({ message: 'Error geocoding what3words', context: { error } }); - - // Show error toast - toast.error(t('calls.what3words_geocoding_error')); - } finally { - setIsGeocodingWhat3Words(false); - } - }; - - /** - * Handles plus code search using Google Maps Geocoding API - * - * Features: - * - Validates empty/null plus code input and shows error toast - * - Uses Google Maps API key from CoreStore configuration - * - Handles API errors gracefully with user-friendly messages - * - URL encodes plus codes properly for special characters - * - Shows loading state during API call - * - Updates coordinates and address fields in form - * - * @param plusCode - The plus code string to geocode - */ - const handlePlusCodeSearch = async (plusCode: string) => { - if (!plusCode.trim()) { - toast.warning(t('calls.plus_code_required')); - return; - } - - setIsGeocodingPlusCode(true); - try { - // Get Google Maps API key from CoreStore config - const apiKey = config?.GoogleMapsKey; - - if (!apiKey) { - throw new Error('Google Maps API key not configured'); - } - - // Make request to Google Maps Geocoding API with plus code - const response = await axios.get(`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(plusCode)}&key=${apiKey}`); - - if (response.data.status === 'OK' && response.data.results.length > 0) { - const result = response.data.results[0]; - const newLocation = { - latitude: result.geometry.location.lat, - longitude: result.geometry.location.lng, - address: result.formatted_address, - }; - - // Update the selected location and form values - handleLocationSelected(newLocation); - - // Show success toast - toast.success(t('calls.plus_code_found')); - } else { - // Show error toast for no results - toast.error(t('calls.plus_code_not_found')); - } - } catch (error) { - logger.error({ message: 'Error geocoding plus code', context: { error } }); - - // Show error toast - toast.error(t('calls.plus_code_geocoding_error')); - } finally { - setIsGeocodingPlusCode(false); - } - }; - - /** - * Handles coordinates search using Google Maps Reverse Geocoding API - * - * Features: - * - Validates and parses coordinates string (lat,lng format) - * - Uses Google Maps API key from CoreStore configuration - * - Handles API errors gracefully with user-friendly messages - * - Shows loading state during API call - * - Updates address field and map location - * - Supports various coordinate formats (decimal degrees) - * - * @param coordinates - The coordinates string to reverse geocode (e.g., "40.7128, -74.0060") - */ - const handleCoordinatesSearch = async (coordinates: string) => { - if (!coordinates.trim()) { - toast.warning(t('calls.coordinates_required')); - return; - } - - // Parse coordinates - expect format like "40.7128, -74.0060" or "40.7128,-74.0060" - const coordRegex = /^(-?\d+\.?\d*),?\s*(-?\d+\.?\d*)$/; - const match = coordinates.trim().match(coordRegex); - - if (!match) { - toast.warning(t('calls.coordinates_invalid_format')); - return; - } - - const latitude = parseFloat(match[1]); - const longitude = parseFloat(match[2]); - - // Validate coordinate ranges - if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) { - toast.warning(t('calls.coordinates_out_of_range')); - return; - } - - setIsGeocodingCoordinates(true); - try { - // Get Google Maps API key from CoreStore config - const apiKey = config?.GoogleMapsKey; - - if (!apiKey) { - throw new Error('Google Maps API key not configured'); - } - - // Make request to Google Maps Reverse Geocoding API - const response = await axios.get(`https://maps.googleapis.com/maps/api/geocode/json?latlng=${latitude},${longitude}&key=${apiKey}`); - - if (response.data.status === 'OK' && response.data.results.length > 0) { - const result = response.data.results[0]; - const newLocation = { - latitude, - longitude, - address: result.formatted_address, - }; - - // Update the selected location and form values - handleLocationSelected(newLocation); - - // Show success toast - toast.success(t('calls.coordinates_found')); - } else { - // Even if no address found, still set the location on the map - const newLocation = { - latitude, - longitude, - address: undefined, - }; - - handleLocationSelected(newLocation); - - // Show info toast - toast.info(t('calls.coordinates_no_address')); - } - } catch (error) { - logger.error({ message: 'Error reverse geocoding coordinates', context: { error } }); - - // Even if geocoding fails, still set the location on the map - const newLocation = { - latitude, - longitude, - address: undefined, - }; - - handleLocationSelected(newLocation); - - // Show warning toast - toast.warning(t('calls.coordinates_geocoding_error')); - } finally { - setIsGeocodingCoordinates(false); - } - }; - // The location card holds four separate policy fields plus the destination selector; each is // hidden on its own, and the card itself goes away once the department has turned all of them off. const showAddress = fieldPolicy.isVisible(NewCallFieldKeys.Address); @@ -656,6 +324,7 @@ export default function NewCall() { const showPlusCode = fieldPolicy.isVisible(NewCallFieldKeys.PlusCode); const showDestinationPoi = fieldPolicy.isVisible(NewCallFieldKeys.DestinationPoi); const showDispatchList = fieldPolicy.isVisible(NewCallFieldKeys.DispatchList); + const visibleIdentifierFields = CALL_IDENTIFIER_FIELDS.filter((field) => fieldPolicy.isVisible(field.key)); const showLocationCard = showAddress || showGeolocation || showWhat3Words || showPlusCode || showDestinationPoi; if (isLoading) { @@ -690,7 +359,7 @@ export default function NewCall() { {t('calls.create_new_call')} - + {t('calls.name')} @@ -712,7 +381,7 @@ export default function NewCall() { - + {t('calls.nature')} @@ -734,7 +403,7 @@ export default function NewCall() { - + {t('calls.priority')} @@ -767,7 +436,7 @@ export default function NewCall() { - + {t('calls.type')} @@ -801,7 +470,7 @@ export default function NewCall() { {fieldPolicy.isVisible(NewCallFieldKeys.Note) ? ( - + {t('calls.note')} @@ -824,109 +493,85 @@ export default function NewCall() { {/* Address Field */} {showAddress ? ( - - - {t('calls.address')} - - ( - - - - - - - - - )} - /> - + ( + + )} + /> ) : null} {/* GPS Coordinates Field */} {showGeolocation ? ( - - - {t('calls.coordinates')} - - ( - - - - - - - - - )} - /> - + ( + + )} + /> ) : null} {/* what3words Field */} {showWhat3Words ? ( - - - {t('calls.what3words')} - - ( - - - - - - - - - )} - /> - + ( + + )} + /> ) : null} - {/* Plus Code Field */} + {/* Plus Code Field — a lookup aid only (never stored), so never required. */} {showPlusCode ? ( - - - {t('calls.plus_code')} - - ( - - - - - - - - - )} - /> - + ( + + )} + /> ) : null} {/* Map Preview — the map is how a geolocation gets picked, so it follows that field. */} @@ -953,6 +598,7 @@ export default function NewCall() { selectedPoiId={value ? Number(value) : null} isLoading={isLoading && destinationPois.length === 0} onChange={(poiId) => onChange(poiId != null ? poiId.toString() : '')} + isRequired={fieldPolicy.isRequired(NewCallFieldKeys.DestinationPoi)} /> )} /> @@ -962,7 +608,7 @@ export default function NewCall() { {fieldPolicy.isVisible(NewCallFieldKeys.ContactName) ? ( - + {t('calls.contact_name')} @@ -981,7 +627,7 @@ export default function NewCall() { {fieldPolicy.isVisible(NewCallFieldKeys.ContactInfo) ? ( - + {t('calls.contact_info')} @@ -998,9 +644,48 @@ export default function NewCall() { ) : null} + {visibleIdentifierFields.length > 0 ? ( + + {visibleIdentifierFields.map((field, index) => ( + + + {t(CALL_FIELD_LABEL_KEYS[field.key])} + + ( + + + + )} + /> + + ))} + + ) : null} + + {fieldPolicy.isVisible(NewCallFieldKeys.DispatchOn) ? ( + + + + {t('calls.dispatch_on')} + + } + /> + + + ) : null} + {showDispatchList ? ( - {t('calls.dispatch_to')} + + {t('calls.dispatch_to')} + {fieldPolicy.isRequired(NewCallFieldKeys.DispatchList) ? '*' : ''} + @@ -1046,20 +731,7 @@ export default function NewCall() { setShowDispatchModal(false)} onConfirm={handleDispatchSelection} initialSelection={dispatchSelection} /> {/* Address selection bottom sheet */} - setShowAddressSelection(false)} isLoading={false}> - - {t('calls.select_address')} - - {addressResults.map((result, index) => ( - - ))} - - - + ); } diff --git a/src/components/calls/call-location-fields.tsx b/src/components/calls/call-location-fields.tsx new file mode 100644 index 00000000..0aa586e4 --- /dev/null +++ b/src/components/calls/call-location-fields.tsx @@ -0,0 +1,79 @@ +import { useColorScheme } from 'nativewind'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView } from 'react-native'; + +import { type GeocodingResult } from '@/hooks/use-call-location-search'; + +import { CustomBottomSheet } from '../ui/bottom-sheet'; +import { Box } from '../ui/box'; +import { Button, ButtonText } from '../ui/button'; +import { FormControl, FormControlLabel, FormControlLabelText } from '../ui/form-control'; +import { Input, InputField } from '../ui/input'; +import { SearchIcon } from '../ui/lucide-icons'; +import { Text } from '../ui/text'; + +interface CallLocationSearchFieldProps { + label: string; + isRequired?: boolean; + /** Prefix for the input's and search button's test ids, e.g. "address" -> "address-input". */ + testIDPrefix: string; + placeholder: string; + value?: string; + onChangeText: (value: string) => void; + onBlur?: () => void; + onSearch: (value: string) => void; + isSearching: boolean; +} + +/** A call form's text field with a lookup button: address, GPS coordinates, what3words or plus code. */ +export const CallLocationSearchField: React.FC = ({ label, isRequired, testIDPrefix, placeholder, value, onChangeText, onBlur, onSearch, isSearching }) => { + const { colorScheme } = useColorScheme(); + + return ( + + + {label} + + + + + + + + + + + ); +}; + +interface CallAddressSelectionSheetProps { + isOpen: boolean; + onClose: () => void; + results: GeocodingResult[]; + onSelect: (result: GeocodingResult) => void; +} + +/** Lets the user pick one when an address lookup comes back with several matches. */ +export const CallAddressSelectionSheet: React.FC = ({ isOpen, onClose, results, onSelect }) => { + const { t } = useTranslation(); + + return ( + + + {t('calls.select_address')} + + {results.map((result, index) => ( + + ))} + + + + ); +}; diff --git a/src/components/calls/destination-poi-selector.tsx b/src/components/calls/destination-poi-selector.tsx index 87dc8f6c..9d88625c 100644 --- a/src/components/calls/destination-poi-selector.tsx +++ b/src/components/calls/destination-poi-selector.tsx @@ -17,9 +17,11 @@ interface DestinationPoiSelectorProps { selectedPoiId: number | null; isLoading: boolean; onChange: (poiId: number | null) => void; + /** Marks the label the way a required form field is marked (the department's call field policy). */ + isRequired?: boolean; } -export const DestinationPoiSelector: React.FC = ({ destinationPois, poiTypes, selectedPoiId, isLoading, onChange }) => { +export const DestinationPoiSelector: React.FC = ({ destinationPois, poiTypes, selectedPoiId, isLoading, onChange, isRequired = false }) => { const { t } = useTranslation(); const [isOpen, setIsOpen] = React.useState(false); @@ -37,7 +39,10 @@ export const DestinationPoiSelector: React.FC = ({ return ( <> - {t('calls.destination_poi')} + + {t('calls.destination_poi')} + {isRequired ? '*' : ''} +