From e58ba8cb3fb41cda34e5be6e6de328f54fb43de0 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Thu, 8 Oct 2026 16:01:41 -0700 Subject: [PATCH] RR-T50 Fixing Call Field Support, Call Number Support --- src/api/calls/__tests__/saveCall.test.ts | 69 ++ src/api/calls/calls.ts | 30 + src/app/(app)/work-orders/[id].tsx | 2 +- .../call/[id]/__tests__/field-policy.test.tsx | 554 +++++++++++++ .../[id]/__tests__/save-submit-guard.test.tsx | 9 + src/app/call/[id]/edit.tsx | 765 ++++++++++++++---- .../call/new/__tests__/field-policy.test.tsx | 387 +++++++++ src/app/call/new/index.tsx | 565 ++++++++----- src/components/operations/scope-picker.tsx | 6 +- .../operations/time-report-editor.tsx | 10 +- src/lib/__tests__/call-field-policy.test.ts | 153 ++++ src/lib/call-field-policy.ts | 190 +++++ src/lib/operations/__tests__/time.test.ts | 15 + src/lib/operations/time.ts | 6 + src/models/v4/operations/index.ts | 2 + src/translations/ar.json | 10 +- src/translations/de.json | 10 +- src/translations/el.json | 10 +- src/translations/en.json | 10 +- src/translations/es.json | 10 +- src/translations/fr.json | 10 +- src/translations/it.json | 10 +- src/translations/pl.json | 10 +- src/translations/sv.json | 10 +- src/translations/uk.json | 10 +- 25 files changed, 2470 insertions(+), 393 deletions(-) create mode 100644 src/api/calls/__tests__/saveCall.test.ts create mode 100644 src/app/call/[id]/__tests__/field-policy.test.tsx create mode 100644 src/app/call/new/__tests__/field-policy.test.tsx create mode 100644 src/lib/__tests__/call-field-policy.test.ts create mode 100644 src/lib/call-field-policy.ts diff --git a/src/api/calls/__tests__/saveCall.test.ts b/src/api/calls/__tests__/saveCall.test.ts new file mode 100644 index 0000000..a82ddfe --- /dev/null +++ b/src/api/calls/__tests__/saveCall.test.ts @@ -0,0 +1,69 @@ +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, + }; +}); + +const { __mockPost: mockPost, __mockPut: mockPut } = jest.requireMock('../../common/client') as { __mockPost: jest.Mock; __mockPut: jest.Mock }; + +const baseCall = { name: 'Structure Fire', nature: 'Smoke showing', priority: 1 }; + +describe('call save payloads', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPost.mockResolvedValue({ data: { Id: '42' } }); + mockPut.mockResolvedValue({ data: { Id: '42' } }); + }); + + describe('createCall (SaveCall)', () => { + it('sends the call identifiers', async () => { + await createCall({ ...baseCall, externalId: 'CAD-9', incidentId: 'INC-1', referenceId: 'REF-2' }); + + expect(mockPost).toHaveBeenCalledWith(expect.objectContaining({ ExternalId: 'CAD-9', IncidentId: 'INC-1', ReferenceId: 'REF-2' })); + }); + + it('sends blank identifiers when none were entered', async () => { + await createCall(baseCall); + + expect(mockPost).toHaveBeenCalledWith(expect.objectContaining({ ExternalId: '', IncidentId: '', ReferenceId: '' })); + }); + + it('sends DispatchOnUtc only when a dispatch time is set', async () => { + await createCall({ ...baseCall, dispatchOnUtc: '2026-10-09T14:30:00.000Z' }); + expect(mockPost).toHaveBeenLastCalledWith(expect.objectContaining({ DispatchOnUtc: '2026-10-09T14:30:00.000Z' })); + + await createCall(baseCall); + expect(mockPost.mock.calls[1][0]).not.toHaveProperty('DispatchOnUtc'); + }); + + it('never sends the picker-only properties this app has no input for', async () => { + await createCall({ ...baseCall, dispatchOnUtc: '2026-10-09T14:30:00.000Z' }); + + for (const property of ['ProtocolIds', 'LinkedCallId', 'IndoorMapZoneId', 'IndoorMapFloorId']) { + expect(mockPost.mock.calls[0][0]).not.toHaveProperty(property); + } + }); + }); + + describe('updateCall (EditCall)', () => { + it('sends the call identifiers, blank meaning "keep what is stored"', async () => { + await updateCall({ ...baseCall, callId: '42', externalId: 'CAD-9', incidentId: '', referenceId: 'REF-2' }); + + expect(mockPut).toHaveBeenCalledWith(expect.objectContaining({ Id: '42', ExternalId: 'CAD-9', IncidentId: '', ReferenceId: 'REF-2' })); + }); + + it('sends DispatchOnUtc only when a dispatch time is set, leaving the stored schedule alone otherwise', async () => { + await updateCall({ ...baseCall, callId: '42', dispatchOnUtc: '2026-10-09T14:30:00.000Z' }); + expect(mockPut).toHaveBeenLastCalledWith(expect.objectContaining({ DispatchOnUtc: '2026-10-09T14:30:00.000Z' })); + + await updateCall({ ...baseCall, 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 32d5226..bb3ed53 100644 --- a/src/api/calls/calls.ts +++ b/src/api/calls/calls.ts @@ -43,6 +43,17 @@ export interface CreateCallRequest { type?: string; contactName?: string; contactInfo?: string; + /** External (CAD) call id. On update a blank value keeps the stored one. */ + externalId?: string; + /** Incident id. On update a blank value keeps the stored one. */ + incidentId?: string; + /** Reference id. On update a blank value keeps the stored one. */ + referenceId?: string; + /** + * Scheduled dispatch time, ISO 8601 UTC. Left out of the request when not set: on create the call goes out + * now, on update the stored schedule is kept (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. */ @@ -69,6 +80,17 @@ export interface UpdateCallRequest { type?: string; contactName?: string; contactInfo?: string; + /** External (CAD) call id. On update a blank value keeps the stored one. */ + externalId?: string; + /** Incident id. On update a blank value keeps the stored one. */ + incidentId?: string; + /** Reference id. On update a blank value keeps the stored one. */ + referenceId?: string; + /** + * Scheduled dispatch time, ISO 8601 UTC. Left out of the request when not set: on create the call goes out + * now, on update the stored schedule is kept (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. */ @@ -128,6 +150,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 || '', @@ -175,6 +201,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/(app)/work-orders/[id].tsx b/src/app/(app)/work-orders/[id].tsx index c6a432f..2e30027 100644 --- a/src/app/(app)/work-orders/[id].tsx +++ b/src/app/(app)/work-orders/[id].tsx @@ -86,7 +86,7 @@ export default function WorkOrderScreen() { const attach = async (source: 'camera' | 'library') => { setMessage(null); try { - const photo = await capturePhoto(source, `work-order-${order?.Number ?? 'photo'}.jpg`); + const photo = await capturePhoto(source, `work-order-${(order?.Number ?? 'photo').replace(/[^A-Za-z0-9._-]+/g, '-')}.jpg`); if (!photo) return; const ok = await useWorkOrdersStore.getState().attach({ uri: photo.uri, name: photo.name, type: photo.contentType }); await discardPhoto(photo); diff --git a/src/app/call/[id]/__tests__/field-policy.test.tsx b/src/app/call/[id]/__tests__/field-policy.test.tsx new file mode 100644 index 0000000..72e777b --- /dev/null +++ b/src/app/call/[id]/__tests__/field-policy.test.tsx @@ -0,0 +1,554 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react-native'; +import React from 'react'; + +import { getNewCallFieldPolicy } from '@/api/calls/newCallFieldPolicy'; +import { type NewCallFieldRuleData } from '@/models/v4/calls/newCallFieldPolicyResultData'; + +// The department's call field policy applies to edits too: the edit screen offers the same fields as the +// new-call screen, hides the ones the policy hides, marks and checks the required ones against what the +// save will send, and never clears a hidden field. Runs the real policy hook against a mocked endpoint. + +const baseFormValues = { + name: 'Structure Fire', + nature: 'Smoke showing', + note: 'Stored note', + address: '123 Main St', + coordinates: '39.7392, -104.9903', + what3words: 'filled.count.soap', + plusCode: '', + latitude: 39.7392 as number | undefined, + longitude: -104.9903 as number | undefined, + priority: 'High', + type: 'Fire', + destinationPoiId: '7', + contactName: 'Jane Caller', + contactInfo: '555-0100', + externalId: 'CAD-9', + incidentId: 'INC-1', + referenceId: 'REF-2', + dispatchOn: '', + dispatchSelection: { everyone: false, users: ['user-1'], groups: [] as string[], roles: [] as string[], units: [] as string[] }, +}; + +// A whole-second instant some minutes away, as an ISO string ('Z'), and as the zone-less form older +// servers send for a stored dispatch time. +const minutesFromNow = (minutes: number) => { + const date = new Date(Date.now() + minutes * 60 * 1000); + date.setMilliseconds(0); + return { iso: date.toISOString(), zoneless: date.toISOString().replace('.000Z', '') }; +}; + +let mockFormValues = { ...baseFormValues }; +// Receives the values the screen pre-fills the form with from the stored call. +const mockReset = jest.fn(); + +// Bypass validation: this suite is about the field policy, not the zod schema. The values stand in for +// the form as the user leaves it (it starts out holding the stored call). +jest.mock('react-hook-form', () => ({ + useForm: () => ({ + control: {}, + handleSubmit: (onValid: (values: unknown) => unknown) => () => onValid({ ...mockFormValues }), + formState: { errors: {} }, + setValue: jest.fn(), + reset: mockReset, + getValues: () => mockFormValues, + }), + Controller: ({ render: renderField }: { render: (arg: unknown) => React.ReactElement }) => renderField({ field: { onChange: jest.fn(), onBlur: jest.fn(), value: '' } }), +})); + +jest.mock('@hookform/resolvers/zod', () => ({ zodResolver: () => jest.fn() })); + +// Stable across renders (effects depend on it); shows interpolated values so the toast can be read. +const mockT = (key: string, options?: { fields?: string; minutes?: number }) => (options?.fields !== undefined ? `${key}|${options.fields}` : options?.minutes !== undefined ? `${key}|${options.minutes}` : key); +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: mockT }) })); + +jest.mock('expo-router', () => ({ + router: { push: jest.fn(), back: jest.fn() }, + Stack: { Screen: () => null }, + useFocusEffect: jest.fn(), + useLocalSearchParams: () => ({ id: 'call-1' }), +})); + +jest.mock('@/api/calls/newCallFieldPolicy', () => ({ getNewCallFieldPolicy: jest.fn() })); +jest.mock('@/api/dispatch', () => ({ getNewCallData: jest.fn().mockResolvedValue({ Data: { DestinationPois: [], PoiTypes: [] } }) })); + +jest.mock('@/lib/logging', () => ({ logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn(), debug: jest.fn() } })); + +const mockTrackEvent = jest.fn(); +jest.mock('@/hooks/use-analytics', () => ({ useAnalytics: () => ({ trackEvent: mockTrackEvent }) })); + +const mockToastShow = jest.fn(); +jest.mock('@/components/ui/toast', () => ({ useToast: () => ({ show: mockToastShow }) })); + +// The stored call has a location, so the map preview renders instead of the "select location" button. +jest.mock('@/components/maps/location-picker', () => { + const ReactModule = require('react'); + const { View } = require('react-native'); + return () => ReactModule.createElement(View, { testID: 'location-picker' }); +}); +jest.mock('@/components/maps/full-screen-location-picker', () => 'FullScreenLocationPicker'); +jest.mock('@/components/calls/dispatch-selection-modal', () => ({ DispatchSelectionModal: () => null })); +jest.mock('@/components/common/header-back-button', () => ({ HeaderBackButton: () => null })); + +jest.mock('@/components/ui/select', () => { + const ReactModule = require('react'); + const { View } = require('react-native'); + const passthrough = + (testID: string) => + ({ children }: { children?: React.ReactNode }) => + ReactModule.createElement(View, { testID }, children); + + return { + Select: passthrough('select'), + SelectTrigger: passthrough('select-trigger'), + SelectInput: () => null, + SelectIcon: () => null, + SelectPortal: () => null, + SelectBackdrop: () => null, + SelectContent: passthrough('select-content'), + SelectItem: () => null, + }; +}); + +jest.mock('@/components/ui/bottom-sheet', () => ({ CustomBottomSheet: () => null })); + +jest.mock('lucide-react-native', () => ({ + ChevronDownIcon: () => null, + PlusIcon: () => null, + SearchIcon: () => null, +})); + +jest.mock('nativewind', () => ({ + useColorScheme: () => ({ colorScheme: 'light' }), + cssInterop: jest.fn(), + styled: jest.fn((Component: unknown) => Component), +})); + +const mockCallsState = { + callPriorities: [{ Id: 1, Name: 'High', Color: '#ff0000' }], + callTypes: [{ Id: 1, Name: 'Fire' }], + isLoading: false, + error: null, + fetchCallPriorities: jest.fn(), + fetchCallTypes: jest.fn(), +}; + +jest.mock('@/stores/calls/store', () => { + const useCallsStore = (selector?: (state: unknown) => unknown) => (selector ? selector(mockCallsState) : mockCallsState); + useCallsStore.getState = () => mockCallsState; + return { useCallsStore }; +}); + +const mockUpdateCall = jest.fn(); + +const baseCall = { + CallId: 'call-1', + Name: 'Structure Fire', + Nature: 'Smoke showing', + Note: '

Stored note

', + Address: '123 Main St', + Geolocation: '39.7392,-104.9903', + Latitude: '39.7392', + Longitude: '-104.9903', + Priority: 1, + Type: 'Fire', + What3Words: 'filled.count.soap', + ContactName: 'Jane Caller', + ContactInfo: '555-0100', + ExternalId: 'CAD-9', + IncidentId: 'INC-1', + ReferenceId: 'REF-2', + DestinationPoiId: 42 as number | undefined, + State: 0 as number | string, + // Sent an hour ago: not a schedule. + DispatchedOnUtc: minutesFromNow(-60).zoneless, +}; + +const mockDetailState = { + call: { ...baseCall }, + callExtraData: { Dispatches: [] }, + isLoading: false, + error: null, + fetchCallDetail: jest.fn(), + updateCall: mockUpdateCall, +}; + +jest.mock('@/stores/calls/detail-store', () => { + const useCallDetailStore = (selector?: (state: unknown) => unknown) => (selector ? selector(mockDetailState) : mockDetailState); + useCallDetailStore.getState = () => mockDetailState; + return { useCallDetailStore }; +}); + +jest.mock('@/stores/app/core-store', () => { + const coreState = { config: { GoogleMapsKey: '', W3WKey: '' } }; + return { useCoreStore: (selector?: (state: unknown) => unknown) => (selector ? selector(coreState) : coreState) }; +}); + +const mockedGetPolicy = getNewCallFieldPolicy as jest.MockedFunction; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const EditCall = require('../edit').default as React.ComponentType; + +const withPolicy = (rules: NewCallFieldRuleData[]) => mockedGetPolicy.mockResolvedValue({ Rules: rules }); + +// Every toast on this screen renders a box holding a single line of text; read that text back. +const toastMessages = (): string[] => + mockToastShow.mock.calls.map(([options]) => { + const box = (options as { render: (props: { id: string }) => React.ReactElement }).render({ id: 'toast' }) as React.ReactElement<{ children: React.ReactElement<{ children: string }> }>; + return box.props.children.props.children; + }); + +const FIELD_TEST_IDS = [ + 'note-field', + 'destination-field', + 'address-field', + 'coordinates-field', + 'what3words-field', + 'plus-code-field', + 'location-picker', + 'contact-name-field', + 'contact-info-field', + 'external-id-field', + 'incident-id-field', + 'reference-id-field', + 'dispatch-on-field', + 'open-dispatch-modal-button', +]; + +// The Save button stays disabled until the policy has loaded, so wait for that before pressing it. +const renderLoaded = async () => { + render(); + const saveButton = await screen.findByTestId('save-call-button'); + await waitFor(() => expect(mockedGetPolicy).toHaveBeenCalled()); + await waitFor(() => expect(saveButton.props.accessibilityState?.disabled).toBeFalsy()); + return saveButton; +}; + +describe('Edit call field policy', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFormValues = { ...baseFormValues, dispatchSelection: { ...baseFormValues.dispatchSelection } }; + mockDetailState.call = { ...baseCall }; + mockUpdateCall.mockResolvedValue(undefined); + }); + + it('offers every field the new-call screen offers when the department has no policy', async () => { + withPolicy([]); + + await renderLoaded(); + + for (const testID of FIELD_TEST_IDS) { + expect(screen.getByTestId(testID)).toBeTruthy(); + } + }); + + it('hides every field the policy hides', async () => { + withPolicy( + ['note', 'destinationPoi', 'address', 'geolocation', 'what3words', 'pluscode', 'contactName', 'contactInfo', 'externalId', 'incidentId', 'referenceId', 'dispatchOn', 'dispatchList'].map((key) => ({ + Key: key, + Visible: false, + Required: false, + })) + ); + + await renderLoaded(); + + for (const testID of FIELD_TEST_IDS) { + expect(screen.queryByTestId(testID)).toBeNull(); + } + expect(screen.queryByText('calls.call_location')).toBeNull(); + expect(screen.queryByTestId('call-identifiers-card')).toBeNull(); + }); + + it('pre-fills the identifiers from the stored call', async () => { + withPolicy([]); + + await renderLoaded(); + + expect(mockReset).toHaveBeenCalledWith(expect.objectContaining({ externalId: 'CAD-9', incidentId: 'INC-1', referenceId: 'REF-2' })); + }); + + it('marks required fields', async () => { + withPolicy([ + { Key: 'note', Visible: true, Required: true }, + { Key: 'what3words', Visible: true, Required: true }, + { Key: 'dispatchList', Visible: true, Required: true }, + ]); + + await renderLoaded(); + + expect(within(screen.getByTestId('note-field')).getByText('*')).toBeTruthy(); + expect(within(screen.getByTestId('what3words-field')).getByText('*')).toBeTruthy(); + expect(within(screen.getByTestId('address-field')).queryByText('*')).toBeNull(); + expect(screen.getByText('calls.dispatch_to *')).toBeTruthy(); + }); + + it('refuses to save while a required identifier is blank', async () => { + withPolicy([ + { Key: 'externalId', Visible: true, Required: true }, + { Key: 'incidentId', Visible: true, Required: true }, + { Key: 'referenceId', Visible: true, Required: true }, + ]); + mockFormValues = { ...mockFormValues, externalId: '', referenceId: ' ' }; + + const saveButton = await renderLoaded(); + + expect(within(screen.getByTestId('incident-id-field')).getByText('*')).toBeTruthy(); + + fireEvent.press(saveButton); + + await waitFor(() => expect(toastMessages()).toContain('calls.required_fields_missing|calls.external_id, calls.reference_id')); + expect(mockUpdateCall).not.toHaveBeenCalled(); + }); + + it('refuses to save while a required field is blank, naming it by its label', async () => { + withPolicy([ + { Key: 'contactInfo', Visible: true, Required: true }, + { Key: 'destinationPoi', Visible: true, Required: true }, + { Key: 'address', Visible: true, Required: true }, + ]); + mockFormValues = { ...mockFormValues, contactInfo: ' ', destinationPoiId: 'none' }; + + const saveButton = await renderLoaded(); + fireEvent.press(saveButton); + + await waitFor(() => expect(toastMessages()).toContain('calls.required_fields_missing|calls.contact_info, calls.destination')); + expect(mockUpdateCall).not.toHaveBeenCalled(); + }); + + it('checks the values the form will send, which start out as the stored call', async () => { + withPolicy( + ['note', 'address', 'geolocation', 'what3words', 'contactName', 'contactInfo', 'externalId', 'incidentId', 'referenceId', 'destinationPoi', 'dispatchList'].map((key) => ({ + Key: key, + Visible: true, + Required: true, + })) + ); + + const saveButton = await renderLoaded(); + fireEvent.press(saveButton); + + await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1)); + }); + + it('does not hold an edit to fields it cannot fill in', async () => { + // The dispatch time is never required of an edit; the rest have no input on this screen and this app + // never sends ProtocolIds, LinkedCallId or IndoorMapZoneId, so the server leaves them alone too. + // A plus code is never stored on a call. + withPolicy( + ['dispatchOn', 'protocols', 'linkedCall', 'indoorLocation', 'pluscode'].map((key) => ({ + Key: key, + Visible: true, + Required: true, + })) + ); + + const saveButton = await renderLoaded(); + fireEvent.press(saveButton); + + await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1)); + const payload = mockUpdateCall.mock.calls[0]?.[0]; + expect(payload).not.toHaveProperty('protocolIds'); + expect(payload).not.toHaveProperty('linkedCallId'); + expect(payload).not.toHaveProperty('indoorMapZoneId'); + }); + + it('requires a dispatch list of an active call but not of a pending one', async () => { + withPolicy([{ Key: 'dispatchList', Visible: true, Required: true }]); + mockFormValues = { ...mockFormValues, dispatchSelection: { everyone: false, users: [], groups: [], roles: [], units: [] } }; + + const saveButton = await renderLoaded(); + fireEvent.press(saveButton); + + await waitFor(() => expect(toastMessages()).toContain('calls.required_fields_missing|calls.dispatch_to')); + expect(mockUpdateCall).not.toHaveBeenCalled(); + + screen.unmount(); + mockToastShow.mockClear(); + mockDetailState.call = { ...baseCall, State: 8 }; + + const pendingSaveButton = await renderLoaded(); + fireEvent.press(pendingSaveButton); + + await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1)); + }); + + it('sends hidden fields as unchanged instead of clearing them', async () => { + withPolicy( + ['note', 'address', 'geolocation', 'what3words', 'contactName', 'contactInfo', 'externalId', 'incidentId', 'referenceId', 'destinationPoi', 'dispatchList'].map((key) => ({ + Key: key, + Visible: false, + Required: true, + })) + ); + + const saveButton = await renderLoaded(); + fireEvent.press(saveButton); + + await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1)); + const payload = mockUpdateCall.mock.calls[0]?.[0]; + + // Blank text keeps the stored value on EditCall. + expect(payload).toEqual(expect.objectContaining({ note: '', address: '', what3words: '', contactName: '', contactInfo: '', externalId: '', incidentId: '', referenceId: '' })); + // No point keeps the stored point. + expect(payload).not.toHaveProperty('latitude'); + expect(payload).not.toHaveProperty('longitude'); + // The destination is always overwritten, so it goes back as stored rather than as the form holds it. + expect(payload.destinationPoiId).toBe(42); + // The recipients go back as loaded: a blank list means "everyone" to older servers. + expect(payload).toEqual(expect.objectContaining({ dispatchEveryone: false, dispatchUsers: ['user-1'] })); + }); + + it('sends visible fields as the form holds them', async () => { + withPolicy([]); + + const saveButton = await renderLoaded(); + fireEvent.press(saveButton); + + await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1)); + expect(mockUpdateCall.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + note: 'Stored note', + address: '123 Main St', + what3words: 'filled.count.soap', + contactName: 'Jane Caller', + contactInfo: '555-0100', + externalId: 'CAD-9', + incidentId: 'INC-1', + referenceId: 'REF-2', + destinationPoiId: 7, + latitude: 39.7392, + longitude: -104.9903, + }) + ); + }); + + it('names the fields when the server refuses the save for them', async () => { + withPolicy([]); + mockUpdateCall.mockRejectedValueOnce({ isAxiosError: true, response: { status: 400, data: 'Required call fields are missing: incidentId, referenceId' } }); + + const saveButton = await renderLoaded(); + fireEvent.press(saveButton); + + await waitFor(() => expect(toastMessages()).toContain('calls.required_fields_missing|calls.incident_id, calls.reference_id')); + }); + + describe('scheduled dispatch', () => { + it('never requires or marks a dispatch time on an edit', async () => { + withPolicy([{ Key: 'dispatchOn', Visible: true, Required: true }]); + + const saveButton = await renderLoaded(); + + expect(screen.getByTestId('dispatch-on-input')).toBeTruthy(); + expect(within(screen.getByTestId('dispatch-on-field')).queryByText('*')).toBeNull(); + + fireEvent.press(saveButton); + + await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1)); + expect(mockUpdateCall.mock.calls[0]?.[0]).not.toHaveProperty('dispatchOnUtc'); + }); + + it('pre-fills the stored dispatch time, read as UTC, only while the call is still scheduled', async () => { + withPolicy([]); + const scheduled = minutesFromNow(45); + mockDetailState.call = { ...baseCall, DispatchedOnUtc: scheduled.zoneless }; + + await renderLoaded(); + + expect(mockReset).toHaveBeenCalledWith(expect.objectContaining({ dispatchOn: scheduled.iso })); + + screen.unmount(); + mockReset.mockClear(); + mockDetailState.call = { ...baseCall }; + + await renderLoaded(); + + expect(mockReset).toHaveBeenCalledWith(expect.objectContaining({ dispatchOn: '' })); + }); + + it('offers clear only when there is no stored schedule, since EditCall cannot remove one', async () => { + withPolicy([]); + + await renderLoaded(); + fireEvent.press(screen.getByTestId('dispatch-on-input')); + expect(screen.getByTestId('dispatch-on-input-clear')).toBeTruthy(); + + screen.unmount(); + mockDetailState.call = { ...baseCall, DispatchedOnUtc: minutesFromNow(45).zoneless }; + + await renderLoaded(); + fireEvent.press(screen.getByTestId('dispatch-on-input')); + expect(screen.getByTestId('dispatch-on-input-done')).toBeTruthy(); + expect(screen.queryByTestId('dispatch-on-input-clear')).toBeNull(); + }); + + it('saves an untouched stored schedule without resending it, even inside the lead time', async () => { + withPolicy([]); + const scheduled = minutesFromNow(10); + mockDetailState.call = { ...baseCall, DispatchedOnUtc: scheduled.zoneless }; + mockFormValues = { ...mockFormValues, dispatchOn: scheduled.iso }; + + const saveButton = await renderLoaded(); + fireEvent.press(saveButton); + + await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1)); + expect(mockUpdateCall.mock.calls[0]?.[0]).not.toHaveProperty('dispatchOnUtc'); + }); + + it('refuses a new dispatch time less than 15 minutes ahead', async () => { + withPolicy([]); + mockFormValues = { ...mockFormValues, dispatchOn: minutesFromNow(14).iso }; + + const saveButton = await renderLoaded(); + fireEvent.press(saveButton); + + await waitFor(() => expect(toastMessages()).toContain('calls.dispatch_on_too_soon|15')); + expect(mockUpdateCall).not.toHaveBeenCalled(); + }); + + it('sends a new dispatch time as DispatchOnUtc', async () => { + withPolicy([]); + const scheduled = minutesFromNow(45); + const rescheduled = minutesFromNow(120); + mockDetailState.call = { ...baseCall, DispatchedOnUtc: scheduled.zoneless }; + mockFormValues = { ...mockFormValues, dispatchOn: rescheduled.iso }; + + const saveButton = await renderLoaded(); + fireEvent.press(saveButton); + + await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1)); + expect(mockUpdateCall.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ dispatchOnUtc: rescheduled.iso })); + }); + + it('sends nothing for a hidden dispatch time', async () => { + withPolicy([{ Key: 'dispatchOn', Visible: false, Required: false }]); + mockFormValues = { ...mockFormValues, dispatchOn: minutesFromNow(120).iso }; + + const saveButton = await renderLoaded(); + fireEvent.press(saveButton); + + await waitFor(() => expect(mockUpdateCall).toHaveBeenCalledTimes(1)); + expect(mockUpdateCall.mock.calls[0]?.[0]).not.toHaveProperty('dispatchOnUtc'); + }); + }); + + it('shows the generic error for any other failure', async () => { + withPolicy([]); + mockUpdateCall.mockRejectedValueOnce(new Error('network down')); + + const saveButton = await renderLoaded(); + fireEvent.press(saveButton); + + await waitFor(() => expect(toastMessages()).toContain('call_detail.update_call_error')); + }); + + it('does not save before the policy has loaded', async () => { + mockedGetPolicy.mockReturnValue(new Promise(() => undefined)); + + render(); + fireEvent.press(await screen.findByTestId('save-call-button')); + + await waitFor(() => expect(mockedGetPolicy).toHaveBeenCalled()); + expect(mockUpdateCall).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/call/[id]/__tests__/save-submit-guard.test.tsx b/src/app/call/[id]/__tests__/save-submit-guard.test.tsx index 16c492c..b3d00da 100644 --- a/src/app/call/[id]/__tests__/save-submit-guard.test.tsx +++ b/src/app/call/[id]/__tests__/save-submit-guard.test.tsx @@ -52,6 +52,15 @@ jest.mock('@/hooks/use-analytics', () => ({ useAnalytics: () => ({ trackEvent: m const mockToastShow = jest.fn(); jest.mock('@/components/ui/toast', () => ({ useToast: () => ({ show: mockToastShow }) })); +jest.mock('@/hooks/use-new-call-field-policy', () => ({ + useNewCallFieldPolicy: () => ({ + isLoaded: true, + isVisible: () => true, + isRequired: () => false, + missingRequired: () => [], + }), +})); + jest.mock('@/components/maps/location-picker', () => 'LocationPicker'); jest.mock('@/components/maps/full-screen-location-picker', () => 'FullScreenLocationPicker'); jest.mock('@/components/calls/dispatch-selection-modal', () => ({ DispatchSelectionModal: () => null })); diff --git a/src/app/call/[id]/edit.tsx b/src/app/call/[id]/edit.tsx index b8f7271..3d7c5aa 100644 --- a/src/app/call/[id]/edit.tsx +++ b/src/app/call/[id]/edit.tsx @@ -14,6 +14,7 @@ import * as z from 'zod'; import { getNewCallData } from '@/api/dispatch'; 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'; @@ -29,8 +30,21 @@ 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 { useNewCallFieldPolicy } from '@/hooks/use-new-call-field-policy'; +import { + CALL_FORM_UNCOLLECTED_FIELD_KEYS, + formatCallFieldLabels, + formatCallGeolocation, + getMissingCallFieldsFromError, + getScheduledDispatchPrefill, + isPendingCallState, + isScheduledDispatchTooSoon, + MIN_SCHEDULED_DISPATCH_LEAD_MINUTES, + toDispatchOnUtc, +} from '@/lib/call-field-policy'; import { logger } from '@/lib/logging'; import { getDestinationPoiIdFromValue, getDestinationPoiSelectOptions, NO_DESTINATION_POI_VALUE } from '@/lib/poi'; +import { NewCallFieldKeys } from '@/models/v4/calls/newCallFieldPolicyResultData'; import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; import { type PoiTypeResultData } from '@/models/v4/mapping/poiTypeResultData'; import { useCoreStore } from '@/stores/app/core-store'; @@ -54,6 +68,11 @@ const formSchema = z.object({ destinationPoiId: z.string().optional(), contactName: z.string().optional(), contactInfo: z.string().optional(), + externalId: z.string().optional(), + incidentId: z.string().optional(), + referenceId: z.string().optional(), + // Scheduled dispatch time as an ISO 8601 UTC string; '' for "dispatch now" / "leave the schedule alone". + dispatchOn: z.string().optional(), dispatchSelection: z.object({ everyone: z.boolean(), users: z.array(z.string()), @@ -99,6 +118,15 @@ interface GeocodingResponse { status: string; } +// The fields of a what3words convert-to-coordinates answer this screen reads. +interface What3WordsResponse { + coordinates?: { + lng: number; + lat: number; + }; + nearestPlace?: string; +} + export default function EditCall() { const { t } = useTranslation(); const { colorScheme } = useColorScheme(); @@ -137,6 +165,28 @@ export default function EditCall() { const toast = useToast(); const toastRef = useRef(toast); toastRef.current = toast; + // A toast in the style this screen already uses, for the messages that only carry text. + const showToast = useCallback((tone: 'success' | 'error' | 'warning' | 'info', message: string) => { + const background = tone === 'success' ? 'bg-green-500' : tone === 'error' ? 'bg-red-500' : tone === 'warning' ? 'bg-orange-500' : 'bg-blue-500'; + + toastRef.current.show({ + placement: 'top', + render: () => { + return ( + + {message} + + ); + }, + }); + }, []); + // The department's call field policy applies to edits too: the fields it hides stay hidden, and the + // ones it requires must still be filled in when the call is saved. The server checks the same rules + // against the call as it will be after the edit. + const fieldPolicy = useNewCallFieldPolicy(); + // The stored dispatch time while the call is still waiting to go out, as the form was pre-filled with it. + // Saving it back unchanged is not a new schedule: it is left out, and is not held to the lead-time rule. + const [storedDispatchOn, setStoredDispatchOn] = useState(''); const [showLocationPicker, setShowLocationPicker] = useState(false); const [showDispatchModal, setShowDispatchModal] = useState(false); const [showAddressSelection, setShowAddressSelection] = useState(false); @@ -193,6 +243,10 @@ export default function EditCall() { destinationPoiId: NO_DESTINATION_POI_VALUE, contactName: '', contactInfo: '', + externalId: '', + incidentId: '', + referenceId: '', + dispatchOn: '', dispatchSelection: { everyone: false, users: [], @@ -338,13 +392,19 @@ export default function EditCall() { // Update local state for dispatch selection setDispatchSelection(initialDispatchSelection); + // Only a call that is scheduled and not sent yet has a dispatch time worth showing; the dispatch time + // of a call already sent is history, not a schedule. + const scheduledDispatchOn = getScheduledDispatchPrefill(call.DispatchedOnUtc); + setStoredDispatchOn(scheduledDispatchOn); + reset({ name: call.Name || '', nature: stripHtml(call.Nature), note: stripHtml(call.Note), address: call.Address || '', coordinates: call.Geolocation || '', - what3words: '', + what3words: call.What3Words || '', + // A plus code is only a way to look a location up; calls do not store one. plusCode: '', latitude: call.Latitude ? parseFloat(call.Latitude) : undefined, longitude: call.Longitude ? parseFloat(call.Longitude) : undefined, @@ -353,6 +413,10 @@ export default function EditCall() { destinationPoiId: call.DestinationPoiId ? call.DestinationPoiId.toString() : NO_DESTINATION_POI_VALUE, contactName: call.ContactName || '', contactInfo: call.ContactInfo || '', + externalId: call.ExternalId || '', + incidentId: call.IncidentId || '', + referenceId: call.ReferenceId || '', + dispatchOn: scheduledDispatchOn, dispatchSelection: initialDispatchSelection, }); @@ -403,6 +467,14 @@ export default function EditCall() { setIsSubmitting(true); try { + // Until the policy lands it reads as "nothing hidden, nothing required", so a save in that + // window would both skip the department's rules and send fields it hides. Hold it back, as the + // new-call screen does. + if (!fieldPolicy.isLoaded) { + showToast('error', t('calls.field_policy_loading')); + return; + } + const destinationPoiId = getDestinationPoiIdFromValue(data.destinationPoiId); // If we have latitude and longitude, add them to the data @@ -411,6 +483,51 @@ export default function EditCall() { data.longitude = selectedLocation.longitude; } + const dispatchSelectionValue = data.dispatchSelection; + const hasDispatchList = + !!dispatchSelectionValue && + (dispatchSelectionValue.everyone || dispatchSelectionValue.users.length > 0 || dispatchSelectionValue.groups.length > 0 || dispatchSelectionValue.roles.length > 0 || dispatchSelectionValue.units.length > 0); + + // The same required check as a new call, run against what this save will send (the form starts + // out holding the stored call). Skipped, as the server skips them on an edit: + // - fields this app has no input for (see CALL_FORM_UNCOLLECTED_FIELD_KEYS); + // - the dispatch time, which is never required of an edit; + // - the plus code, which only looks a location up and is never stored on a call; + // - the dispatch list of a pending call, which nobody has been sent yet. + const isPending = isPendingCallState(call?.State); + const missingFields = fieldPolicy + .missingRequired({ + [NewCallFieldKeys.Address]: data.address, + [NewCallFieldKeys.Geolocation]: formatCallGeolocation(data.latitude, data.longitude), + [NewCallFieldKeys.What3Words]: data.what3words, + [NewCallFieldKeys.Note]: data.note, + [NewCallFieldKeys.ContactName]: data.contactName, + [NewCallFieldKeys.ContactInfo]: data.contactInfo, + [NewCallFieldKeys.ExternalId]: data.externalId, + [NewCallFieldKeys.IncidentId]: data.incidentId, + [NewCallFieldKeys.ReferenceId]: data.referenceId, + [NewCallFieldKeys.DestinationPoi]: destinationPoiId != null ? String(destinationPoiId) : '', + [NewCallFieldKeys.DispatchList]: hasDispatchList, + }) + .filter((key) => !CALL_FORM_UNCOLLECTED_FIELD_KEYS.has(key) && key !== NewCallFieldKeys.DispatchOn && key !== NewCallFieldKeys.PlusCode && !(isPending && key === NewCallFieldKeys.DispatchList)); + + if (missingFields.length > 0) { + showToast('error', t('calls.required_fields_missing', { fields: formatCallFieldLabels(missingFields, t) })); + return; + } + + // A new dispatch time reschedules the call, so it has to leave time to change the call before it goes + // out (the web form's rule). An empty or untouched input sends nothing and keeps the stored schedule; + // the API has no way to clear one. + const reschedule = fieldPolicy.isVisible(NewCallFieldKeys.DispatchOn) && !!data.dispatchOn && data.dispatchOn !== storedDispatchOn; + + if (reschedule && isScheduledDispatchTooSoon(data.dispatchOn ?? '')) { + showToast('error', t('calls.dispatch_on_too_soon', { minutes: MIN_SCHEDULED_DISPATCH_LEAD_MINUTES })); + return; + } + + const dispatchOnUtc = reschedule ? toDispatchOnUtc(data.dispatchOn) : undefined; + const priority = data.priority ? callPriorities.find((p) => p.Name === data.priority) : null; const type = data.type ? callTypes.find((t) => t.Name === data.type) : null; @@ -445,6 +562,14 @@ export default function EditCall() { dispatchCount: (data.dispatchSelection?.users.length || 0) + (data.dispatchSelection?.groups.length || 0) + (data.dispatchSelection?.roles.length || 0) + (data.dispatchSelection?.units.length || 0), }); + // A field the policy hides must come through the save untouched. For EditCall's text fields a + // blank value means "keep what is stored", and a blank geolocation keeps the stored point; the + // destination is always overwritten, so a hidden one is sent back as stored. (Newer servers + // also ignore whatever is posted for a hidden field.) The dispatch list is sent as loaded: a + // hidden list cannot be changed here, and a blank one would mean "everyone" to older servers. + const isShown = fieldPolicy.isVisible; + const sendLocation = isShown(NewCallFieldKeys.Geolocation); + // Update the call using the store const updatePayload = { callId: callId!, @@ -453,15 +578,19 @@ 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 || '', - destinationPoiId: destinationPoiId, - ...(data.latitude != null && { latitude: data.latitude }), - ...(data.longitude != null && { longitude: data.longitude }), - what3words: data.what3words || '', + note: isShown(NewCallFieldKeys.Note) ? data.note || '' : '', + address: isShown(NewCallFieldKeys.Address) ? data.address || '' : '', + destinationPoiId: isShown(NewCallFieldKeys.DestinationPoi) ? destinationPoiId : (call?.DestinationPoiId ?? null), + ...(sendLocation && data.latitude != null && { latitude: data.latitude }), + ...(sendLocation && data.longitude != null && { longitude: data.longitude }), + what3words: isShown(NewCallFieldKeys.What3Words) ? data.what3words || '' : '', plusCode: data.plusCode || '', - contactName: data.contactName || '', - contactInfo: data.contactInfo || '', + contactName: isShown(NewCallFieldKeys.ContactName) ? data.contactName || '' : '', + contactInfo: isShown(NewCallFieldKeys.ContactInfo) ? data.contactInfo || '' : '', + externalId: isShown(NewCallFieldKeys.ExternalId) ? data.externalId || '' : '', + incidentId: isShown(NewCallFieldKeys.IncidentId) ? data.incidentId || '' : '', + referenceId: isShown(NewCallFieldKeys.ReferenceId) ? data.referenceId || '' : '', + ...(dispatchOnUtc ? { dispatchOnUtc } : {}), dispatchUsers: data.dispatchSelection?.users || [], dispatchGroups: data.dispatchSelection?.groups || [], dispatchRoles: data.dispatchSelection?.roles || [], @@ -508,23 +637,19 @@ export default function EditCall() { error: error instanceof Error ? error.message : 'Unknown error', }); + // The server enforces the department's field policy against the call as it will be after the + // edit, including fields this screen has no input for. Name them rather than showing a generic + // failure. + const rejectedFields = getMissingCallFieldsFromError(error); + // Show error toast - toast.show({ - placement: 'top', - render: () => { - return ( - - {t('call_detail.update_call_error')} - - ); - }, - }); + showToast('error', rejectedFields ? t('calls.required_fields_missing', { fields: formatCallFieldLabels(rejectedFields, t) }) : t('call_detail.update_call_error')); } finally { isSubmittingRef.current = false; setIsSubmitting(false); } }, - [selectedLocation, callPriorities, callTypes, callId, safeTrack, toast, t] + [selectedLocation, callPriorities, callTypes, callId, call, fieldPolicy, storedDispatchOn, safeTrack, showToast, toast, t] ); const handleSavePress = useCallback(async () => { @@ -545,6 +670,9 @@ export default function EditCall() { setValue('address', location.address); } + // Keep the coordinates input in step with the map, as the new-call screen does. + setValue('coordinates', `${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}`); + // Analytics: Track location selection safeTrack('call_edit_location_selected', { timestamp: new Date().toISOString(), @@ -745,6 +873,136 @@ export default function EditCall() { }); }; + // Coordinates, what3words and plus code lookups (same behaviour as New Call), so an edit offers every + // location field a new call does. + const handleCoordinatesSearch = async (coordinates: string) => { + if (!coordinates.trim()) { + showToast('warning', t('calls.coordinates_required')); + return; + } + + // Expect "40.7128, -74.0060" or "40.7128,-74.0060" + const match = coordinates.trim().match(/^(-?\d+\.?\d*),?\s*(-?\d+\.?\d*)$/); + + if (!match) { + showToast('warning', t('calls.coordinates_invalid_format')); + return; + } + + const latitude = parseFloat(match[1] || '0'); + const longitude = parseFloat(match[2] || '0'); + + if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) { + showToast('warning', t('calls.coordinates_out_of_range')); + return; + } + + safeTrack('call_edit_coordinates_search_attempted', { timestamp: new Date().toISOString(), callId: callId || '' }); + + setIsGeocodingCoordinates(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?latlng=${latitude},${longitude}&key=${apiKey}`); + const result = response.data.status === 'OK' ? response.data.results[0] : undefined; + + // The point is what was asked for; a reverse-geocoded address is a bonus. + handleLocationSelected(result ? { latitude, longitude, address: result.formatted_address } : { latitude, longitude }); + showToast(result ? 'success' : 'info', t(result ? 'calls.coordinates_found' : 'calls.coordinates_no_address')); + } catch (error) { + logger.error({ message: 'Error reverse geocoding coordinates', context: { callId, error } }); + + // Even if geocoding fails, still set the location on the map + handleLocationSelected({ latitude, longitude }); + showToast('warning', t('calls.coordinates_geocoding_error')); + } finally { + setIsGeocodingCoordinates(false); + } + }; + + const handleWhat3WordsSearch = async (what3words: string) => { + if (!what3words.trim()) { + showToast('warning', t('calls.what3words_required')); + return; + } + + // Three words separated by dots + if (!/^[a-z]+\.[a-z]+\.[a-z]+$/.test(what3words.trim().toLowerCase())) { + showToast('warning', t('calls.what3words_invalid_format')); + return; + } + + safeTrack('call_edit_what3words_search_attempted', { timestamp: new Date().toISOString(), callId: callId || '' }); + + setIsGeocodingWhat3Words(true); + try { + const apiKey = config?.W3WKey; + + if (!apiKey) { + throw new Error('what3words API key not configured'); + } + + const response = await axios.get(`https://api.what3words.com/v3/convert-to-coordinates?words=${encodeURIComponent(what3words)}&key=${apiKey}`); + + if (response.data.coordinates) { + handleLocationSelected({ + latitude: response.data.coordinates.lat, + longitude: response.data.coordinates.lng, + ...(response.data.nearestPlace ? { address: response.data.nearestPlace } : {}), + }); + showToast('success', t('calls.what3words_found')); + } else { + showToast('error', t('calls.what3words_not_found')); + } + } catch (error) { + logger.error({ message: 'Error geocoding what3words', context: { callId, error } }); + showToast('error', t('calls.what3words_geocoding_error')); + } finally { + setIsGeocodingWhat3Words(false); + } + }; + + const handlePlusCodeSearch = async (plusCode: string) => { + if (!plusCode.trim()) { + showToast('warning', t('calls.plus_code_required')); + return; + } + + safeTrack('call_edit_plus_code_search_attempted', { timestamp: new Date().toISOString(), callId: callId || '' }); + + setIsGeocodingPlusCode(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(plusCode)}&key=${apiKey}`); + const result = response.data.status === 'OK' ? response.data.results[0] : undefined; + + if (result) { + handleLocationSelected({ + latitude: result.geometry.location.lat, + longitude: result.geometry.location.lng, + address: result.formatted_address, + }); + showToast('success', t('calls.plus_code_found')); + } else { + showToast('error', t('calls.plus_code_not_found')); + } + } catch (error) { + logger.error({ message: 'Error geocoding plus code', context: { callId, error } }); + showToast('error', t('calls.plus_code_geocoding_error')); + } finally { + setIsGeocodingPlusCode(false); + } + }; + // The calls store's isLoading/error are shared with the background call-list fetches that run on // app resume and active-call selection. Gate only on what this form actually needs -- the call // itself -- so a background refetch (or a failure in one) can never replace a half-edited form. @@ -784,6 +1042,15 @@ export default function EditCall() { ); } + // Same field set and rules as the new-call screen. The location card groups four of them, so it only + // disappears once the policy has hidden all four; the map fills in the geolocation, so it follows that + // rule. + 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 showLocationCard = showAddress || showGeolocation || showWhat3Words || showPlusCode; + return ( <> - - - - {t('calls.destination')} - - { - const selectedDestinationLabel = value === NO_DESTINATION_POI_VALUE ? t('common.none') : destinationPoiOptions.find((option) => option.value === value)?.label; - - return ( - - ); - }} - /> - - + {fieldPolicy.isVisible(NewCallFieldKeys.DestinationPoi) ? ( + + + + {t('calls.destination')} + + { + const selectedDestinationLabel = value === NO_DESTINATION_POI_VALUE ? t('common.none') : destinationPoiOptions.find((option) => option.value === value)?.label; + + return ( + + ); + }} + /> + + + ) : null} + + {fieldPolicy.isVisible(NewCallFieldKeys.Note) ? ( + + + + {t('calls.note')} + + ( + + )} + /> + + + ) : null} + + {showLocationCard ? ( + + {t('calls.call_location')} + + {/* Address Field */} + {showAddress ? ( + + + {t('calls.address')} + + ( + + + + + + + + + )} + /> + + ) : null} - - - - {t('calls.note')} - - ( - - )} - /> - - + {/* GPS Coordinates Field */} + {showGeolocation ? ( + + + {t('calls.coordinates')} + + ( + + + + + + + + + )} + /> + + ) : null} - - {t('calls.call_location')} + {/* what3words Field */} + {showWhat3Words ? ( + + + {t('calls.what3words')} + + ( + + + + + + + + + )} + /> + + ) : null} - {/* Address Field */} - - - {t('calls.address')} - - ( - - + {/* Plus Code Field */} + {showPlusCode ? ( + + + {t('calls.plus_code')} + + ( + + + + + + + + + )} + /> + + ) : null} + + {/* Map Preview: the map is how the geolocation gets filled in, so it follows that rule. */} + {showGeolocation ? ( + + {selectedLocation ? ( + + ) : ( + + )} + + ) : null} + + ) : null} + + {fieldPolicy.isVisible(NewCallFieldKeys.ContactName) ? ( + + + + {t('calls.contact_name')} + + ( + + + + )} + /> + + + ) : null} + + {fieldPolicy.isVisible(NewCallFieldKeys.ContactInfo) ? ( + + + + {t('calls.contact_info')} + + ( + + + + )} + /> + + + ) : null} + + {/* Call identifiers: one card for the three, shown while any of them is. */} + {fieldPolicy.isVisible(NewCallFieldKeys.ExternalId) || fieldPolicy.isVisible(NewCallFieldKeys.IncidentId) || fieldPolicy.isVisible(NewCallFieldKeys.ReferenceId) ? ( + + {fieldPolicy.isVisible(NewCallFieldKeys.ExternalId) ? ( + + + {t('calls.external_id')} + + ( - + - - - - )} - /> - - - {/* Map Preview */} - - {selectedLocation ? ( - - ) : ( - - )} - - - - - - - {t('calls.contact_name')} - - ( - - - - )} - /> - - - - - - - {t('calls.contact_info')} - - ( - - - - )} - /> - - - - - {t('calls.dispatch_to')} - - + )} + /> + + ) : null} + {fieldPolicy.isVisible(NewCallFieldKeys.IncidentId) ? ( + + + {t('calls.incident_id')} + + ( + + + + )} + /> + + ) : null} + {fieldPolicy.isVisible(NewCallFieldKeys.ReferenceId) ? ( + + + {t('calls.reference_id')} + + ( + + + + )} + /> + + ) : null} + + ) : null} + + {fieldPolicy.isVisible(NewCallFieldKeys.DispatchOn) ? ( + + + + {t('calls.dispatch_on')} + + ( + + )} + /> + + + ) : null} + + {fieldPolicy.isVisible(NewCallFieldKeys.DispatchList) ? ( + + + {t('calls.dispatch_to')} + {fieldPolicy.isRequired(NewCallFieldKeys.DispatchList) ? ' *' : null} + + + + ) : null} - 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 0000000..acc99c0 --- /dev/null +++ b/src/app/call/new/__tests__/field-policy.test.tsx @@ -0,0 +1,387 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react-native'; +import React from 'react'; + +import { createCall } from '@/api/calls/calls'; +import { getNewCallFieldPolicy } from '@/api/calls/newCallFieldPolicy'; +import { type NewCallFieldRuleData } from '@/models/v4/calls/newCallFieldPolicyResultData'; + +// The department's new-call field policy: every built-in input this screen renders must disappear when +// the policy hides it, show as required when the policy requires it, and be checked before the call is +// created. Runs the real policy hook against a mocked policy endpoint. + +const baseFormValues = { + name: 'Structure Fire', + nature: 'Smoke showing', + note: '', + address: '123 Main St', + coordinates: '', + what3words: '', + plusCode: '', + latitude: undefined as number | undefined, + longitude: undefined as number | undefined, + priority: 'High', + type: 'Fire', + destinationPoiId: 'none', + contactName: '', + contactInfo: '', + externalId: '', + incidentId: '', + referenceId: '', + dispatchOn: '', + dispatchSelection: { everyone: true, users: [], groups: [], roles: [], units: [] }, +}; + +const minutesFromNow = (minutes: number) => new Date(Date.now() + minutes * 60 * 1000).toISOString(); + +let mockFormValues = { ...baseFormValues }; + +// Bypass validation: this suite is about the field policy, not the zod schema. +jest.mock('react-hook-form', () => ({ + useForm: () => ({ + control: {}, + handleSubmit: (onValid: (values: unknown) => unknown) => () => onValid({ ...mockFormValues }), + formState: { errors: {} }, + setValue: jest.fn(), + reset: jest.fn(), + getValues: () => mockFormValues, + }), + Controller: ({ render: renderField }: { render: (arg: unknown) => React.ReactElement }) => renderField({ field: { onChange: jest.fn(), onBlur: jest.fn(), value: '' } }), +})); + +jest.mock('@hookform/resolvers/zod', () => ({ zodResolver: () => jest.fn() })); + +// Stable across renders (effects depend on it); shows interpolated values so the toast can be read. +const mockT = (key: string, options?: { fields?: string; minutes?: number }) => (options?.fields !== undefined ? `${key}|${options.fields}` : options?.minutes !== undefined ? `${key}|${options.minutes}` : key); +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: mockT }) })); + +jest.mock('expo-router', () => ({ + router: { push: jest.fn(), back: jest.fn() }, + Stack: { Screen: () => null }, + useFocusEffect: jest.fn(), +})); + +jest.mock('@/api/calls/calls', () => ({ createCall: jest.fn() })); +jest.mock('@/api/calls/newCallFieldPolicy', () => ({ getNewCallFieldPolicy: jest.fn() })); +jest.mock('@/api/dispatch', () => ({ getNewCallData: jest.fn().mockResolvedValue({ Data: { DestinationPois: [], PoiTypes: [] } }) })); + +jest.mock('@/lib/logging', () => ({ logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn(), debug: jest.fn() } })); + +const mockTrackEvent = jest.fn(); +jest.mock('@/hooks/use-analytics', () => ({ useAnalytics: () => ({ trackEvent: mockTrackEvent }) })); + +const mockToast = { success: jest.fn(), error: jest.fn(), warning: jest.fn(), info: jest.fn(), show: jest.fn() }; +jest.mock('@/hooks/use-toast', () => ({ useToast: () => mockToast })); + +jest.mock('@/components/maps/location-picker', () => 'LocationPicker'); +jest.mock('@/components/maps/full-screen-location-picker', () => 'FullScreenLocationPicker'); +jest.mock('@/components/calls/dispatch-selection-modal', () => ({ DispatchSelectionModal: () => null })); +jest.mock('@/components/common/header-back-button', () => ({ HeaderBackButton: () => null })); + +jest.mock('@/components/ui/select', () => { + const ReactModule = require('react'); + const { View } = require('react-native'); + const passthrough = + (testID: string) => + ({ children }: { children?: React.ReactNode }) => + ReactModule.createElement(View, { testID }, children); + + return { + Select: passthrough('select'), + SelectTrigger: passthrough('select-trigger'), + SelectInput: () => null, + SelectIcon: () => null, + SelectPortal: () => null, + SelectBackdrop: () => null, + SelectContent: passthrough('select-content'), + SelectItem: () => null, + }; +}); + +jest.mock('@/components/ui/bottom-sheet', () => ({ CustomBottomSheet: () => null })); + +jest.mock('lucide-react-native', () => ({ + ChevronDownIcon: () => null, + PlusIcon: () => null, + SearchIcon: () => null, +})); + +jest.mock('nativewind', () => ({ + useColorScheme: () => ({ colorScheme: 'light' }), + cssInterop: jest.fn(), + styled: jest.fn((Component: unknown) => Component), +})); + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), + SafeAreaProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +const mockCallsState = { + callPriorities: [{ Id: 1, Name: 'High', Color: '#ff0000' }], + callTypes: [{ Id: 1, Name: 'Fire' }], + isLoading: false, + error: null, + fetchCallPriorities: jest.fn(), + fetchCallTypes: jest.fn(), +}; + +jest.mock('@/stores/calls/store', () => ({ + useCallsStore: (selector?: (state: unknown) => unknown) => (selector ? selector(mockCallsState) : mockCallsState), +})); + +jest.mock('@/stores/app/core-store', () => { + const coreState = { config: { GoogleMapsKey: '', W3WKey: '' } }; + return { useCoreStore: (selector?: (state: unknown) => unknown) => (selector ? selector(coreState) : coreState) }; +}); + +const mockedCreateCall = createCall as jest.MockedFunction; +const mockedGetPolicy = getNewCallFieldPolicy as jest.MockedFunction; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const NewCall = require('../index').default as React.ComponentType; + +const withPolicy = (rules: NewCallFieldRuleData[]) => mockedGetPolicy.mockResolvedValue({ Rules: rules }); + +const FIELD_TEST_IDS = [ + 'note-field', + 'destination-field', + 'address-field', + 'coordinates-field', + 'what3words-field', + 'plus-code-field', + 'open-location-picker-button', + 'contact-name-field', + 'contact-info-field', + 'external-id-field', + 'incident-id-field', + 'reference-id-field', + 'dispatch-on-field', + 'open-dispatch-modal-button', +]; + +// The Create button stays disabled until the policy has loaded, so wait for that before pressing it. +const renderLoaded = async () => { + render(); + const createButton = await screen.findByTestId('create-call-button'); + await waitFor(() => expect(mockedGetPolicy).toHaveBeenCalled()); + await waitFor(() => expect(createButton.props.accessibilityState?.disabled).toBeFalsy()); + return createButton; +}; + +describe('New call field policy', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFormValues = { ...baseFormValues }; + mockedCreateCall.mockResolvedValue({ Id: 'call-1' } as Awaited>); + }); + + it('shows every built-in field when the department has no policy', async () => { + withPolicy([]); + + await renderLoaded(); + + for (const testID of FIELD_TEST_IDS) { + expect(screen.getByTestId(testID)).toBeTruthy(); + } + }); + + it('hides every field the policy hides', async () => { + withPolicy( + ['note', 'destinationPoi', 'address', 'geolocation', 'what3words', 'pluscode', 'contactName', 'contactInfo', 'externalId', 'incidentId', 'referenceId', 'dispatchOn', 'dispatchList'].map((key) => ({ + Key: key, + Visible: false, + Required: false, + })) + ); + + await renderLoaded(); + + for (const testID of FIELD_TEST_IDS) { + expect(screen.queryByTestId(testID)).toBeNull(); + } + expect(screen.queryByText('calls.call_location')).toBeNull(); + expect(screen.queryByTestId('call-identifiers-card')).toBeNull(); + }); + + it('keeps the location card while any of its fields is visible, hiding the map with the geolocation', async () => { + withPolicy([ + { Key: 'address', Visible: false, Required: false }, + { Key: 'geolocation', Visible: false, Required: false }, + { Key: 'pluscode', Visible: false, Required: false }, + ]); + + await renderLoaded(); + + expect(screen.getByText('calls.call_location')).toBeTruthy(); + expect(screen.getByTestId('what3words-field')).toBeTruthy(); + expect(screen.queryByTestId('address-field')).toBeNull(); + expect(screen.queryByTestId('open-location-picker-button')).toBeNull(); + }); + + it('marks required fields', async () => { + withPolicy([ + { Key: 'contactName', Visible: true, Required: true }, + { Key: 'destinationPoi', Visible: true, Required: true }, + { Key: 'dispatchList', Visible: true, Required: true }, + ]); + + await renderLoaded(); + + expect(within(screen.getByTestId('contact-name-field')).getByText('*')).toBeTruthy(); + expect(within(screen.getByTestId('destination-field')).getByText('*')).toBeTruthy(); + expect(within(screen.getByTestId('contact-info-field')).queryByText('*')).toBeNull(); + expect(screen.getByText('calls.dispatch_to *')).toBeTruthy(); + }); + + it('keeps the identifiers card while any of its fields is visible, marking the required ones', async () => { + withPolicy([ + { Key: 'externalId', Visible: false, Required: false }, + { Key: 'incidentId', Visible: true, Required: true }, + ]); + + await renderLoaded(); + + expect(screen.getByTestId('call-identifiers-card')).toBeTruthy(); + expect(screen.queryByTestId('external-id-field')).toBeNull(); + expect(within(screen.getByTestId('incident-id-field')).getByText('*')).toBeTruthy(); + expect(within(screen.getByTestId('reference-id-field')).queryByText('*')).toBeNull(); + }); + + it('refuses to create the call while a required field is blank, naming it by its label', async () => { + withPolicy([ + { Key: 'contactName', Visible: true, Required: true }, + { Key: 'geolocation', Visible: true, Required: true }, + { Key: 'note', Visible: true, Required: false }, + ]); + mockFormValues = { ...baseFormValues, latitude: 0, longitude: 0 }; + + const createButton = await renderLoaded(); + fireEvent.press(createButton); + + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('calls.required_fields_missing|calls.contact_name, calls.coordinates')); + expect(mockedCreateCall).not.toHaveBeenCalled(); + }); + + it('refuses to create the call while a required identifier is blank', async () => { + withPolicy([ + { Key: 'externalId', Visible: true, Required: true }, + { Key: 'incidentId', Visible: true, Required: true }, + { Key: 'referenceId', Visible: true, Required: true }, + ]); + mockFormValues = { ...baseFormValues, incidentId: 'INC-1' }; + + const createButton = await renderLoaded(); + fireEvent.press(createButton); + + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('calls.required_fields_missing|calls.external_id, calls.reference_id')); + expect(mockedCreateCall).not.toHaveBeenCalled(); + }); + + it('sends the contact fields and identifiers it checked', async () => { + withPolicy([ + { Key: 'contactName', Visible: true, Required: true }, + { Key: 'contactInfo', Visible: true, Required: true }, + { Key: 'externalId', Visible: true, Required: true }, + { Key: 'incidentId', Visible: true, Required: true }, + { Key: 'referenceId', Visible: true, Required: true }, + ]); + mockFormValues = { ...baseFormValues, contactName: 'Jane Caller', contactInfo: '555-0100', externalId: 'CAD-9', incidentId: 'INC-1', referenceId: 'REF-2' }; + + const createButton = await renderLoaded(); + fireEvent.press(createButton); + + await waitFor(() => expect(mockedCreateCall).toHaveBeenCalledTimes(1)); + expect(mockedCreateCall.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ contactName: 'Jane Caller', contactInfo: '555-0100', externalId: 'CAD-9', incidentId: 'INC-1', referenceId: 'REF-2' })); + }); + + it('leaves fields this screen has no input for to the server, and names them when it refuses', async () => { + withPolicy([ + { Key: 'protocols', Visible: true, Required: true }, + { Key: 'linkedCall', Visible: true, Required: true }, + { Key: 'indoorLocation', Visible: true, Required: true }, + ]); + mockedCreateCall.mockRejectedValueOnce({ isAxiosError: true, response: { status: 400, data: 'Required call fields are missing: indoorLocation' } }); + + const createButton = await renderLoaded(); + fireEvent.press(createButton); + + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('calls.required_fields_missing|calls.indoor_location')); + expect(mockedCreateCall).toHaveBeenCalledTimes(1); + for (const property of ['protocolIds', 'linkedCallId', 'indoorMapZoneId']) { + expect(mockedCreateCall.mock.calls[0]?.[0]).not.toHaveProperty(property); + } + }); + + describe('scheduled dispatch', () => { + it('marks a required dispatch time and refuses to create the call without one', async () => { + withPolicy([{ Key: 'dispatchOn', Visible: true, Required: true }]); + + const createButton = await renderLoaded(); + + expect(within(screen.getByTestId('dispatch-on-field')).getByText('*')).toBeTruthy(); + expect(screen.getByTestId('dispatch-on-input')).toBeTruthy(); + + fireEvent.press(createButton); + + 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 () => { + withPolicy([]); + mockFormValues = { ...baseFormValues, dispatchOn: minutesFromNow(10) }; + + const createButton = await renderLoaded(); + fireEvent.press(createButton); + + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('calls.dispatch_on_too_soon|15')); + expect(mockedCreateCall).not.toHaveBeenCalled(); + }); + + it('sends a valid dispatch time as DispatchOnUtc', async () => { + withPolicy([{ Key: 'dispatchOn', Visible: true, Required: true }]); + const dispatchOn = minutesFromNow(90); + mockFormValues = { ...baseFormValues, dispatchOn }; + + const createButton = await renderLoaded(); + fireEvent.press(createButton); + + await waitFor(() => expect(mockedCreateCall).toHaveBeenCalledTimes(1)); + expect(mockedCreateCall.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ dispatchOnUtc: dispatchOn })); + }); + + it('leaves DispatchOnUtc out when no time is set', async () => { + withPolicy([]); + + const createButton = await renderLoaded(); + fireEvent.press(createButton); + + await waitFor(() => expect(mockedCreateCall).toHaveBeenCalledTimes(1)); + expect(mockedCreateCall.mock.calls[0]?.[0]).not.toHaveProperty('dispatchOnUtc'); + }); + + it('sends nothing for a hidden dispatch time', async () => { + withPolicy([{ Key: 'dispatchOn', Visible: false, Required: true }]); + mockFormValues = { ...baseFormValues, dispatchOn: minutesFromNow(5) }; + + const createButton = await renderLoaded(); + + expect(screen.queryByTestId('dispatch-on-field')).toBeNull(); + + fireEvent.press(createButton); + + await waitFor(() => expect(mockedCreateCall).toHaveBeenCalledTimes(1)); + expect(mockedCreateCall.mock.calls[0]?.[0]).not.toHaveProperty('dispatchOnUtc'); + }); + }); + + it('shows the generic error for any other failure', async () => { + withPolicy([]); + mockedCreateCall.mockRejectedValueOnce({ isAxiosError: true, response: { status: 400, data: 'Bad destination' } }); + + const createButton = await renderLoaded(); + fireEvent.press(createButton); + + await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('calls.create_error')); + }); +}); diff --git a/src/app/call/new/index.tsx b/src/app/call/new/index.tsx index 0bc4077..bb8c1e4 100644 --- a/src/app/call/new/index.tsx +++ b/src/app/call/new/index.tsx @@ -14,6 +14,7 @@ import * as z from 'zod'; import { createCall } from '@/api/calls/calls'; import { getNewCallData } from '@/api/dispatch'; 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'; @@ -31,9 +32,18 @@ import { Textarea, TextareaInput } from '@/components/ui/textarea'; import { useAnalytics } from '@/hooks/use-analytics'; import { useNewCallFieldPolicy } from '@/hooks/use-new-call-field-policy'; import { useToast } from '@/hooks/use-toast'; +import { + CALL_FORM_UNCOLLECTED_FIELD_KEYS, + formatCallFieldLabels, + formatCallGeolocation, + getMissingCallFieldsFromError, + isScheduledDispatchTooSoon, + MIN_SCHEDULED_DISPATCH_LEAD_MINUTES, + toDispatchOnUtc, +} from '@/lib/call-field-policy'; import { logger } from '@/lib/logging'; import { getDestinationPoiIdFromValue, getDestinationPoiSelectOptions, NO_DESTINATION_POI_VALUE } from '@/lib/poi'; -import { type NewCallFieldKey, NewCallFieldKeys } from '@/models/v4/calls/newCallFieldPolicyResultData'; +import { NewCallFieldKeys } from '@/models/v4/calls/newCallFieldPolicyResultData'; import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; import { type PoiTypeResultData } from '@/models/v4/mapping/poiTypeResultData'; import { useCoreStore } from '@/stores/app/core-store'; @@ -54,22 +64,6 @@ const sanitizeErrorString = (error: unknown): string => { return 'Unknown error'; }; -// 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', -}; - // Define the form schema using zod const formSchema = z.object({ name: z.string().min(1, { message: 'Name is required' }), @@ -86,6 +80,11 @@ const formSchema = z.object({ destinationPoiId: z.string().optional(), contactName: z.string().optional(), contactInfo: z.string().optional(), + externalId: z.string().optional(), + incidentId: z.string().optional(), + referenceId: z.string().optional(), + // Scheduled dispatch time as an ISO 8601 UTC string; '' for "dispatch now" / "leave the schedule alone". + dispatchOn: z.string().optional(), dispatchSelection: z .object({ everyone: z.boolean(), @@ -210,6 +209,10 @@ export default function NewCall() { destinationPoiId: NO_DESTINATION_POI_VALUE, contactName: '', contactInfo: '', + externalId: '', + incidentId: '', + referenceId: '', + dispatchOn: '', dispatchSelection: { everyone: false, users: [], @@ -301,39 +304,47 @@ export default function NewCall() { // and a numeric id would collide with the "0 counts as empty" rule for numbers. const destinationPoiId = getDestinationPoiIdFromValue(data.destinationPoiId); - // 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. - // DispatchOn is deliberately absent: this app has no scheduling input, so validating it here - // could only produce a required field the dispatcher has no way to fill. The server still - // enforces it and rejects the save with a reason. - 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]: destinationPoiId != null ? String(destinationPoiId) : '', - [NewCallFieldKeys.DispatchList]: - dispatchSelection.everyone || dispatchSelection.units.length > 0 || dispatchSelection.users.length > 0 || dispatchSelection.groups.length > 0 || dispatchSelection.roles.length > 0, - }); + // Only the fields this screen has an input for are checked (see + // CALL_FORM_UNCOLLECTED_FIELD_KEYS). This app has no pending save, so a required dispatch time is + // always enforced, as the server does. + const missingFields = fieldPolicy + .missingRequired({ + [NewCallFieldKeys.Address]: data.address, + // The same rule the server applies: a single zero coordinate is a place, 0,0 is not. + [NewCallFieldKeys.Geolocation]: formatCallGeolocation(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.ExternalId]: data.externalId, + [NewCallFieldKeys.IncidentId]: data.incidentId, + [NewCallFieldKeys.ReferenceId]: data.referenceId, + [NewCallFieldKeys.DispatchOn]: data.dispatchOn, + [NewCallFieldKeys.DestinationPoi]: destinationPoiId != null ? String(destinationPoiId) : '', + [NewCallFieldKeys.DispatchList]: + dispatchSelection.everyone || dispatchSelection.units.length > 0 || dispatchSelection.users.length > 0 || dispatchSelection.groups.length > 0 || dispatchSelection.roles.length > 0, + }) + .filter((key) => !CALL_FORM_UNCOLLECTED_FIELD_KEYS.has(key)); if (missingFields.length > 0) { - const missingLabels = missingFields.map((key) => { - const labelKey = NEW_CALL_FIELD_LABEL_KEYS[key]; + toast.error(t('calls.required_fields_missing', { fields: formatCallFieldLabels(missingFields, t) })); + return; + } - return labelKey ? t(labelKey) : key; - }); + // A scheduled dispatch has to leave time to change the call before it goes out -- the web form's + // rule. A hidden input sends nothing, so the call goes out now. + const scheduleDispatch = fieldPolicy.isVisible(NewCallFieldKeys.DispatchOn) && !!data.dispatchOn; - toast.error(t('calls.required_fields_missing', { fields: missingLabels.join(', ') })); + if (scheduleDispatch && isScheduledDispatchTooSoon(data.dispatchOn ?? '')) { + toast.error(t('calls.dispatch_on_too_soon', { minutes: MIN_SCHEDULED_DISPATCH_LEAD_MINUTES })); return; } + const dispatchOnUtc = scheduleDispatch ? toDispatchOnUtc(data.dispatchOn) : undefined; + // Analytics: Track call creation attempt trackEvent('call_create_attempted', { timestamp: new Date().toISOString(), @@ -373,6 +384,14 @@ export default function NewCall() { longitude: data.longitude || 0, what3words: data.what3words || '', plusCode: data.plusCode || '', + // Collected on this form (and checked against the policy above) but previously never sent, so + // a department requiring them saw every call refused by the server. + contactName: data.contactName || '', + contactInfo: data.contactInfo || '', + externalId: data.externalId || '', + incidentId: data.incidentId || '', + referenceId: data.referenceId || '', + ...(dispatchOnUtc ? { dispatchOnUtc } : {}), dispatchUsers: data.dispatchSelection?.users || [], dispatchGroups: data.dispatchSelection?.groups || [], dispatchRoles: data.dispatchSelection?.roles || [], @@ -407,8 +426,15 @@ export default function NewCall() { error: error instanceof Error ? error.message : 'Unknown error', }); - // Show error toast - toast.error(t('calls.create_error')); + // The server enforces the department's field policy too, including fields this form has no + // input for (the dispatch time). Name them rather than showing a generic failure. + const rejectedFields = getMissingCallFieldsFromError(error); + + if (rejectedFields) { + toast.error(t('calls.required_fields_missing', { fields: formatCallFieldLabels(rejectedFields, t) })); + } else { + toast.error(t('calls.create_error')); + } } finally { isSubmittingRef.current = false; setIsSubmitting(false); @@ -909,6 +935,15 @@ export default function NewCall() { ); } + // Every rule the department can set drives its own control. The location card groups four of them, + // so it only disappears once the policy has hidden all four. The map fills in the geolocation, so it + // follows that rule. + 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 showLocationCard = showAddress || showGeolocation || showWhat3Words || showPlusCode; + return ( <> - - - - {t('calls.destination')} - - { - const selectedDestinationLabel = value === NO_DESTINATION_POI_VALUE ? t('common.none') : destinationPoiOptions.find((option) => option.value === value)?.label; - - return ( - - ); - }} - /> - - + {fieldPolicy.isVisible(NewCallFieldKeys.DestinationPoi) ? ( + + + + {t('calls.destination')} + + { + const selectedDestinationLabel = value === NO_DESTINATION_POI_VALUE ? t('common.none') : destinationPoiOptions.find((option) => option.value === value)?.label; + + return ( + + ); + }} + /> + + + ) : null} {fieldPolicy.isVisible(NewCallFieldKeys.Note) ? ( - + {t('calls.note')} @@ -1085,152 +1122,164 @@ export default function NewCall() { ) : null} - - {t('calls.call_location')} + {showLocationCard ? ( + + {t('calls.call_location')} + + {/* Address Field */} + {showAddress ? ( + + + {t('calls.address')} + + ( + + + + + + + + + )} + /> + + ) : null} - {/* Address Field */} - - - {t('calls.address')} - - ( - - - - - - - - - )} - /> - + {/* GPS Coordinates Field */} + {showGeolocation ? ( + + + {t('calls.coordinates')} + + ( + + + + + + + + + )} + /> + + ) : null} - {/* GPS Coordinates Field */} - - - {t('calls.coordinates')} - - ( - - - - - - - - - )} - /> - + {/* what3words Field */} + {showWhat3Words ? ( + + + {t('calls.what3words')} + + ( + + + + + + + + + )} + /> + + ) : null} - {/* what3words Field */} - - - {t('calls.what3words')} - - ( - - - - - - - - - )} - /> - + {/* Plus Code Field */} + {showPlusCode ? ( + + + {t('calls.plus_code')} + + ( + + + + + + + + + )} + /> + + ) : null} - {/* Plus Code Field */} - - - {t('calls.plus_code')} - - ( - - - - - - - - - )} - /> - - - {/* Map Preview */} - - {selectedLocation ? ( - - ) : ( - - )} - - + )} + + ) : null} + + ) : null} {fieldPolicy.isVisible(NewCallFieldKeys.ContactName) ? ( - + {t('calls.contact_name')} @@ -1249,7 +1298,7 @@ export default function NewCall() { {fieldPolicy.isVisible(NewCallFieldKeys.ContactInfo) ? ( - + {t('calls.contact_info')} @@ -1266,12 +1315,86 @@ export default function NewCall() { ) : null} - - {t('calls.dispatch_to')} - - + {/* Call identifiers: one card for the three, shown while any of them is. */} + {fieldPolicy.isVisible(NewCallFieldKeys.ExternalId) || fieldPolicy.isVisible(NewCallFieldKeys.IncidentId) || fieldPolicy.isVisible(NewCallFieldKeys.ReferenceId) ? ( + + {fieldPolicy.isVisible(NewCallFieldKeys.ExternalId) ? ( + + + {t('calls.external_id')} + + ( + + + + )} + /> + + ) : null} + {fieldPolicy.isVisible(NewCallFieldKeys.IncidentId) ? ( + + + {t('calls.incident_id')} + + ( + + + + )} + /> + + ) : null} + {fieldPolicy.isVisible(NewCallFieldKeys.ReferenceId) ? ( + + + {t('calls.reference_id')} + + ( + + + + )} + /> + + ) : null} + + ) : null} + + {fieldPolicy.isVisible(NewCallFieldKeys.DispatchOn) ? ( + + + + {t('calls.dispatch_on')} + + } + /> + + + ) : null} + + {fieldPolicy.isVisible(NewCallFieldKeys.DispatchList) ? ( + + + {t('calls.dispatch_to')} + {fieldPolicy.isRequired(NewCallFieldKeys.DispatchList) ? ' *' : null} + + + + ) : null}