diff --git a/src/components/DelegatorList.tsx b/src/components/DelegatorList.tsx
index 291e3ad08e3a..06fce77dc3cb 100644
--- a/src/components/DelegatorList.tsx
+++ b/src/components/DelegatorList.tsx
@@ -6,7 +6,9 @@ import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
import React from 'react';
+import {View} from 'react-native';
+import UserAvatar from './Avatar/UserAvatar';
import MenuItem from './MenuItem';
import Text from './Text';
@@ -27,25 +29,30 @@ function DelegatorList({delegators, message}: DelegatorListProps) {
return (
<>
{message}
- {delegators?.map((delegatorEmail) => {
- const delegatorDetails = personalDetailsByLogin[delegatorEmail.toLowerCase()];
- const formattedLogin = formatPhoneNumber(delegatorDetails?.login ?? '');
- const displayLogin = formattedLogin || delegatorEmail;
-
- return (
-
- );
- })}
+
+ {delegators?.map((delegatorEmail) => {
+ const delegatorDetails = personalDetailsByLogin[delegatorEmail.toLowerCase()];
+ const formattedLogin = formatPhoneNumber(delegatorDetails?.login ?? '');
+ const displayLogin = formattedLogin || delegatorEmail;
+
+ return (
+
+
+
+
+
+
+ {delegatorDetails?.displayName ?? displayLogin}
+ {displayLogin}
+
+
+
+ );
+ })}
+
>
);
}
diff --git a/src/components/MenuItem/index.ts b/src/components/MenuItem/index.ts
index 4c40cd6eadf0..68a2dd4adb4c 100644
--- a/src/components/MenuItem/index.ts
+++ b/src/components/MenuItem/index.ts
@@ -13,7 +13,9 @@
*
*
*
- *
+ *
+ *
+ *
*
* {translate('common.settings')}
*
@@ -25,24 +27,30 @@
* ```
*/
import MenuItemContent from './layout/MenuItemContent';
+import MenuItemLeading from './layout/MenuItemLeading';
import MenuItemRoot from './layout/MenuItemRoot';
import MenuItemRow from './layout/MenuItemRow';
import MenuItemTrailing from './layout/MenuItemTrailing';
import MenuItemIcon from './leaves/leading/MenuItemIcon';
-import MenuItemDescription from './leaves/text/MenuItemDescription';
+import MenuItemDescription from './leaves/text/description/MenuItemDescription';
+import MenuItemDescriptionPlaceholder from './leaves/text/description/MenuItemDescriptionPlaceholder';
import MenuItemTitle from './leaves/text/MenuItemTitle';
import MenuItemChevron from './leaves/trailing/MenuItemChevron';
+import MenuItemRightLabel from './leaves/trailing/MenuItemRightLabel';
import LegacyMenuItem from './MenuItem';
const MenuItem = Object.assign(LegacyMenuItem, {
Root: MenuItemRoot,
Row: MenuItemRow,
+ Leading: MenuItemLeading,
Content: MenuItemContent,
Trailing: MenuItemTrailing,
Icon: MenuItemIcon,
Title: MenuItemTitle,
Description: MenuItemDescription,
+ DescriptionPlaceholder: MenuItemDescriptionPlaceholder,
Chevron: MenuItemChevron,
+ RightLabel: MenuItemRightLabel,
});
export default MenuItem;
diff --git a/src/components/MenuItem/layout/MenuItemLeading.tsx b/src/components/MenuItem/layout/MenuItemLeading.tsx
new file mode 100644
index 000000000000..5349e769d497
--- /dev/null
+++ b/src/components/MenuItem/layout/MenuItemLeading.tsx
@@ -0,0 +1,17 @@
+import useThemeStyles from '@hooks/useThemeStyles';
+
+import type {PropsWithChildren} from 'react';
+
+import React from 'react';
+import {View} from 'react-native';
+
+type MenuItemLeadingProps = PropsWithChildren;
+
+/** The leading cell of a `MenuItem.Row`. Sets no width of its own — it centers and sizes to its content */
+function MenuItemLeading({children}: MenuItemLeadingProps) {
+ const styles = useThemeStyles();
+
+ return {children};
+}
+
+export default MenuItemLeading;
diff --git a/src/components/MenuItem/layout/MenuItemRoot.tsx b/src/components/MenuItem/layout/MenuItemRoot.tsx
index 1f1b53698888..4e7cace2e138 100644
--- a/src/components/MenuItem/layout/MenuItemRoot.tsx
+++ b/src/components/MenuItem/layout/MenuItemRoot.tsx
@@ -14,6 +14,7 @@ import variables from '@styles/variables';
import CONST from '@src/CONST';
import type WithSentryLabel from '@src/types/utils/SentryLabel';
+import type WithTestID from '@src/types/utils/TestID';
import type {PropsWithChildren} from 'react';
import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native';
@@ -22,7 +23,8 @@ import React, {useRef} from 'react';
import {View} from 'react-native';
type MenuItemRootProps = PropsWithChildren &
- WithSentryLabel & {
+ WithSentryLabel &
+ WithTestID & {
/** Function to fire when the row is pressed */
onPress?: (event: GestureResponderEvent | KeyboardEvent) => void | Promise;
@@ -37,7 +39,7 @@ type MenuItemRootProps = PropsWithChildren &
accessibilityLabel?: string;
};
-function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, accessibilityLabel}: MenuItemRootProps) {
+function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testID, accessibilityLabel}: MenuItemRootProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const pressableRef = useRef(null);
@@ -89,6 +91,7 @@ function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, acces
accessible
tabIndex={isInteractive ? 0 : -1}
sentryLabel={sentryLabel}
+ testID={testID}
>
{({pressed}) => (
@@ -110,3 +113,4 @@ function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, acces
}
export default MenuItemRoot;
+export type {MenuItemRootProps};
diff --git a/src/components/MenuItem/leaves/leading/MenuItemIcon.tsx b/src/components/MenuItem/leaves/leading/MenuItemIcon.tsx
index b15d1c20b795..ce01c3244591 100644
--- a/src/components/MenuItem/leaves/leading/MenuItemIcon.tsx
+++ b/src/components/MenuItem/leaves/leading/MenuItemIcon.tsx
@@ -10,14 +10,13 @@ import getButtonState from '@libs/getButtonState';
import type IconAsset from '@src/types/utils/IconAsset';
import React from 'react';
-import {View} from 'react-native';
type MenuItemIconProps = {
/** Icon to display */
src: IconAsset;
};
-/** The leading icon cell of a `MenuItem.Row` */
+/** An icon glyph, filled from the row's interaction state */
function MenuItemIcon({src}: MenuItemIconProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
@@ -32,15 +31,14 @@ function MenuItemIcon({src}: MenuItemIconProps) {
});
return (
-
-
-
+
);
}
diff --git a/src/components/MenuItem/leaves/text/MenuItemTitle.tsx b/src/components/MenuItem/leaves/text/MenuItemTitle.tsx
index ccafead71111..d9fbf40d7fc0 100644
--- a/src/components/MenuItem/leaves/text/MenuItemTitle.tsx
+++ b/src/components/MenuItem/leaves/text/MenuItemTitle.tsx
@@ -8,19 +8,30 @@ import convertToLTR from '@libs/convertToLTR';
import CONST from '@src/CONST';
+import type {ReactElement} from 'react';
+
import React from 'react';
-type MenuItemTitleProps = {
- /** Text to render as the title */
- children: string | number;
-};
+type MenuItemTitleProps =
+ | {
+ /** Text to render as the title */
+ children: string | number;
+
+ accessibilityLabel?: never;
+ }
+ | {
+ /** Element to render in place of plain text, e.g. a `DisplayNames` with per-name tooltips */
+ children: ReactElement;
+
+ accessibilityLabel: string;
+ };
/** The title block of a `MenuItem.Content`. Bold, single line */
-function MenuItemTitle({children}: MenuItemTitleProps) {
+function MenuItemTitle({children, accessibilityLabel}: MenuItemTitleProps) {
const styles = useThemeStyles();
const {isDisabled, isInteractive} = useMenuItemConfig();
- useMenuItemAccessibilityLabel('title', String(children));
+ useMenuItemAccessibilityLabel('title', accessibilityLabel ?? String(children));
return (
{children}
diff --git a/src/components/MenuItem/leaves/text/description/MenuItemDescriptionPlaceholder.tsx b/src/components/MenuItem/leaves/text/description/MenuItemDescriptionPlaceholder.tsx
new file mode 100644
index 000000000000..478338f6bc67
--- /dev/null
+++ b/src/components/MenuItem/leaves/text/description/MenuItemDescriptionPlaceholder.tsx
@@ -0,0 +1,30 @@
+import {useMenuItemAccessibilityLabel} from '@components/MenuItem/MenuItemAccessibilityContext';
+import Text from '@components/Text';
+
+import useStyleUtils from '@hooks/useStyleUtils';
+import useThemeStyles from '@hooks/useThemeStyles';
+
+import variables from '@styles/variables';
+
+import React from 'react';
+
+import type MenuItemDescriptionProps from './types';
+
+/** The normal-size description of a `MenuItem.Content`, for a description-only row */
+function MenuItemDescriptionPlaceholder({children, numberOfLines = 2}: MenuItemDescriptionProps) {
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+
+ useMenuItemAccessibilityLabel('description', String(children));
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default MenuItemDescriptionPlaceholder;
diff --git a/src/components/MenuItem/leaves/text/description/types.ts b/src/components/MenuItem/leaves/text/description/types.ts
new file mode 100644
index 000000000000..8b2b782a0d2e
--- /dev/null
+++ b/src/components/MenuItem/leaves/text/description/types.ts
@@ -0,0 +1,10 @@
+/** Props shared by every description leaf of a `MenuItem.Content` */
+type MenuItemDescriptionProps = {
+ /** Text to render as the description */
+ children: string | number;
+
+ /** Maximum number of lines to render before the text is truncated */
+ numberOfLines?: number;
+};
+
+export default MenuItemDescriptionProps;
diff --git a/src/components/MenuItem/leaves/trailing/MenuItemRightLabel.tsx b/src/components/MenuItem/leaves/trailing/MenuItemRightLabel.tsx
new file mode 100644
index 000000000000..a3e40f22926a
--- /dev/null
+++ b/src/components/MenuItem/leaves/trailing/MenuItemRightLabel.tsx
@@ -0,0 +1,19 @@
+import Text from '@components/Text';
+
+import useThemeStyles from '@hooks/useThemeStyles';
+
+import React from 'react';
+
+type MenuItemRightLabelProps = {
+ /** Text to render as the label */
+ children: string;
+};
+
+/** A short trailing hint of a `MenuItem.Row`, such as a `Required` marker */
+function MenuItemRightLabel({children}: MenuItemRightLabelProps) {
+ const styles = useThemeStyles();
+
+ return {children};
+}
+
+export default MenuItemRightLabel;
diff --git a/src/components/MenuItem/presets/MenuItemAction.tsx b/src/components/MenuItem/presets/MenuItemAction.tsx
index 8b48e92da517..31007833738f 100644
--- a/src/components/MenuItem/presets/MenuItemAction.tsx
+++ b/src/components/MenuItem/presets/MenuItemAction.tsx
@@ -1,4 +1,5 @@
import MenuItemContent from '@components/MenuItem/layout/MenuItemContent';
+import MenuItemLeading from '@components/MenuItem/layout/MenuItemLeading';
import MenuItemRoot from '@components/MenuItem/layout/MenuItemRoot';
import MenuItemRow from '@components/MenuItem/layout/MenuItemRow';
import MenuItemIcon from '@components/MenuItem/leaves/leading/MenuItemIcon';
@@ -8,39 +9,44 @@ import {callFunctionIfActionIsAllowed} from '@userActions/Session';
import type IconAsset from '@src/types/utils/IconAsset';
import type WithSentryLabel from '@src/types/utils/SentryLabel';
+import type WithTestID from '@src/types/utils/TestID';
import type {GestureResponderEvent} from 'react-native';
import React from 'react';
-type MenuItemActionProps = WithSentryLabel & {
- /** The title text of the row */
- title: string;
+type MenuItemActionProps = WithSentryLabel &
+ WithTestID & {
+ /** The title text of the row */
+ title: string;
- /** Leading icon to display */
- icon: IconAsset;
+ /** Leading icon to display */
+ icon: IconAsset;
- /** Function to fire when the row is pressed */
- onPress: (event: GestureResponderEvent | KeyboardEvent) => void | Promise;
+ /** Function to fire when the row is pressed */
+ onPress: (event: GestureResponderEvent | KeyboardEvent) => void | Promise;
- /** Whether the menu item is disabled */
- isDisabled?: boolean;
-};
+ /** Whether the menu item is disabled */
+ isDisabled?: boolean;
+ };
/**
* The action MenuItem preset — a tappable row with a leading icon and a title that performs an
* action in place (delete, select, add, etc.)
*/
-function MenuItemAction({title, icon, onPress, isDisabled = false, sentryLabel}: MenuItemActionProps) {
+function MenuItemAction({title, icon, onPress, isDisabled = false, sentryLabel, testID}: MenuItemActionProps) {
return (
-
+
+
+
{title}
diff --git a/src/components/MenuItem/presets/MenuItemAvatarNavigation.tsx b/src/components/MenuItem/presets/MenuItemAvatarNavigation.tsx
new file mode 100644
index 000000000000..74ba9b915b34
--- /dev/null
+++ b/src/components/MenuItem/presets/MenuItemAvatarNavigation.tsx
@@ -0,0 +1,75 @@
+import UserAvatar from '@components/Avatar/UserAvatar';
+import MenuItemContent from '@components/MenuItem/layout/MenuItemContent';
+import MenuItemLeading from '@components/MenuItem/layout/MenuItemLeading';
+import MenuItemRoot from '@components/MenuItem/layout/MenuItemRoot';
+import MenuItemRow from '@components/MenuItem/layout/MenuItemRow';
+import MenuItemTrailing from '@components/MenuItem/layout/MenuItemTrailing';
+import MenuItemDescription from '@components/MenuItem/leaves/text/description/MenuItemDescription';
+import MenuItemTitle from '@components/MenuItem/leaves/text/MenuItemTitle';
+import MenuItemChevron from '@components/MenuItem/leaves/trailing/MenuItemChevron';
+
+import type {AvatarSource} from '@libs/UserAvatarUtils';
+
+import {callFunctionIfActionIsAllowed} from '@userActions/Session';
+
+import type WithSentryLabel from '@src/types/utils/SentryLabel';
+import type WithTestID from '@src/types/utils/TestID';
+
+import type {GestureResponderEvent} from 'react-native';
+
+import React from 'react';
+
+type MenuItemAvatarNavigationProps = WithSentryLabel &
+ WithTestID & {
+ /** The title text of the row. Typically the name of whoever the avatar belongs to */
+ title: string;
+
+ /** Supporting line under the title — an email, address, or other secondary identifier */
+ description: string;
+
+ /** Account ID the avatar belongs to. Used to resolve `avatarSource` when it is a default-avatar URL */
+ accountID: number;
+
+ /** Avatar for the leading cell. Falls back to the generic fallback avatar when omitted */
+ avatarSource?: AvatarSource;
+
+ /** Function to fire when the row is pressed */
+ onPress: (event: GestureResponderEvent | KeyboardEvent) => void | Promise;
+
+ /** Whether the menu item is disabled */
+ isDisabled?: boolean;
+ };
+
+/**
+ * The avatar-navigation MenuItem preset — a tappable row led by a person's avatar, with their name as the title
+ * and a secondary identifier below, that navigates.
+ */
+function MenuItemAvatarNavigation({title, description, accountID, avatarSource, onPress, isDisabled = false, sentryLabel, testID}: MenuItemAvatarNavigationProps) {
+ return (
+
+
+
+
+
+
+ {title}
+ {description}
+
+
+
+
+
+
+ );
+}
+
+export default MenuItemAvatarNavigation;
diff --git a/src/components/MenuItem/presets/MenuItemEmptyField.tsx b/src/components/MenuItem/presets/MenuItemEmptyField.tsx
new file mode 100644
index 000000000000..23e0c6b02edb
--- /dev/null
+++ b/src/components/MenuItem/presets/MenuItemEmptyField.tsx
@@ -0,0 +1,54 @@
+import MenuItemContent from '@components/MenuItem/layout/MenuItemContent';
+import MenuItemRoot from '@components/MenuItem/layout/MenuItemRoot';
+import MenuItemRow from '@components/MenuItem/layout/MenuItemRow';
+import MenuItemTrailing from '@components/MenuItem/layout/MenuItemTrailing';
+import MenuItemDescriptionPlaceholder from '@components/MenuItem/leaves/text/description/MenuItemDescriptionPlaceholder';
+import MenuItemChevron from '@components/MenuItem/leaves/trailing/MenuItemChevron';
+
+import {callFunctionIfActionIsAllowed} from '@userActions/Session';
+
+import type WithSentryLabel from '@src/types/utils/SentryLabel';
+import type WithTestID from '@src/types/utils/TestID';
+
+import type {PropsWithChildren} from 'react';
+import type {GestureResponderEvent} from 'react-native';
+
+import React from 'react';
+
+type MenuItemEmptyFieldProps = PropsWithChildren &
+ WithSentryLabel &
+ WithTestID & {
+ /** Name of the field, standing in for the value the field does not have yet */
+ description: string;
+
+ /** Function to fire when the row is pressed */
+ onPress: (event: GestureResponderEvent | KeyboardEvent) => void | Promise;
+
+ /** Whether the menu item is disabled */
+ isDisabled?: boolean;
+ };
+
+/** The empty-field MenuItem preset — a form field the user has not filled in yet */
+function MenuItemEmptyField({description, onPress, children, isDisabled = false, sentryLabel, testID}: MenuItemEmptyFieldProps) {
+ return (
+
+
+
+ {description}
+
+
+ {children}
+
+
+
+
+ );
+}
+
+export default MenuItemEmptyField;
diff --git a/src/components/MenuItem/presets/MenuItemNavigation.tsx b/src/components/MenuItem/presets/MenuItemNavigation.tsx
index 9a2770595b00..7dfb2fd48110 100644
--- a/src/components/MenuItem/presets/MenuItemNavigation.tsx
+++ b/src/components/MenuItem/presets/MenuItemNavigation.tsx
@@ -1,4 +1,5 @@
import MenuItemContent from '@components/MenuItem/layout/MenuItemContent';
+import MenuItemLeading from '@components/MenuItem/layout/MenuItemLeading';
import MenuItemRoot from '@components/MenuItem/layout/MenuItemRoot';
import MenuItemRow from '@components/MenuItem/layout/MenuItemRow';
import MenuItemTrailing from '@components/MenuItem/layout/MenuItemTrailing';
@@ -10,39 +11,44 @@ import {callFunctionIfActionIsAllowed} from '@userActions/Session';
import type IconAsset from '@src/types/utils/IconAsset';
import type WithSentryLabel from '@src/types/utils/SentryLabel';
+import type WithTestID from '@src/types/utils/TestID';
import type {GestureResponderEvent} from 'react-native';
import React from 'react';
-type MenuItemNavigationProps = WithSentryLabel & {
- /** The title text of the row */
- title: string;
+type MenuItemNavigationProps = WithSentryLabel &
+ WithTestID & {
+ /** The title text of the row */
+ title: string;
- /** Leading icon to display */
- icon: IconAsset;
+ /** Leading icon to display */
+ icon: IconAsset;
- /** Function to fire when the row is pressed */
- onPress: (event: GestureResponderEvent | KeyboardEvent) => void | Promise;
+ /** Function to fire when the row is pressed */
+ onPress: (event: GestureResponderEvent | KeyboardEvent) => void | Promise;
- /** Whether the menu item is disabled */
- isDisabled?: boolean;
-};
+ /** Whether the menu item is disabled */
+ isDisabled?: boolean;
+ };
/**
* The navigation MenuItem preset — a tappable row with a leading icon, a title, and a trailing
* chevron signaling that pressing it takes the user somewhere else
*/
-function MenuItemNavigation({title, icon, onPress, isDisabled = false, sentryLabel}: MenuItemNavigationProps) {
+function MenuItemNavigation({title, icon, onPress, isDisabled = false, sentryLabel, testID}: MenuItemNavigationProps) {
return (
-
+
+
+
{title}
diff --git a/src/components/MenuItem/presets/MenuItemWithLabel.tsx b/src/components/MenuItem/presets/MenuItemWithLabel.tsx
new file mode 100644
index 000000000000..5c0a82a28838
--- /dev/null
+++ b/src/components/MenuItem/presets/MenuItemWithLabel.tsx
@@ -0,0 +1,42 @@
+import type {MenuItemRootProps} from '@components/MenuItem/layout/MenuItemRoot';
+import MenuItemRoot from '@components/MenuItem/layout/MenuItemRoot';
+import Text from '@components/Text';
+
+import useThemeStyles from '@hooks/useThemeStyles';
+
+import {callFunctionIfActionIsAllowed} from '@userActions/Session';
+
+import React from 'react';
+import {View} from 'react-native';
+
+type MenuItemWithLabelProps = MenuItemRootProps & {
+ /** Text above the row, naming what the row holds */
+ label: string;
+};
+
+/**
+ * The labeled MenuItem preset — a `Root` with a label above it. The label sits outside `Root`, so
+ * it stays out of the press target and does not pick up the row's hover background.
+ */
+function MenuItemWithLabel({label, onPress, isDisabled = false, sentryLabel, testID, accessibilityLabel, children}: MenuItemWithLabelProps) {
+ const styles = useThemeStyles();
+
+ return (
+
+
+ {label}
+
+
+ {children}
+
+
+ );
+}
+
+export default MenuItemWithLabel;
diff --git a/src/components/MoneyRequestConfirmationList/sections/InvoiceSenderField.tsx b/src/components/MoneyRequestConfirmationList/sections/InvoiceSenderField.tsx
index e009a9ec865d..429849945629 100644
--- a/src/components/MoneyRequestConfirmationList/sections/InvoiceSenderField.tsx
+++ b/src/components/MoneyRequestConfirmationList/sections/InvoiceSenderField.tsx
@@ -1,8 +1,9 @@
+import WorkspaceAvatar from '@components/Avatar/WorkspaceAvatar';
import MenuItem from '@components/MenuItem';
+import MenuItemWithLabel from '@components/MenuItem/presets/MenuItemWithLabel';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
-import useThemeStyles from '@hooks/useThemeStyles';
import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
import Navigation from '@libs/Navigation/Navigation';
@@ -41,7 +42,6 @@ const createCanUpdateSenderWorkspaceSelector =
isFromGlobalCreate && !isInvoiceRoomParticipant && canSendInvoice(policies ?? null, currentUserLogin);
function InvoiceSenderField({selectedParticipants, isReadOnly, didConfirm, transaction}: InvoiceSenderFieldProps) {
- const styles = useThemeStyles();
const {translate} = useLocalize();
const senderPolicyID = selectedParticipants.find((participant) => participant.isSender)?.policyID;
@@ -58,29 +58,46 @@ function InvoiceSenderField({selectedParticipants, isReadOnly, didConfirm, trans
selector: createCanUpdateSenderWorkspaceSelector(isInvoiceRoomParticipant, currentUserLogin, isFromGlobalCreate),
});
+ const isInteractive = !isReadOnly && !!canUpdateSenderWorkspace;
+ const onPress = isInteractive
+ ? () => {
+ if (!transaction?.transactionID) {
+ return;
+ }
+ Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_SEND_FROM.path));
+ }
+ : undefined;
+
return (
-
- }
+ isLoading={isThisDeviceLoading}
+ onPress={() => {
+ if (!localCredentialID) {
+ return;
+ }
+ showConfirmModal('thisDevice');
+ }}
/>
)}
{otherDeviceCount > 0 && (
-
-
-
- }
+ isLoading={isOtherDevicesLoading}
+ onPress={() => {
+ showConfirmModal(otherDevicesConfirmMode());
+ }}
/>
)}
diff --git a/src/pages/MultifactorAuthentication/RevokeRow.tsx b/src/pages/MultifactorAuthentication/RevokeRow.tsx
new file mode 100644
index 000000000000..6c30d33c954f
--- /dev/null
+++ b/src/pages/MultifactorAuthentication/RevokeRow.tsx
@@ -0,0 +1,46 @@
+import Button from '@components/ButtonComposed';
+import MenuItem from '@components/MenuItem';
+
+import useLocalize from '@hooks/useLocalize';
+
+import CONST from '@src/CONST';
+
+import React from 'react';
+
+type RevokeRowProps = {
+ /** Which set of devices this row revokes */
+ title: string;
+
+ /** Whether this row's revoke request is in flight */
+ isLoading: boolean;
+
+ /** Opens the confirmation modal for this row's set of devices */
+ onPress: () => void;
+};
+
+/** A row naming a set of registered devices, with a `Revoke` button in the trailing cell */
+function RevokeRow({title, isLoading, onPress}: RevokeRowProps) {
+ const {translate} = useLocalize();
+
+ return (
+
+
+
+ {title}
+
+
+
+
+
+
+ );
+}
+
+export default RevokeRow;
diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnersList.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnersList.tsx
index 3122a604b4dd..50996971f814 100644
--- a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnersList.tsx
+++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnersList.tsx
@@ -1,10 +1,9 @@
import Button from '@components/ButtonComposed';
import DotIndicatorMessage from '@components/DotIndicatorMessage';
-import MenuItem from '@components/MenuItem';
+import MenuItemAvatarNavigation from '@components/MenuItem/presets/MenuItemAvatarNavigation';
import ScrollView from '@components/ScrollView';
import Text from '@components/Text';
-import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
@@ -33,7 +32,6 @@ type BeneficialOwnersListProps = {
};
function BeneficialOwnersList({handleConfirmation, ownerKeys, handleOwnerEdit}: BeneficialOwnersListProps) {
- const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']);
const {translate} = useLocalize();
const styles = useThemeStyles();
const {isOffline} = useNetwork();
@@ -49,21 +47,14 @@ function BeneficialOwnersList({handleConfirmation, ownerKeys, handleOwnerEdit}:
const ownerData = getValuesForBeneficialOwner(ownerKey, reimbursementAccountDraft);
return (
- {
handleOwnerEdit(ownerKey);
}}
- iconWidth={40}
- iconHeight={40}
- interactive
- shouldShowRightIcon
- displayInDefaultIconColor
/>
);
});
diff --git a/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/CompanyOwnersListUBO.tsx b/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/CompanyOwnersListUBO.tsx
index 9e590ff3fc53..20aae6b7065d 100644
--- a/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/CompanyOwnersListUBO.tsx
+++ b/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/CompanyOwnersListUBO.tsx
@@ -1,10 +1,11 @@
+import UserAvatar from '@components/Avatar/UserAvatar';
import Button from '@components/ButtonComposed';
import DotIndicatorMessage from '@components/DotIndicatorMessage';
import MenuItem from '@components/MenuItem';
+import MenuItemAvatarNavigation from '@components/MenuItem/presets/MenuItemAvatarNavigation';
import ScrollView from '@components/ScrollView';
import Text from '@components/Text';
-import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
@@ -43,7 +44,6 @@ type CompanyOwnersListUBOProps = {
const REQUESTOR_PERSONAL_INFO_KEYS = INPUT_IDS.PERSONAL_INFO_STEP;
function CompanyOwnersListUBO({isAnyoneElseUBO, isUserUBO, handleUBOsConfirmation, beneficialOwnerKeys, handleUBOEdit}: CompanyOwnersListUBOProps) {
- const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']);
const {translate} = useLocalize();
const styles = useThemeStyles();
const {isOffline} = useNetwork();
@@ -63,21 +63,14 @@ function CompanyOwnersListUBO({isAnyoneElseUBO, isUserUBO, handleUBOsConfirmatio
const beneficialOwnerData = getValuesForBeneficialOwner(ownerKey, reimbursementAccountDraft);
return (
- {
handleUBOEdit(ownerKey);
}}
- iconWidth={40}
- iconHeight={40}
- interactive
- shouldShowRightIcon
- displayInDefaultIconColor
/>
);
});
@@ -92,18 +85,17 @@ function CompanyOwnersListUBO({isAnyoneElseUBO, isUserUBO, handleUBOsConfirmatio
{`${translate('beneficialOwnerInfoStep.owners')}:`}
{isUserUBO && (
-
+
+
+
+
+
+
+ {`${requestorData.firstName} ${requestorData.lastName}`}
+ {`${requestorData.requestorAddressStreet}, ${requestorData.requestorAddressCity}, ${requestorData.requestorAddressState} ${requestorData.requestorAddressZipCode}`}
+
+
+
)}
{extraBeneficialOwners}
diff --git a/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx b/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx
index 099008f0f268..d0a2b1c85b60 100644
--- a/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx
+++ b/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx
@@ -3,8 +3,10 @@ import Button from '@components/ButtonComposed';
import FixedFooter from '@components/FixedFooter';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import MenuItem from '@components/MenuItem';
+import MenuItemWithLabel from '@components/MenuItem/presets/MenuItemWithLabel';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import {usePersonalDetails} from '@components/OnyxListItemProvider';
+import ReportActionAvatars from '@components/ReportActionAvatars';
import ScreenWrapper from '@components/ScreenWrapper';
import ScrollView from '@components/ScrollView';
import Text from '@components/Text';
@@ -106,23 +108,28 @@ function ScheduleCallConfirmationPage() {
}}
/>
-
- {translate('scheduledCall.confirmation.description')}
-
+
+ {translate('scheduledCall.confirmation.description')}
+
+
+
+
+
+
+ {!!guideDetails?.displayName && {guideDetails.displayName}}
+ {!!guideDetails?.login && {guideDetails.login}}
+
+
+
{
if (!route?.params?.reportID) {
return;
@@ -134,7 +141,6 @@ function ScheduleCallConfirmationPage() {
title={translate('scheduledCall.confirmation.minutes')}
description={translate('scheduledCall.confirmation.meetingLength')}
interactive={false}
- style={styles.mb3}
/>
diff --git a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx
index f6d8525e30d9..91759e58688d 100644
--- a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx
+++ b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx
@@ -309,20 +309,26 @@ function IOURequestEditReportCommon({
const headerMessage = useMemo(() => (searchValue && !reportOptions.length ? translate('common.noResultsFound') : ''), [searchValue, reportOptions.length, translate]);
+ const policyForMovingExpensesName = policyForMovingExpenses?.name;
const createReportOption = useMemo(() => {
if (!createReport || (isEditing && !isOwner && !isAdmin)) {
return undefined;
}
return (
-
+
+
+
+
+
+
+ {translate('report.newReport.createReport')}
+ {!!policyForMovingExpensesName && {policyForMovingExpensesName}}
+
+
+
);
- }, [icons.Document, createReport, translate, policyForMovingExpenses?.name, handleCreateReport, isEditing, isOwner, isAdmin]);
+ }, [icons.Document, createReport, translate, policyForMovingExpensesName, handleCreateReport, isEditing, isOwner, isAdmin]);
const shouldShowNotFoundPage = useMemo(() => {
if (createReportOption) {
@@ -374,12 +380,17 @@ function IOURequestEditReportCommon({
customListHeaderContent={createReportOption}
listFooterContent={
shouldShowRemoveFromReport ? (
-
+
+
+
+
+
+
+ {translate('iou.removeFromReport')}
+ {translate('iou.moveToPersonalSpace')}
+
+
+
) : undefined
}
listEmptyContent={createReportOption}
diff --git a/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx b/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx
index 984273e6e73c..fcfa27d56137 100644
--- a/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx
+++ b/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx
@@ -1,3 +1,4 @@
+import UserAvatar from '@components/Avatar/UserAvatar';
import Button from '@components/ButtonComposed';
import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper';
import HeaderPageLayout from '@components/HeaderPageLayout';
@@ -68,14 +69,20 @@ function ConfirmDelegatePage({route}: ConfirmDelegatePageProps) {
>
{translate('delegate.confirmCopilot')}
-
+
+
+
+
+
+
+ {displayName}
+ {formattedLogin}
+
+
+
+ );
+}
+
function DynamicNewTaskPage() {
const [task] = useOnyx(ONYXKEYS.TASK);
const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${task?.shareDestination}`);
@@ -79,6 +110,9 @@ function DynamicNewTaskPage() {
const detailsBackPath = useDynamicBackPath(DYNAMIC_ROUTES.NEW_TASK.path);
const confirmButtonRef = useRef(null);
+ const navigateToAssignee = () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NEW_TASK_ASSIGNEE.path));
+ const navigateToShareDestination = task?.parentReportID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NEW_TASK_SHARE_DESTINATION.path));
+
useEffect(() => {
if (!task?.parentReportID) {
return;
@@ -188,26 +222,89 @@ function DynamicNewTaskPage() {
numberOfLinesTitle={2}
titleStyle={styles.flex1}
/>
- Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NEW_TASK_ASSIGNEE.path))}
- shouldShowRightIcon
- titleWithTooltips={assigneeTooltipDetails}
- />
- Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NEW_TASK_SHARE_DESTINATION.path))}
- interactive={!task?.parentReportID}
- shouldShowRightIcon={!task?.parentReportID}
- titleWithTooltips={shareDestination?.shouldUseFullTitleToDisplay ? undefined : shareDestination?.displayNamesWithTooltips}
- rightLabel={translate('common.required')}
- />
+ {assignee?.displayName ? (
+
+
+ {!!task?.assigneeAccountID && (
+
+
+
+ )}
+
+
+
+
+ {!!assignee.subtitle && {formatPhoneNumber(assignee.subtitle)}}
+
+
+
+
+
+
+ ) : (
+
+ )}
+ {shareDestination?.displayName ? (
+
+
+
+
+
+
+ {shareDestination.shouldUseFullTitleToDisplay || !shareDestination.displayNamesWithTooltips?.length ? (
+ {shareDestination.displayName}
+ ) : (
+
+
+
+ )}
+ {!!shareDestination.subtitle && {shareDestination.subtitle}}
+
+ {!task?.parentReportID && (
+
+
+
+ )}
+
+
+ ) : (
+
+
+
+ {translate('common.share')}
+
+
+ {translate('common.required')}
+ {!!navigateToShareDestination && }
+
+
+
+ )}
diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx
index 10595d6c725d..c386d01d52b2 100644
--- a/src/pages/workspace/categories/CategorySettingsPage.tsx
+++ b/src/pages/workspace/categories/CategorySettingsPage.tsx
@@ -576,7 +576,7 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti
))}
{canWriteRules && (
- navigateToCategoryRule(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_RULES_NEW.path)}
diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx
index b5940cb80b74..87774e614701 100644
--- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx
+++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx
@@ -177,10 +177,7 @@ function WorkspaceCompanyCardsSettingsPage({
{translate('workspace.moreFeatures.companyCards.setTransactionLiabilityDescription')}
{isDirectFeedType && (
- {
if (!isUserValidated) {
verifyAccountAndResume(undefined);
@@ -188,7 +185,17 @@ function WorkspaceCompanyCardsSettingsPage({
}
refreshCardFeed();
}}
- />
+ >
+
+
+
+
+
+ {translate('workspace.companyCards.assignNewCards.title')}
+ {translate('workspace.companyCards.assignNewCards.description')}
+
+
+
)}
{isCsvFeed && (
{translate('common.to')}
- editStep(CONST.COMPANY_CARD.STEP.ASSIGNEE)}
/>
>
)}
-
+
+
+
{memberCards.length > 0 && (
<>
diff --git a/src/stories/MenuItemComparison.stories.tsx b/src/stories/MenuItemComparison.stories.tsx
index 951c78b40cd7..9c643ea18bfd 100644
--- a/src/stories/MenuItemComparison.stories.tsx
+++ b/src/stories/MenuItemComparison.stories.tsx
@@ -1,16 +1,29 @@
+/* eslint-disable rulesdir/prefer-actions-set-data -- stories seed Onyx directly so the ID-driven avatar cases render real data */
+import UserAvatar from '@components/Avatar/UserAvatar';
+import WorkspaceAvatar from '@components/Avatar/WorkspaceAvatar';
+import Button from '@components/ButtonComposed';
import CompactMenuContext from '@components/CompactMenuContext';
+import DisplayNames from '@components/DisplayNames';
+import type {DisplayNameWithTooltip} from '@components/DisplayNames/types';
import MenuItem from '@components/MenuItem';
import MenuItemAction from '@components/MenuItem/presets/MenuItemAction';
+import MenuItemAvatarNavigation from '@components/MenuItem/presets/MenuItemAvatarNavigation';
import MenuItemNavigation from '@components/MenuItem/presets/MenuItemNavigation';
+import MenuItemWithLabel from '@components/MenuItem/presets/MenuItemWithLabel';
+import ReportActionAvatars from '@components/ReportActionAvatars';
import Text from '@components/Text';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useThemeStyles from '@hooks/useThemeStyles';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+
import type {Meta} from 'storybook-react-rsbuild';
import React from 'react';
import {View} from 'react-native';
+import Onyx from 'react-native-onyx';
/**
* Grid comparison of the legacy `MenuItem` monolith, the new composable
@@ -20,10 +33,44 @@ import {View} from 'react-native';
const story: Meta = {
title: 'Components/MenuItemComparison',
component: MenuItem,
+ // Storybook awaits loaders before the first render, so the ID-driven avatar cases always see the seeded data,
+ // and the writes only happen while this story is open.
+ loaders: [seedStoryOnyxData],
};
const CARD_WIDTH = 360;
+/** Account and report the ID-driven (`iconAccountID`/`iconReportID`) avatar cases resolve against */
+const STORY_ACCOUNT_ID = 90210;
+const STORY_REPORT_ID = 'menuItemComparisonStoryReport';
+const STORY_POLICY_ID = 'menuItemComparisonStoryPolicy';
+
+/** Seeds the personal details, policy and report the ID-driven avatar cases read from */
+async function seedStoryOnyxData() {
+ await Promise.all([
+ Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {
+ [STORY_ACCOUNT_ID]: {
+ accountID: STORY_ACCOUNT_ID,
+ displayName: 'John Doe',
+ login: 'john@example.com',
+ },
+ }),
+ Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${STORY_POLICY_ID}`, {
+ id: STORY_POLICY_ID,
+ name: 'Expensify Inc',
+ }),
+ Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${STORY_REPORT_ID}`, {
+ reportID: STORY_REPORT_ID,
+ reportName: '#announce',
+ type: CONST.REPORT.TYPE.CHAT,
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
+ policyID: STORY_POLICY_ID,
+ }),
+ ]);
+}
+
+const STORY_TOOLTIP_DETAILS: DisplayNameWithTooltip[] = [{displayName: 'John Doe', accountID: STORY_ACCOUNT_ID, login: 'john@example.com'}];
+
function noop() {}
function Label({children}: {children: string}) {
@@ -31,20 +78,25 @@ function Label({children}: {children: string}) {
return {children};
}
-function Card({title, legacy, composable, preset}: {title: string; legacy: React.ReactNode; composable: React.ReactNode; preset?: React.ReactNode}) {
+function Card({title, note, legacy, composable, preset}: {title: string; note?: string; legacy: React.ReactNode; composable?: React.ReactNode; preset?: React.ReactNode}) {
const styles = useThemeStyles();
return (
- {title}
-
- {legacy}
+ {title}
+ {!!note && {note}}
-
- {composable}
+
+ {legacy}
+ {!!composable && (
+
+
+ {composable}
+
+ )}
{!!preset && (
@@ -55,16 +107,519 @@ function Card({title, legacy, composable, preset}: {title: string; legacy: React
);
}
+/** A labelled row inside a card, for cases that need several variants side by side */
+function Variant({label, children}: {label: string; children: React.ReactNode}) {
+ const styles = useThemeStyles();
+
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+
+function SectionHeading({title, children}: {title: string; children?: string}) {
+ const styles = useThemeStyles();
+
+ return (
+
+ {title}
+ {!!children && {children}}
+
+ );
+}
+
function Comparison() {
const styles = useThemeStyles();
- const icons = useMemoizedLazyExpensifyIcons(['Gear']);
+ const icons = useMemoizedLazyExpensifyIcons(['Gear', 'FallbackAvatar']);
- if (!icons.Gear) {
+ if (!icons.Gear || !icons.FallbackAvatar) {
return null;
}
return (
+
+
+
+ }
+ composable={
+
+
+
+
+
+
+
+
+
+ john@example.com
+
+
+
+
+
+
+ }
+ />
+
+
+ }
+ composable={
+
+
+
+
+
+
+
+ John Doe
+ john.doe.with.a.very.long.email.address.for.truncation@example.com
+
+
+
+
+ }
+ />
+
+
+ }
+ composable={
+
+
+
+
+
+
+ John Doe
+ john@example.com
+
+
+
+
+
+
+ }
+ preset={
+
+ }
+ />
+
+
+
+
+
+
+
+
+ >
+ }
+ preset={
+ <>
+
+
+
+
+
+
+ >
+ }
+ />
+
+
+
+
+
+
+
+
+ >
+ }
+ composable={
+ <>
+
+
+
+
+ Vacation delegate
+
+
+
+
+
+
+
+
+
+
+
+ John Doe
+ Vacation delegate
+
+
+
+
+
+
+
+ >
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+
+ >
+ }
+ composable={
+ <>
+
+
+
+
+
+
+
+ John Doe
+ john@example.com
+
+
+
+
+
+
+
+
+
+
+
+ John Doe
+ john@example.com
+
+
+
+
+
+
+
+
+
+
+
+ John Doe
+ john@example.com
+
+
+
+
+ >
+ }
+ />
+
+
+ }
+ composable={
+
+
+
+
+
+
+ Expensify Inc
+ Workspace
+
+
+
+
+
+
+ }
+ />
+
+
+ }
+ composable={
+
+
+
+
+
+
+
+
+
+ john@example.com
+
+
+
+
+
+
+ }
+ />
+
+
+ }
+ composable={
+
+
+
+
+
+
+ Expensify Inc
+
+
+ Required
+
+
+
+
+ }
+ />
+
+
+ }
+ composable={
+
+
+
+
+
+
+ John Doe
+ john@example.com
+
+
+
+ }
+ />
+
+ Cases the compound API and the Action/Navigation presets already cover.
+
-
+
+
+
Settings
@@ -125,7 +682,9 @@ function Comparison() {
composable={
-
+
+
+
Settings
@@ -158,7 +717,9 @@ function Comparison() {
composable={
-
+
+
+
Settings
Manage your preferences
@@ -171,6 +732,73 @@ function Comparison() {
}
/>
+
+ }
+ composable={
+
+
+
+
+
+
+ Create report
+ Expensify Inc
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+ }
+ composable={
+
+
+
+ This device
+
+
+
+
+
+
+ }
+ />
+
-
+
+
+
Settings
@@ -230,7 +860,9 @@ function Comparison() {
isDisabled
>
-
+
+
+
Settings
@@ -265,7 +897,9 @@ function Comparison() {
accessibilityLabel="Settings"
>
-
+
+
+
Settings
@@ -295,7 +929,9 @@ function Comparison() {
composable={
-
+
+
+
Edit columns
@@ -330,7 +966,9 @@ function Comparison() {
composable={
-
+
+
+
Edit columns
Choose what to display
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 10d53d4bd09c..d15e5811851d 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -5342,6 +5342,11 @@ const staticStyles = (theme: ThemeColors) =>
...spacing.gap3,
},
+ menuItemLeading: {
+ ...flex.justifyContentCenter,
+ ...flex.alignItemsCenter,
+ },
+
menuItemTrailing: {
...flex.flexRow,
...flex.alignItemsCenter,
diff --git a/src/types/utils/TestID.ts b/src/types/utils/TestID.ts
new file mode 100644
index 000000000000..b99722ab2b34
--- /dev/null
+++ b/src/types/utils/TestID.ts
@@ -0,0 +1,6 @@
+/** Test ID used to locate a component in tests */
+type WithTestID = {
+ testID?: string;
+};
+
+export default WithTestID;
diff --git a/tests/unit/components/VacationDelegateMenuItemTest.tsx b/tests/unit/components/VacationDelegateMenuItemTest.tsx
index 7adaa8da7969..69f541d595dd 100644
--- a/tests/unit/components/VacationDelegateMenuItemTest.tsx
+++ b/tests/unit/components/VacationDelegateMenuItemTest.tsx
@@ -3,7 +3,7 @@
* - existing accounts (personal details present) for both email and phone-number logins
* - new accounts (personal details missing, e.g. after cache clear) for both email and phone-number logins
*/
-import {render} from '@testing-library/react-native';
+import {render, screen} from '@testing-library/react-native';
import VacationDelegateMenuItem from '@components/VacationDelegateMenuItem';
@@ -56,23 +56,16 @@ jest.mock('@components/OfflineWithFeedback', () => {
return MockOfflineWithFeedback;
});
-// Capture the props passed to the MenuItem so the test can assert against `title`, `description`,
-// and `avatarID` directly (rather than re-encoding through `toJSON()`).
-const capturedMenuItemProps: Array> = [];
+// Capture the props passed to the MenuItemAvater so the test can assert against `avatarID` directly
+// (rather than re-encoding through `toJSON()`).
+const capturedAvatarProps: Array> = [];
-jest.mock('@components/MenuItem', () => {
- function MockMenuItem(props: Record) {
- capturedMenuItemProps.push(props);
+jest.mock('@components/Avatar/UserAvatar', () => {
+ function MockUserAvatar(props: Record) {
+ capturedAvatarProps.push(props);
return null;
}
- return MockMenuItem;
-});
-
-jest.mock('@components/Text', () => {
- function MockText({children}: {children: React.ReactNode}) {
- return children;
- }
- return MockText;
+ return MockUserAvatar;
});
const mockUseVacationDelegatePersonalDetails = jest.mocked(useVacationDelegatePersonalDetails);
@@ -82,17 +75,31 @@ const PHONE_DELEGATE_WITH_SMS_DOMAIN = '+919789942470@expensify.sms';
const PHONE_DELEGATE_RAW = '+919789942470';
const PHONE_DELEGATE_LOCALIZED = '97899 42470';
-function lastMenuItemProps() {
- return capturedMenuItemProps.at(-1) ?? {};
+function lastAvatarProps() {
+ return capturedAvatarProps.at(-1) ?? {};
}
describe('VacationDelegateMenuItem', () => {
beforeEach(() => {
jest.clearAllMocks();
- capturedMenuItemProps.length = 0;
+ capturedAvatarProps.length = 0;
mockUseVacationDelegatePersonalDetails.mockReturnValue(undefined);
});
+ function renderMenuItem(delegate?: string) {
+ render(
+ ,
+ );
+ }
+
+ function textOccurrences(text: string) {
+ return screen.queryAllByText(text).length;
+ }
+
describe('existing account (personal details available)', () => {
it('renders the displayName for an email delegate without `@expensify.sms` artifacts', () => {
const personalDetails: PersonalDetails = {
@@ -102,19 +109,12 @@ describe('VacationDelegateMenuItem', () => {
};
mockUseVacationDelegatePersonalDetails.mockReturnValue(personalDetails);
- render(
- ,
- );
-
- const props = lastMenuItemProps();
- expect(props.title).toBe('Jane Doe');
- expect(props.description).toBe(EMAIL_DELEGATE);
- expect(props.avatarID).toBe(42);
- expect(JSON.stringify(props)).not.toContain('@expensify.sms');
+ renderMenuItem(EMAIL_DELEGATE);
+
+ expect(screen.getByText('Jane Doe')).toBeTruthy();
+ expect(screen.getByText(EMAIL_DELEGATE)).toBeTruthy();
+ expect(lastAvatarProps().accountID).toBe(42);
+ expect(screen.queryByText(/@expensify\.sms/)).toBeNull();
});
// The backend defaults `displayName` to the login, so the title has to be formatted rather than
@@ -127,20 +127,12 @@ describe('VacationDelegateMenuItem', () => {
};
mockUseVacationDelegatePersonalDetails.mockReturnValue(personalDetails);
- render(
- ,
- );
-
- const props = lastMenuItemProps();
- expect(props.title).toBe(PHONE_DELEGATE_LOCALIZED);
- expect(props.title).not.toBe(PHONE_DELEGATE_RAW);
- expect(props.description).toBe(PHONE_DELEGATE_LOCALIZED);
- expect(props.avatarID).toBe(43);
- expect(JSON.stringify(props)).not.toContain('@expensify.sms');
+ renderMenuItem(PHONE_DELEGATE_WITH_SMS_DOMAIN);
+
+ expect(textOccurrences(PHONE_DELEGATE_LOCALIZED)).toBe(2);
+ expect(screen.queryByText(PHONE_DELEGATE_RAW)).toBeNull();
+ expect(lastAvatarProps().accountID).toBe(43);
+ expect(screen.queryByText(/@expensify\.sms/)).toBeNull();
});
// Bug #89578 — the reported case: the backend hands back the login as the display name with the SMS domain
@@ -153,17 +145,10 @@ describe('VacationDelegateMenuItem', () => {
};
mockUseVacationDelegatePersonalDetails.mockReturnValue(personalDetails);
- render(
- ,
- );
-
- const props = lastMenuItemProps();
- expect(props.title).toBe(PHONE_DELEGATE_LOCALIZED);
- expect(props.title).not.toBe(PHONE_DELEGATE_RAW);
+ renderMenuItem(PHONE_DELEGATE_WITH_SMS_DOMAIN);
+
+ expect(textOccurrences(PHONE_DELEGATE_LOCALIZED)).toBe(2);
+ expect(screen.queryByText(PHONE_DELEGATE_RAW)).toBeNull();
});
it('keeps the name a phone-number account did set, and localizes the number below it', () => {
@@ -174,17 +159,11 @@ describe('VacationDelegateMenuItem', () => {
};
mockUseVacationDelegatePersonalDetails.mockReturnValue(personalDetails);
- render(
- ,
- );
-
- const props = lastMenuItemProps();
- expect(props.title).toBe('Jane Doe');
- expect(props.description).toBe(PHONE_DELEGATE_LOCALIZED);
+ renderMenuItem(PHONE_DELEGATE_WITH_SMS_DOMAIN);
+
+ expect(screen.getByText('Jane Doe')).toBeTruthy();
+ expect(textOccurrences(PHONE_DELEGATE_LOCALIZED)).toBe(1);
+ expect(screen.queryByText(PHONE_DELEGATE_RAW)).toBeNull();
});
// A local contact is stored without the country code. Personal details come back as E.164, which used
@@ -197,17 +176,11 @@ describe('VacationDelegateMenuItem', () => {
};
mockUseVacationDelegatePersonalDetails.mockReturnValue(personalDetails);
- render(
- ,
- );
-
- const props = lastMenuItemProps();
- expect(props.title).toBe(PHONE_DELEGATE_LOCALIZED);
- expect(props.title).not.toBe(PHONE_DELEGATE_RAW);
+ renderMenuItem('9789942470@expensify.sms');
+
+ expect(screen.getByText(PHONE_DELEGATE_LOCALIZED)).toBeTruthy();
+ expect(screen.getByText('9789942470')).toBeTruthy();
+ expect(screen.queryByText(PHONE_DELEGATE_RAW)).toBeNull();
});
});
@@ -215,50 +188,28 @@ describe('VacationDelegateMenuItem', () => {
it('renders the raw email as title and description when no personal details exist', () => {
mockUseVacationDelegatePersonalDetails.mockReturnValue(undefined);
- render(
- ,
- );
-
- const props = lastMenuItemProps();
- expect(props.title).toBe(EMAIL_DELEGATE);
- expect(props.description).toBe(EMAIL_DELEGATE);
- expect(JSON.stringify(props)).not.toContain('@expensify.sms');
+ renderMenuItem(EMAIL_DELEGATE);
+
+ expect(textOccurrences(EMAIL_DELEGATE)).toBe(2);
+ expect(screen.queryByText(/@expensify\.sms/)).toBeNull();
});
// Bug #89578 — the exact scenario reported.
it('renders the localized phone number when no personal details exist', () => {
mockUseVacationDelegatePersonalDetails.mockReturnValue(undefined);
- render(
- ,
- );
-
- const props = lastMenuItemProps();
- expect(props.title).toBe(PHONE_DELEGATE_LOCALIZED);
- expect(props.title).not.toBe(PHONE_DELEGATE_RAW);
- expect(props.description).toBe(PHONE_DELEGATE_LOCALIZED);
- expect(JSON.stringify(props)).not.toContain('@expensify.sms');
+ renderMenuItem(PHONE_DELEGATE_WITH_SMS_DOMAIN);
+
+ expect(textOccurrences(PHONE_DELEGATE_LOCALIZED)).toBe(2);
+ expect(screen.queryByText(PHONE_DELEGATE_RAW)).toBeNull();
+ expect(screen.queryByText(/@expensify\.sms/)).toBeNull();
});
});
it('renders the empty-state menu item when no vacation delegate is set', () => {
- render(
- ,
- );
+ renderMenuItem();
- const props = lastMenuItemProps();
- expect(props.description).toBe('common.vacationDelegate');
- expect(props.title).toBeUndefined();
+ expect(screen.getByText('common.vacationDelegate')).toBeTruthy();
+ expect(capturedAvatarProps).toHaveLength(0);
});
});
diff --git a/tests/unit/pages/settings/ConfirmDelegatePageTest.tsx b/tests/unit/pages/settings/ConfirmDelegatePageTest.tsx
index d1b4952b2a6b..2d64e48a2d1c 100644
--- a/tests/unit/pages/settings/ConfirmDelegatePageTest.tsx
+++ b/tests/unit/pages/settings/ConfirmDelegatePageTest.tsx
@@ -90,11 +90,11 @@ jest.mock('@components/Text', () => {
return MockText;
});
-jest.mock('@components/MenuItem', () => {
- function MockMenuItem({title, description}: {title: string; description: string}) {
- return `title=${title}|description=${description}`;
+jest.mock('@components/Avatar/UserAvatar', () => {
+ function MockUserAvatar() {
+ return null;
}
- return MockMenuItem;
+ return MockUserAvatar;
});
jest.mock('@components/MenuItemWithTopDescription', () => {
@@ -126,7 +126,7 @@ describe('ConfirmDelegatePage', () => {
const output = renderPage();
- expect(output).toContain(`title=${FORMATTED_SMS_LOGIN}`);
+ expect(output).toContain(FORMATTED_SMS_LOGIN);
expect(output).not.toContain(CONST.SMS.DOMAIN);
});
@@ -135,7 +135,7 @@ describe('ConfirmDelegatePage', () => {
const output = renderPage();
- expect(output).toContain(`title=${FORMATTED_SMS_LOGIN}`);
+ expect(output).toContain(FORMATTED_SMS_LOGIN);
expect(output).not.toContain(CONST.SMS.DOMAIN);
});
@@ -146,7 +146,7 @@ describe('ConfirmDelegatePage', () => {
// `formatPhoneNumber` returns a non-phone string untouched apart from swapping its spaces for
// non-breaking ones, which is what the 41 other call sites wrapping a display name already do.
- expect(output).toContain('title=Ada\u00A0Lovelace');
- expect(output).toContain(`description=${FORMATTED_SMS_LOGIN}`);
+ expect(output).toContain('Ada\u00A0Lovelace');
+ expect(output).toContain(FORMATTED_SMS_LOGIN);
});
});