Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/react-native-cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,16 @@ jobs:
node-version: '24'
cache: 'yarn'

# Pin the JDK instead of taking the runner default: ubuntu-latest (26.04) defaults to JDK 25,
# where AGP's prefab step emits "A restricted method in java.lang.System has been called" and
# fails configureCMake (react-native-worklets).
- name: ☕ Setup Java
if: matrix.platform == 'android'
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'

- name: Setup Expo
uses: expo/expo-github-action@v8
with:
Expand Down
16 changes: 16 additions & 0 deletions src/app/(app)/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,18 @@ export default function CommandBoard() {
[units, users, boards, activeBoardCallId]
);

// Unit type icon for a lane resource — department units only (ad-hoc units have no department type).
const resolveResourceIcon = useCallback(
(kind: number, resourceId: string) => {
if (kind !== ResourceAssignmentKind.RealUnit && kind !== ResourceAssignmentKind.LinkedDeptUnit) {
return null;
}
const unit = units.find((u) => u.UnitId === resourceId);
return unit ? { iconUrl: unit.TypeIconUrl, mapIcon: unit.TypeMapIcon } : null;
},
[units]
);

const resourceOptions = useMemo<AssignableResourceOption[]>(() => {
const entry = activeBoardCallId ? boards[activeBoardCallId] : undefined;
const activeAssignments = (entry?.board?.Assignments ?? []).filter((a) => !a.ReleasedOn);
Expand All @@ -427,6 +439,8 @@ export default function CommandBoard() {
name: u.Name,
detail: [u.Type, u.GroupName].filter(Boolean).join(' • '),
statusLabel: unitCurrentStatuses.find((s) => s.UnitId === u.UnitId)?.State,
typeIconUrl: u.TypeIconUrl,
typeMapIcon: u.TypeMapIcon,
assignedNodeId: assignedNodeOf(ResourceAssignmentKind.RealUnit, u.UnitId),
}));
const personnelOptions = users.map((u) => ({
Expand Down Expand Up @@ -853,6 +867,7 @@ export default function CommandBoard() {
onOpenLaneChat={handleOpenLaneChat}
onMoveResource={handleMoveResource}
onViewResource={(assignment) => setViewResource({ assignment, context: 'lane' })}
resolveResourceIcon={resolveResourceIcon}
resolveResourceName={resolveResourceName}
viewportHeight={viewportHeight}
viewportWidth={viewportWidth}
Expand All @@ -868,6 +883,7 @@ export default function CommandBoard() {
onMoveResource={handleMoveResource}
onViewResource={(assignment) => setViewResource({ assignment, context: 'lane' })}
resolveLeadName={resolveLeadName}
resolveResourceIcon={resolveResourceIcon}
resolveResourceName={resolveResourceName}
/>
)}
Expand Down
2 changes: 2 additions & 0 deletions src/app/(app)/poi/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Heading } from '@/components/ui/heading';
import { HStack } from '@/components/ui/hstack';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { TypeIcon } from '@/components/units/unit-type-icon';
import { openMapsWithDirections } from '@/lib/navigation';
import { createPoiTypeMap, getPoiDisplayName, getPoiSelectionLabel, getPoiTypeName, isPoiDestinationEnabled } from '@/lib/poi-utils';
import { useLocationStore } from '@/stores/app/location-store';
Expand Down Expand Up @@ -83,6 +84,7 @@ export default function PoiDetailScreen() {
<VStack space="sm">
<Heading size="xl">{displayName}</Heading>
<HStack className="items-center gap-2">
<TypeIcon iconUrl={poi.IconUrl ?? poiTypesById[poi.PoiTypeId]?.IconUrl} size={24} testID="poi-detail-type-icon" />
<Badge action="muted" variant="outline">
<BadgeText>{poiTypeName}</BadgeText>
</Badge>
Expand Down
29 changes: 18 additions & 11 deletions src/app/call/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { SharedTabs, type TabItem } from '@/components/ui/shared-tabs';
import { Spinner } from '@/components/ui/spinner';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { TypeIcon, UnitTypeIcon } from '@/components/units/unit-type-icon';
import { useAnalytics } from '@/hooks/use-analytics';
import { isFieldRedacted, ProtectedFieldIds } from '@/lib/data-protection/redacted';
import { logger } from '@/lib/logging';
Expand Down Expand Up @@ -474,7 +475,10 @@ export default function CallDetail() {
<VStack className="space-y-3">
{callExtraData.Dispatches.map((dispatched, index) => (
<Box key={index} className="rounded-lg bg-gray-50 p-3 dark:bg-gray-700">
<Text className="font-semibold">{dispatched.Name}</Text>
<HStack className="items-center" space="sm">
<UnitTypeIcon iconUrl={dispatched.TypeIconUrl} mapIcon={dispatched.TypeMapIcon} size={24} testID={`dispatched-type-icon-${index}`} />
<Text className="flex-1 font-semibold">{dispatched.Name}</Text>
</HStack>
<HStack className="mt-1">
<Text className="mr-2 text-sm text-gray-600 dark:text-gray-400">
{t('call_detail.group')}: {dispatched.Group}
Expand Down Expand Up @@ -593,16 +597,19 @@ export default function CallDetail() {
{/* Header */}
<Box className="mx-4 mt-3 rounded-xl bg-white p-4 shadow-xs dark:bg-gray-800">
<HStack className="mb-2 items-center justify-between">
<Heading size="md">
{/* The call NUMBER is not cataloged, so it stays visible and the record stays findable. */}
{isFieldRedacted(call.RedactedFields, ProtectedFieldIds.callName, call.Name) ? (
<ProtectedText value={call.Name} fieldId={ProtectedFieldIds.callName} redactedFields={call.RedactedFields} />
) : (
<>
{call.Name} ({call.Number})
</>
)}
</Heading>
<HStack className="min-w-0 flex-1 items-center" space="sm">
<TypeIcon iconUrl={call.TypeIconUrl} mapIcon={call.TypeMapIcon} size={28} testID="call-detail-type-icon" />
<Heading size="md" className="shrink">
{/* The call NUMBER is not cataloged, so it stays visible and the record stays findable. */}
{isFieldRedacted(call.RedactedFields, ProtectedFieldIds.callName, call.Name) ? (
<ProtectedText value={call.Name} fieldId={ProtectedFieldIds.callName} redactedFields={call.RedactedFields} />
) : (
<>
{call.Name} ({call.Number})
</>
)}
</Heading>
</HStack>
{/* Start Command opens (or creates) this call's IC board — multiple boards may exist */}
<HStack space="sm" className="items-center">
{hasCommandBoard && activeBoardCallId === call.CallId ? (
Expand Down
5 changes: 5 additions & 0 deletions src/app/call/__tests__/[id].security.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ jest.mock('expo-clipboard', () => ({
setStringAsync: jest.fn(),
}));

// Dispatched rows show the unit type icon (expo-image); the native module can't load under the react-native mock.
jest.mock('expo-image', () => ({
Image: () => null,
}));

// Mock storage
jest.mock('@/lib/storage', () => ({
getItem: jest.fn(),
Expand Down
5 changes: 5 additions & 0 deletions src/app/call/__tests__/[id].test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ jest.mock('expo-clipboard', () => ({
setStringAsync: jest.fn(),
}));

// Dispatched rows show the unit type icon (expo-image); the native module can't load under the react-native mock.
jest.mock('expo-image', () => ({
Image: () => null,
}));

jest.mock('expo-constants', () => ({
expoConfig: {
extra: {
Expand Down
67 changes: 67 additions & 0 deletions src/components/calls/__tests__/call-card-type-icon.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { render, screen } from '@testing-library/react-native';
import React from 'react';

jest.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));

jest.mock('lucide-react-native', () => {
const React = require('react');
const icon = (name: string) => (props: any) => React.createElement('View', { ...props, testID: `mock-${name}-icon` });
return {
AlertTriangle: icon('alert-triangle'),
ClipboardList: icon('clipboard-list'),
MapPin: icon('map-pin'),
Phone: icon('phone'),
Timer: icon('timer'),
};
});

jest.mock('expo-image', () => {
const React = require('react');
return { Image: (props: any) => React.createElement('View', props) };
});

jest.mock('@/components/ui/html-renderer', () => ({
HtmlRenderer: () => null,
}));

import { MAP_ICONS } from '@/constants/map-icons';
import { type CallResultData } from '@/models/v4/calls/callResultData';

import { CallCard } from '../call-card';

const baseCall = {
CallId: '42',
Number: '2026-42',
Name: 'Structure fire',
Nature: '',
Address: '1 Main St',
Priority: 0,
LoggedOnUtc: '2026-10-21T10:00:00Z',
} as CallResultData;

const getTypeIcon = () => screen.getByTestId('call-card-type-icon-42', { includeHiddenElements: true });

describe('CallCard call type icon', () => {
it('shows the uploaded call-type icon in place of the alert glyph', () => {
const iconUrl = 'https://api.resgrid.dev/api/v4/TypeIcons/fire-key';
render(<CallCard priority={undefined} call={{ ...baseCall, TypeIconUrl: iconUrl, TypeMapIcon: 'fire' } as CallResultData} />);

expect(getTypeIcon().props.source).toEqual({ uri: iconUrl });
expect(screen.queryByTestId('mock-alert-triangle-icon')).toBeNull();
});

it('uses the built-in call icon the type picked when nothing was uploaded', () => {
render(<CallCard priority={undefined} call={{ ...baseCall, TypeIconUrl: null, TypeMapIcon: 'caraccident' } as CallResultData} />);

expect(getTypeIcon().props.source).toBe(MAP_ICONS.caraccident.uri);
});

it('keeps the alert glyph when older servers omit the type icon fields', () => {
render(<CallCard priority={undefined} call={baseCall} />);

expect(screen.queryByTestId('call-card-type-icon-42', { includeHiddenElements: true })).toBeNull();
expect(screen.getByTestId('mock-alert-triangle-icon')).toBeTruthy();
});
});
18 changes: 14 additions & 4 deletions src/components/calls/call-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { HtmlRenderer } from '@/components/ui/html-renderer';
import { Icon } from '@/components/ui/icon';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { TypeIcon, UnitTypeIcon } from '@/components/units/unit-type-icon';
import { getTimeAgoUtc, readableColors } from '@/lib/utils';
import { type CallPriorityResultData } from '@/models/v4/callPriorities/callPriorityResultData';
import type { CallResultData } from '@/models/v4/calls/callResultData';
Expand Down Expand Up @@ -69,7 +70,7 @@ export const CallCard: React.FC<CallCardProps> = ({ call, priority, showTimerIco
{/* Header with Call Number and Priority */}
<HStack className="mb-4 items-center justify-between">
<HStack className="items-center space-x-2">
<AlertTriangle size={20} color={textColor} />
<TypeIcon fallback={<AlertTriangle size={20} color={textColor} />} iconUrl={call.TypeIconUrl} mapIcon={call.TypeMapIcon} size={24} testID={`call-card-type-icon-${call.CallId}`} />
<Text
style={{
color: textColor,
Expand Down Expand Up @@ -166,9 +167,18 @@ export const CallCard: React.FC<CallCardProps> = ({ call, priority, showTimerIco
return (
<HStack key={`${d.Id || 'noid'}-${d.Name}-${index}`} style={{ backgroundColor: 'rgba(255,255,255,0.25)', borderRadius: 6, overflow: 'hidden' }} className="items-center">
<Box style={{ backgroundColor: typeBgColor, minWidth: 24, height: 24 }} className="items-center justify-center">
<Text style={{ color: '#fff' }} className="text-xs font-bold">
{typeLetter}
</Text>
{/* Units show their type icon when the department gave the type one; the letter stays the fallback. */}
<UnitTypeIcon
fallback={
<Text style={{ color: '#fff' }} className="text-xs font-bold">
{typeLetter}
</Text>
}
iconUrl={typeLetter === 'U' ? d.TypeIconUrl : null}
mapIcon={typeLetter === 'U' ? d.TypeMapIcon : null}
size={20}
testID={`dispatch-chip-type-icon-${index}`}
/>
</Box>
<Text style={{ color: textColor, paddingHorizontal: 6 }} className="text-xs font-medium" numberOfLines={1}>
{d.Name}
Expand Down
7 changes: 6 additions & 1 deletion src/components/calls/destination-poi-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import { type PoiResultData, type PoiTypeResultData } from '@/models/v4/mapping/
import { CustomBottomSheet } from '../ui/bottom-sheet';
import { Box } from '../ui/box';
import { Button, ButtonText } from '../ui/button';
import { HStack } from '../ui/hstack';
import { Text } from '../ui/text';
import { VStack } from '../ui/vstack';
import { TypeIcon } from '../units/unit-type-icon';

interface DestinationPoiSelectorProps {
destinationPois: PoiResultData[];
Expand Down Expand Up @@ -61,7 +63,10 @@ export const DestinationPoiSelector: React.FC<DestinationPoiSelectorProps> = ({
{groupedDestinationPois.length > 0 ? (
groupedDestinationPois.map((group) => (
<VStack key={group.poiTypeId} space="sm" className="mb-4">
<Text className="px-1 text-xs font-semibold uppercase text-gray-500">{group.title}</Text>
<HStack className="items-center px-1" space="xs">
<TypeIcon iconUrl={poiTypesById[group.poiTypeId]?.IconUrl ?? group.items[0]?.IconUrl} size={16} testID={`destination-poi-type-icon-${group.poiTypeId}`} />
<Text className="text-xs font-semibold uppercase text-gray-500">{group.title}</Text>
</HStack>
{group.items.map((poi) => (
<Button key={poi.PoiId} variant={selectedPoiId === poi.PoiId ? 'solid' : 'outline'} className="w-full justify-start" onPress={() => handleSelect(poi.PoiId)}>
<ButtonText className="flex-1 text-left" numberOfLines={2}>
Expand Down
8 changes: 5 additions & 3 deletions src/components/calls/dispatch-selection-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Input, InputField } from '@/components/ui/input';
import { CheckIcon, SearchIcon, UsersIcon, X } from '@/components/ui/lucide-icons';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { UnitTypeIcon } from '@/components/units/unit-type-icon';
import { type DispatchSelection, useDispatchStore } from '@/stores/dispatch/store';
import { TermKeys, useTerm } from '@/stores/terminology/store';

Expand Down Expand Up @@ -193,9 +194,10 @@ export const DispatchSelectionModal: React.FC<DispatchSelectionModalProps> = ({
<Box className={`size-5 items-center justify-center rounded border-2 ${selection.units.includes(unit.Id) ? 'border-blue-500 bg-blue-500' : 'border-gray-300 dark:border-gray-600'}`}>
{selection.units.includes(unit.Id) ? <CheckIcon size={12} color="#ffffff" /> : null}
</Box>
<VStack className="flex-1">
<Text className="pl-4 font-medium">{unit.Name}</Text>
</VStack>
<HStack className="flex-1 items-center pl-4" space="sm">
<UnitTypeIcon iconUrl={unit.TypeIconUrl} mapIcon={unit.TypeMapIcon} size={24} testID={`dispatch-unit-type-icon-${unit.Id}`} />
<Text className="flex-1 font-medium">{unit.Name}</Text>
</HStack>
</HStack>
</TouchableOpacity>
</Card>
Expand Down
33 changes: 32 additions & 1 deletion src/components/command/__tests__/incident-map-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,17 @@ jest.mock('@/components/maps/mapbox', () => {
};
});

// Last props MapPins rendered with, so a test can check what reached the pins (not just which ids).
const mockMapPinsProps: { current: { pins: Record<string, unknown>[]; commandOverlay?: unknown } | null } = { current: null };
jest.mock('@/components/maps/map-pins', () => {
const React = require('react');
const { View } = require('react-native');
return {
__esModule: true,
default: ({ pins }: { pins: { Id: string }[] }) => React.createElement(View, { testID: 'mock-map-pins', accessibilityLabel: pins.map((p) => p.Id).join(',') }),
default: (props: { pins: { Id: string }[]; commandOverlay?: unknown }) => {
mockMapPinsProps.current = props;
return React.createElement(View, { testID: 'mock-map-pins', accessibilityLabel: props.pins.map((p) => p.Id).join(',') });
},
};
});

Expand Down Expand Up @@ -149,4 +154,30 @@ describe('IncidentMapCard', () => {

unmount();
});

it('keeps the uploaded icons on the pins it narrows to this incident (personnel role icon included)', async () => {
const roleIconUrl = 'https://api.resgrid.dev/api/v4/PersonnelRoles/Icon/medic.png';
const unitIconUrl = 'https://api.resgrid.dev/api/v4/UnitTypes/Icon/engine.png';
mockOverlay['u5'] = { callId: '101' };
mockOverlay['pabc-123'] = { callId: '101' };
mockGetMapDataAndMarkers.mockResolvedValue({
Data: {
MapMakerInfos: [
{ Id: 'u5', Type: 1, Latitude: 34, Longitude: -118, Title: 'Engine 5', ImagePath: 'Engine_Responding', IconUrl: unitIconUrl },
{ Id: 'pabc-123', Type: 3, Latitude: 34.1, Longitude: -118.1, Title: 'Alex Reed', ImagePath: 'Person_RespondingCall', IconUrl: roleIconUrl },
{ Id: 'pdef-456', Type: 3, Latitude: 34.2, Longitude: -118.2, Title: 'No Icon', ImagePath: 'Person_RespondingCall', IconUrl: null },
],
},
});

const { getByTestId, unmount } = render(<IncidentMapCard callId="101" command={command()} annotations={[]} />);

await waitFor(() => expect(getByTestId('mock-map-pins').props.accessibilityLabel).toBe('u5,pabc-123'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unhandled asynchronous assertion failures from waitFor lack operation context when the incident map pin assertion rejects. Wrap the awaited waitFor call in try/catch and throw a new Error with the message "Failed to verify incident map pins" and the original error as its cause.

Kody rule violation: Handle async operations with proper error handling

try {
  await waitFor(() => expect(getByTestId('mock-map-pins').props.accessibilityLabel).toBe('u5,pabc-123'));
} catch (error) {
  throw new Error('Failed to verify incident map pins', { cause: error });
}
Prompt for LLM

File src/components/command/__tests__/incident-map-card.test.tsx:

Line 175:

Unhandled asynchronous assertion failures from waitFor lack operation context when the incident map pin assertion rejects. Wrap the awaited waitFor call in try/catch and throw a new Error with the message "Failed to verify incident map pins" and the original error as its cause.

Suggested Code:

try {
  await waitFor(() => expect(getByTestId('mock-map-pins').props.accessibilityLabel).toBe('u5,pabc-123'));
} catch (error) {
  throw new Error('Failed to verify incident map pins', { cause: error });
}

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​


expect(mockMapPinsProps.current?.pins).toEqual([expect.objectContaining({ Id: 'u5', IconUrl: unitIconUrl }), expect.objectContaining({ Id: 'pabc-123', Type: 3, IconUrl: roleIconUrl, ImagePath: 'Person_RespondingCall' })]);
// The overlay rides alongside, keyed by marker id, so the lane label/ring is added without rebuilding the pin.
expect(mockMapPinsProps.current?.commandOverlay).toBe(mockOverlay);

unmount();
});
});
Loading
Loading