Repository navigation
RD-T47 Build fix #150
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
RD-T47 Build fix #150
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| import { fireEvent, render, screen } from '@testing-library/react-native'; | ||
| import React from 'react'; | ||
|
|
||
| import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; | ||
| import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData'; | ||
|
|
||
| import { ResourcesPanel } from '../resources-panel'; | ||
|
|
||
| jest.mock('react-i18next', () => ({ | ||
| useTranslation: () => ({ | ||
| t: (key: string, fallback?: string) => (typeof fallback === 'string' ? fallback : key), | ||
| }), | ||
| })); | ||
|
|
||
| jest.mock('expo-router', () => ({ | ||
| router: { push: jest.fn() }, | ||
| })); | ||
|
|
||
| jest.mock('@/components/ui/icon', () => ({ | ||
| Icon: () => null, | ||
| })); | ||
|
|
||
| jest.mock('../animated-refresh-icon', () => ({ | ||
| AnimatedRefreshIcon: () => null, | ||
| })); | ||
|
|
||
| // Multi-unit selection is not part of this test; a closed selection keeps the rows on their normal press path. | ||
| jest.mock('../unit-multi-select', () => ({ | ||
| useUnitMultiSelect: () => ({ | ||
| isSelectionMode: false, | ||
| selectedUnitIds: [], | ||
| selectedUnitIdSet: new Set<string>(), | ||
| toggleSelectionMode: jest.fn(), | ||
| handleUnitPress: () => false, | ||
| handleUnitLongPress: jest.fn(), | ||
| addUnits: jest.fn(), | ||
| clearSelection: jest.fn(), | ||
| endSelection: jest.fn(), | ||
| }), | ||
| UnitMultiSelectToggle: () => null, | ||
| UnitSelectionBar: () => null, | ||
| UnitSelectionCheckbox: () => null, | ||
| })); | ||
|
|
||
| let mockUnits: UnitInfoResultData[] = []; | ||
| let mockPersonnel: PersonnelInfoResultData[] = []; | ||
|
|
||
| jest.mock('@/stores/units/store', () => ({ | ||
| useUnitsStore: (selector: (state: unknown) => unknown) => selector({ units: mockUnits, isLoading: false, fetchUnits: jest.fn() }), | ||
| })); | ||
|
|
||
| jest.mock('@/stores/personnel/store', () => ({ | ||
| usePersonnelStore: (selector: (state: unknown) => unknown) => selector({ personnel: mockPersonnel, isLoading: false, fetchPersonnel: jest.fn() }), | ||
| })); | ||
|
|
||
| jest.mock('@/stores/dispatch/dashboard-view-store', () => ({ | ||
| useDashboardViewStore: (selector: (state: unknown) => unknown) => selector({ availableOnly: false, collapsedCards: {}, setCardCollapsed: jest.fn() }), | ||
| selectCardCollapsed: () => () => false, | ||
| })); | ||
|
|
||
| const person = (patch: Partial<PersonnelInfoResultData> = {}): PersonnelInfoResultData => | ||
| ({ | ||
| UserId: 'u-jane', | ||
| FirstName: 'Jane', | ||
| LastName: 'Doe', | ||
| GroupName: 'Crisis Call Center', | ||
| Status: 'Available', | ||
| StatusColor: '#16a34a', | ||
| Staffing: 'On Duty', | ||
| StaffingColor: '#2563eb', | ||
| Roles: ['Paramedic', 'Supervisor'], | ||
| ...patch, | ||
| }) as PersonnelInfoResultData; | ||
|
|
||
| const unit = (patch: Partial<UnitInfoResultData> = {}): UnitInfoResultData => | ||
| ({ | ||
| UnitId: 'mct1', | ||
| Name: 'MCT 1', | ||
| Type: 'Mobile Crisis Team', | ||
| GroupName: 'Central Region', | ||
| CurrentStatus: 'Available', | ||
| CurrentStatusColor: '#16a34a', | ||
| Roles: [ | ||
| { RoleId: 'r1', RoleName: 'Clinician', UserId: 'u1', Name: 'Jordan Kim' }, | ||
| { RoleId: 'r2', RoleName: 'Peer', UserId: 'u2', Name: 'Maria Gonzalez' }, | ||
| { RoleId: 'r3', RoleName: 'Driver', UserId: 'u3', Name: 'Sam Okafor' }, | ||
| { RoleId: 'r4', RoleName: 'Trainee', UserId: '', Name: '' }, | ||
| ], | ||
| ...patch, | ||
| }) as UnitInfoResultData; | ||
|
|
||
| describe('ResourcesPanel row details', () => { | ||
| afterEach(() => { | ||
| mockUnits = []; | ||
| mockPersonnel = []; | ||
| }); | ||
|
|
||
| it("shows a person's group, roles, status and staffing", () => { | ||
| mockPersonnel = [person()]; | ||
|
|
||
| render(<ResourcesPanel />); | ||
|
|
||
| expect(screen.getByText('Crisis Call Center')).toBeTruthy(); | ||
| expect(screen.getByTestId('resource-roles-u-jane')).toHaveTextContent('Paramedic, Supervisor'); | ||
| expect(screen.getByText('Available')).toBeTruthy(); | ||
| expect(screen.getByTestId('resource-staffing-u-jane')).toHaveTextContent('On Duty'); | ||
| }); | ||
|
|
||
| it('leaves out the roles line for a person who holds none', () => { | ||
| mockPersonnel = [person({ Roles: [] })]; | ||
|
|
||
| render(<ResourcesPanel />); | ||
|
|
||
| expect(screen.queryByTestId('resource-roles-u-jane')).toBeNull(); | ||
| expect(screen.getByTestId('resource-staffing-u-jane')).toHaveTextContent('On Duty'); | ||
| }); | ||
|
|
||
| it("shows a unit's type with its group, and how many of its role seats are filled followed by who fills them", () => { | ||
| mockUnits = [unit()]; | ||
|
|
||
| render(<ResourcesPanel />); | ||
|
|
||
| expect(screen.getByText('Mobile Crisis Team · Central Region')).toBeTruthy(); | ||
| expect(screen.getByTestId('unit-crew-count-mct1')).toHaveTextContent('3/4'); | ||
| expect(screen.getByTestId('unit-crew-names-mct1')).toHaveTextContent('Jordan Kim, Maria Gonzalez, Sam Okafor'); | ||
| // Staffing is a personnel field; units show their status only | ||
| expect(screen.queryByTestId('resource-staffing-mct1')).toBeNull(); | ||
| }); | ||
|
|
||
| it('shows the count alone when none of the seats is filled, and nothing for a unit with no roles', () => { | ||
| mockUnits = [unit({ UnitId: 'empty', Name: 'FU 1', Roles: [{ RoleId: 'r1', RoleName: 'Clinician', UserId: '', Name: '' }] }), unit({ UnitId: 'none', Name: 'MCT 2', Roles: [] })]; | ||
|
|
||
| render(<ResourcesPanel />); | ||
|
|
||
| expect(screen.getByTestId('unit-crew-count-empty')).toHaveTextContent('0/1'); | ||
| expect(screen.queryByTestId('unit-crew-names-empty')).toBeNull(); | ||
| expect(screen.queryByTestId('unit-crew-none')).toBeNull(); | ||
| }); | ||
|
|
||
| it('finds resources by staffing, role and the people on a unit', () => { | ||
| mockUnits = [unit()]; | ||
| mockPersonnel = [person(), person({ UserId: 'u-alex', FirstName: 'Alex', LastName: 'Rivera', Staffing: 'Off Duty', Roles: ['Dispatcher'] })]; | ||
|
|
||
| render(<ResourcesPanel />); | ||
| const search = screen.getByPlaceholderText('dispatch.search_resources_placeholder'); | ||
|
|
||
| fireEvent.changeText(search, 'off duty'); | ||
| expect(screen.getByText('Alex Rivera')).toBeTruthy(); | ||
| expect(screen.queryByText('Jane Doe')).toBeNull(); | ||
| expect(screen.queryByText('MCT 1')).toBeNull(); | ||
|
|
||
| fireEvent.changeText(search, 'supervisor'); | ||
| expect(screen.getByText('Jane Doe')).toBeTruthy(); | ||
| expect(screen.queryByText('Alex Rivera')).toBeNull(); | ||
|
|
||
| fireEvent.changeText(search, 'maria'); | ||
| expect(screen.getByText('MCT 1')).toBeTruthy(); | ||
| expect(screen.queryByText('Jane Doe')).toBeNull(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| import { fireEvent, render, screen } from '@testing-library/react-native'; | ||
| import React from 'react'; | ||
|
|
||
| import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData'; | ||
|
|
||
| import { UnitsPanel } from '../units-panel'; | ||
|
|
||
| jest.mock('react-i18next', () => ({ | ||
| useTranslation: () => ({ | ||
| t: (key: string, fallback?: string) => (typeof fallback === 'string' ? fallback : key), | ||
| }), | ||
| })); | ||
|
|
||
| jest.mock('expo-router', () => ({ | ||
| router: { push: jest.fn() }, | ||
| })); | ||
|
|
||
| jest.mock('@/components/ui/icon', () => ({ | ||
| Icon: () => null, | ||
| })); | ||
|
|
||
| jest.mock('../animated-refresh-icon', () => ({ | ||
| AnimatedRefreshIcon: () => null, | ||
| })); | ||
|
|
||
| jest.mock('../resources-panel', () => ({ | ||
| ResourcesPanel: () => null, | ||
| })); | ||
|
|
||
| const units = [ | ||
| { | ||
| UnitId: 'e1', | ||
| Name: 'Engine 1', | ||
| Type: 'Engine', | ||
| GroupName: 'Station 1', | ||
| CurrentStatus: 'Available', | ||
| CurrentStatusId: '0', | ||
| CurrentDestinationId: '', | ||
| Roles: [ | ||
| { RoleId: 'r1', RoleName: 'Officer', UserId: 'u1', Name: 'Taylor Brooks' }, | ||
| { RoleId: 'r2', RoleName: 'Driver', UserId: 'u2', Name: 'Priya Natarajan' }, | ||
| { RoleId: 'r3', RoleName: 'Firefighter', UserId: '', Name: '' }, | ||
| ], | ||
| }, | ||
| { UnitId: 'b1', Name: 'Boat 1', Type: '', GroupName: 'Station 2', CurrentStatus: 'Available', CurrentStatusId: '0', CurrentDestinationId: '', Roles: [] }, | ||
| ] as unknown as UnitInfoResultData[]; | ||
|
|
||
| describe('UnitsPanel unit type and crew', () => { | ||
| it("shows the unit's type with its group, and its filled role seats followed by the people on them", () => { | ||
| render(<UnitsPanel units={units} isLoading={false} onRefresh={jest.fn()} />); | ||
|
|
||
| expect(screen.getByText('Engine · Station 1')).toBeTruthy(); | ||
| expect(screen.getByTestId('unit-crew-count-e1')).toHaveTextContent('2/3'); | ||
| expect(screen.getByTestId('unit-crew-names-e1')).toHaveTextContent('Taylor Brooks, Priya Natarajan'); | ||
| // No type and no roles: the group alone, and no crew line | ||
| expect(screen.getByText('Station 2')).toBeTruthy(); | ||
| expect(screen.queryByTestId('unit-crew-b1')).toBeNull(); | ||
| }); | ||
|
|
||
| it('finds a unit by a person on it', () => { | ||
| render(<UnitsPanel units={units} isLoading={false} onRefresh={jest.fn()} />); | ||
|
|
||
| fireEvent.changeText(screen.getByPlaceholderText('dispatch.search_units_placeholder'), 'priya'); | ||
|
|
||
| expect(screen.getByText('Engine 1')).toBeTruthy(); | ||
| expect(screen.queryByText('Boat 1')).toBeNull(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,7 @@ import { useReadableColor } from '@/hooks/use-readable-color'; | |
| import { isPersonnelDispatch, isUnitDispatch } from '@/lib/dispatch-types'; | ||
| import { contentOnFill } from '@/lib/readable-color'; | ||
| import { isPersonnelAvailable, isUnitAvailable } from '@/lib/resource-availability'; | ||
| import { getUnitSubtitle } from '@/lib/unit-crew'; | ||
| import { type DispatchedEventResultData } from '@/models/v4/calls/dispatchedEventResultData'; | ||
| import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; | ||
| import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData'; | ||
|
|
@@ -25,6 +26,7 @@ import { useUnitsStore } from '@/stores/units/store'; | |
|
|
||
| import { AnimatedRefreshIcon } from './animated-refresh-icon'; | ||
| import { PanelHeader } from './panel-header'; | ||
| import { UnitCrewLine } from './unit-crew-line'; | ||
| import { UnitMultiSelectToggle, UnitSelectionBar, UnitSelectionCheckbox, useUnitMultiSelect } from './unit-multi-select'; | ||
|
|
||
| interface ResourcesPanelProps { | ||
|
|
@@ -48,6 +50,12 @@ type ResourceRow = { | |
| subtitle: string; | ||
| status: string; | ||
| statusColor: string; | ||
| // Personnel only: staffing level and the roles they hold | ||
| staffing?: string; | ||
| staffingColor?: string; | ||
| roles?: string; | ||
| /** Text the search matches beyond name, subtitle and status: staffing and roles for a person, the crew for a unit. */ | ||
| searchText: string; | ||
| available: boolean; | ||
| isOnCall: boolean; | ||
| href: string; | ||
|
|
@@ -149,16 +157,18 @@ const ResourcesPanelComponent: React.FC<ResourcesPanelProps> = ({ | |
| kind: 'unit', | ||
| entityId: u.UnitId, | ||
| name: u.Name, | ||
| subtitle: u.Type || u.GroupName || t('dispatch.unassigned'), | ||
| subtitle: getUnitSubtitle(u, t('dispatch.unassigned')), | ||
| status: u.CurrentStatus || t('dispatch.available'), | ||
| statusColor: u.CurrentStatusColor || '#22c55e', | ||
| searchText: (u.Roles ?? []).map((r) => `${r.RoleName ?? ''} ${r.Name ?? ''}`).join(' '), | ||
| available: isUnitAvailable(u), | ||
| isOnCall: dispatchedUnitNames.has(u.Name.toLowerCase()) || Boolean(selectedCallId && u.CurrentDestinationId === selectedCallId), | ||
| href: `/units/${u.UnitId}`, | ||
| unit: u, | ||
| })); | ||
| const personnelRows: ResourceRow[] = personnelSource.map((p) => { | ||
| const fullName = `${p.FirstName} ${p.LastName}`; | ||
| const roles = (p.Roles ?? []).filter(Boolean).join(', '); | ||
| return { | ||
| id: `person-${p.UserId}`, | ||
| kind: 'personnel', | ||
|
|
@@ -167,6 +177,10 @@ const ResourcesPanelComponent: React.FC<ResourcesPanelProps> = ({ | |
| subtitle: p.GroupName || t('dispatch.unassigned'), | ||
| status: p.Status || t('dispatch.unknown'), | ||
| statusColor: p.StatusColor || '#6b7280', | ||
| staffing: p.Staffing || t('dispatch.unknown'), | ||
| staffingColor: p.StaffingColor || '#6b7280', | ||
| roles, | ||
| searchText: `${p.Staffing ?? ''} ${roles}`, | ||
| available: isPersonnelAvailable(p), | ||
| isOnCall: dispatchedPersonnelIds.has(p.UserId) || dispatchedPersonnelNames.has(fullName.toLowerCase()) || Boolean(selectedCallId && p.StatusDestinationId === selectedCallId), | ||
| href: `/personnel/${p.UserId}`, | ||
|
|
@@ -180,7 +194,7 @@ const ResourcesPanelComponent: React.FC<ResourcesPanelProps> = ({ | |
| } | ||
| const query = searchQuery.trim().toLowerCase(); | ||
| if (query) { | ||
| all = all.filter((r) => r.name.toLowerCase().includes(query) || r.subtitle.toLowerCase().includes(query) || r.status.toLowerCase().includes(query)); | ||
| all = all.filter((r) => r.name.toLowerCase().includes(query) || r.subtitle.toLowerCase().includes(query) || r.status.toLowerCase().includes(query) || r.searchText.toLowerCase().includes(query)); | ||
| } | ||
| return all; | ||
| }, [units, personnel, availableOnly, searchQuery, isCallFilterActive, callDispatches, selectedCallId, dispatchedUnitNames, dispatchedPersonnelIds, dispatchedPersonnelNames, t]); | ||
|
|
@@ -370,22 +384,38 @@ const ResourcesPanelComponent: React.FC<ResourcesPanelProps> = ({ | |
| <Text className="text-xs text-gray-500 dark:text-gray-400" numberOfLines={1}> | ||
| {r.subtitle} | ||
| </Text> | ||
| {r.kind === 'unit' ? <UnitCrewLine unit={r.unit} /> : null} | ||
| {r.roles ? ( | ||
| <Text className="text-xs text-indigo-500 dark:text-indigo-400" numberOfLines={1} testID={`resource-roles-${r.entityId}`}> | ||
| {r.roles} | ||
| </Text> | ||
| ) : null} | ||
| </VStack> | ||
| </HStack> | ||
| <HStack className="items-center" space="xs"> | ||
| <Pressable onPress={detailPressHandlers.get(r.id)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} style={styles.detailsButton}> | ||
| <ExternalLink size={12} color="#6b7280" /> | ||
| </Pressable> | ||
| <Circle size={8} fill={statusMark} color={statusMark} /> | ||
| <Text style={{ color: statusText }} className="text-xs font-medium" numberOfLines={1}> | ||
| {r.status} | ||
| </Text> | ||
| {onSetStatus ? ( | ||
| <Pressable onPress={onSetStatus} style={styles.statusButton}> | ||
| <Icon as={Plus} size="xs" className="text-indigo-500" /> | ||
| <VStack className="items-end" space="xs"> | ||
| <HStack className="items-center" space="xs"> | ||
| <Pressable onPress={detailPressHandlers.get(r.id)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} style={styles.detailsButton}> | ||
| <ExternalLink size={12} color="#6b7280" /> | ||
| </Pressable> | ||
| <Circle size={8} fill={statusMark} color={statusMark} /> | ||
| <Text style={{ color: statusText }} className="text-xs font-medium" numberOfLines={1}> | ||
| {r.status} | ||
| </Text> | ||
| {onSetStatus ? ( | ||
| <Pressable onPress={onSetStatus} style={styles.statusButton}> | ||
| <Icon as={Plus} size="xs" className="text-indigo-500" /> | ||
| </Pressable> | ||
| ) : null} | ||
| </HStack> | ||
| {r.staffing ? ( | ||
| <HStack className="items-center" space="xs" testID={`resource-staffing-${r.entityId}`}> | ||
| <Circle size={6} fill={readable.mark(r.staffingColor)} color={readable.mark(r.staffingColor)} /> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Null handling is missing in src/components/dispatch-console/resources-panel.tsx and src/lib/unit-crew.ts:22, where ResourceRow.staffingColor may be absent and readable.mark receives undefined. Provide a '#6b7280' fallback before calling readable.mark. Kody rule violation: Add null checks before accessing properties <Circle size={6} fill={readable.mark(r.staffingColor ?? '#6b7280')} color={readable.mark(r.staffingColor ?? '#6b7280')} />Prompt for LLMTalk to Kody by mentioning @kody Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction. |
||
| <Text className="text-xs text-gray-500 dark:text-gray-400" numberOfLines={1}> | ||
| {r.staffing} | ||
| </Text> | ||
| </HStack> | ||
| ) : null} | ||
| </HStack> | ||
| </VStack> | ||
| </HStack> | ||
| </Box> | ||
| </Pressable> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Index the staffing label that the row displays.
When
p.Staffingis empty and the status is known, the row displayst('dispatch.unknown'). ItssearchTextomits that label. A search for the displayed staffing value then hides the row. Reuse the staffing fallback insearchText.🤖 Prompt for AI Agents