From d7950e30f7498b53d3ac5b862569c9d8d620749c Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 6 Oct 2026 18:51:21 -0700 Subject: [PATCH 1/2] RU-T53 Hold to Confirm, Close Call Fixes --- src/api/calls/__tests__/closeCall.test.ts | 59 +++ src/api/calls/calls.ts | 6 + src/app/(app)/_layout.tsx | 3 + .../close-call-bottom-sheet.test.tsx | 59 +++ .../calls/close-call-bottom-sheet.tsx | 20 +- src/components/sidebar/sidebar-content.tsx | 133 ++++-- .../__tests__/hold-to-confirm-button.test.tsx | 141 +++++++ .../__tests__/status-bottom-sheet.test.tsx | 133 +++++- .../status/hold-to-confirm-button.tsx | 378 ++++++++++++++++++ src/components/status/status-bottom-sheet.tsx | 326 ++++++++++++--- .../ui/utils/__tests__/dom-props.test.ts | 23 ++ src/components/ui/utils/dom-props.ts | 6 +- src/lib/__tests__/call-close.test.ts | 33 ++ src/lib/__tests__/status-flow.test.ts | 113 ++++++ src/lib/call-close.ts | 42 ++ src/lib/status-flow.ts | 122 ++++++ src/models/v4/configs/getConfigResultData.ts | 2 + src/models/v4/statuses/statusesResultData.ts | 2 + .../v4/unitStatus/unitStatusResultData.ts | 2 + .../calls/__tests__/detail-store.test.ts | 40 ++ src/stores/calls/detail-store.ts | 4 +- .../__tests__/call-closed-parsing.test.ts | 37 ++ src/stores/push-notification/store.ts | 9 +- src/stores/status/store.ts | 15 +- src/translations/ar.json | 9 + src/translations/de.json | 9 + src/translations/el.json | 9 + src/translations/en.json | 9 + src/translations/es.json | 9 + src/translations/fr.json | 9 + src/translations/it.json | 9 + src/translations/pl.json | 9 + src/translations/sv.json | 9 + src/translations/uk.json | 9 + 34 files changed, 1717 insertions(+), 81 deletions(-) create mode 100644 src/api/calls/__tests__/closeCall.test.ts create mode 100644 src/components/status/__tests__/hold-to-confirm-button.test.tsx create mode 100644 src/components/status/hold-to-confirm-button.tsx create mode 100644 src/components/ui/utils/__tests__/dom-props.test.ts create mode 100644 src/lib/__tests__/call-close.test.ts create mode 100644 src/lib/__tests__/status-flow.test.ts create mode 100644 src/lib/call-close.ts create mode 100644 src/lib/status-flow.ts create mode 100644 src/stores/push-notification/__tests__/call-closed-parsing.test.ts diff --git a/src/api/calls/__tests__/closeCall.test.ts b/src/api/calls/__tests__/closeCall.test.ts new file mode 100644 index 00000000..f4ea99fd --- /dev/null +++ b/src/api/calls/__tests__/closeCall.test.ts @@ -0,0 +1,59 @@ +import { closeCall } from '../calls'; + +jest.mock('../../common/client', () => { + const put = jest.fn(); + return { + createApiEndpoint: jest.fn(() => ({ get: jest.fn(), post: jest.fn(), put, delete: jest.fn() })), + __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 { __mockPut: mockPut } = jest.requireMock('../../common/client') as { __mockPut: jest.Mock }; +const { cacheManager } = jest.requireMock('@/lib/cache/cache-manager') as { cacheManager: { remove: jest.Mock } }; + +describe('closeCall api', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPut.mockResolvedValue({ data: { Id: '42' } }); + }); + + it('sends Id, Type, Notes and SendNotification when the caller chose whether to notify', async () => { + await closeCall({ callId: '42', type: 3, note: 'Nothing found', sendNotification: true }); + expect(mockPut).toHaveBeenCalledWith({ Id: '42', Type: 3, Notes: 'Nothing found', SendNotification: true }); + + await closeCall({ callId: '42', type: 1, sendNotification: false }); + expect(mockPut).toHaveBeenLastCalledWith({ Id: '42', Type: 1, Notes: '', SendNotification: false }); + }); + + it('leaves SendNotification out when not set, so older callers send the same body as before', async () => { + await closeCall({ callId: '42', type: 2, note: 'Cancelled by caller' }); + + expect(mockPut).toHaveBeenCalledWith({ Id: '42', Type: 2, Notes: 'Cancelled by caller' }); + expect(mockPut.mock.calls[0][0]).not.toHaveProperty('SendNotification'); + }); + + it('invalidates the active calls cache and returns the body', async () => { + await expect(closeCall({ callId: '42', type: 1 })).resolves.toEqual({ Id: '42' }); + expect(cacheManager.remove).toHaveBeenCalledWith('/Calls/GetActiveCalls'); + }); + + it('propagates a refusal to the caller', async () => { + const refusal = Object.assign(new Error('Request failed with status code 400'), { isAxiosError: true, response: { status: 400, data: 'This call has an active incident command.' } }); + mockPut.mockRejectedValueOnce(refusal); + + await expect(closeCall({ callId: '42', type: 1, sendNotification: true })).rejects.toBe(refusal); + expect(cacheManager.remove).not.toHaveBeenCalled(); + }); +}); diff --git a/src/api/calls/calls.ts b/src/api/calls/calls.ts index 89ddea15..a81ffbf7 100644 --- a/src/api/calls/calls.ts +++ b/src/api/calls/calls.ts @@ -92,6 +92,11 @@ export interface CloseCallRequest { callId: string; type: number; note?: string; + /** + * Alert everyone attached to the call (dispatched personnel, groups, roles, units and the incident + * command team) that it is closed. Omitted from the request when not set, leaving the server default. + */ + sendNotification?: boolean; } /** @@ -194,6 +199,7 @@ export const closeCall = async (callData: CloseCallRequest) => { Id: callData.callId, Type: callData.type, Notes: callData.note || '', + ...(callData.sendNotification !== undefined ? { SendNotification: callData.sendNotification } : {}), }; const response = await closeCallApi.put(data); diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index e4ed7dac..e5a1fbbe 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -650,6 +650,9 @@ export default function TabLayout() { + {/* Opened from Settings; without a declaration expo-router adds it to the tab bar as "account-security". */} + + diff --git a/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx b/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx index a2b6ba2b..14d8cc69 100644 --- a/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx +++ b/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx @@ -175,6 +175,13 @@ jest.mock('@/components/ui/textarea', () => ({ }, })); +jest.mock('@/components/ui/switch', () => ({ + Switch: ({ value, onValueChange, testID }: any) => { + const { Switch: RNSwitch } = require('react-native'); + return ; + }, +})); + const mockRouter = { back: jest.fn(), replace: jest.fn(), @@ -282,6 +289,7 @@ describe('CloseCallBottomSheet', () => { callId: 'test-call-1', type: 1, note: 'Call resolved successfully', + sendNotification: true, }); expect(mockShowToast).toHaveBeenCalledWith('success', 'call_detail.close_call_success'); expect(mockFetchCalls).toHaveBeenCalled(); @@ -309,6 +317,7 @@ describe('CloseCallBottomSheet', () => { callId: 'test-call-1', type: 2, note: '', + sendNotification: true, }); expect(mockShowToast).toHaveBeenCalledWith('success', 'call_detail.close_call_success'); expect(mockFetchCalls).toHaveBeenCalled(); @@ -338,6 +347,55 @@ describe('CloseCallBottomSheet', () => { expect(mockRouter.replace).not.toHaveBeenCalled(); }); + it('shows the server reason when the close is refused (call has an active incident command)', async () => { + const reason = 'This call has an active incident command. Close the incident command first, then close the call.'; + mockCloseCall.mockRejectedValue(Object.assign(new Error('Request failed with status code 400'), { isAxiosError: true, response: { status: 400, data: reason } })); + + render(); + selectCloseCallType('1'); + fireEvent.press(screen.getAllByText('call_detail.close_call')[1]); + + await waitFor(() => { + expect(mockShowToast).toHaveBeenCalledWith('error', reason); + }); + expect(mockShowToast).not.toHaveBeenCalledWith('error', 'call_detail.close_call_error'); + expect(mockFetchCalls).not.toHaveBeenCalled(); + expect(mockRouter.replace).not.toHaveBeenCalled(); + }); + + it('notifies everyone on the call by default and lets the switch turn it off', async () => { + mockCloseCall.mockResolvedValue(undefined); + mockFetchCalls.mockResolvedValue(undefined); + + render(); + + expect(screen.getByText('call_detail.close_call_notify')).toBeTruthy(); + expect(screen.getByText('call_detail.close_call_notify_hint')).toBeTruthy(); + const notifySwitch = screen.getByTestId('close-call-notify-switch'); + expect(notifySwitch.props.value).toBe(true); + + fireEvent(notifySwitch, 'onValueChange', false); + selectCloseCallType('7'); + fireEvent.press(screen.getAllByText('call_detail.close_call')[1]); + + await waitFor(() => { + expect(mockCloseCall).toHaveBeenCalledWith({ callId: 'test-call-1', type: 7, note: '', sendNotification: false }); + }); + }); + + it('turns the notify switch back on when the sheet is cancelled', () => { + const mockOnClose = jest.fn(); + render(); + + fireEvent(screen.getByTestId('close-call-notify-switch'), 'onValueChange', false); + expect(screen.getByTestId('close-call-notify-switch').props.value).toBe(false); + + fireEvent.press(screen.getByText('common.cancel')); + + expect(mockOnClose).toHaveBeenCalled(); + expect(screen.getByTestId('close-call-notify-switch').props.value).toBe(true); + }); + it.each([ { type: '1', expected: 1 }, { type: '2', expected: 2 }, @@ -365,6 +423,7 @@ describe('CloseCallBottomSheet', () => { callId: 'test-call-1', type: expected, note: '', + sendNotification: true, }); expect(mockFetchCalls).toHaveBeenCalled(); }); diff --git a/src/components/calls/close-call-bottom-sheet.tsx b/src/components/calls/close-call-bottom-sheet.tsx index c72dff2b..3b4e5149 100644 --- a/src/components/calls/close-call-bottom-sheet.tsx +++ b/src/components/calls/close-call-bottom-sheet.tsx @@ -9,11 +9,13 @@ import { FormControl, FormControlLabel, FormControlLabelText } from '@/component import { Heading } from '@/components/ui/heading'; import { HStack } from '@/components/ui/hstack'; import { Select, SelectBackdrop, SelectContent, SelectIcon, SelectInput, SelectItem, SelectPortal, SelectTrigger } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; import { Text } from '@/components/ui/text'; import { Textarea, TextareaInput } from '@/components/ui/textarea'; import { VStack } from '@/components/ui/vstack'; import { useAnalytics } from '@/hooks/use-analytics'; import { useKeyboardHeight } from '@/hooks/use-keyboard-height'; +import { getCallCloseErrorMessage } from '@/lib/call-close'; import { logger } from '@/lib/logging'; import { useCallDetailStore } from '@/stores/calls/detail-store'; import { useCallsStore } from '@/stores/calls/store'; @@ -36,6 +38,8 @@ export const CloseCallBottomSheet: React.FC = ({ isOp const fetchCalls = useCallsStore((state) => state.fetchCalls); const [closeCallType, setCloseCallType] = useState(''); const [closeCallNote, setCloseCallNote] = useState(''); + // Alert everyone on the call (personnel, groups, roles, units, command team) that it closed — on by default. + const [sendNotification, setSendNotification] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); // Track when close call bottom sheet is opened/rendered @@ -51,6 +55,7 @@ export const CloseCallBottomSheet: React.FC = ({ isOp const handleClose = React.useCallback(() => { setCloseCallType(''); setCloseCallNote(''); + setSendNotification(true); onClose(); }, [onClose]); @@ -67,6 +72,7 @@ export const CloseCallBottomSheet: React.FC = ({ isOp callId, type: parseInt(closeCallType), note: closeCallNote, + sendNotification, }); // Show success toast @@ -80,12 +86,12 @@ export const CloseCallBottomSheet: React.FC = ({ isOp await fetchCalls(); } catch (error) { logger.error({ message: 'Error closing call', context: { error, callId } }); - // Show error toast - showToast('error', t('call_detail.close_call_error')); + // Show the server's reason when it gave one (e.g. the call still has an active incident command) + showToast('error', getCallCloseErrorMessage(error) ?? t('call_detail.close_call_error')); } finally { setIsSubmitting(false); } - }, [closeCallType, showToast, t, callId, closeCallNote, handleClose, fetchCalls, router, closeCall]); + }, [closeCallType, showToast, t, callId, closeCallNote, sendNotification, handleClose, fetchCalls, router, closeCall]); const isButtonDisabled = isLoading || isSubmitting; @@ -143,6 +149,14 @@ export const CloseCallBottomSheet: React.FC = ({ isOp + + + {t('call_detail.close_call_notify')} + {t('call_detail.close_call_notify_hint')} + + + + - ))} + {label} + + ); + })} + + {offeredStatuses.isRestricted ? ( + setShowAllStatuses(true)} className="items-center py-1"> + {t('status.show_all_statuses', { count: offeredStatuses.hiddenCount })} + + ) : hasNextStatusRestriction ? ( + setShowAllStatuses(false)} className="items-center py-1"> + {t('status.show_next_statuses')} + + ) : null} )} @@ -191,4 +255,27 @@ const Sidebar = ({ onClose }: SidebarProps) => { ); }; +const styles = StyleSheet.create({ + currentOutline: { + borderWidth: 3, + borderColor: '#dc2626', + }, + currentPill: { + backgroundColor: '#dc2626', + borderRadius: 999, + paddingHorizontal: 6, + paddingVertical: 1, + }, + currentPillText: { + color: '#ffffff', + fontSize: 10, + fontWeight: '700', + textTransform: 'uppercase', + }, + holdContent: { + paddingHorizontal: 12, + paddingVertical: 12, + }, +}); + export default Sidebar; diff --git a/src/components/status/__tests__/hold-to-confirm-button.test.tsx b/src/components/status/__tests__/hold-to-confirm-button.test.tsx new file mode 100644 index 00000000..a1357d0a --- /dev/null +++ b/src/components/status/__tests__/hold-to-confirm-button.test.tsx @@ -0,0 +1,141 @@ +import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; +import { Text } from 'react-native'; + +import { HoldToConfirmButton, STATUS_HOLD_DURATION_MS } from '../hold-to-confirm-button'; + +jest.mock('lucide-react-native', () => { + const React = require('react'); + const { View } = require('react-native'); + const icon = (props: Record) => React.createElement(View, props); + return new Proxy({}, { get: () => icon }); +}); + +const renderButton = (props: Partial> = {}) => { + const onConfirm = jest.fn(); + const onTap = jest.fn(); + + render( + + Vertrokken + + ); + + return { onConfirm, onTap, button: screen.getByTestId('hold') }; +}; + +describe('HoldToConfirmButton', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('confirms once the hold reaches the full duration', () => { + const { onConfirm, onTap, button } = renderButton(); + + fireEvent(button, 'pressIn'); + act(() => { + jest.advanceTimersByTime(STATUS_HOLD_DURATION_MS - 1); + }); + expect(onConfirm).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(1); + }); + expect(onConfirm).toHaveBeenCalledTimes(1); + + // Letting go after completion is not a tap and does not confirm again. + fireEvent(button, 'pressOut'); + expect(onTap).not.toHaveBeenCalled(); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('counts down while held', () => { + const { button } = renderButton(); + + fireEvent(button, 'pressIn'); + act(() => { + jest.advanceTimersByTime(600); + }); + + expect(screen.getByTestId('hold-countdown')).toBeTruthy(); + expect(screen.getByText('1.4s')).toBeTruthy(); + }); + + it('cancels when released early, and treats a quick release as a tap', () => { + const { onConfirm, onTap, button } = renderButton(); + + fireEvent(button, 'pressIn'); + act(() => { + jest.advanceTimersByTime(100); + }); + fireEvent(button, 'pressOut'); + expect(onTap).toHaveBeenCalledTimes(1); + + fireEvent(button, 'pressIn'); + act(() => { + jest.advanceTimersByTime(1200); + }); + fireEvent(button, 'pressOut'); + expect(onTap).toHaveBeenCalledTimes(1); + + act(() => { + jest.advanceTimersByTime(STATUS_HOLD_DURATION_MS * 2); + }); + expect(onConfirm).not.toHaveBeenCalled(); + expect(screen.queryByTestId('hold-countdown')).toBeNull(); + }); + + it('honours a custom duration', () => { + const { onConfirm, button } = renderButton({ durationMs: 500 }); + + fireEvent(button, 'pressIn'); + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('does nothing while disabled', () => { + const { onConfirm, button } = renderButton({ disabled: true }); + + fireEvent(button, 'pressIn'); + act(() => { + jest.advanceTimersByTime(STATUS_HOLD_DURATION_MS); + }); + + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('lets a screen reader confirm with the activate action', () => { + const { onConfirm, button } = renderButton(); + + fireEvent(button, 'accessibilityAction', { nativeEvent: { actionName: 'activate' } }); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('resets after confirming so the button can be held again', () => { + const { onConfirm, button } = renderButton(); + + fireEvent(button, 'pressIn'); + act(() => { + jest.advanceTimersByTime(STATUS_HOLD_DURATION_MS); + }); + fireEvent(button, 'pressOut'); + act(() => { + jest.advanceTimersByTime(1000); + }); + + fireEvent(button, 'pressIn'); + act(() => { + jest.advanceTimersByTime(STATUS_HOLD_DURATION_MS); + }); + + expect(onConfirm).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/components/status/__tests__/status-bottom-sheet.test.tsx b/src/components/status/__tests__/status-bottom-sheet.test.tsx index b5552af3..57c826d6 100644 --- a/src/components/status/__tests__/status-bottom-sheet.test.tsx +++ b/src/components/status/__tests__/status-bottom-sheet.test.tsx @@ -245,7 +245,7 @@ jest.mock('@/stores/toast/store', () => ({ })); import React from 'react'; -import { render, fireEvent, waitFor, screen } from '@testing-library/react-native'; +import { act, render, fireEvent, waitFor, screen } from '@testing-library/react-native'; import { useTranslation } from 'react-i18next'; import { useStatusBottomSheetStore, useStatusesStore } from '@/stores/status/store'; @@ -4205,4 +4205,135 @@ describe('StatusBottomSheet', () => { expect(findButton('Submit')?.props.accessibilityState?.disabled).toBe(false); }); }); + + describe('status flow: current status, next statuses and hold to set', () => { + // Belgian EMS sequence: after "Departed" only "On Scene" is offered. + const available = { Id: 10, Type: 1, StateId: 1, Text: 'Available', BColor: '#28a745', Color: '#fff', Gps: false, Note: 0, Detail: 0, NextIds: [] as number[] }; + const departed = { Id: 12, Type: 1, StateId: 1, Text: 'Departed', BColor: '#f0ad4e', Color: '#000', Gps: false, Note: 0, Detail: 2, NextIds: [13] }; + const onScene = { Id: 13, Type: 1, StateId: 1, Text: 'On Scene', BColor: '#dc3545', Color: '#fff', Gps: false, Note: 0, Detail: 0, NextIds: [10] }; + const flowStatuses = { UnitType: '0', Statuses: [available, departed, onScene] }; + const departedUnitStatus = { UnitId: 'unit-1', State: 'Departed', StateId: 12, DestinationId: 0, DestinationType: 0 }; + + const setCoreStore = (overrides: Record) => { + const store = { ...defaultCoreStore, activeStatuses: flowStatuses, activeUnitStatus: departedUnitStatus, ...overrides }; + mockGetState.mockReturnValue(store as any); + mockUseCoreStore.mockImplementation((selector: any) => (selector ? selector(store) : store)); + }; + + // Setters that write to the mocked state, so a re-render sees what a hold selected. + const mountStatefulSheetStore = (overrides: Record = {}) => { + let state: any = { ...defaultBottomSheetStore, isOpen: true, currentStep: 'select-status', cameFromStatusSelection: true, holdConfirmed: false, lastFetchedAt: 1, ...overrides }; + const update = (patch: Record) => { + state = { ...state, ...patch }; + }; + state.setSelectedStatus = jest.fn((selectedStatus) => update({ selectedStatus })); + state.setHoldConfirmed = jest.fn((holdConfirmed) => update({ holdConfirmed })); + state.setCurrentStep = jest.fn((currentStep) => update({ currentStep })); + mockUseStatusBottomSheetStore.mockImplementation((selector: any) => (selector ? selector(state) : state)); + return () => state; + }; + + afterEach(() => { + jest.useRealTimers(); + }); + + it('offers only the next statuses and keeps the current one visible, outlined', () => { + setCoreStore({}); + mountStatefulSheetStore(); + + render(); + + expect(screen.getByText('On Scene')).toBeTruthy(); + expect(screen.queryByText('Available')).toBeNull(); + expect(screen.getByTestId('status-current-banner')).toBeTruthy(); + expect(screen.getByText('Departed')).toBeTruthy(); + expect(screen.getByText('status.current')).toBeTruthy(); + expect(screen.getByText('status.show_all_statuses')).toBeTruthy(); + }); + + it('shows every status on request, with the current one marked in the list', () => { + setCoreStore({}); + mountStatefulSheetStore(); + + render(); + fireEvent.press(screen.getByTestId('status-show-all')); + + expect(screen.getByText('Available')).toBeTruthy(); + expect(screen.getByText('On Scene')).toBeTruthy(); + expect(screen.queryByTestId('status-current-banner')).toBeNull(); + expect(screen.getByTestId('status-option-12').props.accessibilityLabel).toBe('Departed, status.current'); + expect(screen.getByTestId('status-show-next')).toBeTruthy(); + }); + + it('matches the current status by text on servers that send no StateId', () => { + setCoreStore({ activeUnitStatus: { UnitId: 'unit-1', State: 'Departed' } }); + mountStatefulSheetStore(); + + render(); + + expect(screen.queryByText('Available')).toBeNull(); + expect(screen.getByTestId('status-current-banner')).toBeTruthy(); + }); + + it('offers everything when the current status belongs to another unit', () => { + setCoreStore({ activeUnitStatus: { ...departedUnitStatus, UnitId: 'unit-2' } }); + mountStatefulSheetStore(); + + render(); + + expect(screen.getByText('Available')).toBeTruthy(); + expect(screen.queryByTestId('status-current-banner')).toBeNull(); + }); + + it('in hold mode saves a held status that needs nothing more, without a Next tap', async () => { + jest.useFakeTimers(); + setCoreStore({ config: { StatusHoldToConfirm: true }, activeUnitStatus: { UnitId: 'unit-1', State: 'On Scene', StateId: 13 } }); + mountStatefulSheetStore(); + + const { rerender } = render(); + + expect(screen.queryByText('Next')).toBeNull(); + expect(screen.getByText('status.hold_to_set_instructions')).toBeTruthy(); + + fireEvent(screen.getByTestId('status-hold-10'), 'pressIn'); + act(() => { + jest.advanceTimersByTime(2000); + }); + rerender(); + + await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalledWith(expect.objectContaining({ Type: '10', RespondingTo: '0' }))); + }); + + it('in hold mode opens the destination step when the held status has no call to default to', () => { + jest.useFakeTimers(); + setCoreStore({ config: { StatusHoldToConfirm: true }, activeUnitStatus: null }); + const getState = mountStatefulSheetStore(); + + const { rerender } = render(); + + fireEvent(screen.getByTestId('status-hold-12'), 'pressIn'); + act(() => { + jest.advanceTimersByTime(2000); + }); + rerender(); + + expect(getState().currentStep).toBe('select-destination'); + expect(getState().holdConfirmed).toBe(false); + expect(mockSaveUnitStatus).not.toHaveBeenCalled(); + }); + + it('in hold mode explains the gesture when a status is only tapped', () => { + jest.useFakeTimers(); + setCoreStore({ config: { StatusHoldToConfirm: true } }); + mountStatefulSheetStore(); + + render(); + + fireEvent(screen.getByTestId('status-hold-13'), 'pressIn'); + fireEvent(screen.getByTestId('status-hold-13'), 'pressOut'); + + expect(mockShowToast).toHaveBeenCalledWith('info', 'status.hold_to_set_hint'); + expect(mockSaveUnitStatus).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/components/status/hold-to-confirm-button.tsx b/src/components/status/hold-to-confirm-button.tsx new file mode 100644 index 00000000..c2d13fd6 --- /dev/null +++ b/src/components/status/hold-to-confirm-button.tsx @@ -0,0 +1,378 @@ +import { Check, Fingerprint } from 'lucide-react-native'; +import React from 'react'; +import { type LayoutChangeEvent, Pressable, type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'; +import Animated, { cancelAnimation, Easing, interpolate, useAnimatedStyle, useSharedValue, withRepeat, withSequence, withSpring, withTiming } from 'react-native-reanimated'; + +import { Text } from '@/components/ui/text'; + +/** How long a status button has to be held (department setting "Hold to set status"). */ +export const STATUS_HOLD_DURATION_MS = 2000; + +/** A release sooner than this is a tap, which gets the "press and hold" hint instead of silence. */ +const TAP_THRESHOLD_MS = 350; + +/** How long the completed state (flash + check) stays before the button resets itself. */ +const DONE_RESET_MS = 900; + +type HoldPhase = 'idle' | 'holding' | 'done'; + +export interface HoldToConfirmButtonProps { + /** Runs once the hold completes. */ + onConfirm: () => void; + /** Runs when the button is tapped instead of held (show a "press and hold" hint). */ + onTap?: () => void; + durationMs?: number; + disabled?: boolean; + /** The button's own colour (the status colour). */ + backgroundColor: string; + /** Text/icon colour on top of `backgroundColor`. */ + foregroundColor: string; + style?: StyleProp; + contentStyle?: StyleProp; + children: React.ReactNode; + /** Shows the fingerprint "hold me" glyph at the right edge while idle. */ + showHoldGlyph?: boolean; + testID?: string; + accessibilityLabel?: string; + accessibilityHint?: string; +} + +const parseHex = (color: string): [number, number, number] | null => { + const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim()); + if (!match) { + return null; + } + + const hex = match[1].length === 3 ? match[1].replace(/(.)/g, '$1$1') : match[1]; + return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)]; +}; + +/** A translucent tint of the foreground colour: light text gets a light sweep, dark text a dark one. */ +const tint = (foregroundColor: string, alpha: number): string => { + const rgb = parseHex(foregroundColor); + if (!rgb) { + return `rgba(255,255,255,${alpha})`; + } + + return `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${alpha})`; +}; + +/** + * A button that confirms with a press and hold instead of a tap. + * + * While held a tinted fill sweeps across the button with a glowing leading edge and a shimmer running + * through it, the button squeezes in slightly, a halo pulses around it and a pill counts down the + * remaining time. On completion the button flashes, bounces and pops a check. Letting go early springs + * the fill back; a quick tap calls `onTap` so the caller can explain the gesture. + * + * The confirmation itself runs off a JS timer started with the animation, so it fires exactly once and + * does not depend on the UI-thread animation finishing (or being mocked away in tests). Screen readers + * get a plain "activate" action that confirms directly, since a timed hold is not reachable there. + */ +export const HoldToConfirmButton: React.FC = ({ + onConfirm, + onTap, + durationMs = STATUS_HOLD_DURATION_MS, + disabled = false, + backgroundColor, + foregroundColor, + style, + contentStyle, + children, + showHoldGlyph = true, + testID, + accessibilityLabel, + accessibilityHint, +}) => { + const [phase, setPhase] = React.useState('idle'); + const [remainingMs, setRemainingMs] = React.useState(durationMs); + + const progress = useSharedValue(0); + const scale = useSharedValue(1); + const halo = useSharedValue(0); + const flash = useSharedValue(0); + const shimmer = useSharedValue(0); + const check = useSharedValue(0); + const width = useSharedValue(0); + + const startedAtRef = React.useRef(0); + const confirmTimerRef = React.useRef | null>(null); + const tickTimerRef = React.useRef | null>(null); + const resetTimerRef = React.useRef | null>(null); + const phaseRef = React.useRef('idle'); + // Read through a ref so a re-render between press-in and completion cannot run a stale callback. + const onConfirmRef = React.useRef(onConfirm); + onConfirmRef.current = onConfirm; + + const setPhaseBoth = React.useCallback((next: HoldPhase) => { + phaseRef.current = next; + setPhase(next); + }, []); + + const clearTimers = React.useCallback(() => { + if (confirmTimerRef.current) { + clearTimeout(confirmTimerRef.current); + confirmTimerRef.current = null; + } + + if (tickTimerRef.current) { + clearInterval(tickTimerRef.current); + tickTimerRef.current = null; + } + }, []); + + React.useEffect( + () => () => { + clearTimers(); + if (resetTimerRef.current) { + clearTimeout(resetTimerRef.current); + } + }, + [clearTimers] + ); + + const settleToIdle = React.useCallback(() => { + cancelAnimation(progress); + cancelAnimation(halo); + cancelAnimation(shimmer); + progress.value = withTiming(0, { duration: 220, easing: Easing.out(Easing.cubic) }); + scale.value = withSpring(1, { damping: 14, stiffness: 220 }); + halo.value = withTiming(0, { duration: 180 }); + shimmer.value = 0; + check.value = withTiming(0, { duration: 150 }); + }, [check, halo, progress, scale, shimmer]); + + const complete = React.useCallback(() => { + clearTimers(); + setPhaseBoth('done'); + setRemainingMs(0); + + cancelAnimation(halo); + cancelAnimation(shimmer); + progress.value = 1; + halo.value = withTiming(0, { duration: 260 }); + flash.value = withSequence(withTiming(0.6, { duration: 90 }), withTiming(0, { duration: 380 })); + scale.value = withSequence(withTiming(1.05, { duration: 120, easing: Easing.out(Easing.quad) }), withSpring(1, { damping: 8, stiffness: 260 })); + check.value = withSpring(1, { damping: 9, stiffness: 240 }); + + resetTimerRef.current = setTimeout(() => { + resetTimerRef.current = null; + setPhaseBoth('idle'); + setRemainingMs(durationMs); + settleToIdle(); + }, DONE_RESET_MS); + + onConfirmRef.current(); + }, [check, clearTimers, durationMs, flash, halo, progress, scale, setPhaseBoth, settleToIdle, shimmer]); + + const handlePressIn = React.useCallback(() => { + if (disabled || phaseRef.current !== 'idle') { + return; + } + + startedAtRef.current = Date.now(); + setPhaseBoth('holding'); + setRemainingMs(durationMs); + + progress.value = 0; + progress.value = withTiming(1, { duration: durationMs, easing: Easing.linear }); + scale.value = withSpring(0.97, { damping: 15, stiffness: 260 }); + halo.value = withRepeat(withSequence(withTiming(1, { duration: 420 }), withTiming(0.35, { duration: 420 })), -1, false); + shimmer.value = 0; + shimmer.value = withRepeat(withTiming(1, { duration: 850, easing: Easing.inOut(Easing.quad) }), -1, false); + + confirmTimerRef.current = setTimeout(complete, durationMs); + tickTimerRef.current = setInterval(() => { + setRemainingMs(Math.max(0, durationMs - (Date.now() - startedAtRef.current))); + }, 100); + }, [complete, disabled, durationMs, halo, progress, scale, setPhaseBoth, shimmer]); + + const handlePressOut = React.useCallback(() => { + if (phaseRef.current !== 'holding') { + return; + } + + const heldFor = Date.now() - startedAtRef.current; + clearTimers(); + setPhaseBoth('idle'); + setRemainingMs(durationMs); + settleToIdle(); + + if (heldFor < TAP_THRESHOLD_MS) { + onTap?.(); + } + }, [clearTimers, durationMs, onTap, setPhaseBoth, settleToIdle]); + + const handleAccessibilityAction = React.useCallback( + (event: { nativeEvent: { actionName: string } }) => { + if (event.nativeEvent.actionName === 'activate' && !disabled && phaseRef.current === 'idle') { + complete(); + } + }, + [complete, disabled] + ); + + const handleLayout = React.useCallback( + (event: LayoutChangeEvent) => { + width.value = event.nativeEvent.layout.width; + }, + [width] + ); + + const containerStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })); + + const haloStyle = useAnimatedStyle(() => ({ + opacity: halo.value, + transform: [{ scale: 1 + halo.value * 0.03 }], + })); + + // A full-width layer slid in from the left, so the filled part is exactly progress × width. + const fillStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: (progress.value - 1) * width.value }], + })); + + const edgeStyle = useAnimatedStyle(() => ({ + opacity: progress.value > 0.01 && progress.value < 0.995 ? 1 : 0, + transform: [{ translateX: progress.value * width.value - 3 }], + })); + + const shimmerStyle = useAnimatedStyle(() => ({ + opacity: interpolate(shimmer.value, [0, 0.15, 0.85, 1], [0, 1, 1, 0]), + transform: [{ translateX: shimmer.value * (width.value + 80) - 60 }, { skewX: '-20deg' }], + })); + + const flashStyle = useAnimatedStyle(() => ({ + opacity: flash.value, + })); + + const checkStyle = useAnimatedStyle(() => ({ + opacity: check.value, + transform: [{ scale: 0.4 + check.value * 0.6 }], + })); + + const fillColor = tint(foregroundColor, 0.28); + const edgeColor = tint(foregroundColor, 0.9); + const shimmerColor = tint(foregroundColor, 0.22); + const pillBackground = tint(foregroundColor, 0.18); + const remainingLabel = `${(remainingMs / 1000).toFixed(1)}s`; + + return ( + + {/* The halo sits behind the button and pulses while it is held. */} + + + + + + + + + + {children} + + {phase === 'holding' ? ( + + {remainingLabel} + + ) : null} + + {phase === 'done' ? ( + + + + ) : null} + + {phase === 'idle' && showHoldGlyph ? ( + + + + ) : null} + + + + ); +}; + +const styles = StyleSheet.create({ + wrapper: { + width: '100%', + }, + disabled: { + opacity: 0.5, + }, + halo: { + borderRadius: 12, + borderWidth: 3, + margin: -4, + shadowOpacity: 0.7, + shadowRadius: 10, + shadowOffset: { width: 0, height: 0 }, + }, + button: { + overflow: 'hidden', + borderRadius: 8, + }, + content: { + flexDirection: 'row', + alignItems: 'center', + }, + label: { + flex: 1, + }, + shimmer: { + position: 'absolute', + top: -10, + bottom: -10, + width: 36, + }, + edge: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + width: 3, + shadowOpacity: 0.9, + shadowRadius: 6, + shadowOffset: { width: 0, height: 0 }, + elevation: 4, + }, + flash: { + backgroundColor: '#ffffff', + }, + pill: { + marginLeft: 8, + minWidth: 44, + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 999, + alignItems: 'center', + justifyContent: 'center', + }, + pillText: { + fontSize: 12, + fontWeight: '700', + fontVariant: ['tabular-nums'], + }, + checkPill: { + minWidth: 32, + }, + glyph: { + marginLeft: 8, + opacity: 0.55, + }, +}); diff --git a/src/components/status/status-bottom-sheet.tsx b/src/components/status/status-bottom-sheet.tsx index aa3dba50..dc07c704 100644 --- a/src/components/status/status-bottom-sheet.tsx +++ b/src/components/status/status-bottom-sheet.tsx @@ -3,18 +3,20 @@ import { ArrowLeft, ArrowRight, Check } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { InteractionManager, ScrollView, TouchableOpacity } from 'react-native'; +import { InteractionManager, ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native'; import { useKeyboardHeight } from '@/hooks/use-keyboard-height'; import { logger } from '@/lib/logging'; import { createPoiTypeMap, getPoiSelectionLabel } from '@/lib/poi-utils'; import { getUnitStatusCallDestinationId, resolveDefaultStatusCall } from '@/lib/status-destination'; +import { canSubmitStatusWithoutInput, getOfferedStatuses, resolveCurrentStatusId } from '@/lib/status-flow'; import { invertColor } from '@/lib/utils'; import { type CallResultData } from '@/models/v4/calls/callResultData'; import { CustomStateDetailTypes, statusDetailAllowsCalls, statusDetailAllowsPois, statusDetailAllowsStations } from '@/models/v4/customStatuses/customStateDetailTypes'; import { DestinationEntityTypes } from '@/models/v4/destinations/destinationEntityTypes'; import { type GroupResultData } from '@/models/v4/groups/groupsResultData'; import { type PoiResultData } from '@/models/v4/mapping/poiResultData'; +import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData'; import { SaveUnitStatusInput, SaveUnitStatusRoleInput } from '@/models/v4/unitStatus/saveUnitStatusInput'; import { acquireLocationFix, getLocationFixErrorMessage, readRecentLocation } from '@/services/location-fix'; import { offlineEventManager } from '@/services/offline-event-manager.service'; @@ -32,6 +34,10 @@ import { Spinner } from '../ui/spinner'; import { Text } from '../ui/text'; import { Textarea, TextareaInput } from '../ui/textarea'; import { VStack } from '../ui/vstack'; +import { HoldToConfirmButton } from './hold-to-confirm-button'; + +/** The red outline that marks the unit's current status (crews asked for it to be unmistakable). */ +const CURRENT_STATUS_BORDER = '#dc2626'; type DestinationTab = 'call' | 'station' | 'poi'; @@ -126,6 +132,8 @@ export const StatusBottomSheet = () => { const selectedDestinationType = useStatusBottomSheetStore((state) => state.selectedDestinationType); const selectedStatus = useStatusBottomSheetStore((state) => state.selectedStatus); const cameFromStatusSelection = useStatusBottomSheetStore((state) => state.cameFromStatusSelection); + const holdConfirmed = useStatusBottomSheetStore((state) => state.holdConfirmed); + const setHoldConfirmed = useStatusBottomSheetStore((state) => state.setHoldConfirmed); const note = useStatusBottomSheetStore((state) => state.note); const availableCalls = useStatusBottomSheetStore((state) => state.availableCalls); const availableStations = useStatusBottomSheetStore((state) => state.availableStations); @@ -151,6 +159,9 @@ export const StatusBottomSheet = () => { const activeStatuses = useCoreStore((state) => state.activeStatuses); const unitRoleAssignments = useRolesStore((state) => state.unitRoleAssignments); const saveUnitStatus = useStatusesStore((state) => state.saveUnitStatus); + // Department "Hold to set status": a two-second press and hold replaces tap + Next/Submit. + const isHoldMode = useCoreStore((state) => state.config?.StatusHoldToConfirm === true); + const [showAllStatuses, setShowAllStatuses] = React.useState(false); // NOTE: location is read via useLocationStore.getState() inside handleSubmit // instead of subscribing — this sheet is mounted at the root and subscribing // here re-rendered the whole 900-line component on every GPS fix, even when @@ -212,6 +223,24 @@ export const StatusBottomSheet = () => { // there is a call it could be carrying, so an "Available" with nothing open is never held up. const isAwaitingDestinationData = isDestinationDataPending && (shouldShowDestinationStep || hasPotentialDefaultCall); + // The unit's current status within this unit type's statuses. activeUnitStatus is refreshed after a + // unit switch, so a status that still belongs to the previous unit marks nothing. + const currentStatusId = React.useMemo(() => { + if (!activeUnitStatus || (activeUnit?.UnitId && activeUnitStatus.UnitId && String(activeUnitStatus.UnitId) !== String(activeUnit.UnitId))) { + return null; + } + + return resolveCurrentStatusId(activeStatuses?.Statuses, activeUnitStatus); + }, [activeStatuses?.Statuses, activeUnit?.UnitId, activeUnitStatus]); + const currentStatus = React.useMemo(() => activeStatuses?.Statuses?.find((status) => String(status.Id) === currentStatusId) ?? null, [activeStatuses?.Statuses, currentStatusId]); + const offeredStatuses = React.useMemo(() => getOfferedStatuses(activeStatuses?.Statuses, currentStatusId, showAllStatuses), [activeStatuses?.Statuses, currentStatusId, showAllStatuses]); + // Whether "Show next statuses only" has anything to go back to once the crew chose "Show all". + const hasNextStatusRestriction = React.useMemo(() => showAllStatuses && getOfferedStatuses(activeStatuses?.Statuses, currentStatusId, false).isRestricted, [activeStatuses?.Statuses, currentStatusId, showAllStatuses]); + + // A held status saves straight away unless it still needs the crew: a required note, or a destination + // the sheet cannot fill from the active / dispatched call. + const canSubmitHeldStatus = canSubmitStatusWithoutInput(selectedStatus, { allowsCalls, hasDefaultCall: !!defaultCall }); + const effectiveDestination = React.useMemo((): StatusDestination => { if (!selectedStatus) { return NO_DESTINATION; @@ -282,6 +311,7 @@ export const StatusBottomSheet = () => { callRowOffsetsRef.current = {}; // The sheet stays mounted at the root, so the next open must start with the default again. setHasExplicitDestinationChoice(false); + setShowAllStatuses(false); } }, [isOpen]); @@ -695,6 +725,40 @@ export const StatusBottomSheet = () => { } }, [activeCallId, activeUnit, effectiveDestination, getStatusId, isAwaitingDestinationData, note, reset, saveUnitStatus, selectedStatus, setActiveCall, shouldShowDestinationStep, showToast, t, unitRoleAssignments]); + // A completed hold (in this list, or on a sidebar button that opened the sheet) acts once the status is + // selected and the destination lists it may default from have landed: it saves when nothing else is + // needed, and otherwise opens the step that still needs the crew. + React.useEffect(() => { + if (!isOpen || !holdConfirmed || !selectedStatus || isSubmitting || isAwaitingDestinationData) { + return; + } + + setHoldConfirmed(false); + + if (canSubmitHeldStatus) { + void handleSubmit(); + return; + } + + if (currentStep === 'select-status') { + setCurrentStep(shouldShowDestinationStep ? 'select-destination' : 'add-note'); + } + }, [canSubmitHeldStatus, currentStep, handleSubmit, holdConfirmed, isAwaitingDestinationData, isOpen, isSubmitting, selectedStatus, setCurrentStep, setHoldConfirmed, shouldShowDestinationStep]); + + const handleStatusHold = (statusId: string) => { + const status = activeStatuses?.Statuses?.find((item) => item.Id.toString() === statusId); + if (!status || pendingSubmissionRef.current !== null) { + return; + } + + setSelectedStatus(status); + setHoldConfirmed(true); + }; + + const showHoldHint = () => { + showToast('info', t('status.hold_to_set_hint')); + }; + // True while the default call is still to be applied (list loading, or loaded but the auto-select // effect has not run yet) — "No destination" must not flash as the choice in that window. const isDefaultCallPending = isOpen && !!selectedStatus && allowsCalls && !hasExplicitDestinationChoice && hasPotentialDefaultCall && (isDestinationDataPending || !!defaultCall); @@ -884,20 +948,133 @@ export const StatusBottomSheet = () => { ); + // In hold mode the submitting control is a press-and-hold button too, so a status that needed a destination + // or a note is confirmed the same way as one saved straight from the list. + const renderHoldSubmitButton = () => ( + + void handleSubmit()} + onTap={showHoldHint} + disabled={!canProceedFromCurrentStep()} + backgroundColor="#2563eb" + foregroundColor="#ffffff" + contentStyle={styles.holdSubmitContent} + accessibilityLabel={t('status.hold_to_submit')} + accessibilityHint={t('status.hold_to_set_hint')} + > + + {isSubmitting ? : null} + + {isSubmitting ? t('common.submitting') : t('status.hold_to_submit')} + + + + + ); + // Status and destination steps advance with Next, or submit when they are the last step. - const renderAdvanceButton = () => ( - + const renderAdvanceButton = () => { + if (isHoldMode && isLastStep) { + return renderHoldSubmitButton(); + } + + return ( + + ); + }; + + const renderCurrentPill = () => ( + + {t('status.current')} + ); + const renderCurrentStatusBanner = (status: StatusesResultData) => { + const background = status.BColor || '#ffffff'; + const foreground = invertColor(background, true); + + return ( + + {t('status.current_status')} + + + {status.Text} + + {renderCurrentPill()} + + + ); + }; + + const renderStatusOption = (status: StatusesResultData) => { + const statusDetailDescription = getStatusDetailDescription(Number(status.Detail)); + const isCurrent = String(status.Id) === currentStatusId; + const background = status.BColor || '#ffffff'; + const foreground = invertColor(background, true); + + const details = ( + + + + {status.Text} + + {isCurrent ? renderCurrentPill() : null} + + {Number(status.Detail) > 0 ? {statusDetailDescription} : null} + {Number(status.Note) > 0 ? {Number(status.Note) === 1 ? t('status.note_optional') : t('status.note_required')} : null} + + ); + + if (isHoldMode) { + return ( + + handleStatusHold(status.Id.toString())} + onTap={showHoldHint} + disabled={isSubmitting || holdConfirmed} + backgroundColor={background} + foregroundColor={foreground} + style={isCurrent ? styles.currentOutline : styles.optionOutline} + contentStyle={styles.holdOptionContent} + accessibilityLabel={isCurrent ? `${status.Text}, ${t('status.current')}` : status.Text} + accessibilityHint={t('status.hold_to_set_hint')} + > + {details} + + + ); + } + + const isSelected = selectedStatus?.Id.toString() === status.Id.toString(); + + return ( + handleStatusSelect(status.Id.toString())} + className={`mb-3 rounded-lg border-2 p-3 ${isSelected ? 'border-blue-500' : 'border-gray-200 dark:border-gray-700'}`} + style={[{ backgroundColor: status.BColor || (isSelected ? '#dbeafe' : '#ffffff') }, isCurrent && !isSelected ? styles.currentOutline : null]} + accessibilityLabel={isCurrent ? `${status.Text}, ${t('status.current')}` : undefined} + > + + + {details} + + + ); + }; + // Opening straight into 'add-note' (status picked up front, no destination to choose) // leaves nothing to go back to, so the note step offers Cancel instead of Previous. const hasStepBeforeNote = cameFromStatusSelection || shouldShowDestinationStep; @@ -940,50 +1117,46 @@ export const StatusBottomSheet = () => { {currentStep === 'select-status' ? ( - {t('status.select_status_type')} + {isHoldMode ? t('status.hold_to_set_instructions') : t('status.select_status_type')} + + {/* The current status stays visible even when the list only offers what follows it. */} + {currentStatus && !offeredStatuses.offered.some((status) => String(status.Id) === currentStatusId) ? renderCurrentStatusBanner(currentStatus) : null} - {activeStatuses?.Statuses && activeStatuses.Statuses.length > 0 ? ( - activeStatuses.Statuses.map((status) => { - const statusDetailDescription = getStatusDetailDescription(Number(status.Detail)); - const isSelected = selectedStatus?.Id.toString() === status.Id.toString(); - - return ( - handleStatusSelect(status.Id.toString())} - className={`mb-3 rounded-lg border-2 p-3 ${isSelected ? 'border-blue-500' : 'border-gray-200 dark:border-gray-700'}`} - style={{ - backgroundColor: status.BColor || (isSelected ? '#dbeafe' : '#ffffff'), - }} - > - - - - - {status.Text} - - {Number(status.Detail) > 0 ? {statusDetailDescription} : null} - {Number(status.Note) > 0 ? {Number(status.Note) === 1 ? t('status.note_optional') : t('status.note_required')} : null} - - - - ); - }) + {offeredStatuses.offered.length > 0 ? ( + offeredStatuses.offered.map((status) => renderStatusOption(status)) ) : ( {t('status.no_statuses_available')} )} + + {offeredStatuses.isRestricted ? ( + setShowAllStatuses(true)} className="items-center py-2"> + {t('status.show_all_statuses', { count: offeredStatuses.hiddenCount })} + + ) : hasNextStatusRestriction ? ( + setShowAllStatuses(false)} className="items-center py-2"> + {t('status.show_next_statuses')} + + ) : null} - {isLastStep ? renderStatusSummary() : null} + {isLastStep && !isHoldMode ? renderStatusSummary() : null} + {isHoldMode && (holdConfirmed || isSubmitting) ? renderStatusSummary() : null} - + - {renderAdvanceButton()} + {!isHoldMode ? ( + renderAdvanceButton() + ) : holdConfirmed || isSubmitting ? ( + + + {t('common.submitting')} + + ) : null} ) : null} @@ -1173,9 +1346,13 @@ export const StatusBottomSheet = () => { {t('common.cancel')} )} - + {isHoldMode ? ( + renderHoldSubmitButton() + ) : ( + + )} @@ -1216,9 +1393,13 @@ export const StatusBottomSheet = () => { {t('common.cancel')} )} - + {isHoldMode ? ( + renderHoldSubmitButton() + ) : ( + + )} @@ -1228,3 +1409,50 @@ export const StatusBottomSheet = () => { ); }; + +const styles = StyleSheet.create({ + holdSubmit: { + flex: 1, + marginLeft: 12, + }, + holdSubmitContent: { + paddingHorizontal: 14, + paddingVertical: 10, + }, + holdOptionContent: { + padding: 12, + }, + optionOutline: { + borderWidth: 2, + borderColor: 'transparent', + }, + currentOutline: { + borderWidth: 3, + borderColor: CURRENT_STATUS_BORDER, + }, + currentPill: { + backgroundColor: CURRENT_STATUS_BORDER, + borderRadius: 999, + paddingHorizontal: 8, + paddingVertical: 2, + }, + currentPillText: { + color: '#ffffff', + fontSize: 11, + fontWeight: '700', + textTransform: 'uppercase', + }, + currentBanner: { + borderWidth: 3, + borderColor: CURRENT_STATUS_BORDER, + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 8, + }, + currentBannerCaption: { + fontSize: 11, + fontWeight: '600', + opacity: 0.8, + textTransform: 'uppercase', + }, +}); diff --git a/src/components/ui/utils/__tests__/dom-props.test.ts b/src/components/ui/utils/__tests__/dom-props.test.ts new file mode 100644 index 00000000..1bb2556c --- /dev/null +++ b/src/components/ui/utils/__tests__/dom-props.test.ts @@ -0,0 +1,23 @@ +import { toDomProps } from '../dom-props'; + +describe('toDomProps', () => { + it('maps testID to data-testid and drops React Native only props', () => { + const result = toDomProps({ testID: 'card', numberOfLines: 2, accessibilityLabel: 'x', title: 'kept' } as Record); + + expect(result).toEqual({ 'data-testid': 'card', title: 'kept' }); + }); + + it('flattens a React Native style array into one style object', () => { + const result = toDomProps({ style: [{ color: 'red' }, false, null, { fontWeight: 'bold', color: 'blue' }] } as Record) as { style: unknown }; + + expect(Array.isArray(result.style)).toBe(false); + expect(result.style).toEqual({ color: 'blue', fontWeight: 'bold' }); + }); + + it('leaves a plain style object as it is', () => { + const style = { margin: 4 }; + const result = toDomProps({ style } as Record) as { style: unknown }; + + expect(result.style).toBe(style); + }); +}); diff --git a/src/components/ui/utils/dom-props.ts b/src/components/ui/utils/dom-props.ts index aa82407c..8da54997 100644 --- a/src/components/ui/utils/dom-props.ts +++ b/src/components/ui/utils/dom-props.ts @@ -1,3 +1,5 @@ +import { StyleSheet } from 'react-native'; + type WithTestID = { testID?: string }; // React Native-only props that must never reach a DOM element. React warns @@ -51,7 +53,9 @@ export function toDomProps(props: T & WithTestID): Omit Object.assign(new Error(response ? `Request failed with status code ${response.status}` : 'Network Error'), { isAxiosError: true, response }); + +const ACTIVE_COMMAND = 'This call has an active incident command. Close the incident command first, then close the call.'; + +describe('call close helpers', () => { + it('reads the plain-text reason of a 400', () => { + expect(getCallCloseErrorMessage(axiosError({ status: 400, data: ` ${ACTIVE_COMMAND} ` }))).toBe(ACTIVE_COMMAND); + }); + + it('reads a JSON reason (Message, then detail/title)', () => { + expect(getCallCloseErrorMessage(axiosError({ status: 400, data: { Message: 'Call is already closed' } }))).toBe('Call is already closed'); + expect(getCallCloseErrorMessage(axiosError({ status: 400, data: { title: 'One or more validation errors occurred.' } }))).toBe('One or more validation errors occurred.'); + }); + + it('has no reason for network and server failures, empty bodies or HTML pages', () => { + expect(getCallCloseErrorMessage(axiosError())).toBeNull(); + expect(getCallCloseErrorMessage(axiosError({ status: 500, data: 'Server exploded' }))).toBeNull(); + expect(getCallCloseErrorMessage(axiosError({ status: 400, data: '' }))).toBeNull(); + expect(getCallCloseErrorMessage(axiosError({ status: 400, data: 'Bad Request' }))).toBeNull(); + expect(getCallCloseErrorMessage(new Error('boom'))).toBeNull(); + }); + + it('treats 4xx (except timeouts and rate limits) as a refusal', () => { + expect(isCallCloseRejection(axiosError({ status: 400 }))).toBe(true); + expect(isCallCloseRejection(axiosError({ status: 404 }))).toBe(true); + expect(isCallCloseRejection(axiosError({ status: 408 }))).toBe(false); + expect(isCallCloseRejection(axiosError({ status: 429 }))).toBe(false); + expect(isCallCloseRejection(axiosError({ status: 503 }))).toBe(false); + expect(isCallCloseRejection(axiosError())).toBe(false); + }); +}); diff --git a/src/lib/__tests__/status-flow.test.ts b/src/lib/__tests__/status-flow.test.ts new file mode 100644 index 00000000..64775585 --- /dev/null +++ b/src/lib/__tests__/status-flow.test.ts @@ -0,0 +1,113 @@ +import { canSubmitStatusWithoutInput, getOfferedStatuses, resolveCurrentStatusId } from '../status-flow'; + +// A Belgian EMS unit set: Standplaats → (dispatch) → Vertrokken → Ter Plaatse → Naar ZH / Standplaats / Buiten dienst. +const standplaats = { Id: 10, Text: 'Standplaats', NextIds: [] as number[], Detail: 0, Note: 0 }; +const telefonisch = { Id: 11, Text: 'Telefonisch', NextIds: [] as number[], Detail: 0, Note: 0 }; +const vertrokken = { Id: 12, Text: 'Vertrokken', NextIds: [13], Detail: 2, Note: 0 }; +const terPlaatse = { Id: 13, Text: 'Ter Plaatse', NextIds: [14, 10, 16], Detail: 2, Note: 0 }; +const naarZh = { Id: 14, Text: 'Naar ZH', NextIds: [15], Detail: 4, Note: 0 }; +const aankomstZh = { Id: 15, Text: 'Aankomst ZH', NextIds: [], Detail: 0, Note: 0 }; +const buitenDienst = { Id: 16, Text: 'Buiten dienst', NextIds: [], Detail: 0, Note: 2 }; +const statuses = [standplaats, telefonisch, vertrokken, terPlaatse, naarZh, aankomstZh, buitenDienst]; + +describe('resolveCurrentStatusId', () => { + it('matches the server StateId first', () => { + expect(resolveCurrentStatusId(statuses, { StateId: 12, State: 'Something else' })).toBe('12'); + }); + + it('falls back to the status text for servers that do not send StateId', () => { + expect(resolveCurrentStatusId(statuses, { State: ' ter plaatse ' })).toBe('13'); + expect(resolveCurrentStatusId(statuses, { StateId: 0, State: 'Vertrokken' })).toBe('12'); + }); + + it('falls back to the text when the id is not one of these statuses', () => { + expect(resolveCurrentStatusId(statuses, { StateId: 999, State: 'Naar ZH' })).toBe('14'); + }); + + it('treats id 0 as a status only when there is no text to contradict it', () => { + const builtIn = [ + { Id: 0, Text: 'Available' }, + { Id: 2, Text: 'Responding' }, + ]; + + // Responder sends only the personnel status type. + expect(resolveCurrentStatusId(builtIn, { StateId: 0 })).toBe('0'); + // A unit with no status yet reports 0 as "Unknown". + expect(resolveCurrentStatusId(builtIn, { StateId: 0, State: 'Unknown' })).toBeNull(); + expect(resolveCurrentStatusId(builtIn, { StateId: 0, State: 'Available' })).toBe('0'); + }); + + it('is null when the current status is not in the list or unknown', () => { + expect(resolveCurrentStatusId(statuses, { StateId: 999, State: 'Responding' })).toBeNull(); + expect(resolveCurrentStatusId(statuses, { State: '' })).toBeNull(); + expect(resolveCurrentStatusId(statuses, null)).toBeNull(); + expect(resolveCurrentStatusId([], { StateId: 12 })).toBeNull(); + }); +}); + +describe('getOfferedStatuses', () => { + it('offers only On Scene after Departed', () => { + const result = getOfferedStatuses(statuses, '12', false); + + expect(result.offered.map((status) => status.Text)).toEqual(['Ter Plaatse']); + expect(result.isRestricted).toBe(true); + expect(result.hiddenCount).toBe(6); + }); + + it('offers To Hospital, To Station and Out of Service after On Scene, in the department order', () => { + expect(getOfferedStatuses(statuses, '13', false).offered.map((status) => status.Id)).toEqual([10, 14, 16]); + }); + + it('offers everything when the crew asks for all statuses', () => { + const result = getOfferedStatuses(statuses, '12', true); + + expect(result.offered).toHaveLength(statuses.length); + expect(result.isRestricted).toBe(false); + expect(result.hiddenCount).toBe(0); + }); + + it('offers everything when the current status has no next statuses or is unknown', () => { + expect(getOfferedStatuses(statuses, '10', false).isRestricted).toBe(false); + expect(getOfferedStatuses(statuses, null, false).offered).toHaveLength(statuses.length); + expect(getOfferedStatuses([{ Id: 1, Text: 'A' }], '1', false).isRestricted).toBe(false); + }); + + it('never leaves the crew without buttons when every next status was removed', () => { + const orphaned = [{ ...vertrokken, NextIds: [99] }, standplaats]; + + expect(getOfferedStatuses(orphaned, '12', false).offered).toHaveLength(2); + }); + + it('accepts string ids from older payloads', () => { + const stringy = [ + { Id: '1', Text: 'A', NextIds: ['2'] }, + { Id: '2', Text: 'B' }, + ]; + + expect(getOfferedStatuses(stringy, '1', false).offered.map((status) => status.Text)).toEqual(['B']); + }); +}); + +describe('canSubmitStatusWithoutInput', () => { + it('saves a status with no destination and no required note straight away', () => { + expect(canSubmitStatusWithoutInput(standplaats, { allowsCalls: false, hasDefaultCall: false })).toBe(true); + }); + + it('saves a call status straight away when the call can be filled in', () => { + expect(canSubmitStatusWithoutInput(vertrokken, { allowsCalls: true, hasDefaultCall: true })).toBe(true); + }); + + it('asks for the destination when there is no call to default to, or the status takes another kind', () => { + expect(canSubmitStatusWithoutInput(vertrokken, { allowsCalls: true, hasDefaultCall: false })).toBe(false); + expect(canSubmitStatusWithoutInput(naarZh, { allowsCalls: false, hasDefaultCall: true })).toBe(false); + }); + + it('asks for a required note but skips an optional one', () => { + expect(canSubmitStatusWithoutInput(buitenDienst, { allowsCalls: false, hasDefaultCall: false })).toBe(false); + expect(canSubmitStatusWithoutInput({ Detail: 0, Note: 1 }, { allowsCalls: false, hasDefaultCall: false })).toBe(true); + }); + + it('cannot submit without a status', () => { + expect(canSubmitStatusWithoutInput(null, { allowsCalls: true, hasDefaultCall: true })).toBe(false); + }); +}); diff --git a/src/lib/call-close.ts b/src/lib/call-close.ts new file mode 100644 index 00000000..504052b4 --- /dev/null +++ b/src/lib/call-close.ts @@ -0,0 +1,42 @@ +import { isAxiosError } from 'axios'; + +/** Anything longer than this is not a reason written for a person (a stack trace, an error page). */ +const MAX_SERVER_MESSAGE_LENGTH = 500; + +/** + * The server answered a call close with a refusal that sending it again cannot change: a 4xx other than a + * timeout or rate limit, e.g. 400 "This call has an active incident command". + */ +export const isCallCloseRejection = (error: unknown): boolean => { + const status = isAxiosError(error) ? error.response?.status : undefined; + return typeof status === 'number' && status >= 400 && status < 500 && status !== 408 && status !== 429; +}; + +/** + * The reason the server gave for refusing a call close, when it gave one. The v4 controller answers a refused + * close with a plain-text 400 body (for example "This call has an active incident command. Close the incident + * command first, then close the call."); a JSON body's Message/detail/title is accepted too. Returns null for + * network and server (5xx) failures, empty bodies and HTML error pages, so callers fall back to their own text. + */ +export const getCallCloseErrorMessage = (error: unknown): string | null => { + if (!isAxiosError(error) || !isCallCloseRejection(error)) { + return null; + } + + const data: unknown = error.response?.data; + let text: unknown = data; + if (data && typeof data === 'object') { + const body = data as Record; + text = body.Message ?? body.message ?? body.detail ?? body.title; + } + + if (typeof text !== 'string') { + return null; + } + + const trimmed = text.trim(); + if (!trimmed || trimmed.startsWith('<') || trimmed.length > MAX_SERVER_MESSAGE_LENGTH) { + return null; + } + return trimmed; +}; diff --git a/src/lib/status-flow.ts b/src/lib/status-flow.ts new file mode 100644 index 00000000..b02b2fcd --- /dev/null +++ b/src/lib/status-flow.ts @@ -0,0 +1,122 @@ +/** + * Status flow for the status pickers: which status is current, and which statuses to offer next. + * + * A department can give each status option a list of "next statuses" (Custom Statuses → option → + * Next statuses; v4 `NextIds`). While a status with such a list is current the picker offers only + * those, plus a "Show all statuses" escape for anything out of sequence. It is a display hint: the + * server accepts any status, and dispatchers can set any status from the website. + * + * Shared byte-for-byte by the Unit and Responder apps. + */ + +export interface StatusFlowOption { + Id: number | string; + Text: string; + /** Ids of the options offered next while this one is current. Empty/missing = no restriction. */ + NextIds?: (number | string)[] | null; + Detail?: number | string; + Note?: number | string; +} + +export interface CurrentStatusRef { + /** The current status's option id (v4 `StateId`). Older servers do not send it. */ + StateId?: number | string | null; + /** The current status's display text (v4 `State`). */ + State?: string | null; +} + +export interface OfferedStatuses { + /** The statuses to show, in the department's order. */ + offered: T[]; + /** True when the list is narrowed to the current status's next statuses. */ + isRestricted: boolean; + /** How many statuses the restriction hides (what "Show all statuses" would add). */ + hiddenCount: number; +} + +const toId = (value: number | string | null | undefined): string => (value == null ? '' : String(value).trim()); + +const toNumber = (value: unknown): number => { + const parsed = Number(value); + return Number.isNaN(parsed) ? 0 : parsed; +}; + +/** + * The id of the option in `statuses` that is the current status, or null when it is not one of them + * (no status yet, a status from another set, or a built-in status the list does not carry). + * + * Matches on the server's `StateId` first. Servers that do not send it — and statuses whose id is + * not in the list — fall back to the display text, which is unique within a set in practice. + */ +export const resolveCurrentStatusId = (statuses: readonly StatusFlowOption[] | null | undefined, current: CurrentStatusRef | null | undefined): string | null => { + if (!statuses || statuses.length === 0 || !current) { + return null; + } + + const stateId = toId(current.StateId); + const stateText = current.State?.trim().toLowerCase(); + // 0 is a real option id in the built-in sets ("Available"), but a unit with no status yet also reports 0 + // (as "Unknown"); with text to go on, a 0 is only trusted through the text match below. + if (stateId !== '' && (stateId !== '0' || !stateText)) { + const byId = statuses.find((status) => toId(status.Id) === stateId); + if (byId) { + return toId(byId.Id); + } + } + + if (!stateText) { + return null; + } + + const byText = statuses.find((status) => status.Text?.trim().toLowerCase() === stateText); + return byText ? toId(byText.Id) : null; +}; + +/** + * The statuses to offer. Everything when `showAll` is set, when the current status is unknown, or when + * it has no next statuses; otherwise only its next statuses. A list that would come out empty (every + * next status since removed) falls back to everything rather than leaving the crew with no buttons. + */ +export const getOfferedStatuses = (statuses: readonly T[] | null | undefined, currentStatusId: string | null, showAll: boolean): OfferedStatuses => { + const all = statuses ? [...statuses] : []; + + if (showAll || !currentStatusId) { + return { offered: all, isRestricted: false, hiddenCount: 0 }; + } + + const current = all.find((status) => toId(status.Id) === currentStatusId); + const nextIds = new Set((current?.NextIds ?? []).map((id) => toId(id)).filter((id) => id !== '' && id !== '0')); + + if (nextIds.size === 0) { + return { offered: all, isRestricted: false, hiddenCount: 0 }; + } + + const offered = all.filter((status) => nextIds.has(toId(status.Id))); + if (offered.length === 0) { + return { offered: all, isRestricted: false, hiddenCount: 0 }; + } + + return { offered, isRestricted: true, hiddenCount: all.length - offered.length }; +}; + +/** + * Whether a status can be saved the moment it is held, with nothing more to ask: no required note, and + * either no destination step or a destination step whose call the picker can fill from the active / + * dispatched call. Anything else opens the step that still needs the crew. + */ +export const canSubmitStatusWithoutInput = (status: Pick | null | undefined, options: { allowsCalls: boolean; hasDefaultCall: boolean }): boolean => { + if (!status) { + return false; + } + + // CustomStateNoteTypes: 0 none, 1 optional, 2 required. An optional note is skipped by a hold. + if (toNumber(status.Note) === 2) { + return false; + } + + if (toNumber(status.Detail) === 0) { + return true; + } + + return options.allowsCalls && options.hasDefaultCall; +}; diff --git a/src/models/v4/configs/getConfigResultData.ts b/src/models/v4/configs/getConfigResultData.ts index 7b51e18c..b2cd3683 100644 --- a/src/models/v4/configs/getConfigResultData.ts +++ b/src/models/v4/configs/getConfigResultData.ts @@ -37,4 +37,6 @@ export class GetConfigResultData { public AppMapboxAccessToken: string = ''; /** True when the department's own Mapbox account (token and custom style) is in effect; its style then waits for that token. */ public IsDepartmentMapOverride: boolean = false; + /** Department sets statuses with a two-second press and hold instead of tap + Next/Submit. */ + public StatusHoldToConfirm?: boolean = false; } diff --git a/src/models/v4/statuses/statusesResultData.ts b/src/models/v4/statuses/statusesResultData.ts index 1b03f2f9..dbc78237 100644 --- a/src/models/v4/statuses/statusesResultData.ts +++ b/src/models/v4/statuses/statusesResultData.ts @@ -8,4 +8,6 @@ export class StatusesResultData { public Gps: boolean = false; // Require GPS public Note: number = 0; // Note Type public Detail: number = 0; // Detail Type + /** Ids of the statuses offered next while this one is current; empty = offer every status. */ + public NextIds?: number[] = []; } diff --git a/src/models/v4/unitStatus/unitStatusResultData.ts b/src/models/v4/unitStatus/unitStatusResultData.ts index 3673ca06..883a50bc 100644 --- a/src/models/v4/unitStatus/unitStatusResultData.ts +++ b/src/models/v4/unitStatus/unitStatusResultData.ts @@ -3,6 +3,8 @@ export class UnitStatusResultData { public Name: string = ''; public Type: string = ''; public State: string = ''; + /** Id of the current status option (matches StatusesResultData.Id); 0/missing on older servers. */ + public StateId?: number | string | null = null; public StateCss: string = ''; public StateStyle: string = ''; public Timestamp: string = ''; diff --git a/src/stores/calls/__tests__/detail-store.test.ts b/src/stores/calls/__tests__/detail-store.test.ts index 1063d1db..29887129 100644 --- a/src/stores/calls/__tests__/detail-store.test.ts +++ b/src/stores/calls/__tests__/detail-store.test.ts @@ -615,6 +615,46 @@ describe('useCallDetailStore - Notes', () => { expect(mockCloseCall).toHaveBeenCalledTimes(closeTypes.length); }); + + it('passes the notify choice through to the API', async () => { + const closeData = { callId: 'call123', type: 1, note: '', sendNotification: false }; + mockCloseCall.mockResolvedValue({} as any); + + const { result } = renderHook(() => useCallDetailStore()); + + await act(async () => { + await result.current.closeCall(closeData); + }); + + expect(mockCloseCall).toHaveBeenCalledWith(closeData); + }); + + it('keeps the server reason when the close is refused (call has an active incident command)', async () => { + const reason = 'This call has an active incident command. Close the incident command first, then close the call.'; + const refusal = Object.assign(new Error('Request failed with status code 400'), { isAxiosError: true, response: { status: 400, data: reason } }); + mockCloseCall.mockRejectedValue(refusal); + + const { result } = renderHook(() => useCallDetailStore()); + + await act(async () => { + await expect(result.current.closeCall({ callId: 'call123', type: 1, sendNotification: true })).rejects.toBe(refusal); + }); + + expect(result.current.error).toBe(reason); + expect(result.current.isLoading).toBe(false); + }); + + it('falls back to the error message when the server gave no reason', async () => { + mockCloseCall.mockRejectedValue(new Error('Network Error')); + + const { result } = renderHook(() => useCallDetailStore()); + + await act(async () => { + await expect(result.current.closeCall({ callId: 'call123', type: 1 })).rejects.toThrow('Network Error'); + }); + + expect(result.current.error).toBe('Network Error'); + }); }); describe('Integration - Update and Close Call', () => { diff --git a/src/stores/calls/detail-store.ts b/src/stores/calls/detail-store.ts index 976a1ff0..7907c438 100644 --- a/src/stores/calls/detail-store.ts +++ b/src/stores/calls/detail-store.ts @@ -3,6 +3,7 @@ import { create } from 'zustand'; import { getCallFiles, getCallImages, saveCallImage } from '@/api/calls/callFiles'; import { getCallNotes, saveCallNote } from '@/api/calls/callNotes'; import { closeCall, type CloseCallRequest, getCall, getCallExtraData, updateCall, type UpdateCallRequest } from '@/api/calls/calls'; +import { getCallCloseErrorMessage } from '@/lib/call-close'; import { logger } from '@/lib/logging'; import { type CallFileResultData } from '@/models/v4/callFiles/callFileResultData'; import { type CallNoteResultData } from '@/models/v4/callNotes/callNoteResultData'; @@ -214,7 +215,8 @@ export const useCallDetailStore = create((set, get) => ({ set({ isLoading: false }); } catch (error) { set({ - error: error instanceof Error ? error.message : 'Failed to close call', + // Prefer the server's reason (e.g. the call still has an active incident command) + error: getCallCloseErrorMessage(error) ?? (error instanceof Error ? error.message : 'Failed to close call'), isLoading: false, }); throw error; diff --git a/src/stores/push-notification/__tests__/call-closed-parsing.test.ts b/src/stores/push-notification/__tests__/call-closed-parsing.test.ts new file mode 100644 index 00000000..37e79f59 --- /dev/null +++ b/src/stores/push-notification/__tests__/call-closed-parsing.test.ts @@ -0,0 +1,37 @@ +import { parseNotificationData } from '../store'; + +jest.mock('@/lib/logging', () => ({ + logger: { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + }, +})); + +jest.mock('@/services/notification-sound.service', () => ({ + notificationSoundService: { + playNotificationSound: jest.fn(() => Promise.resolve()), + }, +})); + +describe('call closed event code parsing', () => { + it('routes an NC event code to the call it names', () => { + // Core sends "NC:{callId}" when a call is closed: the leading "N" keeps the push an ordinary + // notification (not a critical call alert), and the app still opens the call on a tap. + const parsed = parseNotificationData({ eventCode: 'NC:1234', title: 'Call Closed - Structure Fire', body: 'Call 26-12 closed as Closed.' }); + + expect(parsed.type).toBe('call'); + expect(parsed.id).toBe('1234'); + expect(parsed.title).toBe('Call Closed - Structure Fire'); + }); + + it('matches the prefix without regard to case', () => { + expect(parseNotificationData({ eventCode: 'nc:77' }).type).toBe('call'); + expect(parseNotificationData({ eventCode: 'nc:77' }).id).toBe('77'); + }); + + it('leaves the single letter call prefix unchanged', () => { + expect(parseNotificationData({ eventCode: 'C:1234' }).type).toBe('call'); + expect(parseNotificationData({ eventCode: 'C:1234' }).id).toBe('1234'); + }); +}); diff --git a/src/stores/push-notification/store.ts b/src/stores/push-notification/store.ts index 104eb2a0..8456b8f0 100644 --- a/src/stores/push-notification/store.ts +++ b/src/stores/push-notification/store.ts @@ -29,6 +29,13 @@ interface PushNotificationModalState { parseNotification: (notificationData: PushNotificationData) => ParsedNotification; } +// Whole event code prefixes, checked before the first-character map below. +// "NC:{callId}": a call the unit or member was on has been closed. It leads with "n" so the server sends it as an +// ordinary notification rather than a critical call alert; a tap still opens the call. +const EVENT_CODE_TYPES: Record = { + nc: 'call', +}; + // First character of the event code prefix sent by the Resgrid backend, e.g. // "C:1234" call, "M:5678" message, "t:9012" chat, "g:3456" group chat. const EVENT_CODE_PREFIXES: Record = { @@ -57,7 +64,7 @@ export const parseNotificationData = (notificationData: PushNotificationData): P // Colon form ("C:1234", "t:{channelId}"): split on the FIRST colon only, so // an id that itself contains one survives intact. const lowerPrefix = eventCode.slice(0, separatorIndex).toLowerCase(); - type = EVENT_CODE_PREFIXES[lowerPrefix.charAt(0)] ?? 'unknown'; + type = EVENT_CODE_TYPES[lowerPrefix] ?? EVENT_CODE_PREFIXES[lowerPrefix.charAt(0)] ?? 'unknown'; id = eventCode.slice(separatorIndex + 1); } else if (eventCode.length > 1) { // Legacy colon-less form ("C1234"): first character is the type prefix, the diff --git a/src/stores/status/store.ts b/src/stores/status/store.ts index 714f76da..6bdfe33a 100644 --- a/src/stores/status/store.ts +++ b/src/stores/status/store.ts @@ -32,6 +32,11 @@ interface StatusBottomSheetStore { selectedDestinationType: DestinationType; selectedStatus: StatusType | null; cameFromStatusSelection: boolean; + /** + * The crew completed a press-and-hold on the selected status (department "Hold to set status"), so the + * sheet saves it as soon as nothing else is needed — or opens the step that still is. Cleared once acted on. + */ + holdConfirmed: boolean; note: string; availableCalls: CallResultData[]; availableStations: GroupResultData[]; @@ -40,7 +45,8 @@ interface StatusBottomSheetStore { lastFetchedAt: number; isLoading: boolean; error: string | null; - setIsOpen: (isOpen: boolean, status?: StatusType) => void; + setIsOpen: (isOpen: boolean, status?: StatusType, options?: { holdConfirmed?: boolean }) => void; + setHoldConfirmed: (holdConfirmed: boolean) => void; setCurrentStep: (step: StatusStep) => void; setSelectedCall: (call: CallResultData | null) => void; setSelectedStation: (station: GroupResultData | null) => void; @@ -98,6 +104,7 @@ export const useStatusBottomSheetStore = create((set, ge selectedDestinationType: 'none', selectedStatus: null, cameFromStatusSelection: false, + holdConfirmed: false, note: '', availableCalls: [], availableStations: [], @@ -106,7 +113,7 @@ export const useStatusBottomSheetStore = create((set, ge lastFetchedAt: 0, isLoading: false, error: null, - setIsOpen: (isOpen, status) => { + setIsOpen: (isOpen, status, options) => { if (!isOpen) { set({ isOpen: false }); return; @@ -118,6 +125,7 @@ export const useStatusBottomSheetStore = create((set, ge selectedStatus: null, currentStep: 'select-status', cameFromStatusSelection: true, + holdConfirmed: false, }); return; } @@ -127,8 +135,10 @@ export const useStatusBottomSheetStore = create((set, ge selectedStatus: status, currentStep: getInitialStepForStatus(status), cameFromStatusSelection: false, + holdConfirmed: options?.holdConfirmed === true, }); }, + setHoldConfirmed: (holdConfirmed) => set({ holdConfirmed }), setCurrentStep: (step) => set({ currentStep: step }), setSelectedCall: (call) => set({ selectedCall: call }), setSelectedStation: (station) => set({ selectedStation: station }), @@ -180,6 +190,7 @@ export const useStatusBottomSheetStore = create((set, ge selectedDestinationType: 'none', selectedStatus: null, cameFromStatusSelection: false, + holdConfirmed: false, note: '', availableCalls: [], availableStations: [], diff --git a/src/translations/ar.json b/src/translations/ar.json index 5eb3c296..23ddac69 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -139,6 +139,8 @@ "close_call_error": "فشل في إغلاق المكالمة", "close_call_note": "ملاحظة الإغلاق", "close_call_note_placeholder": "أدخل ملاحظة حول إغلاق المكالمة", + "close_call_notify": "إشعار جميع المعنيين بالمكالمة", + "close_call_notify_hint": "ينبّه الأفراد والمجموعات والأدوار والوحدات المُرسلة وفريق القيادة بأن المكالمة قد أُغلقت.", "close_call_success": "تم إغلاق المكالمة بنجاح", "close_call_type": "نوع الإغلاق", "close_call_type_placeholder": "اختر نوع الإغلاق", @@ -2035,8 +2037,13 @@ "calls_and_pois_destinations_enabled": "Can respond to calls or POIs", "calls_stations_pois_destinations_enabled": "Can respond to calls, stations, or POIs", "calls_tab": "المكالمات", + "current": "الحالية", + "current_status": "الحالة الحالية", "failed_to_save_status": "فشل في حفظ الحالة. يرجى المحاولة مرة أخرى.", "general_status": "حالة عامة بدون وجهة محددة", + "hold_to_set_hint": "اضغط مطولاً لمدة ثانيتين لتعيين الحالة", + "hold_to_set_instructions": "اضغط مطولاً على حالة لمدة ثانيتين لتعيينها", + "hold_to_submit": "اضغط مطولاً للإرسال", "loading_pois": "Loading POIs...", "loading_stations": "جاري تحميل المحطات...", "no_destination": "بدون وجهة", @@ -2055,6 +2062,8 @@ "selected_destination": "الوجهة المختارة", "selected_status": "الحالة المختارة", "set_status": "تعيين الحالة", + "show_all_statuses": "إظهار كل الحالات ({{count}} أخرى)", + "show_next_statuses": "إظهار الحالات التالية فقط", "station_destination_enabled": "يمكن الاستجابة للمحطات", "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "المحطات", diff --git a/src/translations/de.json b/src/translations/de.json index df9f9bad..f181e345 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -139,6 +139,8 @@ "close_call_error": "Anruf konnte nicht geschlossen werden", "close_call_note": "Abschlussnotiz", "close_call_note_placeholder": "Eine Notiz zum Schließen des Anrufs eingeben", + "close_call_notify": "Alle Beteiligten des Anrufs benachrichtigen", + "close_call_notify_hint": "Informiert alarmierte Einsatzkräfte, Gruppen, Rollen, Einheiten und die Einsatzleitung, dass der Anruf geschlossen ist.", "close_call_success": "Anruf erfolgreich geschlossen", "close_call_type": "Abschlusstyp", "close_call_type_placeholder": "Abschlusstyp auswählen", @@ -2035,8 +2037,13 @@ "calls_and_pois_destinations_enabled": "Can respond to calls or POIs", "calls_stations_pois_destinations_enabled": "Can respond to calls, stations, or POIs", "calls_tab": "Anrufe", + "current": "Aktuell", + "current_status": "Aktueller Status", "failed_to_save_status": "Status konnte nicht gespeichert werden. Bitte erneut versuchen.", "general_status": "Allgemeiner Status ohne spezifisches Ziel", + "hold_to_set_hint": "Zwei Sekunden gedrückt halten, um den Status zu setzen", + "hold_to_set_instructions": "Einen Status zwei Sekunden gedrückt halten, um ihn zu setzen", + "hold_to_submit": "Zum Senden gedrückt halten", "loading_pois": "Loading POIs...", "loading_stations": "Stationen werden geladen...", "no_destination": "Kein Ziel", @@ -2055,6 +2062,8 @@ "selected_destination": "Ausgewähltes Ziel", "selected_status": "Ausgewählter Status", "set_status": "Status festlegen", + "show_all_statuses": "Alle Status anzeigen ({{count}} weitere)", + "show_next_statuses": "Nur nächste Status anzeigen", "station_destination_enabled": "Kann auf Stationen reagieren", "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Stationen", diff --git a/src/translations/el.json b/src/translations/el.json index 33fa5a6a..85680b99 100644 --- a/src/translations/el.json +++ b/src/translations/el.json @@ -139,6 +139,8 @@ "close_call_error": "Αποτυχία κλεισίματος κλήσης", "close_call_note": "Σημείωση Κλεισίματος", "close_call_note_placeholder": "Εισαγάγετε μια σημείωση σχετικά με το κλείσιμο της κλήσης", + "close_call_notify": "Ειδοποίηση όλων όσων συμμετέχουν στην κλήση", + "close_call_notify_hint": "Ειδοποιεί το προσωπικό, τις ομάδες, τους ρόλους και τις μονάδες που κλήθηκαν, καθώς και την ομάδα διοίκησης, ότι η κλήση έκλεισε.", "close_call_success": "Η κλήση έκλεισε με επιτυχία", "close_call_type": "Τύπος Κλεισίματος", "close_call_type_placeholder": "Επιλέξτε τύπο κλεισίματος", @@ -2035,8 +2037,13 @@ "calls_and_pois_destinations_enabled": "Μπορεί να ανταποκριθεί σε κλήσεις ή σημεία ενδιαφέροντος", "calls_stations_pois_destinations_enabled": "Μπορεί να ανταποκριθεί σε κλήσεις, σταθμούς ή σημεία ενδιαφέροντος", "calls_tab": "Κλήσεις", + "current": "Τρέχουσα", + "current_status": "Τρέχουσα κατάσταση", "failed_to_save_status": "Αποτυχία αποθήκευσης κατάστασης. Δοκιμάστε ξανά.", "general_status": "Γενική κατάσταση χωρίς συγκεκριμένο προορισμό", + "hold_to_set_hint": "Πατήστε παρατεταμένα για 2 δευτερόλεπτα για να ορίσετε την κατάσταση", + "hold_to_set_instructions": "Πατήστε παρατεταμένα μια κατάσταση για 2 δευτερόλεπτα για να την ορίσετε", + "hold_to_submit": "Κρατήστε πατημένο για υποβολή", "loading_pois": "Φόρτωση σημείων ενδιαφέροντος...", "loading_stations": "Φόρτωση σταθμών...", "no_destination": "Χωρίς Προορισμό", @@ -2055,6 +2062,8 @@ "selected_destination": "Επιλεγμένος Προορισμός", "selected_status": "Επιλεγμένη Κατάσταση", "set_status": "Ορισμός Κατάστασης", + "show_all_statuses": "Εμφάνιση όλων των καταστάσεων ({{count}} ακόμη)", + "show_next_statuses": "Εμφάνιση μόνο των επόμενων καταστάσεων", "station_destination_enabled": "Μπορεί να ανταποκριθεί σε σταθμούς", "stations_and_pois_destinations_enabled": "Μπορεί να ανταποκριθεί σε σταθμούς ή σημεία ενδιαφέροντος", "stations_tab": "Σταθμοί", diff --git a/src/translations/en.json b/src/translations/en.json index 0285089c..d9dd83c2 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -139,6 +139,8 @@ "close_call_error": "Failed to close call", "close_call_note": "Close Note", "close_call_note_placeholder": "Enter a note about closing the call", + "close_call_notify": "Notify everyone on the call", + "close_call_notify_hint": "Alerts the dispatched personnel, groups, roles, units and command team that the call is closed.", "close_call_success": "Call closed successfully", "close_call_type": "Close Type", "close_call_type_placeholder": "Select close type", @@ -2035,8 +2037,13 @@ "calls_and_pois_destinations_enabled": "Can respond to calls or POIs", "calls_stations_pois_destinations_enabled": "Can respond to calls, stations, or POIs", "calls_tab": "Calls", + "current": "Current", + "current_status": "Current status", "failed_to_save_status": "Failed to save status. Please try again.", "general_status": "General status without specific destination", + "hold_to_set_hint": "Press and hold for 2 seconds to set the status", + "hold_to_set_instructions": "Press and hold a status for 2 seconds to set it", + "hold_to_submit": "Hold to submit", "loading_pois": "Loading POIs...", "loading_stations": "Loading stations...", "no_destination": "No Destination", @@ -2055,6 +2062,8 @@ "selected_destination": "Selected Destination", "selected_status": "Selected Status", "set_status": "Set Status", + "show_all_statuses": "Show all statuses ({{count}} more)", + "show_next_statuses": "Show next statuses only", "station_destination_enabled": "Can respond to stations", "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Stations", diff --git a/src/translations/es.json b/src/translations/es.json index fc334d98..862b0805 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -139,6 +139,8 @@ "close_call_error": "Error al cerrar la llamada", "close_call_note": "Nota de cierre", "close_call_note_placeholder": "Introduce una nota sobre el cierre de la llamada", + "close_call_notify": "Notificar a todos los de la llamada", + "close_call_notify_hint": "Avisa al personal, los grupos, los roles y las unidades despachados y al equipo de mando de que la llamada está cerrada.", "close_call_success": "Llamada cerrada con éxito", "close_call_type": "Tipo de cierre", "close_call_type_placeholder": "Selecciona el tipo de cierre", @@ -2035,8 +2037,13 @@ "calls_and_pois_destinations_enabled": "Can respond to calls or POIs", "calls_stations_pois_destinations_enabled": "Can respond to calls, stations, or POIs", "calls_tab": "Llamadas", + "current": "Actual", + "current_status": "Estado actual", "failed_to_save_status": "Error al guardar el estado. Por favor, inténtelo de nuevo.", "general_status": "Estado general sin destino específico", + "hold_to_set_hint": "Mantenga pulsado 2 segundos para establecer el estado", + "hold_to_set_instructions": "Mantenga pulsado un estado 2 segundos para establecerlo", + "hold_to_submit": "Mantener para enviar", "loading_pois": "Loading POIs...", "loading_stations": "Cargando estaciones...", "no_destination": "Sin Destino", @@ -2055,6 +2062,8 @@ "selected_destination": "Destino Seleccionado", "selected_status": "Estado Seleccionado", "set_status": "Establecer Estado", + "show_all_statuses": "Mostrar todos los estados ({{count}} más)", + "show_next_statuses": "Mostrar solo los estados siguientes", "station_destination_enabled": "Puede responder a estaciones", "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Estaciones", diff --git a/src/translations/fr.json b/src/translations/fr.json index 29d61276..6a91528a 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -139,6 +139,8 @@ "close_call_error": "Échec de la clôture de l'appel", "close_call_note": "Note de clôture", "close_call_note_placeholder": "Saisir une note sur la clôture de l'appel", + "close_call_notify": "Prévenir tous les intervenants de l'appel", + "close_call_notify_hint": "Informe le personnel, les groupes, les rôles et les unités engagés ainsi que l'équipe de commandement que l'appel est clôturé.", "close_call_success": "Appel clôturé avec succès", "close_call_type": "Type de clôture", "close_call_type_placeholder": "Sélectionner le type de clôture", @@ -2035,8 +2037,13 @@ "calls_and_pois_destinations_enabled": "Can respond to calls or POIs", "calls_stations_pois_destinations_enabled": "Can respond to calls, stations, or POIs", "calls_tab": "Appels", + "current": "Actuel", + "current_status": "Statut actuel", "failed_to_save_status": "Échec de l'enregistrement du statut. Veuillez réessayer.", "general_status": "Statut général sans destination spécifique", + "hold_to_set_hint": "Maintenez appuyé 2 secondes pour définir le statut", + "hold_to_set_instructions": "Maintenez un statut appuyé 2 secondes pour le définir", + "hold_to_submit": "Maintenir pour envoyer", "loading_pois": "Loading POIs...", "loading_stations": "Chargement des stations...", "no_destination": "Aucune destination", @@ -2055,6 +2062,8 @@ "selected_destination": "Destination sélectionnée", "selected_status": "Statut sélectionné", "set_status": "Définir le statut", + "show_all_statuses": "Afficher tous les statuts ({{count}} de plus)", + "show_next_statuses": "Afficher uniquement les statuts suivants", "station_destination_enabled": "Peut répondre aux stations", "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Stations", diff --git a/src/translations/it.json b/src/translations/it.json index 0231eb30..fe216cbc 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -139,6 +139,8 @@ "close_call_error": "Impossibile chiudere la chiamata", "close_call_note": "Nota di chiusura", "close_call_note_placeholder": "Inserisci una nota sulla chiusura della chiamata", + "close_call_notify": "Avvisa tutti i partecipanti alla chiamata", + "close_call_notify_hint": "Avvisa personale, gruppi, ruoli e unità allertati e il team di comando che la chiamata è chiusa.", "close_call_success": "Chiamata chiusa con successo", "close_call_type": "Tipo di chiusura", "close_call_type_placeholder": "Seleziona tipo di chiusura", @@ -2035,8 +2037,13 @@ "calls_and_pois_destinations_enabled": "Can respond to calls or POIs", "calls_stations_pois_destinations_enabled": "Can respond to calls, stations, or POIs", "calls_tab": "Chiamate", + "current": "Attuale", + "current_status": "Stato attuale", "failed_to_save_status": "Impossibile salvare lo stato. Riprovare.", "general_status": "Stato generale senza destinazione specifica", + "hold_to_set_hint": "Tieni premuto per 2 secondi per impostare lo stato", + "hold_to_set_instructions": "Tieni premuto uno stato per 2 secondi per impostarlo", + "hold_to_submit": "Tieni premuto per inviare", "loading_pois": "Loading POIs...", "loading_stations": "Caricamento stazioni...", "no_destination": "Nessuna destinazione", @@ -2055,6 +2062,8 @@ "selected_destination": "Destinazione selezionata", "selected_status": "Stato selezionato", "set_status": "Imposta stato", + "show_all_statuses": "Mostra tutti gli stati (altri {{count}})", + "show_next_statuses": "Mostra solo gli stati successivi", "station_destination_enabled": "Può rispondere a stazioni", "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Stazioni", diff --git a/src/translations/pl.json b/src/translations/pl.json index 7cb1f624..1458c1d3 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -139,6 +139,8 @@ "close_call_error": "Nie udało się zamknąć zgłoszenia", "close_call_note": "Notatka zamknięcia", "close_call_note_placeholder": "Wpisz notatkę o zamknięciu zgłoszenia", + "close_call_notify": "Powiadom wszystkich przypisanych do zgłoszenia", + "close_call_notify_hint": "Informuje zadysponowany personel, grupy, role, jednostki i zespół dowodzenia o zamknięciu zgłoszenia.", "close_call_success": "Zgłoszenie zostało pomyślnie zamknięte", "close_call_type": "Typ zamknięcia", "close_call_type_placeholder": "Wybierz typ zamknięcia", @@ -2035,8 +2037,13 @@ "calls_and_pois_destinations_enabled": "Can respond to calls or POIs", "calls_stations_pois_destinations_enabled": "Can respond to calls, stations, or POIs", "calls_tab": "Zgłoszenia", + "current": "Bieżący", + "current_status": "Bieżący status", "failed_to_save_status": "Nie udało się zapisać statusu. Spróbuj ponownie.", "general_status": "Ogólny status bez określonego celu", + "hold_to_set_hint": "Przytrzymaj przez 2 sekundy, aby ustawić status", + "hold_to_set_instructions": "Przytrzymaj status przez 2 sekundy, aby go ustawić", + "hold_to_submit": "Przytrzymaj, aby wysłać", "loading_pois": "Loading POIs...", "loading_stations": "Ładowanie stacji...", "no_destination": "Brak celu", @@ -2055,6 +2062,8 @@ "selected_destination": "Wybrany cel", "selected_status": "Wybrany status", "set_status": "Ustaw status", + "show_all_statuses": "Pokaż wszystkie statusy (jeszcze {{count}})", + "show_next_statuses": "Pokaż tylko następne statusy", "station_destination_enabled": "Może reagować na stacje", "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Stacje", diff --git a/src/translations/sv.json b/src/translations/sv.json index 0ff426a8..9d945711 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -139,6 +139,8 @@ "close_call_error": "Det gick inte att stänga samtalet", "close_call_note": "Stängningsanteckning", "close_call_note_placeholder": "Ange en anteckning om att stänga samtalet", + "close_call_notify": "Meddela alla som är kopplade till samtalet", + "close_call_notify_hint": "Meddelar larmad personal, grupper, roller, enheter och ledningsgruppen att samtalet är stängt.", "close_call_success": "Samtalet stängdes framgångsrikt", "close_call_type": "Stängningstyp", "close_call_type_placeholder": "Välj stängningstyp", @@ -2035,8 +2037,13 @@ "calls_and_pois_destinations_enabled": "Can respond to calls or POIs", "calls_stations_pois_destinations_enabled": "Can respond to calls, stations, or POIs", "calls_tab": "Samtal", + "current": "Aktuell", + "current_status": "Aktuell status", "failed_to_save_status": "Det gick inte att spara status. Försök igen.", "general_status": "Allmän status utan specifikt mål", + "hold_to_set_hint": "Håll in i 2 sekunder för att sätta statusen", + "hold_to_set_instructions": "Håll in en status i 2 sekunder för att sätta den", + "hold_to_submit": "Håll in för att skicka", "loading_pois": "Loading POIs...", "loading_stations": "Laddar stationer...", "no_destination": "Inget mål", @@ -2055,6 +2062,8 @@ "selected_destination": "Valt mål", "selected_status": "Vald status", "set_status": "Ange status", + "show_all_statuses": "Visa alla statusar ({{count}} till)", + "show_next_statuses": "Visa bara nästa statusar", "station_destination_enabled": "Kan svara på stationer", "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Stationer", diff --git a/src/translations/uk.json b/src/translations/uk.json index c0c39013..3087a033 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -139,6 +139,8 @@ "close_call_error": "Не вдалося закрити виклик", "close_call_note": "Примітка закриття", "close_call_note_placeholder": "Введіть примітку про закриття виклику", + "close_call_notify": "Сповістити всіх учасників виклику", + "close_call_notify_hint": "Сповіщає направлений персонал, групи, ролі, підрозділи та команду управління, що виклик закрито.", "close_call_success": "Виклик успішно закрито", "close_call_type": "Тип закриття", "close_call_type_placeholder": "Виберіть тип закриття", @@ -2035,8 +2037,13 @@ "calls_and_pois_destinations_enabled": "Can respond to calls or POIs", "calls_stations_pois_destinations_enabled": "Can respond to calls, stations, or POIs", "calls_tab": "Виклики", + "current": "Поточний", + "current_status": "Поточний статус", "failed_to_save_status": "Не вдалося зберегти статус. Спробуйте ще раз.", "general_status": "Загальний статус без конкретного призначення", + "hold_to_set_hint": "Утримуйте 2 секунди, щоб встановити статус", + "hold_to_set_instructions": "Утримуйте статус 2 секунди, щоб встановити його", + "hold_to_submit": "Утримуйте, щоб надіслати", "loading_pois": "Loading POIs...", "loading_stations": "Завантаження станцій...", "no_destination": "Без призначення", @@ -2055,6 +2062,8 @@ "selected_destination": "Вибране призначення", "selected_status": "Вибраний статус", "set_status": "Встановити статус", + "show_all_statuses": "Показати всі статуси (ще {{count}})", + "show_next_statuses": "Показати лише наступні статуси", "station_destination_enabled": "Може реагувати на станції", "stations_and_pois_destinations_enabled": "Can respond to stations or POIs", "stations_tab": "Станції", From 1a166fe8383c4ef0a5d683f400688476ef256573 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 6 Oct 2026 19:45:16 -0700 Subject: [PATCH 2/2] RU-T53 PR #291 Fixes --- .../close-call-bottom-sheet.test.tsx | 33 +++- .../calls/close-call-bottom-sheet.tsx | 3 +- .../__tests__/sidebar-content.test.tsx | 163 ++++++++++++++++++ src/components/sidebar/sidebar-content.tsx | 8 +- 4 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 src/components/sidebar/__tests__/sidebar-content.test.tsx diff --git a/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx b/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx index 14d8cc69..afbae878 100644 --- a/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx +++ b/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx @@ -176,9 +176,9 @@ jest.mock('@/components/ui/textarea', () => ({ })); jest.mock('@/components/ui/switch', () => ({ - Switch: ({ value, onValueChange, testID }: any) => { + Switch: ({ value, onValueChange, isDisabled, testID }: any) => { const { Switch: RNSwitch } = require('react-native'); - return ; + return ; }, })); @@ -383,6 +383,35 @@ describe('CloseCallBottomSheet', () => { }); }); + it('locks the notify switch while the close is in flight', async () => { + let resolveClose: () => void = () => {}; + mockCloseCall.mockImplementation( + () => + new Promise((resolve) => { + resolveClose = resolve; + }) + ); + mockFetchCalls.mockResolvedValue(undefined); + + render(); + + expect(screen.getByTestId('close-call-notify-switch').props.disabled).toBeFalsy(); + + selectCloseCallType('1'); + fireEvent.press(screen.getAllByText('call_detail.close_call')[1]); + + await waitFor(() => { + expect(mockCloseCall).toHaveBeenCalled(); + expect(screen.getByTestId('close-call-notify-switch').props.disabled).toBe(true); + }); + + resolveClose(); + + await waitFor(() => { + expect(screen.getByTestId('close-call-notify-switch').props.disabled).toBeFalsy(); + }); + }); + it('turns the notify switch back on when the sheet is cancelled', () => { const mockOnClose = jest.fn(); render(); diff --git a/src/components/calls/close-call-bottom-sheet.tsx b/src/components/calls/close-call-bottom-sheet.tsx index 3b4e5149..30aea8ed 100644 --- a/src/components/calls/close-call-bottom-sheet.tsx +++ b/src/components/calls/close-call-bottom-sheet.tsx @@ -154,7 +154,8 @@ export const CloseCallBottomSheet: React.FC = ({ isOp {t('call_detail.close_call_notify')} {t('call_detail.close_call_notify_hint')} - + {/* Locked while submitting: the choice is already on its way to the server. */} + diff --git a/src/components/sidebar/__tests__/sidebar-content.test.tsx b/src/components/sidebar/__tests__/sidebar-content.test.tsx new file mode 100644 index 00000000..dc65d9ab --- /dev/null +++ b/src/components/sidebar/__tests__/sidebar-content.test.tsx @@ -0,0 +1,163 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { useCoreStore } from '@/stores/app/core-store'; + +import Sidebar from '../sidebar-content'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('expo-router', () => ({ + useRouter: () => ({ push: jest.fn() }), +})); + +jest.mock('@/lib/storage/app', () => ({ + getBaseApiUrl: jest.fn(() => 'https://api.test'), +})); + +jest.mock('@/stores/app/core-store', () => ({ + useCoreStore: jest.fn(), +})); + +const mockSetIsOpen = jest.fn(); +jest.mock('@/stores/status/store', () => ({ + useStatusBottomSheetStore: (selector: (state: { setIsOpen: jest.Mock }) => unknown) => selector({ setIsOpen: mockSetIsOpen }), +})); + +const mockShowToast = jest.fn(); +jest.mock('@/stores/toast/store', () => ({ + useToastStore: (selector: (state: { showToast: jest.Mock }) => unknown) => selector({ showToast: mockShowToast }), +})); + +jest.mock('@/stores/feature-flags/store', () => ({ + useIsChatEnabled: () => false, + useIsChecklistsEnabled: () => false, + useIsDeploymentsEnabled: () => false, + useIsRecordsFieldEnabled: () => false, +})); + +jest.mock('../call-sidebar', () => ({ SidebarCallCard: () => null })); +jest.mock('../check-in-sidebar-widget', () => ({ CheckInSidebarWidget: () => null })); +jest.mock('../roles-sidebar', () => ({ SidebarRolesCard: () => null })); +jest.mock('../status-sidebar', () => ({ SidebarStatusCard: () => null })); +jest.mock('../unit-sidebar', () => ({ SidebarUnitCard: () => null })); +jest.mock('../../common/zero-state', () => ({ __esModule: true, default: () => null })); + +jest.mock('@/components/status/hold-to-confirm-button', () => ({ + HoldToConfirmButton: ({ children, testID, onConfirm, backgroundColor, foregroundColor }: any) => { + const { Pressable } = require('react-native'); + return ( + + {children} + + ); + }, +})); + +jest.mock('@/components/ui/button', () => ({ + Button: ({ children, testID, onPress, style }: any) => { + const { Pressable } = require('react-native'); + return ( + + {children} + + ); + }, + ButtonText: ({ children, style }: any) => { + const { Text } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/hstack', () => ({ + HStack: ({ children }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/vstack', () => ({ + VStack: ({ children }: any) => { + const { View } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/text', () => ({ + Text: ({ children, style }: any) => { + const { Text } = require('react-native'); + return {children}; + }, +})); + +const mockUseCoreStore = useCoreStore as unknown as jest.Mock; + +const available = { Id: 10, Text: 'Available', BColor: '#449d44', NextIds: [11], Detail: 0, Note: 0 }; +const responding = { Id: 11, Text: 'Responding', BColor: '', NextIds: [] as number[], Detail: 0, Note: 0 }; +const outOfService = { Id: 12, Text: 'Out of Service', BColor: '#262626', NextIds: [] as number[], Detail: 0, Note: 0 }; + +const setCoreState = ({ holdMode, currentStateId }: { holdMode: boolean; currentStateId?: number }) => { + const state = { + activeStatuses: { Statuses: [available, responding, outOfService] }, + activeUnitStatus: currentStateId == null ? null : { UnitId: '1', StateId: currentStateId, State: '' }, + activeUnitId: '1', + config: { StatusHoldToConfirm: holdMode }, + }; + mockUseCoreStore.mockImplementation((selector: (s: typeof state) => unknown) => selector(state)); +}; + +const flattenStyle = (style: unknown) => require('react-native').StyleSheet.flatten(style); + +describe('Sidebar status buttons', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders a status without a color on a white button with dark text instead of throwing', () => { + setCoreState({ holdMode: false }); + + const { unmount } = render(); + + const button = screen.getByTestId('sidebar-status-11'); + expect(flattenStyle(button.props.style).backgroundColor).toBe('#ffffff'); + expect(flattenStyle(screen.getByText('Responding').props.style).color).toBe('#000000'); + // Colored statuses keep their own background. + expect(flattenStyle(screen.getByTestId('sidebar-status-10').props.style).backgroundColor).toBe('#449d44'); + + fireEvent.press(button); + expect(mockSetIsOpen).toHaveBeenCalledWith(true, responding); + + unmount(); + }); + + it('gives a hold button for a status without a color the same white fallback', () => { + setCoreState({ holdMode: true }); + + const { unmount } = render(); + + expect(screen.getByTestId('sidebar-status-hold-11').props.accessibilityHint).toBe('#ffffff|#000000'); + + fireEvent.press(screen.getByTestId('sidebar-status-hold-11')); + expect(mockSetIsOpen).toHaveBeenCalledWith(true, responding, { holdConfirmed: true }); + + unmount(); + }); + + it('narrows the buttons to the current status next statuses until all are asked for', () => { + setCoreState({ holdMode: false, currentStateId: 10 }); + + const { unmount } = render(); + + expect(screen.getByTestId('sidebar-status-11')).toBeTruthy(); + expect(screen.queryByTestId('sidebar-status-12')).toBeNull(); + + fireEvent.press(screen.getByTestId('sidebar-status-show-all')); + + expect(screen.getByTestId('sidebar-status-12')).toBeTruthy(); + expect(screen.getByTestId('sidebar-status-show-next')).toBeTruthy(); + + unmount(); + }); +}); diff --git a/src/components/sidebar/sidebar-content.tsx b/src/components/sidebar/sidebar-content.tsx index bb1cf0cc..e3332859 100644 --- a/src/components/sidebar/sidebar-content.tsx +++ b/src/components/sidebar/sidebar-content.tsx @@ -189,7 +189,9 @@ const Sidebar = ({ onClose }: SidebarProps) => { {offeredStatuses.offered.map((status) => { const isCurrent = String(status.Id) === currentStatusId; - const foreground = invertColor(status.BColor, true); + // invertColor throws on a non-hex value, so an option without a color falls back to white like the status sheet. + const background = status.BColor || '#ffffff'; + const foreground = invertColor(background, true); const label = ( @@ -210,7 +212,7 @@ const Sidebar = ({ onClose }: SidebarProps) => { testID={`sidebar-status-hold-${status.Id}`} onConfirm={() => setIsOpen(true, status, { holdConfirmed: true })} onTap={() => showToast('info', t('status.hold_to_set_hint'))} - backgroundColor={status.BColor || '#ffffff'} + backgroundColor={background} foregroundColor={foreground} style={isCurrent ? styles.currentOutline : null} contentStyle={styles.holdContent} @@ -230,7 +232,7 @@ const Sidebar = ({ onClose }: SidebarProps) => { className="w-full justify-center overflow-visible px-3 py-2" action="primary" size="lg" - style={[{ backgroundColor: status.BColor }, isCurrent ? styles.currentOutline : null]} + style={[{ backgroundColor: background }, isCurrent ? styles.currentOutline : null]} onPress={() => setIsOpen(true, status)} accessibilityLabel={isCurrent ? `${status.Text}, ${t('status.current')}` : undefined} >