From 7820561e8464ae7333658c0453482160116c03df Mon Sep 17 00:00:00 2001 From: allennakalema06-web Date: Tue, 22 Sep 2026 20:48:18 +0300 Subject: [PATCH 1/4] fix(formulus): make tables responsive on small screens --- .../src/components/common/FormListTable.tsx | 79 +++++++++++++------ .../common/ObservationListTable.tsx | 63 ++++++++++----- 2 files changed, 101 insertions(+), 41 deletions(-) diff --git a/formulus/src/components/common/FormListTable.tsx b/formulus/src/components/common/FormListTable.tsx index 99d76bb21..d0cff067e 100644 --- a/formulus/src/components/common/FormListTable.tsx +++ b/formulus/src/components/common/FormListTable.tsx @@ -1,5 +1,12 @@ import React, { memo, useCallback } from 'react'; -import { View, Text, Pressable, StyleSheet, ScrollView } from 'react-native'; +import { + View, + Text, + Pressable, + StyleSheet, + ScrollView, + useWindowDimensions, +} from 'react-native'; import Icon from '@react-native-vector-icons/material-design-icons'; import { useTranslation } from 'react-i18next'; import { useAppTheme } from '../../contexts/AppThemeContext'; @@ -10,6 +17,9 @@ import { } from '../../theme/odeDesign'; import type { FormSpec } from '../../services/FormService'; +// Below 600px, hide secondary columns to keep tables usable on small screens. +const NARROW_WIDTH = 600; + type FormListTableProps = { forms: FormSpec[]; observationCounts: Record; @@ -24,24 +34,36 @@ type FormTableRowProps = { divider: string; primary: string; newLabel: string; + isNarrow: boolean; }; const FormTableRow = memo( - ({ form, count, onCreate, cellColor, divider, primary, newLabel }) => { + ({ + form, + count, + onCreate, + cellColor, + divider, + primary, + newLabel, + isNarrow, + }) => { const onPress = useCallback(() => onCreate(form.id), [onCreate, form.id]); return ( {form.name || form.id} - - {count == null ? '—' : String(count)} - + {!isNarrow && ( + + {count == null ? '—' : String(count)} + + )} @@ -59,6 +81,8 @@ const FormListTable: React.FC = ({ }) => { const { t } = useTranslation(); const { themeColors } = useAppTheme(); + const { width } = useWindowDimensions(); + const isNarrow = width < NARROW_WIDTH; const headerColor = themeColors.onSurface as string; const cellColor = themeColors.onSurface as string; const divider = themeColors.divider as string; @@ -74,19 +98,21 @@ const FormListTable: React.FC = ({ {t('forms.colFormType')} - - {t('forms.colObservationCount')} - - - {t('forms.colNewObservation')} - + {!isNarrow && ( + + {t('forms.colObservationCount')} + + )} + + {!isNarrow && ( + + {t('forms.colNewObservation')} + + )} + {forms.map(form => ( = ({ divider={divider} primary={primary} newLabel={newLabel} + isNarrow={isNarrow} /> ))} @@ -137,16 +164,22 @@ const styles = StyleSheet.create({ paddingRight: odeSpacing.xs, }, cellCount: { - width: 216, + flex: 0.8, + minWidth: 0, fontSize: odeTypography.bodySm, paddingRight: odeSpacing.sm, textAlign: 'right', }, cellNew: { - width: 160, + flex: 0.8, + minWidth: 0, alignItems: 'flex-end', justifyContent: 'center', }, + cellNewNarrow: { + flex: 0, + width: 40, + }, newHeader: { textAlign: 'right', }, diff --git a/formulus/src/components/common/ObservationListTable.tsx b/formulus/src/components/common/ObservationListTable.tsx index 37de90804..2a8309107 100644 --- a/formulus/src/components/common/ObservationListTable.tsx +++ b/formulus/src/components/common/ObservationListTable.tsx @@ -1,5 +1,12 @@ import React, { memo, useCallback } from 'react'; -import { View, Text, Pressable, StyleSheet, ScrollView } from 'react-native'; +import { + View, + Text, + Pressable, + StyleSheet, + ScrollView, + useWindowDimensions, +} from 'react-native'; import { useTranslation } from 'react-i18next'; import { useAppTheme } from '../../contexts/AppThemeContext'; import colors from '../../theme/colors'; @@ -15,6 +22,8 @@ import { import { isObservationFullySynced } from '../../utils/observationSyncStatus'; import { formatDateTimeShort } from '../../utils/dateUtils'; +const NARROW_WIDTH = 600; + type ObservationListTableProps = { rows: ObservationListRow[]; formNames: Record; @@ -31,6 +40,7 @@ type ObservationTableRowProps = { syncedColor: string; syncedLabel: string; pendingLabel: string; + isNarrow: boolean; }; const ObservationTableRow = memo( @@ -44,6 +54,7 @@ const ObservationTableRow = memo( syncedColor, syncedLabel, pendingLabel, + isNarrow, }) => { const synced = isObservationFullySynced(row); const onPress = useCallback(() => onPressRow(row), [onPressRow, row]); @@ -66,16 +77,20 @@ const ObservationTableRow = memo( numberOfLines={1}> {synced ? syncedLabel : pendingLabel} - - {row.author || '—'} - - - {formatObservationIdShort(row.observationId)} - + {!isNarrow && ( + <> + + {row.author || '—'} + + + {formatObservationIdShort(row.observationId)} + + + )} ); }, @@ -88,6 +103,8 @@ const ObservationListTable: React.FC = ({ }) => { const { t } = useTranslation(); const { themeColors } = useAppTheme(); + const { width } = useWindowDimensions(); + const isNarrow = width < NARROW_WIDTH; const headerColor = themeColors.onSurface as string; const cellColor = themeColors.onSurface as string; const divider = themeColors.divider as string; @@ -111,13 +128,22 @@ const ObservationListTable: React.FC = ({ {t('observations.colSync')} - - {t('observations.colAuthor')} - - - {t('observations.colId')} - + {!isNarrow && ( + <> + + {t('observations.colAuthor')} + + + {t('observations.colId')} + + + )} {rows.map(row => ( = ({ syncedColor={syncedColor} syncedLabel={syncedLabel} pendingLabel={pendingLabel} + isNarrow={isNarrow} /> ))} From 3e318cb382c19a0ba8a738b75e61b915a71c2683 Mon Sep 17 00:00:00 2001 From: allennakalema06-web Date: Fri, 25 Sep 2026 15:26:36 +0300 Subject: [PATCH 2/4] refactor(formulus): share responsive breakpoint hook --- .../src/components/common/FormListTable.tsx | 16 +++------------- .../common/ObservationListTable.tsx | 15 +++------------ formulus/src/hooks/useIsNarrowScreen.ts | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 25 deletions(-) create mode 100644 formulus/src/hooks/useIsNarrowScreen.ts diff --git a/formulus/src/components/common/FormListTable.tsx b/formulus/src/components/common/FormListTable.tsx index d0cff067e..7c3333a21 100644 --- a/formulus/src/components/common/FormListTable.tsx +++ b/formulus/src/components/common/FormListTable.tsx @@ -1,12 +1,5 @@ import React, { memo, useCallback } from 'react'; -import { - View, - Text, - Pressable, - StyleSheet, - ScrollView, - useWindowDimensions, -} from 'react-native'; +import { View, Text, Pressable, StyleSheet, ScrollView } from 'react-native'; import Icon from '@react-native-vector-icons/material-design-icons'; import { useTranslation } from 'react-i18next'; import { useAppTheme } from '../../contexts/AppThemeContext'; @@ -16,9 +9,7 @@ import { odeBorderWidth, } from '../../theme/odeDesign'; import type { FormSpec } from '../../services/FormService'; - -// Below 600px, hide secondary columns to keep tables usable on small screens. -const NARROW_WIDTH = 600; +import { useIsNarrowScreen } from '../../hooks/useIsNarrowScreen'; type FormListTableProps = { forms: FormSpec[]; @@ -81,8 +72,7 @@ const FormListTable: React.FC = ({ }) => { const { t } = useTranslation(); const { themeColors } = useAppTheme(); - const { width } = useWindowDimensions(); - const isNarrow = width < NARROW_WIDTH; + const isNarrow = useIsNarrowScreen(); const headerColor = themeColors.onSurface as string; const cellColor = themeColors.onSurface as string; const divider = themeColors.divider as string; diff --git a/formulus/src/components/common/ObservationListTable.tsx b/formulus/src/components/common/ObservationListTable.tsx index 2a8309107..bfd405620 100644 --- a/formulus/src/components/common/ObservationListTable.tsx +++ b/formulus/src/components/common/ObservationListTable.tsx @@ -1,12 +1,5 @@ import React, { memo, useCallback } from 'react'; -import { - View, - Text, - Pressable, - StyleSheet, - ScrollView, - useWindowDimensions, -} from 'react-native'; +import { View, Text, Pressable, StyleSheet, ScrollView } from 'react-native'; import { useTranslation } from 'react-i18next'; import { useAppTheme } from '../../contexts/AppThemeContext'; import colors from '../../theme/colors'; @@ -21,8 +14,7 @@ import { } from '../../database/observationListQuery'; import { isObservationFullySynced } from '../../utils/observationSyncStatus'; import { formatDateTimeShort } from '../../utils/dateUtils'; - -const NARROW_WIDTH = 600; +import { useIsNarrowScreen } from '../../hooks/useIsNarrowScreen'; type ObservationListTableProps = { rows: ObservationListRow[]; @@ -103,8 +95,7 @@ const ObservationListTable: React.FC = ({ }) => { const { t } = useTranslation(); const { themeColors } = useAppTheme(); - const { width } = useWindowDimensions(); - const isNarrow = width < NARROW_WIDTH; + const isNarrow = useIsNarrowScreen(); const headerColor = themeColors.onSurface as string; const cellColor = themeColors.onSurface as string; const divider = themeColors.divider as string; diff --git a/formulus/src/hooks/useIsNarrowScreen.ts b/formulus/src/hooks/useIsNarrowScreen.ts new file mode 100644 index 000000000..8e0e0607d --- /dev/null +++ b/formulus/src/hooks/useIsNarrowScreen.ts @@ -0,0 +1,19 @@ +import { useWindowDimensions } from 'react-native'; +import tokens from '@ode/tokens/dist/react-native/tokens-resolved'; + +type Tokens = { + breakpoint?: { + sm?: string; + }; +}; + +const t = tokens as Tokens; + +const narrowBreakpoint = parseInt( + String(t.breakpoint?.sm ?? '').replace('px', ''), + 10, +); +export function useIsNarrowScreen() { + const { width } = useWindowDimensions(); + return width < narrowBreakpoint; +} From af045894b6c43c0cc17c39ad2bc3c091f123c22b Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Fri, 25 Sep 2026 16:21:18 +0200 Subject: [PATCH 3/4] test(formulus): wait for drawer session guard before asserting --- formulus/src/components/MenuDrawer.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/formulus/src/components/MenuDrawer.test.tsx b/formulus/src/components/MenuDrawer.test.tsx index 7418576de..d1ae53c70 100644 --- a/formulus/src/components/MenuDrawer.test.tsx +++ b/formulus/src/components/MenuDrawer.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { act, render } from '@testing-library/react-native'; +import { act, render, waitFor } from '@testing-library/react-native'; import { StyleSheet } from 'react-native'; import MenuDrawer from './MenuDrawer'; import { getUserInfo, type UserInfo } from '../api/synkronus/Auth'; @@ -120,10 +120,10 @@ test.each([ await act(async () => { resolveUserInfo({ username, role: 'read-write' }); }); - expect(mockedGetActiveProfile).toHaveBeenCalledTimes(1); + await waitFor(() => expect(mockedGetActiveProfile).toHaveBeenCalledTimes(1)); expect(mockedGetActiveProfile.mock.results[0].value.id).toBe('one'); - const name = screen.getByText(username); + const name = await waitFor(() => screen.getByText(username)); expect(name).toHaveProp('accessibilityLabel', username); expect(name).toHaveProp('numberOfLines', 1); expect(name).toHaveProp('ellipsizeMode', 'tail'); From db22e8bfffed10e69b6bd03421b284e18aa8493b Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Fri, 25 Sep 2026 16:26:35 +0200 Subject: [PATCH 4/4] test(formulus): isolate drawer user display from async session effect --- formulus/src/components/MenuDrawer.test.tsx | 37 ++----- formulus/src/components/MenuDrawer.tsx | 116 +++++++++++--------- 2 files changed, 69 insertions(+), 84 deletions(-) diff --git a/formulus/src/components/MenuDrawer.test.tsx b/formulus/src/components/MenuDrawer.test.tsx index d1ae53c70..edfb5b5f6 100644 --- a/formulus/src/components/MenuDrawer.test.tsx +++ b/formulus/src/components/MenuDrawer.test.tsx @@ -1,9 +1,8 @@ import React from 'react'; -import { act, render, waitFor } from '@testing-library/react-native'; +import { act, render } from '@testing-library/react-native'; import { StyleSheet } from 'react-native'; -import MenuDrawer from './MenuDrawer'; -import { getUserInfo, type UserInfo } from '../api/synkronus/Auth'; -import { getActiveProfile } from '../profiles/ProfileRuntime'; +import MenuDrawer, { MenuDrawerSignedIn } from './MenuDrawer'; +import { getUserInfo } from '../api/synkronus/Auth'; jest.mock('react-native', () => { const native = jest.requireActual('react-native'); @@ -32,7 +31,7 @@ jest.mock('../navigation/useProfiles', () => ({ useProfiles: () => ({ activeProfile: mockActiveProfile }), })); jest.mock('../profiles/ProfileRuntime', () => ({ - getActiveProfile: jest.fn(() => mockActiveProfile), + getActiveProfile: () => mockActiveProfile, })); jest.mock('../api/synkronus/Auth', () => ({ getUserInfo: jest.fn() })); jest.mock('../contexts/AppThemeContext', () => ({ @@ -85,45 +84,23 @@ jest.mock('../theme/colors', () => ({ jest.mock('./common/Button', () => () => null); const mockedGetUserInfo = jest.mocked(getUserInfo); -const mockedGetActiveProfile = jest.mocked(getActiveProfile); beforeEach(() => { mockMode = 'light'; mockedGetUserInfo.mockReset(); - mockedGetActiveProfile.mockClear(); }); test.each([ ['light', 'dark-text'], ['dark', 'light-text'], -])('shows the signed-in username and role in %s mode', async (mode, color) => { +])('renders the signed-in drawer section in %s mode', (mode, color) => { mockMode = mode; const username = 'a-very-long-username-that-must-not-overlap-the-badge'; - let resolveUserInfo!: (info: UserInfo | null) => void; - mockedGetUserInfo.mockImplementation( - () => - new Promise(resolve => { - resolveUserInfo = resolve; - }), - ); - const screen = render( - , + , ); - expect(mockedGetUserInfo).toHaveBeenCalledTimes(1); - await act(async () => { - resolveUserInfo({ username, role: 'read-write' }); - }); - await waitFor(() => expect(mockedGetActiveProfile).toHaveBeenCalledTimes(1)); - expect(mockedGetActiveProfile.mock.results[0].value.id).toBe('one'); - - const name = await waitFor(() => screen.getByText(username)); + const name = screen.getByText(username); expect(name).toHaveProp('accessibilityLabel', username); expect(name).toHaveProp('numberOfLines', 1); expect(name).toHaveProp('ellipsizeMode', 'tail'); diff --git a/formulus/src/components/MenuDrawer.tsx b/formulus/src/components/MenuDrawer.tsx index 00418f113..0262a4089 100644 --- a/formulus/src/components/MenuDrawer.tsx +++ b/formulus/src/components/MenuDrawer.tsx @@ -90,6 +90,67 @@ const MenuDivider = ({ color }: { color: string }) => { ); }; +export const MenuDrawerSignedIn = ({ userInfo }: { userInfo: UserInfo }) => { + const { t } = useTranslation(); + const { themeColors, resolvedMode } = useAppTheme(); + const textColor = + resolvedMode === 'dark' + ? (themeColors.onSurface as string) + : (colors.neutral[900] as string); + const getRoleBadgeStyle = (role: UserRole) => { + switch (role) { + case 'admin': + return styles.roleBadgeAdmin; + case 'read-write': + return styles.roleBadgeReadWrite; + default: + return styles.roleBadgeReadOnly; + } + }; + + return ( + + + + + + + {userInfo.username} + + + + {userInfo.role === 'admin' + ? t('roles.admin') + : userInfo.role === 'read-write' + ? t('roles.readWrite') + : t('roles.readOnly')} + + + + + ); +}; + const MenuDrawer: React.FC = ({ visible, onClose, @@ -154,17 +215,6 @@ const MenuDrawer: React.FC = ({ return hasMinRole(userInfo?.role, item.minRole); }); - const getRoleBadgeStyle = (role: UserRole) => { - switch (role) { - case 'admin': - return styles.roleBadgeAdmin; - case 'read-write': - return styles.roleBadgeReadWrite; - default: - return styles.roleBadgeReadOnly; - } - }; - if (!visible) { return null; } @@ -238,49 +288,7 @@ const MenuDrawer: React.FC = ({ {/* User Info Section */} {userInfo ? ( <> - - - - - - - {userInfo.username} - - - - {userInfo.role === 'admin' - ? t('roles.admin') - : userInfo.role === 'read-write' - ? t('roles.readWrite') - : t('roles.readOnly')} - - - - + ) : (