Skip to content
Merged
33 changes: 5 additions & 28 deletions formulus/src/components/MenuDrawer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import React from 'react';
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');
Expand Down Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -85,44 +84,22 @@ 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<UserInfo | null>(resolve => {
resolveUserInfo = resolve;
}),
);

const screen = render(
<MenuDrawer
visible
onClose={jest.fn()}
onNavigate={jest.fn()}
onLogout={jest.fn()}
/>,
<MenuDrawerSignedIn userInfo={{ username, role: 'read-write' }} />,
);

expect(mockedGetUserInfo).toHaveBeenCalledTimes(1);
await act(async () => {
resolveUserInfo({ username, role: 'read-write' });
});
expect(mockedGetActiveProfile).toHaveBeenCalledTimes(1);
expect(mockedGetActiveProfile.mock.results[0].value.id).toBe('one');

const name = screen.getByText(username);
expect(name).toHaveProp('accessibilityLabel', username);
expect(name).toHaveProp('numberOfLines', 1);
Expand Down
116 changes: 62 additions & 54 deletions formulus/src/components/MenuDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<View
style={[
styles.userSection,
{ backgroundColor: themeColors.surface as string },
]}>
<View
style={[styles.userAvatar, { backgroundColor: themeColors.primary }]}>
<Icon name="account" size={32} color={themeColors.onPrimary} />
</View>
<View style={styles.userInfo}>
<Text
style={[
styles.userName,
styles.loggedInUserName,
{ color: textColor },
]}
numberOfLines={1}
ellipsizeMode="tail"
accessibilityLabel={userInfo.username}>
{userInfo.username}
</Text>
<View
style={[
styles.roleBadge,
getRoleBadgeStyle(userInfo.role),
userInfo.role === 'admin' && {
backgroundColor: themeColors.primary as string,
},
]}>
<Text style={styles.roleBadgeText}>
{userInfo.role === 'admin'
? t('roles.admin')
: userInfo.role === 'read-write'
? t('roles.readWrite')
: t('roles.readOnly')}
</Text>
</View>
</View>
</View>
);
};

const MenuDrawer: React.FC<MenuDrawerProps> = ({
visible,
onClose,
Expand Down Expand Up @@ -154,17 +215,6 @@ const MenuDrawer: React.FC<MenuDrawerProps> = ({
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;
}
Expand Down Expand Up @@ -238,49 +288,7 @@ const MenuDrawer: React.FC<MenuDrawerProps> = ({
{/* User Info Section */}
{userInfo ? (
<>
<View
style={[styles.userSection, { backgroundColor: sectionBg }]}>
<View
style={[
styles.userAvatar,
{ backgroundColor: themeColors.primary },
]}>
<Icon
name="account"
size={32}
color={themeColors.onPrimary}
/>
</View>
<View style={styles.userInfo}>
<Text
style={[
styles.userName,
styles.loggedInUserName,
{ color: textColor },
]}
numberOfLines={1}
ellipsizeMode="tail"
accessibilityLabel={userInfo.username}>
{userInfo.username}
</Text>
<View
style={[
styles.roleBadge,
getRoleBadgeStyle(userInfo.role),
userInfo.role === 'admin' && {
backgroundColor: themeColors.primary as string,
},
]}>
<Text style={styles.roleBadgeText}>
{userInfo.role === 'admin'
? t('roles.admin')
: userInfo.role === 'read-write'
? t('roles.readWrite')
: t('roles.readOnly')}
</Text>
</View>
</View>
</View>
<MenuDrawerSignedIn userInfo={userInfo} />
<MenuDivider color={menuModalBorderColor} />
</>
) : (
Expand Down
67 changes: 45 additions & 22 deletions formulus/src/components/common/FormListTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
odeBorderWidth,
} from '../../theme/odeDesign';
import type { FormSpec } from '../../services/FormService';
import { useIsNarrowScreen } from '../../hooks/useIsNarrowScreen';

type FormListTableProps = {
forms: FormSpec[];
Expand All @@ -24,24 +25,36 @@ type FormTableRowProps = {
divider: string;
primary: string;
newLabel: string;
isNarrow: boolean;
};

const FormTableRow = memo<FormTableRowProps>(
({ 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 (
<View style={[styles.row, { borderColor: divider }]}>
<Text style={[styles.cellForm, { color: cellColor }]} numberOfLines={1}>
{form.name || form.id}
</Text>
<Text
style={[styles.cellCount, { color: cellColor }]}
numberOfLines={1}>
{count == null ? '—' : String(count)}
</Text>
{!isNarrow && (
<Text
style={[styles.cellCount, { color: cellColor }]}
numberOfLines={1}>
{count == null ? '—' : String(count)}
</Text>
)}
<Pressable
onPress={onPress}
style={styles.cellNew}
style={[styles.cellNew, isNarrow && styles.cellNewNarrow]}
accessibilityRole="button"
accessibilityLabel={newLabel}
hitSlop={8}>
Expand All @@ -59,6 +72,7 @@ const FormListTable: React.FC<FormListTableProps> = ({
}) => {
const { t } = useTranslation();
const { themeColors } = useAppTheme();
const isNarrow = useIsNarrowScreen();
const headerColor = themeColors.onSurface as string;
const cellColor = themeColors.onSurface as string;
const divider = themeColors.divider as string;
Expand All @@ -74,19 +88,21 @@ const FormListTable: React.FC<FormListTableProps> = ({
<Text style={[styles.cellForm, styles.header, { color: headerColor }]}>
{t('forms.colFormType')}
</Text>
<Text style={[styles.cellCount, styles.header, { color: headerColor }]}>
{t('forms.colObservationCount')}
</Text>
<Text
style={[
styles.cellNew,
styles.header,
styles.newHeader,
{ color: headerColor },
]}
numberOfLines={2}>
{t('forms.colNewObservation')}
</Text>
{!isNarrow && (
<Text
style={[styles.cellCount, styles.header, { color: headerColor }]}>
{t('forms.colObservationCount')}
</Text>
)}
<View style={[styles.cellNew, isNarrow && styles.cellNewNarrow]}>
{!isNarrow && (
<Text
style={[styles.header, styles.newHeader, { color: headerColor }]}
numberOfLines={2}>
{t('forms.colNewObservation')}
</Text>
)}
</View>
</View>
{forms.map(form => (
<FormTableRow
Expand All @@ -102,6 +118,7 @@ const FormListTable: React.FC<FormListTableProps> = ({
divider={divider}
primary={primary}
newLabel={newLabel}
isNarrow={isNarrow}
/>
))}
</ScrollView>
Expand Down Expand Up @@ -137,16 +154,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',
},
Expand Down
Loading
Loading