Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
11 changes: 11 additions & 0 deletions src/api/config/terminology.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { type GetTerminologyResult } from '@/models/v4/configs/getTerminologyResult';

import { createApiEndpoint } from '../common/client';

const getTerminologyApi = createApiEndpoint('/Config/GetTerminology');

/** The department's own names for modules and built-in record types (setting 118). */
export const getTerminology = async () => {
const response = await getTerminologyApi.get<GetTerminologyResult>();
return response.data;
};
18 changes: 18 additions & 0 deletions src/app/(app)/__tests__/init-hub-connect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ jest.mock('@/stores/feature-flags/store', () => ({
featureFlagsStore: { getState: () => ({ fetchFlags: jest.fn().mockResolvedValue(undefined), isEnabled: mockIsChatEnabled }) },
}));

const mockFetchTerminology = jest.fn().mockResolvedValue(undefined);
jest.mock('@/stores/terminology/store', () => ({
terminologyStore: { getState: () => ({ fetchTerminology: mockFetchTerminology }) },
}));

const mockConnectUpdateHub = jest.fn().mockResolvedValue(undefined);
const mockConnectGeolocationHub = jest.fn().mockResolvedValue(undefined);
const mockConnectChatHub = jest.fn().mockResolvedValue(undefined);
Expand All @@ -148,6 +153,7 @@ describe('app initialization connects the SignalR update hub', () => {
mockConnectUpdateHub.mockResolvedValue(undefined);
mockConnectGeolocationHub.mockResolvedValue(undefined);
mockConnectChatHub.mockResolvedValue(undefined);
mockFetchTerminology.mockResolvedValue(undefined);
mockUseSignalRLifecycle.mockImplementation(({ hasInitialized }) => {
lifecycleCalls.push({ hasInitialized, updateConnectsSoFar: mockConnectUpdateHub.mock.calls.length });
return undefined as unknown as ReturnType<typeof useSignalRLifecycle>;
Expand All @@ -162,6 +168,17 @@ describe('app initialization connects the SignalR update hub', () => {
});
});

it('fetches the department terminology after rights and before the hubs connect', async () => {
render(<TabLayout />);

await waitFor(() => {
expect(mockConnectUpdateHub).toHaveBeenCalledTimes(1);
});
expect(mockFetchTerminology).toHaveBeenCalledTimes(1);
expect(mockSecurityState.getRights.mock.invocationCallOrder[0]).toBeLessThan(mockFetchTerminology.mock.invocationCallOrder[0]);
expect(mockFetchTerminology.mock.invocationCallOrder[0]).toBeLessThan(mockConnectUpdateHub.mock.invocationCallOrder[0]);
});

it('connects the update hub before arming useSignalRLifecycle, so the two cannot both connect', async () => {
render(<TabLayout />);

Expand Down Expand Up @@ -221,5 +238,6 @@ describe('app initialization connects the SignalR update hub', () => {
expect(mockConnectUpdateHub).not.toHaveBeenCalled();
expect(mockConnectGeolocationHub).not.toHaveBeenCalled();
expect(mockConnectChatHub).not.toHaveBeenCalled();
expect(mockFetchTerminology).not.toHaveBeenCalled();
});
});
2 changes: 2 additions & 0 deletions src/app/(app)/__tests__/map.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,9 @@ jest.mock('@/lib/env', () => ({

jest.mock('@/lib/logging', () => ({
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));
Expand Down
11 changes: 8 additions & 3 deletions src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { usePersonnelStore } from '@/stores/personnel/store';
import { useRolesStore } from '@/stores/roles/store';
import { securityStore } from '@/stores/security/store';
import { useSignalRStore } from '@/stores/signalr/signalr-store';
import { terminologyStore } from '@/stores/terminology/store';
import { useWeatherAlertsStore } from '@/stores/weather-alerts/weather-alerts-store';

// Sets the SDK token now (stored server token, else the built-in one) and again whenever it changes.
Expand Down Expand Up @@ -142,9 +143,10 @@ export default function TabLayout() {
//await usePersonnelStore.getState().init();
if (!isCurrentRun()) return;

// Feature flags must follow rights: the identity key that decides whether persisted
// flags belong to this account reads securityStore.rights.DepartmentId.
await Promise.all([featureFlagsStore.getState().fetchFlags(), dataProtectionStore.getState().fetchCapabilities()]);
// Feature flags and terminology must follow rights: the identity key that decides whether
// persisted flags or names belong to this account reads securityStore.rights.DepartmentId.
// Terminology only relabels screens, and its fetch never rejects.
await Promise.all([featureFlagsStore.getState().fetchFlags(), dataProtectionStore.getState().fetchCapabilities(), terminologyStore.getState().fetchTerminology()]);
if (!isCurrentRun()) return;

// Realtime feeds. Every one of them has to follow the Promise.all above: opening a hub reads
Expand Down Expand Up @@ -477,6 +479,9 @@ export default function TabLayout() {
return (
<NovuProvider
subscriberId={isNovuConfigured ? `${rights?.DepartmentCode}_User_${userId}` : ''}
// Issued by the server for this user's own subscriber; Novu refuses the session without it once
// HMAC is enforced. Absent from older servers, which is fine until then.
subscriberHash={isNovuConfigured ? (rights?.NovuSubscriberHash ?? undefined) : undefined}
applicationIdentifier={config?.NovuApplicationId ?? ''}
backendUrl={config?.NovuBackendApiUrl ?? ''}
socketUrl={config?.NovuSocketUrl ?? ''}
Expand Down
4 changes: 3 additions & 1 deletion src/app/(app)/calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ import { useAnalytics } from '@/hooks/use-analytics';
import { isDateInRange } from '@/lib/utils';
import { type CalendarItemResultData } from '@/models/v4/calendar/calendarItemResultData';
import { useCalendarStore } from '@/stores/calendar/store';
import { TermKeys, useTerm } from '@/stores/terminology/store';

type TabType = 'today' | 'upcoming' | 'calendar';

export default function CalendarScreen() {
const { t } = useTranslation();
const calendarTitle = useTerm(TermKeys.Calendar, t('calendar.title'));
const router = useRouter();
const { trackEvent } = useAnalytics();
const [activeTab, setActiveTab] = useState<TabType>('today');
Expand Down Expand Up @@ -252,7 +254,7 @@ export default function CalendarScreen() {
<>
<Stack.Screen
options={{
title: t('calendar.title'),
title: calendarTitle,
headerShown: true,
}}
/>
Expand Down
4 changes: 3 additions & 1 deletion src/app/(app)/checklists.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import { useChecklistLiveUpdates } from '@/hooks/use-checklist-live-updates';
import { useProtectedReveal } from '@/hooks/use-protected-reveal';
import { checklistError } from '@/lib/checklists/sync';
import { useChecklistsStore } from '@/stores/checklists/store';
import { TermKeys, useTerm } from '@/stores/terminology/store';

export default function ChecklistsScreen() {
const { t } = useTranslation();
const checklistsTitle = useTerm(TermKeys.Checklists, t('checklists.labels.Checklists'));
const { occurrenceId } = useLocalSearchParams<{ occurrenceId?: string }>();
const unitId: string | undefined = undefined;
const state = useChecklistsStore();
Expand Down Expand Up @@ -46,7 +48,7 @@ export default function ChecklistsScreen() {
const message = error ?? state.error;
return (
<View className="flex-1 bg-background-0">
<Stack.Screen options={{ title: t('checklists.labels.Checklists') }} />
<Stack.Screen options={{ title: checklistsTitle }} />
<ScrollView contentContainerStyle={{ padding: 16, gap: 12 }}>
<Button variant="outline" onPress={() => setCalendar((v) => !v)}>
<ButtonText>{t('checklists.ui.calendar')}</ButtonText>
Expand Down
63 changes: 63 additions & 0 deletions src/app/(app)/home/__tests__/_layout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { act, render, screen } from '@testing-library/react-native';
import React from 'react';

import { terminologyStore } from '@/stores/terminology/store';

import HomeLayout from '../_layout';

interface MockTabsProps {
children?: React.ReactNode;
}

interface MockScreenProps {
name: string;
options?: { title?: string };
}

jest.mock('expo-router', () => {
const { Text, View } = require('react-native');
const MockTabs = ({ children }: MockTabsProps) => <View>{children}</View>;
MockTabs.Screen = ({ name, options }: MockScreenProps) => <Text testID={`home-tab-${name}`}>{options?.title}</Text>;
return { Tabs: MockTabs };
});

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

describe('HomeLayout tab titles', () => {
beforeEach(() => {
terminologyStore.setState({ names: {} });
});

afterAll(() => {
terminologyStore.setState({ names: {} });
});

it('keeps the app labels when nothing is renamed', () => {
render(<HomeLayout />);

expect(screen.getByTestId('home-tab-personnel').props.children).toBe('tabs.personnel');
expect(screen.getByTestId('home-tab-units').props.children).toBe('units.title');
expect(screen.getByTestId('home-tab-calls').props.children).toBe('tabs.calls');
});

it("uses the department's names for renamed modules", () => {
terminologyStore.setState({ names: { 'module.personnel': 'Members', 'module.units': 'Apparatus', 'module.calls': 'Incidents' } });

render(<HomeLayout />);

expect(screen.getByTestId('home-tab-personnel').props.children).toBe('Members');
expect(screen.getByTestId('home-tab-units').props.children).toBe('Apparatus');
expect(screen.getByTestId('home-tab-calls').props.children).toBe('Incidents');
expect(screen.getByTestId('home-tab-index').props.children).toBe('tabs.home');
});

it('relabels the tabs when the names arrive after the first render', () => {
render(<HomeLayout />);

act(() => {
terminologyStore.setState({ names: { 'module.calls': 'Incidents' } });
});

expect(screen.getByTestId('home-tab-calls').props.children).toBe('Incidents');
});
});
10 changes: 7 additions & 3 deletions src/app/(app)/home/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ import { useTranslation } from 'react-i18next';
import { useWindowDimensions } from 'react-native';

import { Icon } from '@/components/ui/icon';
import { TermKeys, useTerm } from '@/stores/terminology/store';

export default function HomeLayout() {
const { t } = useTranslation();
const { width, height } = useWindowDimensions();
const isLandscape = width > height;
const personnelTitle = useTerm(TermKeys.Personnel, t('tabs.personnel'));
const unitsTitle = useTerm(TermKeys.Units, t('units.title'));
const callsTitle = useTerm(TermKeys.Calls, t('tabs.calls'));

return (
<Tabs
Expand Down Expand Up @@ -43,7 +47,7 @@ export default function HomeLayout() {
<Tabs.Screen
name="personnel"
options={{
title: t('tabs.personnel'),
title: personnelTitle,
tabBarIcon: ({ color }) => <Icon as={User} stroke={color as string} className="text-primary-500" />,
tabBarButtonTestID: 'home-people-tab',
}}
Expand All @@ -52,7 +56,7 @@ export default function HomeLayout() {
<Tabs.Screen
name="units"
options={{
title: t('units.title'),
title: unitsTitle,
tabBarIcon: ({ color }) => <Icon as={Car} stroke={color as string} className="text-primary-500" />,
tabBarButtonTestID: 'home-units-tab',
}}
Expand All @@ -61,7 +65,7 @@ export default function HomeLayout() {
<Tabs.Screen
name="calls"
options={{
title: t('tabs.calls'),
title: callsTitle,
tabBarIcon: ({ color }) => <Icon as={Phone} stroke={color as string} className="text-primary-500" />,
tabBarButtonTestID: 'home-calls-tab',
}}
Expand Down
6 changes: 4 additions & 2 deletions src/app/(app)/map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar';
import { SharedTabs, type TabItem } from '@/components/ui/shared-tabs';
import { type PoiResultData } from '@/models/v4/mapping/poiResultData';
import { usePoiStore } from '@/stores/poi/store';
import { TermKeys, useTerm } from '@/stores/terminology/store';

export default function HomeMap() {
const { t } = useTranslation();
const mapTitle = useTerm(TermKeys.Mapping, t('map.tabs.map'));
const router = useRouter();
const { tab, poiId } = useLocalSearchParams<{ tab?: string | string[]; poiId?: string | string[] }>();
const { width, height } = useWindowDimensions();
Expand Down Expand Up @@ -50,7 +52,7 @@ export default function HomeMap() {
() => [
{
key: 'map',
title: t('map.tabs.map'),
title: mapTitle,
content: (
<View className="flex-1 overflow-hidden rounded-[28px] border border-neutral-200 bg-white shadow-xs dark:border-neutral-800 dark:bg-neutral-900">
<MapPanel focusedPoi={focusedPoi} />
Expand All @@ -67,7 +69,7 @@ export default function HomeMap() {
),
},
],
[focusedPoi, handlePoiPress, handleViewPoiOnMap, t]
[focusedPoi, handlePoiPress, handleViewPoiOnMap, mapTitle, t]
);

return (
Expand Down
4 changes: 3 additions & 1 deletion src/app/(app)/records.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useRecordsFieldStatus } from '@/stores/feature-flags/store';
import { useDeploymentsStore, useOpenDeploymentCount, useReconciliationCount } from '@/stores/records/deployments-store';
import { useRecordsStore } from '@/stores/records/store';
import { securityStore } from '@/stores/security/store';
import { TermKeys, useTerm } from '@/stores/terminology/store';

// Field Records home (RMS plan RMS-1D): New, Assigned, Drafts, Returned and Recent. Everything shown
// here came from the server's own filtering; this screen never decides what the person may author.
Expand All @@ -42,6 +43,7 @@ const Empty = ({ text }: { text: string }) => <Text className="px-4 py-3 text-sm

export default function RecordsScreen() {
const { t } = useTranslation();
const recordsTitle = useTerm(TermKeys.Records, t('records.title'));
const router = useRouter();
const flagStatus = useRecordsFieldStatus();
const context = useRecordsContext();
Expand Down Expand Up @@ -97,7 +99,7 @@ export default function RecordsScreen() {
return (
<Box className="flex-1 bg-background-0">
<FocusAwareStatusBar />
<Stack.Screen options={{ title: t('records.title') }} />
<Stack.Screen options={{ title: recordsTitle }} />

<ScrollView refreshControl={<RefreshControl refreshing={isSyncing} onRefresh={() => void sync()} />}>
{flagStatus === 'unknown' ? (
Expand Down
4 changes: 3 additions & 1 deletion src/app/(app)/shifts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { formatShiftLongDate, getDateKey, getMonthRange, parseWallClock, toDateP
import { type ShiftDayResultData } from '@/models/v4/shifts/shiftDayResultData';
import { type ShiftResultData } from '@/models/v4/shifts/shiftResultData';
import { type ShiftViewMode, useShiftsStore } from '@/stores/shifts/store';
import { TermKeys, useTerm } from '@/stores/terminology/store';

const SUPERVISOR_VIEWS: ShiftViewMode[] = ['approvals'];
const TRADE_MUTATIONS = new Set(['respond-trade', 'finish-trade', 'cancel-trade', 'review-trade']);
Expand Down Expand Up @@ -67,6 +68,7 @@ CalendarFilterChip.displayName = 'CalendarFilterChip';

const ShiftsScreen: React.FC = () => {
const { t } = useTranslation();
const shiftsTitle = useTerm(TermKeys.Shifts, t('shifts.title'));
const { trackEvent } = useAnalytics();
const { colorScheme } = useColorScheme();

Expand Down Expand Up @@ -400,7 +402,7 @@ const ShiftsScreen: React.FC = () => {

return (
<>
<Stack.Screen options={{ title: t('shifts.title'), headerShown: true }} />
<Stack.Screen options={{ title: shiftsTitle, headerShown: true }} />
<View className="flex-1 bg-gray-50 dark:bg-gray-900" testID="shifts-screen">
<FocusAwareStatusBar />
<ShiftSegmentBar segments={segments} active={activeView} onChange={handleSegmentChange} />
Expand Down
6 changes: 4 additions & 2 deletions src/app/(app)/work-orders/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { capturePhoto, discardPhoto, PhotoPermissionError } from '@/lib/media/ph
import { missingField, todayKey, type TransitionFields, transitionNeeds } from '@/lib/workOrders/status';
import { WorkOrderActivityType } from '@/models/v4/workOrders';
import { useWorkOrdersStatus } from '@/stores/feature-flags/store';
import { TermKeys, useTerm } from '@/stores/terminology/store';
import { useWorkOrdersStore } from '@/stores/workOrders/store';

const blankFields: TransitionFields = { reason: '', resolution: '', cause: '', verification: '', duplicateOf: '', confirmTasks: false };
Expand All @@ -31,6 +32,7 @@ const hoursOf = (text: string) => {
// Transitions and Can* flags drive every button; a stale revision reloads the order and says so.
export default function WorkOrderScreen() {
const { t } = useTranslation();
const workOrdersTitle = useTerm(TermKeys.WorkOrders, t('workOrders.title'));
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
const flagStatus = useWorkOrdersStatus();
Expand Down Expand Up @@ -110,13 +112,13 @@ export default function WorkOrderScreen() {

return (
<VStack className="flex-1 bg-background-0">
<Stack.Screen options={{ title: order?.Number ?? t('workOrders.title') }} />
<Stack.Screen options={{ title: order?.Number ?? workOrdersTitle }} />
<ScrollView contentContainerStyle={{ padding: 16, gap: 12 }} keyboardShouldPersistTaps="handled">
<HStack space="sm" className="items-center">
<Pressable onPress={() => router.back()} testID="work-order-back" accessibilityRole="button" accessibilityLabel={t('workOrders.back')}>
<ArrowLeft size={22} color="#2563eb" />
</Pressable>
<Text className="flex-1 text-xl font-bold">{order?.Title ?? t('workOrders.title')}</Text>
<Text className="flex-1 text-xl font-bold">{order?.Title ?? workOrdersTitle}</Text>
</HStack>
{busy && !detail ? <Spinner /> : null}
{error ? (
Expand Down
6 changes: 4 additions & 2 deletions src/app/(app)/work-orders/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ import { VStack } from '@/components/ui/vstack';
import { isOpen } from '@/lib/workOrders/status';
import { WorkOrderPriority } from '@/models/v4/workOrders';
import { useWorkOrdersStatus } from '@/stores/feature-flags/store';
import { TermKeys, useTerm } from '@/stores/terminology/store';
import { useWorkOrdersStore } from '@/stores/workOrders/store';

// Work orders the person reported or that are assigned to them or their roles (Maintenance.WorkOrders).
// Reporting a problem needs the department's Readiness Pro maintenance; reading never does.
export default function WorkOrdersScreen() {
const { t } = useTranslation();
const workOrdersTitle = useTerm(TermKeys.WorkOrders, t('workOrders.title'));
const router = useRouter();
const flagStatus = useWorkOrdersStatus();
const { access, assignedToMe, items, hasMore, canWrite, busy, error } = useWorkOrdersStore();
Expand All @@ -32,10 +34,10 @@ export default function WorkOrdersScreen() {

return (
<VStack className="flex-1 bg-background-0">
<Stack.Screen options={{ title: t('workOrders.title') }} />
<Stack.Screen options={{ title: workOrdersTitle }} />
<ScrollView contentContainerStyle={{ padding: 16, gap: 12 }} refreshControl={<RefreshControl refreshing={busy} onRefresh={() => void useWorkOrdersStore.getState().load()} />}>
<HStack className="items-center justify-between">
<Text className="text-xl font-bold">{t('workOrders.title')}</Text>
<Text className="text-xl font-bold">{workOrdersTitle}</Text>
{canWrite ? (
<Button size="sm" onPress={() => router.push('/work-orders/new' as Href)} testID="work-orders-report">
<ButtonText>{t('workOrders.report')}</ButtonText>
Expand Down
Loading
Loading