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..76f9d7639302 100644 --- a/src/components/MenuItem/index.ts +++ b/src/components/MenuItem/index.ts @@ -13,7 +13,9 @@ * * * - * + * + * + * * * {translate('common.settings')} * @@ -25,11 +27,13 @@ * ``` */ 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 MenuItemLabel from './leaves/text/MenuItemLabel'; import MenuItemTitle from './leaves/text/MenuItemTitle'; import MenuItemChevron from './leaves/trailing/MenuItemChevron'; import LegacyMenuItem from './MenuItem'; @@ -37,9 +41,11 @@ import LegacyMenuItem from './MenuItem'; const MenuItem = Object.assign(LegacyMenuItem, { Root: MenuItemRoot, Row: MenuItemRow, + Leading: MenuItemLeading, Content: MenuItemContent, Trailing: MenuItemTrailing, Icon: MenuItemIcon, + Label: MenuItemLabel, Title: MenuItemTitle, Description: MenuItemDescription, Chevron: MenuItemChevron, diff --git a/src/components/MenuItem/layout/MenuItemLeading.tsx b/src/components/MenuItem/layout/MenuItemLeading.tsx new file mode 100644 index 000000000000..b05af95dcaa8 --- /dev/null +++ b/src/components/MenuItem/layout/MenuItemLeading.tsx @@ -0,0 +1,20 @@ +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, so an icon, avatar or spinner brings its own footprint. + */ +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 085809d9f233..153fa09f9f03 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}) => ( diff --git a/src/components/MenuItem/leaves/leading/MenuItemIcon.tsx b/src/components/MenuItem/leaves/leading/MenuItemIcon.tsx index 21185f7f6d56..60233bf746c8 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(); @@ -31,15 +30,14 @@ function MenuItemIcon({src}: MenuItemIconProps) { const iconFill = StyleUtils.getIconFillColor(getButtonState(isHovered, isPressed, isComplete, isDisabled, isInteractive), isMenuIcon, isPane); return ( - - - + ); } diff --git a/src/components/MenuItem/leaves/text/MenuItemDescription.tsx b/src/components/MenuItem/leaves/text/MenuItemDescription.tsx index 0bcea9d541a4..1fce3ca2e0a3 100644 --- a/src/components/MenuItem/leaves/text/MenuItemDescription.tsx +++ b/src/components/MenuItem/leaves/text/MenuItemDescription.tsx @@ -1,25 +1,59 @@ 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 type {StyleProp, TextStyle} from 'react-native'; +import type {ValueOf} from 'type-fest'; + import React from 'react'; +const MENU_ITEM_DESCRIPTION_VARIANT = { + /** The small supporting-label look, for a description that sits under a title */ + SUPPORTING: 'supporting', + + /** Normal-size text, for a description standing in for a value the row does not have yet */ + PLACEHOLDER: 'placeholder', +} as const; + +type MenuItemDescriptionVariant = ValueOf; + +type MenuItemDescriptionVariantStyles = Record>; + type MenuItemDescriptionProps = { /** Text to render as the description */ children: string | number; + + /** Maximum number of lines to render before the text is truncated */ + numberOfLines?: number; + + /** + * Typography variant. `supporting` (default) is the small label look; `placeholder` bumps the font to + * the normal size — use it on rows with no title, where the description carries the row on its own. + */ + variant?: MenuItemDescriptionVariant; }; /** The supporting text block of a `MenuItem.Content` */ -function MenuItemDescription({children}: MenuItemDescriptionProps) { +function MenuItemDescription({children, numberOfLines = 2, variant = MENU_ITEM_DESCRIPTION_VARIANT.SUPPORTING}: MenuItemDescriptionProps) { const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); useMenuItemAccessibilityLabel('description', String(children)); + /** Typography applied on top of the shared supporting-label base, keyed by variant */ + const variantStyles: MenuItemDescriptionVariantStyles = { + [MENU_ITEM_DESCRIPTION_VARIANT.SUPPORTING]: styles.textLineHeightNormal, + [MENU_ITEM_DESCRIPTION_VARIANT.PLACEHOLDER]: [StyleUtils.getFontSizeStyle(variables.fontSizeNormal), StyleUtils.getLineHeightStyle(variables.fontSizeNormalHeight)], + }; + return ( {children} @@ -27,3 +61,4 @@ function MenuItemDescription({children}: MenuItemDescriptionProps) { } export default MenuItemDescription; +export {MENU_ITEM_DESCRIPTION_VARIANT}; diff --git a/src/components/MenuItem/leaves/text/MenuItemLabel.tsx b/src/components/MenuItem/leaves/text/MenuItemLabel.tsx new file mode 100644 index 000000000000..032a1516d978 --- /dev/null +++ b/src/components/MenuItem/leaves/text/MenuItemLabel.tsx @@ -0,0 +1,19 @@ +import Text from '@components/Text'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +type MenuItemLabelProps = { + /** Text to render as the label */ + children: string; +}; + +/** The label above a menu item's `Row` */ +function MenuItemLabel({children}: MenuItemLabelProps) { + const styles = useThemeStyles(); + + return {children}; +} + +export default MenuItemLabel; diff --git a/src/components/MenuItem/leaves/text/MenuItemTitle.tsx b/src/components/MenuItem/leaves/text/MenuItemTitle.tsx index ccafead71111..c1e89d5e1c62 100644 --- a/src/components/MenuItem/leaves/text/MenuItemTitle.tsx +++ b/src/components/MenuItem/leaves/text/MenuItemTitle.tsx @@ -8,19 +8,31 @@ 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; + + /** Plain-text form of the title, contributed to the row's accessibility label */ + 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 ( 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/MenuItemEntity.tsx b/src/components/MenuItem/presets/MenuItemEntity.tsx new file mode 100644 index 000000000000..7d8a08351dd1 --- /dev/null +++ b/src/components/MenuItem/presets/MenuItemEntity.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/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 MenuItemEntityProps = WithSentryLabel & + WithTestID & { + /** The entity's name, rendered as the row's title */ + title: string; + + /** Supporting line under the title — an email, address, or other secondary identifier */ + description: string; + + /** Account ID the avatar belongs to. Picks the default avatar when `avatarSource` is absent */ + accountID: number; + + /** The entity's avatar. Falls back to the default avatar for `accountID` 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 entity 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 MenuItemEntity({title, description, accountID, avatarSource, onPress, isDisabled = false, sentryLabel, testID}: MenuItemEntityProps) { + return ( + + + + + + + {title} + {description} + + + + + + + ); +} + +export default MenuItemEntity; 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/MoneyRequestConfirmationList/sections/InvoiceSenderField.tsx b/src/components/MoneyRequestConfirmationList/sections/InvoiceSenderField.tsx index e009a9ec865d..cb6b9aa9e2b8 100644 --- a/src/components/MoneyRequestConfirmationList/sections/InvoiceSenderField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/InvoiceSenderField.tsx @@ -1,4 +1,6 @@ +import WorkspaceAvatar from '@components/Avatar/WorkspaceAvatar'; import MenuItem from '@components/MenuItem'; +import {MENU_ITEM_DESCRIPTION_VARIANT} from '@components/MenuItem/leaves/text/MenuItemDescription'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -18,6 +20,7 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {emailSelector} from '@selectors/Session'; import React from 'react'; +import {View} from 'react-native'; type InvoiceSenderFieldProps = { /** The selected participants */ @@ -58,29 +61,49 @@ function InvoiceSenderField({selectedParticipants, isReadOnly, didConfirm, trans selector: createCanUpdateSenderWorkspaceSelector(isInvoiceRoomParticipant, currentUserLogin, isFromGlobalCreate), }); + const isInteractive = !isReadOnly && !!canUpdateSenderWorkspace; + return ( - { - if (!transaction?.transactionID) { - return; + <> + + {translate('workspace.invoices.sendFrom')} + + { + if (!transaction?.transactionID) { + return; + } + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_SEND_FROM.path)); + } + : undefined } - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_SEND_FROM.path)); - }} - style={styles.moneyRequestMenuItem} - labelStyle={styles.mt2} - titleStyle={styles.flex1} - disabled={didConfirm} - sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.SEND_FROM_FIELD} - /> + isDisabled={didConfirm} + sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.SEND_FROM_FIELD} + > + + + + + + {!!senderWorkspace?.name && {senderWorkspace.name}} + + {translate('workspace.common.workspace')} + + + {isInteractive && ( + + + + )} + + + ); } diff --git a/src/components/VacationDelegateMenuItem.tsx b/src/components/VacationDelegateMenuItem.tsx index 4a3f21a4ea3b..ce17608b5f91 100644 --- a/src/components/VacationDelegateMenuItem.tsx +++ b/src/components/VacationDelegateMenuItem.tsx @@ -9,7 +9,9 @@ import type {BaseVacationDelegate} from '@src/types/onyx/VacationDelegate'; import React from 'react'; +import UserAvatar from './Avatar/UserAvatar'; import MenuItem from './MenuItem'; +import {MENU_ITEM_DESCRIPTION_VARIANT} from './MenuItem/leaves/text/MenuItemDescription'; import OfflineWithFeedback from './OfflineWithFeedback'; import Text from './Text'; @@ -46,7 +48,46 @@ function VacationDelegateMenuItem({vacationDelegate, errors, pendingAction, onCl const formattedDelegateLogin = formatPhoneNumber(vacationDelegatePersonalDetails?.login ?? ''); const fallbackVacationDelegateLogin = formattedDelegateLogin === '' ? vacationDelegate?.delegate : formattedDelegateLogin; - return hasVacationDelegate ? ( + // With a delegate set, the row shows their name with their login underneath. Without one, the field's + // own label takes the description slot as a placeholder, so it renders at the standalone size instead. + const description = hasVacationDelegate ? fallbackVacationDelegateLogin : translate('common.vacationDelegate'); + const title = hasVacationDelegate ? (vacationDelegatePersonalDetails?.displayName ?? fallbackVacationDelegateLogin) : undefined; + + const delegateRow = ( + + + {hasVacationDelegate && ( + + + + )} + + {!!title && {title}} + {!!description && ( + + {description} + + )} + + + + + + + ); + + // The section heading and the offline/error feedback only exist once a delegate is set. + if (!hasVacationDelegate) { + return delegateRow; + } + + return ( <> {translate('common.vacationDelegate')} - + {delegateRow} - ) : ( - ); } diff --git a/src/pages/MultifactorAuthentication/RevokePage.tsx b/src/pages/MultifactorAuthentication/RevokePage.tsx index 30de7ea7da43..f213a042e0a3 100644 --- a/src/pages/MultifactorAuthentication/RevokePage.tsx +++ b/src/pages/MultifactorAuthentication/RevokePage.tsx @@ -31,6 +31,42 @@ const confirmPromptKeys = { all: 'multifactorAuthentication.revoke.confirmationPromptAll', } as const; +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 non-interactive 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} + + + + + + + ); +} + /** * Revoke page for multifactor authentication (biometric/passkey) credentials. * @@ -188,48 +224,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/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnersList.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnersList.tsx index 3122a604b4dd..416c5a712345 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 MenuItemEntity from '@components/MenuItem/presets/MenuItemEntity'; 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..1d6276b40c5f 100644 --- a/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/CompanyOwnersListUBO.tsx +++ b/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/CompanyOwnersListUBO.tsx @@ -1,6 +1,8 @@ +import UserAvatar from '@components/Avatar/UserAvatar'; import Button from '@components/ButtonComposed'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; import MenuItem from '@components/MenuItem'; +import MenuItemEntity from '@components/MenuItem/presets/MenuItemEntity'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; @@ -63,21 +65,14 @@ function CompanyOwnersListUBO({isAnyoneElseUBO, isUserUBO, handleUBOsConfirmatio const beneficialOwnerData = getValuesForBeneficialOwner(ownerKey, reimbursementAccountDraft); return ( - { handleUBOEdit(ownerKey); }} - iconWidth={40} - iconHeight={40} - interactive - shouldShowRightIcon - displayInDefaultIconColor /> ); }); @@ -92,18 +87,20 @@ 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 69460b72c14f..fba8b7c90cdf 100644 --- a/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx +++ b/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx @@ -5,6 +5,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; 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'; @@ -31,6 +32,7 @@ import type {PersonalDetails} from '@src/types/onyx'; import {useRoute} from '@react-navigation/native'; import {addMinutes} from 'date-fns'; import React, {useEffect} from 'react'; +import {View} from 'react-native'; function ScheduleCallConfirmationPage() { const styles = useThemeStyles(); @@ -107,23 +109,31 @@ function ScheduleCallConfirmationPage() { }} /> - - {translate('scheduledCall.confirmation.description')} - + + {translate('scheduledCall.confirmation.description')} + + + {translate('scheduledCall.confirmation.setupSpecialist')} + + + + + + + {!!guideDetails?.displayName && {guideDetails.displayName}} + {!!guideDetails?.login && {guideDetails.login}} + + + { if (!route?.params?.reportID) { return; @@ -135,7 +145,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 ffc1ef9a98c0..3193f68ad42c 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 f7fcb2ddbb23..b8abe7e723db 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'; @@ -66,14 +67,20 @@ function ConfirmDelegatePage({route}: ConfirmDelegatePageProps) { > {translate('delegate.confirmCopilot')} - + + + + + + + {displayName} + {formattedLogin} + + + void; +}; + +/** + * One of the task's participant fields (assignee, share destination). Both render the same shape: the + * field name on top once a value is picked, then that value's avatar, name and secondary line. + */ +function TaskFieldRow({label, avatars, displayName, displayNamesWithTooltips, description, shouldShowRequiredLabel = false, shouldShowChevron = true, onPress}: TaskFieldRowProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const hasValue = !!displayName; + + return ( + + {hasValue && ( + + {label} + + )} + + {!!avatars && {avatars}} + + {hasValue && + (displayNamesWithTooltips?.length ? ( + + + + ) : ( + {displayName} + ))} + {!!description && ( + {description} + )} + + {(shouldShowRequiredLabel || shouldShowChevron) && ( + + {shouldShowRequiredLabel && {translate('common.required')}} + {shouldShowChevron && } + + )} + + + ); +} + function DynamicNewTaskPage() { const [task] = useOnyx(ONYXKEYS.TASK); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${task?.shareDestination}`); @@ -183,25 +262,37 @@ function DynamicNewTaskPage() { numberOfLinesTitle={2} titleStyle={styles.flex1} /> - + ) + } + displayName={assignee?.displayName} + displayNamesWithTooltips={assigneeTooltipDetails} description={assignee?.displayName ? formatPhoneNumber(assignee?.subtitle) : translate('task.assignee')} - iconAccountID={task?.assigneeAccountID} onPress={() => 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')} + + ) + } + displayName={shareDestination?.displayName} + displayNamesWithTooltips={shareDestination?.shouldUseFullTitleToDisplay ? undefined : shareDestination?.displayNamesWithTooltips} + description={shareDestination?.displayName ? (shareDestination.subtitle ?? '') : translate('common.share')} + shouldShowRequiredLabel={!shareDestination?.displayName} + shouldShowChevron={!task?.parentReportID} + onPress={task?.parentReportID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NEW_TASK_SHARE_DESTINATION.path))} /> diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx index 00a4c1c54775..69ca4be8264b 100644 --- a/src/pages/workspace/categories/CategorySettingsPage.tsx +++ b/src/pages/workspace/categories/CategorySettingsPage.tsx @@ -579,7 +579,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 f2c2dccb7f5c..f5fcda7e0652 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx @@ -180,10 +180,7 @@ function WorkspaceCompanyCardsSettingsPage({ {translate('workspace.moreFeatures.companyCards.setTransactionLiabilityDescription')} {isDirectFeedType && ( - { if (!selectedFeed) { return; @@ -194,7 +191,17 @@ function WorkspaceCompanyCardsSettingsPage({ } startCardFeedRefresh(policyID, selectedFeed, policy?.outputCurrency, currencyList, countryByIp); }} - /> + > + + + + + + {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..7c3bfbb439e9 100644 --- a/src/stories/MenuItemComparison.stories.tsx +++ b/src/stories/MenuItemComparison.stories.tsx @@ -1,21 +1,38 @@ +/* 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 {MENU_ITEM_DESCRIPTION_VARIANT} from '@components/MenuItem/leaves/text/MenuItemDescription'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; +import MenuItemEntity from '@components/MenuItem/presets/MenuItemEntity'; import MenuItemNavigation from '@components/MenuItem/presets/MenuItemNavigation'; +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 * `MenuItem.Root`/`Row`/... API, and the `MenuItemAction`/`MenuItemNavigation` presets. * Each card shows the same visual case built with every API that can currently express it. + * + * The "Phase 2" avatar sections lead, because those rows are what is under review, and they carry no + * prose — they are there purely to compare renders. Cards the compound API cannot express yet show + * the legacy render only. The Phase 1 icon rows come last as the already-settled baseline. */ const story: Meta = { title: 'Components/MenuItemComparison', @@ -24,6 +41,34 @@ const story: Meta = { 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'; + +Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [STORY_ACCOUNT_ID]: { + accountID: STORY_ACCOUNT_ID, + displayName: 'Alex Reed', + login: 'alex@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: 'Alex Reed', accountID: STORY_ACCOUNT_ID, login: 'alex@example.com'}]; + function noop() {} function Label({children}: {children: string}) { @@ -31,20 +76,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 +105,518 @@ 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={ + + Assignee + + + + + + + + + alex@example.com + + + + + + + } + /> + + + } + composable={ + + + + + + + + Alexandra Reed-Fitzgerald + alexandra.reed.fitzgerald.with.a.very.long.address@example.com + + + + + } + /> + + + } + composable={ + + + + + + + Alex Reed + alex@example.com + + + + + + + } + preset={ + + } + /> + + + + + + + + + + } + preset={ + <> + + + + + + + + } + /> + + + + + + + + + + } + composable={ + <> + + + + + Vacation delegate + + + + + + + + + + + + Alex Reed + Vacation delegate + + + + + + + + + } + /> + + + + + + + + + + + + + } + composable={ + <> + + + + + + + + Alex Reed + alex@example.com + + + + + + + + + + + + Alex Reed + alex@example.com + + + + + + + + + + + + Alex Reed + alex@example.com + + + + + + } + /> + + + } + composable={ + <> + {/* Outside Root the label loses the row's paddingHorizontal: 20, so the call site restores it */} + + Send from + + + + + + + + Expensify Inc + Workspace + + + + + + + + } + /> + + + } + composable={ + + + Assignee + + + + + + + + + + alex@example.com + + + + + + + } + /> + + + } + composable={ + + + Share + + + + + + + Expensify Inc + + + Required + + + + + } + /> + + + } + composable={ + + Assignee + + + + + + Alex Reed + + + + } + /> + + Cases the compound API and the Action/Navigation presets already cover. + - + + + Settings @@ -125,7 +679,9 @@ function Comparison() { composable={ - + + + Settings @@ -158,7 +714,9 @@ function Comparison() { composable={ - + + + Settings Manage your preferences @@ -171,6 +729,73 @@ function Comparison() { } /> + + } + composable={ + + + + + + + Create report + Expensify Inc + + + + } + /> + + + + + } + /> + } + composable={ + + + + This device + + + + + + + } + /> + - + + + Settings @@ -230,7 +857,9 @@ function Comparison() { isDisabled > - + + + Settings @@ -265,7 +894,9 @@ function Comparison() { accessibilityLabel="Settings" > - + + + Settings @@ -295,7 +926,9 @@ function Comparison() { composable={ - + + + Edit columns @@ -330,7 +963,9 @@ function Comparison() { composable={ - + + + Edit columns Choose what to display diff --git a/src/styles/index.ts b/src/styles/index.ts index ddc3bef8a873..d6cfefc8d5bd 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5352,6 +5352,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;