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 ( - { - if (!transaction?.transactionID) { - return; - } - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_SEND_FROM.path)); - }} - style={styles.moneyRequestMenuItem} - labelStyle={styles.mt2} - titleStyle={styles.flex1} - disabled={didConfirm} + onPress={onPress} + isDisabled={didConfirm} sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.SEND_FROM_FIELD} - /> + > + + + + + + {!!senderWorkspace?.name && {senderWorkspace.name}} + {senderWorkspace?.name ? ( + {translate('workspace.common.workspace')} + ) : ( + {translate('workspace.common.workspace')} + )} + + {isInteractive && ( + + + + )} + + ); } diff --git a/src/components/VacationDelegateMenuItem.tsx b/src/components/VacationDelegateMenuItem.tsx index a2168a61572d..cf6e8ed9f328 100644 --- a/src/components/VacationDelegateMenuItem.tsx +++ b/src/components/VacationDelegateMenuItem.tsx @@ -11,9 +11,11 @@ import type {BaseVacationDelegate} from '@src/types/onyx/VacationDelegate'; import React from 'react'; +import UserAvatar from './Avatar/UserAvatar'; import MenuItem from './MenuItem'; +import MenuItemEmptyField from './MenuItem/presets/MenuItemEmptyField'; +import MenuItemWithLabel from './MenuItem/presets/MenuItemWithLabel'; import OfflineWithFeedback from './OfflineWithFeedback'; -import Text from './Text'; type VacationDelegateSectionProps = { /** Currently selected vacation delegate (if any) */ @@ -49,33 +51,42 @@ function VacationDelegateMenuItem({vacationDelegate, errors, pendingAction, onCl const delegateDisplayName = getVacationDelegateDisplayName(rawDelegateLogin, vacationDelegatePersonalDetails?.displayName, formatPhoneNumber); const delegateDescription = formatPhoneNumber(rawDelegateLogin); - return hasVacationDelegate ? ( - <> - {translate('common.vacationDelegate')} - - + {hasVacationDelegate ? ( + + + + + + + {delegateDisplayName} + {!!delegateDescription && {delegateDescription}} + + + + + + + ) : ( + - - - ) : ( - + )} + ); } diff --git a/src/pages/MultifactorAuthentication/RevokePage.tsx b/src/pages/MultifactorAuthentication/RevokePage.tsx index 9a39b8389973..b5425dd9ea27 100644 --- a/src/pages/MultifactorAuthentication/RevokePage.tsx +++ b/src/pages/MultifactorAuthentication/RevokePage.tsx @@ -3,7 +3,6 @@ import Button from '@components/ButtonComposed'; import ConfirmModal from '@components/ConfirmModal'; import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItem from '@components/MenuItem'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; @@ -22,6 +21,8 @@ import CONST from '@src/CONST'; import React, {useCallback, useEffect, useState} from 'react'; import {View} from 'react-native'; +import RevokeRow from './RevokeRow'; + type ConfirmMode = 'thisDevice' | 'single' | 'multiple' | 'all'; const confirmPromptKeys = { @@ -188,48 +189,24 @@ function MultifactorAuthenticationRevokePage() { {/* The isCurrentDeviceRegistered guard guarantees localCredentialID is truthy here. Do not remove this guard without updating the non-null assertion on localCredentialID below. */} {isCurrentDeviceRegistered && ( - - - - } + 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); }); });