diff --git a/src/components/status/__tests__/status-bottom-sheet.test.tsx b/src/components/status/__tests__/status-bottom-sheet.test.tsx index f7636df..51ac2b7 100644 --- a/src/components/status/__tests__/status-bottom-sheet.test.tsx +++ b/src/components/status/__tests__/status-bottom-sheet.test.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { useStatusBottomSheetStore, useStatusesStore } from '@/stores/status/store'; import { useCoreStore } from '@/stores/app/core-store'; import { useRolesStore } from '@/stores/roles/store'; +import { useToastStore } from '@/stores/toast/store'; import { StatusBottomSheet } from '../status-bottom-sheet'; @@ -31,6 +32,10 @@ jest.mock('@/stores/roles/store', () => ({ useRolesStore: jest.fn(), })); +jest.mock('@/stores/toast/store', () => ({ + useToastStore: jest.fn(), +})); + jest.mock('@/services/offline-event-manager.service', () => ({ offlineEventManager: { initialize: jest.fn(), @@ -69,6 +74,7 @@ const mockTranslation = { 'common.next': 'Next', 'common.previous': 'Previous', 'common.submit': 'Submit', + 'common.submitting': 'Submitting', 'common.optional': 'Optional', 'status.select_status': 'Select Status', 'status.select_status_type': 'What status would you like to set?', @@ -81,6 +87,7 @@ const mockTranslation = { 'status.calls_tab': 'Calls', 'status.stations_tab': 'Stations', 'status.selected_destination': 'Selected Destination', + 'status.selected_status': 'Selected Status', 'status.note': 'Note', 'status.note_required': 'Note required', 'status.note_optional': 'Note optional', @@ -89,6 +96,8 @@ const mockTranslation = { 'status.call_destination_enabled': 'Can respond to calls', 'status.both_destinations_enabled': 'Can respond to calls or stations', 'status.no_statuses_available': 'No statuses available', + 'status.status_saved_successfully': 'Status saved successfully', + 'status.failed_to_save_status': 'Failed to save status', 'calls.loading_calls': 'Loading calls...', 'calls.no_calls_available': 'No calls available', 'status.no_stations_available': 'No stations available', @@ -110,6 +119,7 @@ const mockUseStatusesStore = useStatusesStore as jest.MockedFunction; const mockGetState = (mockUseCoreStore as any).getState; const mockUseRolesStore = useRolesStore as jest.MockedFunction; +const mockUseToastStore = useToastStore as jest.MockedFunction; describe('StatusBottomSheet', () => { const mockReset = jest.fn(); @@ -120,6 +130,7 @@ describe('StatusBottomSheet', () => { const mockSetNote = jest.fn(); const mockFetchDestinationData = jest.fn(); const mockSaveUnitStatus = jest.fn(); + const mockShowToast = jest.fn(); const defaultBottomSheetStore = { isOpen: false, @@ -190,6 +201,7 @@ describe('StatusBottomSheet', () => { mockUseTranslation.mockReturnValue(mockTranslation as any); mockUseStatusBottomSheetStore.mockReturnValue(defaultBottomSheetStore); mockUseStatusesStore.mockReturnValue(defaultStatusesStore); + mockUseToastStore.mockReturnValue({ showToast: mockShowToast }); // Set up the core store mock with getState that returns the store state mockGetState.mockReturnValue(defaultCoreStore as any); @@ -218,7 +230,7 @@ describe('StatusBottomSheet', () => { Id: 'status-1', Text: 'Available', Detail: 1, // Show destination step - Note: 1, // Note required - this gives us 2 steps + Note: 1, // Note optional - this gives us 2 steps }; mockUseStatusBottomSheetStore.mockReturnValue({ @@ -324,7 +336,7 @@ describe('StatusBottomSheet', () => { expect(mockSetSelectedCall).toHaveBeenCalledWith(null); }); - it('should set active call when selecting a call that is not already active', () => { + it('should not set active call when selecting a call', () => { const mockCall = { CallId: 'call-1', Number: 'C001', @@ -363,10 +375,11 @@ describe('StatusBottomSheet', () => { expect(mockSetSelectedCall).toHaveBeenCalledWith(mockCall); expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('call'); - expect(mockSetActiveCall).toHaveBeenCalledWith('call-1'); + // Active call should NOT be set until submission + expect(mockSetActiveCall).not.toHaveBeenCalled(); }); - it('should set active call when selecting a different call than currently active', () => { + it('should not set active call when selecting a different call', () => { const mockCall = { CallId: 'call-2', Number: 'C002', @@ -405,10 +418,11 @@ describe('StatusBottomSheet', () => { expect(mockSetSelectedCall).toHaveBeenCalledWith(mockCall); expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('call'); - expect(mockSetActiveCall).toHaveBeenCalledWith('call-2'); + // Active call should NOT be set until submission + expect(mockSetActiveCall).not.toHaveBeenCalled(); }); - it('should not set active call when selecting the same call that is already active', () => { + it('should not set active call when selecting any call during selection', () => { const mockCall = { CallId: 'call-1', Number: 'C001', @@ -477,7 +491,7 @@ describe('StatusBottomSheet', () => { Id: 'status-1', Text: 'Available', Detail: 1, - Note: 1, // Note required + Note: 1, // Note optional }; mockUseStatusBottomSheetStore.mockReturnValue({ @@ -500,7 +514,7 @@ describe('StatusBottomSheet', () => { Id: 'status-1', Text: 'Available', Detail: 1, - Note: 1, // Note required + Note: 1, // Note optional }; mockUseStatusBottomSheetStore.mockReturnValue({ @@ -549,7 +563,7 @@ describe('StatusBottomSheet', () => { Id: 'status-1', Text: 'Available', Detail: 1, - Note: 1, + Note: 2, // Note required }; mockUseStatusBottomSheetStore.mockReturnValue({ @@ -572,7 +586,7 @@ describe('StatusBottomSheet', () => { Id: 'status-1', Text: 'Available', Detail: 1, - Note: 1, // Note required + Note: 2, // Note required }; mockUseStatusBottomSheetStore.mockReturnValue({ @@ -594,7 +608,7 @@ describe('StatusBottomSheet', () => { Id: 'status-1', Text: 'Available', Detail: 1, - Note: 1, // Note required + Note: 2, // Note required }; mockUseStatusBottomSheetStore.mockReturnValue({ @@ -662,6 +676,276 @@ describe('StatusBottomSheet', () => { expect(screen.getByText('Loading calls...')).toBeTruthy(); }); + it('should set active call on submission when call is selected and different from current', async () => { + const mockCall = { + CallId: 'call-1', + Number: 'C001', + Name: 'Emergency Call', + Address: '123 Main St', + }; + + const selectedStatus = { + Id: 'status-1', + Text: 'Available', + Detail: 0, // No destination step needed + Note: 0, // No note required + }; + + // Mock core store with no active call + mockGetState.mockReturnValue({ + ...defaultCoreStore, + activeCallId: null, + } as any); + (useCoreStore as any).mockImplementation(() => ({ + ...defaultCoreStore, + activeCallId: null, + })); + + mockUseStatusBottomSheetStore.mockReturnValue({ + ...defaultBottomSheetStore, + isOpen: true, + selectedStatus, + selectedCall: mockCall, + selectedDestinationType: 'call', + }); + + render(); + + const submitButton = screen.getByText('Submit'); + fireEvent.press(submitButton); + + await waitFor(() => { + expect(mockSaveUnitStatus).toHaveBeenCalled(); + expect(mockSetActiveCall).toHaveBeenCalledWith('call-1'); + }); + }); + + it('should set active call on submission when selected call is different from current active call', async () => { + const mockCall = { + CallId: 'call-2', + Number: 'C002', + Name: 'Fire Emergency', + Address: '456 Oak St', + }; + + const selectedStatus = { + Id: 'status-1', + Text: 'Available', + Detail: 0, // No destination step needed + Note: 0, // No note required + }; + + // Mock core store with different active call + mockGetState.mockReturnValue({ + ...defaultCoreStore, + activeCallId: 'call-1', + } as any); + (useCoreStore as any).mockImplementation(() => ({ + ...defaultCoreStore, + activeCallId: 'call-1', + })); + + mockUseStatusBottomSheetStore.mockReturnValue({ + ...defaultBottomSheetStore, + isOpen: true, + selectedStatus, + selectedCall: mockCall, + selectedDestinationType: 'call', + }); + + render(); + + const submitButton = screen.getByText('Submit'); + fireEvent.press(submitButton); + + await waitFor(() => { + expect(mockSaveUnitStatus).toHaveBeenCalled(); + expect(mockSetActiveCall).toHaveBeenCalledWith('call-2'); + }); + }); + + it('should not set active call on submission when selected call is same as current active call', async () => { + const mockCall = { + CallId: 'call-1', + Number: 'C001', + Name: 'Emergency Call', + Address: '123 Main St', + }; + + const selectedStatus = { + Id: 'status-1', + Text: 'Available', + Detail: 0, // No destination step needed + Note: 0, // No note required + }; + + // Mock core store with same active call + mockGetState.mockReturnValue({ + ...defaultCoreStore, + activeCallId: 'call-1', + } as any); + (useCoreStore as any).mockImplementation(() => ({ + ...defaultCoreStore, + activeCallId: 'call-1', + })); + + mockUseStatusBottomSheetStore.mockReturnValue({ + ...defaultBottomSheetStore, + isOpen: true, + selectedStatus, + selectedCall: mockCall, + selectedDestinationType: 'call', + }); + + render(); + + const submitButton = screen.getByText('Submit'); + fireEvent.press(submitButton); + + await waitFor(() => { + expect(mockSaveUnitStatus).toHaveBeenCalled(); + expect(mockSetActiveCall).not.toHaveBeenCalled(); + }); + }); + + it('should not set active call on submission when no call is selected', async () => { + const selectedStatus = { + Id: 'status-1', + Text: 'Available', + Detail: 0, // No destination step + Note: 0, // No note required + }; + + mockUseStatusBottomSheetStore.mockReturnValue({ + ...defaultBottomSheetStore, + isOpen: true, + selectedStatus, + selectedDestinationType: 'none', + }); + + render(); + + const submitButton = screen.getByText('Submit'); + fireEvent.press(submitButton); + + await waitFor(() => { + expect(mockSaveUnitStatus).toHaveBeenCalled(); + expect(mockSetActiveCall).not.toHaveBeenCalled(); + }); + }); + + it('should not set active call on submission when station is selected', async () => { + const mockStation = { + GroupId: 'station-1', + Name: 'Fire Station 1', + Address: '456 Oak Ave', + GroupType: 'Station', + }; + + const selectedStatus = { + Id: 'status-1', + Text: 'Available', + Detail: 0, // No destination step needed + Note: 0, // No note required + }; + + mockUseStatusBottomSheetStore.mockReturnValue({ + ...defaultBottomSheetStore, + isOpen: true, + selectedStatus, + selectedStation: mockStation, + selectedDestinationType: 'station', + }); + + render(); + + const submitButton = screen.getByText('Submit'); + fireEvent.press(submitButton); + + await waitFor(() => { + expect(mockSaveUnitStatus).toHaveBeenCalled(); + expect(mockSetActiveCall).not.toHaveBeenCalled(); + }); + }); + + it('should set active call only at end of flow, not during call selection', async () => { + const mockCall = { + CallId: 'call-1', + Number: 'C001', + Name: 'Emergency Call', + Address: '123 Main St', + }; + + const selectedStatus = { + Id: 'status-1', + Text: 'Responding', + Detail: 2, // Show calls + Note: 0, // No note required + }; + + // Mock core store with no active call + mockGetState.mockReturnValue({ + ...defaultCoreStore, + activeCallId: null, + } as any); + (useCoreStore as any).mockImplementation(() => ({ + ...defaultCoreStore, + activeCallId: null, + })); + + const mockStore = { + ...defaultBottomSheetStore, + isOpen: true, + selectedStatus, + availableCalls: [mockCall], + }; + + mockUseStatusBottomSheetStore.mockReturnValue(mockStore); + + render(); + + // Step 1: Select a call - should NOT set active call + const callOption = screen.getByText('C001 - Emergency Call'); + fireEvent.press(callOption); + + expect(mockSetSelectedCall).toHaveBeenCalledWith(mockCall); + expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('call'); + expect(mockSetActiveCall).not.toHaveBeenCalled(); + + // Update mock store to reflect call selection + mockUseStatusBottomSheetStore.mockReturnValue({ + ...mockStore, + selectedCall: mockCall, + selectedDestinationType: 'call', + }); + + // Step 2: Navigate to next step + const nextButton = screen.getByText('Next'); + fireEvent.press(nextButton); + + // setActiveCall should still NOT have been called + expect(mockSetActiveCall).not.toHaveBeenCalled(); + + // Re-render to show the final step (submit) + mockUseStatusBottomSheetStore.mockReturnValue({ + ...mockStore, + selectedCall: mockCall, + selectedDestinationType: 'call', + currentStep: 'select-destination', + }); + + render(); + + // Step 3: Submit - NOW setActiveCall should be called + const submitButtonAfterFlow = screen.getByText('Next'); // This should trigger submit for no note status + fireEvent.press(submitButtonAfterFlow); + + await waitFor(() => { + expect(mockSaveUnitStatus).toHaveBeenCalled(); + expect(mockSetActiveCall).toHaveBeenCalledWith('call-1'); + }); + }); + it('should fetch destination data when opened', () => { const selectedStatus = { Id: 'status-1', @@ -1301,11 +1585,141 @@ describe('StatusBottomSheet', () => { expect(mockSetSelectedDestinationType).not.toHaveBeenCalled(); }); - // New tests for status selection step - it('should render status selection step when no status is pre-selected', () => { - mockUseStatusBottomSheetStore.mockReturnValue({ - ...defaultBottomSheetStore, - isOpen: true, + it('should only select active call and not show no destination as selected when pre-selecting', async () => { + const activeCall = { + CallId: 'active-call-123', + Number: 'C123', + Name: 'Active Emergency Call', + Address: '123 Active St', + }; + + const selectedStatus = { + Id: 'status-1', + Text: 'Responding', + Detail: 2, // Show calls + Note: 0, + }; + + // Mock core store with active call + const coreStoreWithActiveCall = { + ...defaultCoreStore, + activeCallId: 'active-call-123', + }; + mockGetState.mockReturnValue(coreStoreWithActiveCall as any); + mockUseCoreStore.mockImplementation((selector: any) => { + if (selector) { + return selector(coreStoreWithActiveCall); + } + return coreStoreWithActiveCall; + }); + + // First render with initial state + let currentStore = { + ...defaultBottomSheetStore, + isOpen: true, + selectedStatus, + availableCalls: [activeCall], + isLoading: false, + selectedCall: null, + selectedDestinationType: 'none', + }; + + mockUseStatusBottomSheetStore.mockReturnValue(currentStore); + + const { rerender } = render(); + + // Should pre-select the active call + await waitFor(() => { + expect(mockSetSelectedCall).toHaveBeenCalledWith(activeCall); + expect(mockSetSelectedDestinationType).toHaveBeenCalledWith('call'); + }); + + // Simulate state update after pre-selection + const updatedStore = { + ...defaultBottomSheetStore, + isOpen: true, + selectedStatus, + availableCalls: [activeCall], + isLoading: false, + selectedCall: activeCall, + selectedDestinationType: 'call' as const, + }; + + mockUseStatusBottomSheetStore.mockReturnValue(updatedStore); + + rerender(); + + // Verify that the active call is visually selected + const callContainer = screen.getByText('C123 - Active Emergency Call').parent?.parent; + expect(callContainer).toBeTruthy(); + + // Verify that "No Destination" is NOT visually selected by checking the computed styling + const noDestinationContainer = screen.getByText('No Destination').parent?.parent?.parent; + expect(noDestinationContainer).toBeTruthy(); + + // The container should NOT have the selected styling (blue border/background) + expect(noDestinationContainer?.props.className).not.toContain('border-blue-500'); + expect(noDestinationContainer?.props.className).not.toContain('bg-blue-50'); + }); + + it('should not show no destination as selected during loading when there is an active call to pre-select', async () => { + const activeCall = { + CallId: 'active-call-123', + Number: 'C123', + Name: 'Active Emergency Call', + Address: '123 Active St', + }; + + const selectedStatus = { + Id: 'status-1', + Text: 'Responding', + Detail: 2, // Show calls + Note: 0, + }; + + // Mock core store with active call + const coreStoreWithActiveCall = { + ...defaultCoreStore, + activeCallId: 'active-call-123', + }; + mockGetState.mockReturnValue(coreStoreWithActiveCall as any); + mockUseCoreStore.mockImplementation((selector: any) => { + if (selector) { + return selector(coreStoreWithActiveCall); + } + return coreStoreWithActiveCall; + }); + + // Render with loading state (no calls available yet) + const loadingStore = { + ...defaultBottomSheetStore, + isOpen: true, + selectedStatus, + availableCalls: [], // No calls loaded yet + isLoading: true, // Still loading + selectedCall: null, + selectedDestinationType: 'none', + }; + + mockUseStatusBottomSheetStore.mockReturnValue(loadingStore); + + const { rerender } = render(); + + // Verify that "No Destination" is NOT visually selected even during loading + // when there's an active call that should be pre-selected + const noDestinationContainer = screen.getByText('No Destination').parent?.parent?.parent; + expect(noDestinationContainer).toBeTruthy(); + + // The container should NOT have the selected styling during loading + expect(noDestinationContainer?.props.className).not.toContain('border-blue-500'); + expect(noDestinationContainer?.props.className).not.toContain('bg-blue-50'); + }); + + // New tests for status selection step + it('should render status selection step when no status is pre-selected', () => { + mockUseStatusBottomSheetStore.mockReturnValue({ + ...defaultBottomSheetStore, + isOpen: true, currentStep: 'select-status', selectedStatus: null, // No status pre-selected cameFromStatusSelection: true, @@ -1321,6 +1735,47 @@ describe('StatusBottomSheet', () => { expect(screen.getByText('On Scene')).toBeTruthy(); }); + it('should display checkmarks instead of radio buttons for status selection', () => { + mockUseStatusBottomSheetStore.mockReturnValue({ + ...defaultBottomSheetStore, + isOpen: true, + currentStep: 'select-status', + selectedStatus: null, + }); + + render(); + + // Check that we have TouchableOpacity components for each status instead of radio buttons + const statusOptions = screen.getAllByText(/Available|Responding|On Scene/); + expect(statusOptions).toHaveLength(3); + + // Verify the status text is present (indicating TouchableOpacity structure worked) + expect(screen.getByText('Available')).toBeTruthy(); + expect(screen.getByText('Responding')).toBeTruthy(); + expect(screen.getByText('On Scene')).toBeTruthy(); + }); + + it('should display checkmarks instead of radio buttons for destination selection', () => { + const selectedStatus = { + Id: 'status-1', + Text: 'Available', + Detail: 1, // Show destination step + Note: 1, // Note optional + }; + + mockUseStatusBottomSheetStore.mockReturnValue({ + ...defaultBottomSheetStore, + isOpen: true, + selectedStatus, + }); + + render(); + + // The No Destination option should use TouchableOpacity with checkmark instead of radio + expect(screen.getByText('No Destination')).toBeTruthy(); + expect(screen.getByText('General Status')).toBeTruthy(); + }); + it('should handle status selection', () => { const mockSetSelectedStatus = jest.fn(); @@ -1363,8 +1818,8 @@ describe('StatusBottomSheet', () => { expect(screen.getByText('Can respond to calls or stations')).toBeTruthy(); // Detail: 3 // Check for note requirements - expect(screen.getByText('Note required')).toBeTruthy(); // Note: 1 - expect(screen.getByText('Note optional')).toBeTruthy(); // Note: 2 + expect(screen.getByText('Note optional')).toBeTruthy(); // Note: 1 (Responding) + expect(screen.getByText('Note required')).toBeTruthy(); // Note: 2 (On Scene) }); it('should disable next button on status selection when no status is selected', () => { @@ -1458,7 +1913,7 @@ describe('StatusBottomSheet', () => { BColor: '#28a745', Color: '#fff', Gps: false, - Note: 1, // Note required + Note: 2, // Note required Detail: 0, // No destination step }; @@ -1558,7 +2013,7 @@ describe('StatusBottomSheet', () => { BColor: '#28a745', Color: '#fff', Gps: false, - Note: 1, // Note required + Note: 1, // Note optional Detail: 0, // No destination step }; @@ -1743,4 +2198,322 @@ describe('StatusBottomSheet', () => { expect(screen.getByText('No statuses available')).toBeTruthy(); }); + + // NEW TESTS FOR ENHANCED FUNCTIONALITY + + it('should show selected status and destination on note step', () => { + const selectedStatus = { + Id: 1, + Text: 'Available', + Color: '#00FF00', + Detail: 2, + Note: 1, + }; + + const selectedCall = { + CallId: 'call-1', + Number: 'C123', + Name: 'Emergency Call', + Address: '123 Main St', + }; + + mockUseStatusBottomSheetStore.mockReturnValue({ + ...defaultBottomSheetStore, + isOpen: true, + currentStep: 'add-note', + selectedStatus, + selectedCall, + selectedDestinationType: 'call', + }); + + render(); + + expect(screen.getByText('Selected Status:')).toBeTruthy(); + expect(screen.getByText('Available')).toBeTruthy(); + expect(screen.getByText('Selected Destination:')).toBeTruthy(); + expect(screen.getByText('C123 - Emergency Call')).toBeTruthy(); + }); + + it('should disable submit button and show spinner when submitting', async () => { + const selectedStatus = { + Id: 1, + Text: 'Available', + Color: '#00FF00', + Detail: 0, + Note: 0, + }; + + // Mock a slow save operation + const slowSaveUnitStatus = jest.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1000))); + + mockUseStatusBottomSheetStore.mockReturnValue({ + ...defaultBottomSheetStore, + isOpen: true, + currentStep: 'add-note', + selectedStatus, + selectedDestinationType: 'none', + }); + + mockUseStatusesStore.mockReturnValue({ + ...defaultStatusesStore, + saveUnitStatus: slowSaveUnitStatus, + }); + + render(); + + const submitButton = screen.getByText('Submit'); + fireEvent.press(submitButton); + + // Check that the button is disabled and shows submitting text + await waitFor(() => { + expect(screen.getByText('Submitting')).toBeTruthy(); + }); + + // Wait for the operation to complete + await waitFor(() => slowSaveUnitStatus); + }); + + it('should show success toast when status is saved successfully', async () => { + const selectedStatus = { + Id: 1, + Text: 'Available', + Color: '#00FF00', + Detail: 0, + Note: 0, + }; + + mockUseStatusBottomSheetStore.mockReturnValue({ + ...defaultBottomSheetStore, + isOpen: true, + currentStep: 'add-note', + selectedStatus, + selectedDestinationType: 'none', + }); + + render(); + + const submitButton = screen.getByText('Submit'); + fireEvent.press(submitButton); + + await waitFor(() => { + expect(mockSaveUnitStatus).toHaveBeenCalled(); + expect(mockShowToast).toHaveBeenCalledWith('success', 'Status saved successfully'); + }); + }); + + it('should show error toast when status save fails', async () => { + const selectedStatus = { + Id: 1, + Text: 'Available', + Color: '#00FF00', + Detail: 0, + Note: 0, + }; + + const errorSaveUnitStatus = jest.fn().mockRejectedValue(new Error('Network error')); + + mockUseStatusBottomSheetStore.mockReturnValue({ + ...defaultBottomSheetStore, + isOpen: true, + currentStep: 'add-note', + selectedStatus, + selectedDestinationType: 'none', + }); + + mockUseStatusesStore.mockReturnValue({ + ...defaultStatusesStore, + saveUnitStatus: errorSaveUnitStatus, + }); + + render(); + + const submitButton = screen.getByText('Submit'); + fireEvent.press(submitButton); + + await waitFor(() => { + expect(errorSaveUnitStatus).toHaveBeenCalled(); + expect(mockShowToast).toHaveBeenCalledWith('error', 'Failed to save status'); + }); + }); + + it('should prevent double submission when submit is pressed multiple times', async () => { + const selectedStatus = { + Id: 1, + Text: 'Available', + Color: '#00FF00', + Detail: 0, + Note: 0, + }; + + mockUseStatusBottomSheetStore.mockReturnValue({ + ...defaultBottomSheetStore, + isOpen: true, + currentStep: 'add-note', + selectedStatus, + selectedDestinationType: 'none', + }); + + render(); + + const submitButton = screen.getByText('Submit'); + + // Press submit multiple times rapidly + fireEvent.press(submitButton); + fireEvent.press(submitButton); + fireEvent.press(submitButton); + + await waitFor(() => { + // Should only call save once despite multiple presses + expect(mockSaveUnitStatus).toHaveBeenCalledTimes(1); + }); + }); + + it('should disable previous button when submitting on note step', async () => { + const selectedStatus = { + Id: 1, + Text: 'Available', + Color: '#00FF00', + Detail: 2, + Note: 1, + }; + + const slowSaveUnitStatus = jest.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100))); + + mockUseStatusBottomSheetStore.mockReturnValue({ + ...defaultBottomSheetStore, + isOpen: true, + currentStep: 'add-note', + selectedStatus, + selectedDestinationType: 'none', + note: 'Test note', + }); + + mockUseStatusesStore.mockReturnValue({ + ...defaultStatusesStore, + saveUnitStatus: slowSaveUnitStatus, + }); + + render(); + + const submitButton = screen.getByText('Submit'); + fireEvent.press(submitButton); + + await waitFor(() => { + // Check that the submitting state is active + expect(screen.getByText('Submitting')).toBeTruthy(); + }); + + // Wait for the operation to complete + await waitFor(() => expect(slowSaveUnitStatus).toHaveBeenCalled()); + }); + + it('should show "No Destination" correctly when no call or station is selected', () => { + const selectedStatus = { + Id: 1, + Text: 'Available', + Color: '#00FF00', + Detail: 2, + Note: 1, + }; + + mockUseStatusBottomSheetStore.mockReturnValue({ + ...defaultBottomSheetStore, + isOpen: true, + currentStep: 'add-note', + selectedStatus, + selectedDestinationType: 'none', + }); + + render(); + + expect(screen.getByText('Selected Destination:')).toBeTruthy(); + expect(screen.getByText('No Destination')).toBeTruthy(); + }); + + it('should show loading state when call is being selected but selectedCall is null', () => { + const selectedStatus = { + Id: 1, + Text: 'Available', + Color: '#00FF00', + Detail: 2, + Note: 1, + }; + + const availableCalls = [ + { + CallId: 'call-1', + Number: 'C123', + Name: 'Emergency Call', + Address: '123 Main St', + }, + ]; + + // Mock core store with active call ID + const coreStoreWithActiveCall = { + ...defaultCoreStore, + activeCallId: 'call-1', + }; + + mockGetState.mockReturnValue(coreStoreWithActiveCall as any); + mockUseCoreStore.mockImplementation((selector: any) => { + if (selector) { + return selector(coreStoreWithActiveCall); + } + return coreStoreWithActiveCall; + }); + + mockUseStatusBottomSheetStore.mockReturnValue({ + ...defaultBottomSheetStore, + isOpen: true, + currentStep: 'add-note', + selectedStatus, + selectedDestinationType: 'call', // Set to call but no selectedCall yet + selectedCall: null, // This is the issue scenario + availableCalls, + }); + + render(); + + expect(screen.getByText('Selected Destination:')).toBeTruthy(); + expect(screen.getByText('C123 - Emergency Call')).toBeTruthy(); // Should find the call from availableCalls + }); + + it('should show loading text when call type is selected but no calls are available yet', () => { + const selectedStatus = { + Id: 1, + Text: 'Available', + Color: '#00FF00', + Detail: 2, + Note: 1, + }; + + // Mock core store with active call ID + const coreStoreWithActiveCall = { + ...defaultCoreStore, + activeCallId: 'call-1', + }; + + mockGetState.mockReturnValue(coreStoreWithActiveCall as any); + mockUseCoreStore.mockImplementation((selector: any) => { + if (selector) { + return selector(coreStoreWithActiveCall); + } + return coreStoreWithActiveCall; + }); + + mockUseStatusBottomSheetStore.mockReturnValue({ + ...defaultBottomSheetStore, + isOpen: true, + currentStep: 'add-note', + selectedStatus, + selectedDestinationType: 'call', // Set to call but no selectedCall yet + selectedCall: null, // This is the issue scenario + availableCalls: [], // No calls available yet + }); + + render(); + + expect(screen.getByText('Selected Destination:')).toBeTruthy(); + expect(screen.getByText('Loading calls...')).toBeTruthy(); // Should show loading text + }); }); \ No newline at end of file diff --git a/src/components/status/status-bottom-sheet.tsx b/src/components/status/status-bottom-sheet.tsx index 3b5318e..9c6bf56 100644 --- a/src/components/status/status-bottom-sheet.tsx +++ b/src/components/status/status-bottom-sheet.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, ArrowRight, CircleIcon } from 'lucide-react-native'; +import { ArrowLeft, ArrowRight, Check } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React from 'react'; import { useTranslation } from 'react-i18next'; @@ -11,12 +11,12 @@ import { useCoreStore } from '@/stores/app/core-store'; import { useLocationStore } from '@/stores/app/location-store'; import { useRolesStore } from '@/stores/roles/store'; import { useStatusBottomSheetStore, useStatusesStore } from '@/stores/status/store'; +import { useToastStore } from '@/stores/toast/store'; import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '../ui/actionsheet'; import { Button, ButtonText } from '../ui/button'; import { Heading } from '../ui/heading'; import { HStack } from '../ui/hstack'; -import { Radio, RadioGroup, RadioIcon, RadioIndicator, RadioLabel } from '../ui/radio'; import { Spinner } from '../ui/spinner'; import { Text } from '../ui/text'; import { Textarea, TextareaInput } from '../ui/textarea'; @@ -26,6 +26,9 @@ export const StatusBottomSheet = () => { const { t } = useTranslation(); const { colorScheme } = useColorScheme(); const [selectedTab, setSelectedTab] = React.useState<'calls' | 'stations'>('calls'); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const hasPreselectedRef = React.useRef(false); + const { showToast } = useToastStore(); // Initialize offline event manager on mount React.useEffect(() => { @@ -78,11 +81,6 @@ export const StatusBottomSheet = () => { setSelectedCall(call); setSelectedDestinationType('call'); setSelectedStation(null); - - // Set as active call if it's not already the active call - if (activeCallId !== call.CallId) { - setActiveCall(call.CallId); - } } }; @@ -113,21 +111,23 @@ export const StatusBottomSheet = () => { setCurrentStep('select-destination'); } else { // Check if note is required/optional based on selectedStatus - const noteLevel = getStatusProperty('Note', 0); - if (noteLevel === 0) { + const noteType = getStatusProperty('Note', 0); + if (noteType === 0) { // No note step, go straight to submission handleSubmit(); } else { + // Note step required (noteType 1 = optional, noteType 2 = required) setCurrentStep('add-note'); } } } else if (currentStep === 'select-destination') { // Check if note is required/optional based on selectedStatus - const noteLevel = getStatusProperty('Note', 0); - if (noteLevel === 0) { + const noteType = getStatusProperty('Note', 0); + if (noteType === 0) { // No note step, go straight to submission handleSubmit(); } else { + // Note step required (noteType 1 = optional, noteType 2 = required) setCurrentStep('add-note'); } } @@ -156,9 +156,13 @@ export const StatusBottomSheet = () => { }; const handleSubmit = React.useCallback(async () => { + if (isSubmitting) return; // Prevent double submission + try { if (!selectedStatus || !activeUnit) return; + setIsSubmitting(true); + const input = new SaveUnitStatusInput(); input.Id = activeUnit.UnitId; input.Type = getStatusProperty('Id', '0'); @@ -196,12 +200,26 @@ export const StatusBottomSheet = () => { return roleInput; }); + // Set active call if a call was selected and it's different from the current active call + if (selectedDestinationType === 'call' && selectedCall && activeCallId !== selectedCall.CallId) { + setActiveCall(selectedCall.CallId); + } + await saveUnitStatus(input); + + // Show success toast + showToast('success', t('status.status_saved_successfully')); + reset(); } catch (error) { console.error('Failed to save unit status:', error); + // Show error toast + showToast('error', t('status.failed_to_save_status')); + } finally { + setIsSubmitting(false); } }, [ + isSubmitting, selectedStatus, activeUnit, note, @@ -219,6 +237,10 @@ export const StatusBottomSheet = () => { speed, altitude, timestamp, + activeCallId, + setActiveCall, + showToast, + t, ]); // Fetch destination data when status bottom sheet opens @@ -229,26 +251,68 @@ export const StatusBottomSheet = () => { }, [isOpen, activeUnit, selectedStatus, fetchDestinationData]); // Pre-select active call when opening with calls enabled - React.useEffect(() => { - // Only pre-select if: - // 1. Status bottom sheet is open and not loading - // 2. Status has calls enabled (detailLevel 2 or 3) - // 3. There's an active call and it's in the available calls - // 4. No call is currently selected and destination type is 'none' - if (isOpen && !isLoading && selectedStatus && (selectedStatus.Detail === 2 || selectedStatus.Detail === 3) && activeCallId && availableCalls.length > 0 && !selectedCall && selectedDestinationType === 'none') { + React.useLayoutEffect(() => { + // Reset the pre-selection flag when bottom sheet closes + if (!isOpen) { + hasPreselectedRef.current = false; + return; + } + + // Immediate pre-selection: if we have the conditions met, pre-select right away + // This runs on every render to catch the case where availableCalls loads in + if (isOpen && selectedStatus && (selectedStatus.Detail === 2 || selectedStatus.Detail === 3) && activeCallId && !selectedCall && selectedDestinationType === 'none' && !hasPreselectedRef.current) { + // Check if we have calls available (loaded) or should wait + if (!isLoading && availableCalls.length > 0) { + const activeCall = availableCalls.find((call) => call.CallId === activeCallId); + if (activeCall) { + // Update both states immediately in the same render cycle + setSelectedDestinationType('call'); + setSelectedCall(activeCall); + hasPreselectedRef.current = true; + } + } else if (isLoading || availableCalls.length === 0) { + // If still loading, immediately set destination type to 'call' to prevent "No Destination" from showing + // We'll set the actual call once it loads + setSelectedDestinationType('call'); + hasPreselectedRef.current = true; + } + } + + // Handle case where destination type is already 'call' but call hasn't been set yet + // This covers the scenario from the removed redundant effect + if (isOpen && selectedStatus && (selectedStatus.Detail === 2 || selectedStatus.Detail === 3) && activeCallId && !selectedCall && selectedDestinationType === 'call' && !isLoading && availableCalls.length > 0) { const activeCall = availableCalls.find((call) => call.CallId === activeCallId); if (activeCall) { setSelectedCall(activeCall); - setSelectedDestinationType('call'); } } }, [isOpen, isLoading, selectedStatus, activeCallId, availableCalls, selectedCall, selectedDestinationType, setSelectedCall, setSelectedDestinationType]); + // Smart logic: only show "No Destination" as selected if we truly want no destination + // Don't show it as selected if we're about to pre-select an active call or already have one selected + const shouldShowNoDestinationAsSelected = React.useMemo(() => { + // If something else is already selected, don't show no destination as selected + if (selectedCall || selectedStation) { + return false; + } + + // If we're in a state where we should pre-select an active call, don't show no destination as selected + const shouldPreSelectActiveCall = isOpen && selectedStatus && (selectedStatus.Detail === 2 || selectedStatus.Detail === 3) && activeCallId && !selectedCall; + + if (shouldPreSelectActiveCall) { + return false; + } + + // Otherwise, show it as selected only if explicitly set to 'none' + return selectedDestinationType === 'none'; + }, [selectedDestinationType, selectedCall, selectedStation, isOpen, selectedStatus, activeCallId]); + // Determine step logic const detailLevel = getStatusProperty('Detail', 0); const shouldShowDestinationStep = detailLevel > 0; - const isNoteRequired = getStatusProperty('Note', 0) === 1; - const isNoteOptional = getStatusProperty('Note', 0) === 2; + const noteType = getStatusProperty('Note', 0); + const isNoteRequired = noteType === 2; // NoteType 2 = required + const isNoteOptional = noteType === 1; // NoteType 1 = optional const getStepTitle = () => { switch (currentStep) { @@ -290,7 +354,8 @@ export const StatusBottomSheet = () => { if (selectedStatus) { // We can determine exact steps based on the selected status const hasDestinationSelection = getStatusProperty('Detail', 0) > 0; - const hasNoteStep = getStatusProperty('Note', 0) > 0; + const noteType = getStatusProperty('Note', 0); + const hasNoteStep = noteType > 0; // Show note step for noteType 1 (optional) or 2 (required) if (hasDestinationSelection) totalSteps++; if (hasNoteStep) totalSteps++; @@ -324,6 +389,8 @@ export const StatusBottomSheet = () => { }; const canProceedFromCurrentStep = () => { + if (isSubmitting) return false; // Can't proceed while submitting + switch (currentStep) { case 'select-status': return !!selectedStatus; // Must have a status selected @@ -337,13 +404,32 @@ export const StatusBottomSheet = () => { }; const getSelectedDestinationDisplay = () => { - if (selectedDestinationType === 'call' && selectedCall) { + // First, check if we have a selected call or station regardless of destination type + // This handles cases where the destination type might be temporarily incorrect + if (selectedCall) { return `${selectedCall.Number} - ${selectedCall.Name}`; - } else if (selectedDestinationType === 'station' && selectedStation) { + } + + if (selectedStation) { return selectedStation.Name; - } else { - return t('status.no_destination'); } + + // Then check destination type for other scenarios + if (selectedDestinationType === 'call') { + if (activeCallId) { + // Fallback: if we're supposed to have a call selected but selectedCall is null, + // try to find it in availableCalls + const activeCall = availableCalls.find((call) => call.CallId === activeCallId); + if (activeCall) { + return `${activeCall.Number} - ${activeCall.Name}`; + } else { + // Still loading or call not found, show loading state + return t('calls.loading_calls'); + } + } + } + + return t('status.no_destination'); }; return ( @@ -371,15 +457,17 @@ export const StatusBottomSheet = () => { {t('status.select_status_type')} - + {activeStatuses?.Statuses && activeStatuses.Statuses.length > 0 ? ( activeStatuses.Statuses.map((status) => ( - - - - - - + handleStatusSelect(status.Id.toString())} + className={`mb-3 rounded-lg border-2 p-3 ${selectedStatus?.Id.toString() === status.Id.toString() ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800'}`} + > + + + {status.Text} @@ -392,18 +480,18 @@ export const StatusBottomSheet = () => { )} {status.Note > 0 && ( - {status.Note === 1 && t('status.note_required')} - {status.Note === 2 && t('status.note_optional')} + {status.Note === 1 && t('status.note_optional')} + {status.Note === 2 && t('status.note_required')} )} - - + + )) ) : ( {t('status.no_statuses_available')} )} - + @@ -422,10 +510,10 @@ export const StatusBottomSheet = () => { {/* No Destination Option */} - + {t('status.no_destination')} {t('status.general_status')} @@ -452,7 +540,7 @@ export const StatusBottomSheet = () => { {/* Show calls if detailLevel 2 or 3, and either no tabs or calls tab selected */} {(detailLevel === 2 || (detailLevel === 3 && selectedTab === 'calls')) && ( - + {isLoading ? ( @@ -460,29 +548,31 @@ export const StatusBottomSheet = () => { ) : availableCalls && availableCalls.length > 0 ? ( availableCalls.map((call) => ( - - - - - - + handleCallSelect(call.CallId)} + className={`mb-3 rounded-lg border-2 p-3 ${selectedCall?.CallId === call.CallId ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800'}`} + > + + + {call.Number} - {call.Name} {call.Address} - - + + )) ) : ( {t('calls.no_calls_available')} )} - + )} {/* Show stations if detailLevel 1 or 3, and either no tabs or stations tab selected */} {(detailLevel === 1 || (detailLevel === 3 && selectedTab === 'stations')) && ( - + {isLoading ? ( @@ -490,23 +580,25 @@ export const StatusBottomSheet = () => { ) : availableStations && availableStations.length > 0 ? ( availableStations.map((station) => ( - - - - - - + handleStationSelect(station.GroupId)} + className={`mb-3 rounded-lg border-2 p-3 ${selectedStation?.GroupId === station.GroupId ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800'}`} + > + + + {station.Name} {station.Address && {station.Address}} {station.GroupType && {station.GroupType}} - - + + )) ) : ( {t('status.no_stations_available')} )} - + )} @@ -532,14 +624,24 @@ export const StatusBottomSheet = () => { ) : null} - )} {currentStep === 'add-note' && ( + {/* Selected Status */} + + {t('status.selected_status')}: + + {selectedStatus?.Text} + + + + {/* Selected Destination */} {t('status.selected_destination')}: {getSelectedDestinationDisplay()} @@ -555,12 +657,13 @@ export const StatusBottomSheet = () => { - - diff --git a/src/translations/ar.json b/src/translations/ar.json index fa163ac..61ab610 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -327,6 +327,7 @@ "share": "مشاركة", "step": "خطوة", "submit": "إرسال", + "submitting": "جاري الإرسال...", "tryAgainLater": "يرجى المحاولة مرة أخرى لاحقًا", "unknown": "غير معروف", "upload": "رفع", @@ -589,6 +590,7 @@ "both_destinations_enabled": "يمكن الاستجابة للمكالمات أو المحطات", "call_destination_enabled": "يمكن الاستجابة للمكالمات", "calls_tab": "المكالمات", + "failed_to_save_status": "فشل في حفظ الحالة. يرجى المحاولة مرة أخرى.", "general_status": "حالة عامة بدون وجهة محددة", "loading_stations": "جاري تحميل المحطات...", "no_destination": "بدون وجهة", @@ -602,9 +604,11 @@ "select_status": "اختر الحالة", "select_status_type": "ما الحالة التي تريد تعيينها؟", "selected_destination": "الوجهة المختارة", + "selected_status": "الحالة المختارة", "set_status": "تعيين الحالة", "station_destination_enabled": "يمكن الاستجابة للمحطات", - "stations_tab": "المحطات" + "stations_tab": "المحطات", + "status_saved_successfully": "تم حفظ الحالة بنجاح!" }, "tabs": { "calls": "المكالمات", diff --git a/src/translations/en.json b/src/translations/en.json index bf9d823..24444e6 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -327,6 +327,7 @@ "share": "Share", "step": "Step", "submit": "Submit", + "submitting": "Submitting...", "tryAgainLater": "Please try again later", "unknown": "Unknown", "upload": "Upload", @@ -589,6 +590,7 @@ "both_destinations_enabled": "Can respond to calls or stations", "call_destination_enabled": "Can respond to calls", "calls_tab": "Calls", + "failed_to_save_status": "Failed to save status. Please try again.", "general_status": "General status without specific destination", "loading_stations": "Loading stations...", "no_destination": "No Destination", @@ -602,9 +604,11 @@ "select_status": "Select Status", "select_status_type": "What status would you like to set?", "selected_destination": "Selected Destination", + "selected_status": "Selected Status", "set_status": "Set Status", "station_destination_enabled": "Can respond to stations", - "stations_tab": "Stations" + "stations_tab": "Stations", + "status_saved_successfully": "Status saved successfully!" }, "tabs": { "calls": "Calls", diff --git a/src/translations/es.json b/src/translations/es.json index 1f236ad..87f9c9e 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -327,6 +327,7 @@ "share": "Compartir", "step": "Paso", "submit": "Enviar", + "submitting": "Enviando...", "tryAgainLater": "Por favor, inténtelo de nuevo más tarde", "unknown": "Desconocido", "upload": "Subir", @@ -589,6 +590,7 @@ "both_destinations_enabled": "Puede responder a llamadas o estaciones", "call_destination_enabled": "Puede responder a llamadas", "calls_tab": "Llamadas", + "failed_to_save_status": "Error al guardar el estado. Por favor, inténtelo de nuevo.", "general_status": "Estado general sin destino específico", "loading_stations": "Cargando estaciones...", "no_destination": "Sin Destino", @@ -602,9 +604,11 @@ "select_status": "Seleccionar Estado", "select_status_type": "¿Qué estado te gustaría establecer?", "selected_destination": "Destino Seleccionado", + "selected_status": "Estado Seleccionado", "set_status": "Establecer Estado", "station_destination_enabled": "Puede responder a estaciones", - "stations_tab": "Estaciones" + "stations_tab": "Estaciones", + "status_saved_successfully": "¡Estado guardado exitosamente!" }, "tabs": { "calls": "Llamadas",