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 (
-
- }
+ 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;