diff --git a/assets/images/simple-illustrations/simple-illustration__commentbubbles.svg b/assets/images/simple-illustrations/simple-illustration__commentbubbles.svg
new file mode 100644
index 000000000000..829d3ee2e3fe
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__commentbubbles.svg
@@ -0,0 +1,22 @@
+
+
+
diff --git a/assets/images/simple-illustrations/simple-illustration__hourglass.svg b/assets/images/simple-illustrations/simple-illustration__hourglass.svg
new file mode 100644
index 000000000000..539e1e45b795
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__hourglass.svg
@@ -0,0 +1,56 @@
+
+
+
diff --git a/assets/images/simple-illustrations/simple-illustration__trashcan.svg b/assets/images/simple-illustrations/simple-illustration__trashcan.svg
new file mode 100644
index 000000000000..4e66efa0a67e
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__trashcan.svg
@@ -0,0 +1,52 @@
+
+
+
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 35fa4bbf0837..b6e62814466b 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -358,9 +358,9 @@ const ROUTES = {
getUrlWithBackToParam(`create/${iouType}/distance/${transactionID}/${reportID}/`, backTo),
},
MONEY_REQUEST_STEP_MERCHANT: {
- route: 'create/:iouType/merchante/:transactionID/:reportID/',
+ route: 'create/:iouType/merchant/:transactionID/:reportID/',
getRoute: (iouType: ValueOf, transactionID: string, reportID: string, backTo = '') =>
- getUrlWithBackToParam(`create/${iouType}/merchante/${transactionID}/${reportID}/`, backTo),
+ getUrlWithBackToParam(`create/${iouType}/merchant/${transactionID}/${reportID}/`, backTo),
},
MONEY_REQUEST_STEP_PARTICIPANTS: {
route: 'create/:iouType/participants/:transactionID/:reportID/',
@@ -479,6 +479,7 @@ const ROUTES = {
route: 'referral/:contentType',
getRoute: (contentType: string) => `referral/${contentType}` as const,
},
+ PROCESS_MONEY_REQUEST_HOLD: 'hold-request-educational',
} as const;
export {getUrlWithBackToParam};
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 91c4153bacd2..703cb309d641 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -108,6 +108,7 @@ const SCREENS = {
ROOM_MEMBERS: 'RoomMembers',
ROOM_INVITE: 'RoomInvite',
REFERRAL: 'Referral',
+ PROCESS_MONEY_REQUEST_HOLD: 'ProcessMoneyRequestHold',
},
SIGN_IN_WITH_APPLE_DESKTOP: 'AppleSignInDesktop',
SIGN_IN_WITH_GOOGLE_DESKTOP: 'GoogleSignInDesktop',
@@ -231,6 +232,7 @@ const SCREENS = {
SIGN_IN_ROOT: 'SignIn_Root',
DETAILS_ROOT: 'Details_Root',
PROFILE_ROOT: 'Profile_Root',
+ PROCESS_MONEY_REQUEST_HOLD_ROOT: 'ProcessMoneyRequestHold_Root',
REPORT_WELCOME_MESSAGE_ROOT: 'Report_WelcomeMessage_Root',
REPORT_PARTICIPANTS_ROOT: 'ReportParticipants_Root',
ROOM_MEMBERS_ROOT: 'RoomMembers_Root',
diff --git a/src/components/AmountTextInput.js b/src/components/AmountTextInput.js
index 25e1ce6f05ec..231a99f0e6a6 100644
--- a/src/components/AmountTextInput.js
+++ b/src/components/AmountTextInput.js
@@ -32,7 +32,7 @@ const propTypes = {
style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
/** Style for the container */
- containerStyles: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
+ touchableInputWrapperStyle: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
/** Function to call to handle key presses in the text input */
onKeyPress: PropTypes.func,
@@ -44,7 +44,7 @@ const defaultProps = {
onSelectionChange: () => {},
onKeyPress: () => {},
style: {},
- containerStyles: {},
+ touchableInputWrapperStyle: {},
};
function AmountTextInput(props) {
@@ -67,7 +67,7 @@ function AmountTextInput(props) {
onSelectionChange={props.onSelectionChange}
role={CONST.ROLE.PRESENTATION}
onKeyPress={props.onKeyPress}
- containerStyles={[...StyleUtils.parseStyleAsArray(props.containerStyles)]}
+ touchableInputWrapperStyle={props.touchableInputWrapperStyle}
/>
);
}
diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js
index d24d1e18907f..e5b605dd3ff2 100755
--- a/src/components/AttachmentModal.js
+++ b/src/components/AttachmentModal.js
@@ -19,7 +19,6 @@ import fileDownload from '@libs/fileDownload';
import * as FileUtils from '@libs/fileDownload/FileUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
-import * as ReportUtils from '@libs/ReportUtils';
import * as TransactionUtils from '@libs/TransactionUtils';
import useNativeDriver from '@libs/useNativeDriver';
import reportPropTypes from '@pages/reportPropTypes';
@@ -95,6 +94,9 @@ const propTypes = {
/** Whether it is a receipt attachment or not */
isReceiptAttachment: PropTypes.bool,
+
+ /** Whether the receipt can be replaced */
+ canEditReceipt: PropTypes.bool,
};
const defaultProps = {
@@ -113,6 +115,7 @@ const defaultProps = {
onCarouselAttachmentChange: () => {},
isWorkspaceAvatar: false,
isReceiptAttachment: false,
+ canEditReceipt: false,
};
function AttachmentModal(props) {
@@ -372,13 +375,9 @@ function AttachmentModal(props) {
if (!props.isReceiptAttachment || !props.parentReport || !props.parentReportActions) {
return [];
}
- const menuItems = [];
- const parentReportAction = props.parentReportActions[props.report.parentReportActionID];
- const canEdit =
- ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, props.parentReport.reportID, CONST.EDIT_REQUEST_FIELD.RECEIPT, props.transaction) &&
- !TransactionUtils.isDistanceRequest(props.transaction);
- if (canEdit) {
+ const menuItems = [];
+ if (props.canEditReceipt) {
menuItems.push({
icon: Expensicons.Camera,
text: props.translate('common.replace'),
@@ -393,7 +392,7 @@ function AttachmentModal(props) {
text: props.translate('common.download'),
onSelected: () => downloadAttachment(source),
});
- if (TransactionUtils.hasReceipt(props.transaction) && !TransactionUtils.isReceiptBeingScanned(props.transaction) && canEdit) {
+ if (TransactionUtils.hasReceipt(props.transaction) && !TransactionUtils.isReceiptBeingScanned(props.transaction) && props.canEditReceipt) {
menuItems.push({
icon: Expensicons.Trashcan,
text: props.translate('receipt.deleteReceipt'),
diff --git a/src/components/HeaderPageLayout.js b/src/components/HeaderPageLayout.tsx
similarity index 55%
rename from src/components/HeaderPageLayout.js
rename to src/components/HeaderPageLayout.tsx
index 9ef5d4f83a06..304bb2ce49b1 100644
--- a/src/components/HeaderPageLayout.js
+++ b/src/components/HeaderPageLayout.tsx
@@ -1,56 +1,54 @@
-import PropTypes from 'prop-types';
import React, {useMemo} from 'react';
+import type {ReactNode} from 'react';
import {ScrollView, View} from 'react-native';
-import _ from 'underscore';
+import type {StyleProp, ViewStyle} from 'react-native';
import useNetwork from '@hooks/useNetwork';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as Browser from '@libs/Browser';
+import type ChildrenProps from '@src/types/utils/ChildrenProps';
import FixedFooter from './FixedFooter';
import HeaderWithBackButton from './HeaderWithBackButton';
-import headerWithBackButtonPropTypes from './HeaderWithBackButton/headerWithBackButtonPropTypes';
+import type HeaderWithBackButtonProps from './HeaderWithBackButton/types';
import ScreenWrapper from './ScreenWrapper';
-const propTypes = {
- ...headerWithBackButtonPropTypes,
+type HeaderPageLayoutProps = ChildrenProps &
+ HeaderWithBackButtonProps & {
+ /** The background color to apply in the upper half of the screen. */
+ backgroundColor?: string;
- /** Children to display in the lower half of the page (below the header section w/ an animation) */
- children: PropTypes.node.isRequired,
+ /** A fixed footer to display at the bottom of the page. */
+ footer?: ReactNode;
- /** The background color to apply in the upper half of the screen. */
- backgroundColor: PropTypes.string,
+ /** The image to display in the upper half of the screen. */
+ headerContent?: ReactNode;
- /** A fixed footer to display at the bottom of the page. */
- footer: PropTypes.node,
+ /** Style to apply to the header image container */
+ headerContainerStyles?: StyleProp;
- /** The image to display in the upper half of the screen. */
- header: PropTypes.node,
+ /** Style to apply to the ScrollView container */
+ scrollViewContainerStyles?: StyleProp;
- /** Style to apply to the header image container */
- // eslint-disable-next-line react/forbid-prop-types
- headerContainerStyles: PropTypes.arrayOf(PropTypes.object),
+ /** Style to apply to the children container */
+ childrenContainerStyles?: StyleProp;
- /** Style to apply to the ScrollView container */
- // eslint-disable-next-line react/forbid-prop-types
- scrollViewContainerStyles: PropTypes.arrayOf(PropTypes.object),
+ /** Style to apply to the whole section container */
+ style?: StyleProp;
+ };
- /** Style to apply to the children container */
- // eslint-disable-next-line react/forbid-prop-types
- childrenContainerStyles: PropTypes.arrayOf(PropTypes.object),
-};
-
-const defaultProps = {
- backgroundColor: undefined,
- header: null,
- headerContainerStyles: [],
- scrollViewContainerStyles: [],
- childrenContainerStyles: [],
- footer: null,
-};
-
-function HeaderPageLayout({backgroundColor, children, footer, headerContainerStyles, scrollViewContainerStyles, childrenContainerStyles, style, headerContent, ...propsToPassToHeader}) {
+function HeaderPageLayout({
+ backgroundColor,
+ children,
+ footer,
+ headerContainerStyles,
+ scrollViewContainerStyles,
+ childrenContainerStyles,
+ style,
+ headerContent,
+ ...rest
+}: HeaderPageLayoutProps) {
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
@@ -58,7 +56,7 @@ function HeaderPageLayout({backgroundColor, children, footer, headerContainerSty
const {isOffline} = useNetwork();
const appBGColor = StyleUtils.getBackgroundColorStyle(theme.appBG);
const {titleColor, iconFill} = useMemo(() => {
- const isColorfulBackground = (backgroundColor || theme.appBG) !== theme.appBG && (backgroundColor || theme.highlightBG) !== theme.highlightBG;
+ const isColorfulBackground = (backgroundColor ?? theme.appBG) !== theme.appBG && (backgroundColor ?? theme.highlightBG) !== theme.highlightBG;
return {
titleColor: isColorfulBackground ? theme.textColorfulBackground : undefined,
iconFill: isColorfulBackground ? theme.iconColorfulBackground : undefined,
@@ -67,7 +65,7 @@ function HeaderPageLayout({backgroundColor, children, footer, headerContainerSty
return (
-
+
{/** Safari on ios/mac has a bug where overscrolling the page scrollview shows green background color. This is a workaround to fix that. https://github.com/Expensify/App/issues/23422 */}
{Browser.isSafari() && (
-
+
)}
-
- {!Browser.isSafari() && }
-
+
+ {!Browser.isSafari() && }
+
{headerContent}
{children}
- {!_.isNull(footer) && {footer}}
+ {!!footer && {footer}}
>
)}
@@ -107,8 +102,7 @@ function HeaderPageLayout({backgroundColor, children, footer, headerContainerSty
);
}
-HeaderPageLayout.propTypes = propTypes;
-HeaderPageLayout.defaultProps = defaultProps;
HeaderPageLayout.displayName = 'HeaderPageLayout';
+export type {HeaderPageLayoutProps};
export default HeaderPageLayout;
diff --git a/src/components/HoldMenuSectionList.tsx b/src/components/HoldMenuSectionList.tsx
new file mode 100644
index 000000000000..aa5dd75ce159
--- /dev/null
+++ b/src/components/HoldMenuSectionList.tsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import {View} from 'react-native';
+import type {ImageSourcePropType} from 'react-native';
+import type {SvgProps} from 'react-native-svg';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import variables from '@styles/variables';
+import type {TranslationPaths} from '@src/languages/types';
+import Icon from './Icon';
+import * as Illustrations from './Icon/Illustrations';
+import Text from './Text';
+
+type HoldMenuSection = {
+ /** The icon supplied with the section */
+ icon: React.FC | ImageSourcePropType;
+
+ /** Translation key for the title */
+ titleTranslationKey: TranslationPaths;
+
+ /** Translation key for the description */
+ descriptionTranslationKey: TranslationPaths;
+};
+
+function HoldMenuSectionList() {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ const holdMenuSections: HoldMenuSection[] = [
+ {
+ icon: Illustrations.Hourglass,
+ titleTranslationKey: 'iou.whatIsHoldTitle',
+ descriptionTranslationKey: 'iou.whatIsHoldExplain',
+ },
+ {
+ icon: Illustrations.CommentBubbles,
+ titleTranslationKey: 'iou.holdIsTemporaryTitle',
+ descriptionTranslationKey: 'iou.holdIsTemporaryExplain',
+ },
+ {
+ icon: Illustrations.TrashCan,
+ titleTranslationKey: 'iou.deleteHoldTitle',
+ descriptionTranslationKey: 'iou.deleteHoldExplain',
+ },
+ ];
+
+ return (
+ <>
+ {holdMenuSections.map((section, i) => (
+
+
+
+ {translate(section.titleTranslationKey)}
+
+ {translate(section.descriptionTranslationKey)}
+
+
+
+ ))}
+ >
+ );
+}
+
+HoldMenuSectionList.displayName = 'HoldMenuSectionList';
+
+export type {HoldMenuSection};
+
+export default HoldMenuSectionList;
diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts
index 1e574504001d..954c8d0392fc 100644
--- a/src/components/Icon/Illustrations.ts
+++ b/src/components/Icon/Illustrations.ts
@@ -32,6 +32,7 @@ import BigRocket from '@assets/images/simple-illustrations/simple-illustration__
import PinkBill from '@assets/images/simple-illustrations/simple-illustration__bill.svg';
import ChatBubbles from '@assets/images/simple-illustrations/simple-illustration__chatbubbles.svg';
import CoffeeMug from '@assets/images/simple-illustrations/simple-illustration__coffeemug.svg';
+import CommentBubbles from '@assets/images/simple-illustrations/simple-illustration__commentbubbles.svg';
import ConciergeBubble from '@assets/images/simple-illustrations/simple-illustration__concierge-bubble.svg';
import ConciergeNew from '@assets/images/simple-illustrations/simple-illustration__concierge.svg';
import CreditCardsNew from '@assets/images/simple-illustrations/simple-illustration__credit-cards.svg';
@@ -39,6 +40,7 @@ import EmailAddress from '@assets/images/simple-illustrations/simple-illustratio
import HandCard from '@assets/images/simple-illustrations/simple-illustration__handcard.svg';
import HandEarth from '@assets/images/simple-illustrations/simple-illustration__handearth.svg';
import HotDogStand from '@assets/images/simple-illustrations/simple-illustration__hotdogstand.svg';
+import Hourglass from '@assets/images/simple-illustrations/simple-illustration__hourglass.svg';
import InvoiceBlue from '@assets/images/simple-illustrations/simple-illustration__invoice.svg';
import LockOpen from '@assets/images/simple-illustrations/simple-illustration__lockopen.svg';
import Luggage from '@assets/images/simple-illustrations/simple-illustration__luggage.svg';
@@ -53,6 +55,7 @@ import ShieldYellow from '@assets/images/simple-illustrations/simple-illustratio
import SmallRocket from '@assets/images/simple-illustrations/simple-illustration__smallrocket.svg';
import ThumbsUpStars from '@assets/images/simple-illustrations/simple-illustration__thumbsupstars.svg';
import TrackShoe from '@assets/images/simple-illustrations/simple-illustration__track-shoe.svg';
+import TrashCan from '@assets/images/simple-illustrations/simple-illustration__trashcan.svg';
import TreasureChest from '@assets/images/simple-illustrations/simple-illustration__treasurechest.svg';
export {
@@ -111,5 +114,8 @@ export {
Hands,
HandEarth,
SmartScan,
+ Hourglass,
+ CommentBubbles,
+ TrashCan,
TeleScope,
};
diff --git a/src/components/IllustratedHeaderPageLayout.js b/src/components/IllustratedHeaderPageLayout.js
deleted file mode 100644
index 9980d8a7879a..000000000000
--- a/src/components/IllustratedHeaderPageLayout.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import useTheme from '@hooks/useTheme';
-import useThemeStyles from '@hooks/useThemeStyles';
-import HeaderPageLayout from './HeaderPageLayout';
-import headerWithBackButtonPropTypes from './HeaderWithBackButton/headerWithBackButtonPropTypes';
-import Lottie from './Lottie';
-
-const propTypes = {
- ...headerWithBackButtonPropTypes,
-
- /** Children to display in the lower half of the page (below the header section w/ an animation) */
- children: PropTypes.node.isRequired,
-
- /** The illustration to display in the header. Can be either an SVG component or a JSON object representing a Lottie animation. */
- illustration: PropTypes.oneOfType([PropTypes.func, PropTypes.object]).isRequired,
-
- /** The background color to apply in the upper half of the screen. */
- backgroundColor: PropTypes.string,
-
- /** A fixed footer to display at the bottom of the page. */
- footer: PropTypes.node,
-
- /** Overlay content to display on top of animation */
- overlayContent: PropTypes.func,
-};
-
-const defaultProps = {
- backgroundColor: undefined,
- footer: null,
- overlayContent: null,
-};
-
-function IllustratedHeaderPageLayout({backgroundColor, children, illustration, footer, overlayContent, ...propsToPassToHeader}) {
- const theme = useTheme();
- const styles = useThemeStyles();
- return (
-
-
- {overlayContent && overlayContent()}
- >
- }
- headerContainerStyles={[styles.justifyContentCenter, styles.w100]}
- footer={footer}
- // eslint-disable-next-line react/jsx-props-no-spreading
- {...propsToPassToHeader}
- >
- {children}
-
- );
-}
-
-IllustratedHeaderPageLayout.propTypes = propTypes;
-IllustratedHeaderPageLayout.defaultProps = defaultProps;
-IllustratedHeaderPageLayout.displayName = 'IllustratedHeaderPageLayout';
-
-export default IllustratedHeaderPageLayout;
diff --git a/src/components/IllustratedHeaderPageLayout.tsx b/src/components/IllustratedHeaderPageLayout.tsx
new file mode 100644
index 000000000000..72ec0adf7672
--- /dev/null
+++ b/src/components/IllustratedHeaderPageLayout.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import type {ReactNode} from 'react';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import HeaderPageLayout from './HeaderPageLayout';
+import type {HeaderPageLayoutProps} from './HeaderPageLayout';
+import Lottie from './Lottie';
+import type DotLottieAnimation from './LottieAnimations/types';
+
+type IllustratedHeaderPageLayoutProps = HeaderPageLayoutProps & {
+ /** The illustration to display in the header. Can be a JSON object representing a Lottie animation. */
+ illustration: DotLottieAnimation;
+
+ /** The background color to apply in the upper half of the screen. */
+ backgroundColor?: string;
+
+ /** Overlay content to display on top of animation */
+ overlayContent?: () => ReactNode;
+};
+
+function IllustratedHeaderPageLayout({backgroundColor, children, illustration, overlayContent, ...rest}: IllustratedHeaderPageLayoutProps) {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+ return (
+
+
+ {overlayContent?.()}
+ >
+ }
+ headerContainerStyles={[styles.justifyContentCenter, styles.w100]}
+ // eslint-disable-next-line react/jsx-props-no-spreading
+ {...rest}
+ >
+ {children}
+
+ );
+}
+
+IllustratedHeaderPageLayout.displayName = 'IllustratedHeaderPageLayout';
+
+export default IllustratedHeaderPageLayout;
diff --git a/src/components/MoneyReportHeader.js b/src/components/MoneyReportHeader.js
index 8ed6d0746438..5b59fca6cdae 100644
--- a/src/components/MoneyReportHeader.js
+++ b/src/components/MoneyReportHeader.js
@@ -87,9 +87,9 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt
const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID);
const policyType = lodashGet(policy, 'type');
const isPolicyAdmin = policyType !== CONST.POLICY.TYPE.PERSONAL && lodashGet(policy, 'role') === CONST.POLICY.ROLE.ADMIN;
- const isGroupPolicy = _.contains([CONST.POLICY.TYPE.CORPORATE, CONST.POLICY.TYPE.TEAM], policyType);
+ const isPaidGroupPolicy = ReportUtils.isPaidGroupPolicy(moneyRequestReport);
const isManager = ReportUtils.isMoneyRequestReport(moneyRequestReport) && lodashGet(session, 'accountID', null) === moneyRequestReport.managerID;
- const isPayer = isGroupPolicy
+ const isPayer = isPaidGroupPolicy
? // In a group policy, the admin approver can pay the report directly by skipping the approval step
isPolicyAdmin && (isApproved || isManager)
: isPolicyAdmin || (ReportUtils.isMoneyRequestReport(moneyRequestReport) && isManager);
@@ -99,11 +99,11 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt
[isPayer, isDraft, isSettled, moneyRequestReport, reimbursableTotal, chatReport],
);
const shouldShowApproveButton = useMemo(() => {
- if (!isGroupPolicy) {
+ if (!isPaidGroupPolicy) {
return false;
}
return isManager && !isDraft && !isApproved && !isSettled;
- }, [isGroupPolicy, isManager, isDraft, isApproved, isSettled]);
+ }, [isPaidGroupPolicy, isManager, isDraft, isApproved, isSettled]);
const shouldShowSettlementButton = shouldShowPayButton || shouldShowApproveButton;
const shouldShowSubmitButton = isDraft && reimbursableTotal !== 0;
const isFromPaidPolicy = policyType === CONST.POLICY.TYPE.TEAM || policyType === CONST.POLICY.TYPE.CORPORATE;
diff --git a/src/components/ProcessMoneyRequestHoldMenu.tsx b/src/components/ProcessMoneyRequestHoldMenu.tsx
new file mode 100644
index 000000000000..1b711633ed3b
--- /dev/null
+++ b/src/components/ProcessMoneyRequestHoldMenu.tsx
@@ -0,0 +1,66 @@
+import React from 'react';
+import {View} from 'react-native';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Button from './Button';
+import HoldMenuSectionList from './HoldMenuSectionList';
+import type {PopoverAnchorPosition} from './Modal/types';
+import Popover from './Popover';
+import type {AnchorAlignment} from './Popover/types';
+import Text from './Text';
+import TextPill from './TextPill';
+
+type ProcessMoneyRequestHoldMenuProps = {
+ /** Whether the content is visible */
+ isVisible: boolean;
+
+ /** Method to trigger when pressing outside of the popover menu to close it */
+ onClose: () => void;
+
+ /** Method to trigger when pressing confirm button */
+ onConfirm: () => void;
+
+ /** The anchor position of the popover menu */
+ anchorPosition?: PopoverAnchorPosition;
+
+ /** The anchor alignment of the popover menu */
+ anchorAlignment: AnchorAlignment;
+
+ /** The anchor ref of the popover menu */
+ anchorRef: React.RefObject;
+};
+
+function ProcessMoneyRequestHoldMenu({isVisible, onClose, onConfirm, anchorPosition, anchorAlignment, anchorRef}: ProcessMoneyRequestHoldMenuProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ return (
+
+
+
+ {translate('iou.holdEducationalTitle')}
+ {translate('iou.hold')};
+
+
+
+
+
+ );
+}
+
+ProcessMoneyRequestHoldMenu.displayName = 'ProcessMoneyRequestHoldMenu';
+
+export default ProcessMoneyRequestHoldMenu;
diff --git a/src/components/ReportActionItem/MoneyRequestPreview.js b/src/components/ReportActionItem/MoneyRequestPreview.js
index d425e236431b..c052a885245f 100644
--- a/src/components/ReportActionItem/MoneyRequestPreview.js
+++ b/src/components/ReportActionItem/MoneyRequestPreview.js
@@ -210,7 +210,7 @@ function MoneyRequestPreview(props) {
}
let message = translate('iou.cash');
- if (ReportUtils.isGroupPolicyExpenseReport(props.iouReport) && ReportUtils.isReportApproved(props.iouReport) && !ReportUtils.isSettled(props.iouReport)) {
+ if (ReportUtils.isPaidGroupPolicyExpenseReport(props.iouReport) && ReportUtils.isReportApproved(props.iouReport) && !ReportUtils.isSettled(props.iouReport)) {
message += ` • ${translate('iou.approved')}`;
} else if (props.iouReport.isWaitingOnBankAccount) {
message += ` • ${translate('iou.pending')}`;
diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js
index 3437058efa45..37ff163f23c8 100644
--- a/src/components/ReportActionItem/MoneyRequestView.js
+++ b/src/components/ReportActionItem/MoneyRequestView.js
@@ -1,7 +1,7 @@
import lodashGet from 'lodash/get';
import lodashValues from 'lodash/values';
import PropTypes from 'prop-types';
-import React, {useCallback, useMemo} from 'react';
+import React, {useCallback} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import categoryPropTypes from '@components/categoryPropTypes';
@@ -37,6 +37,7 @@ import AnimatedEmptyStateBackground from '@pages/home/report/AnimatedEmptyStateB
import reportActionPropTypes from '@pages/home/report/reportActionPropTypes';
import iouReportPropTypes from '@pages/iouReportPropTypes';
import reportPropTypes from '@pages/reportPropTypes';
+import {policyDefaultProps, policyPropTypes} from '@pages/workspace/withPolicy';
import * as IOU from '@userActions/IOU';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -83,6 +84,9 @@ const propTypes = {
/** The actions from the parent report */
parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)),
+ /** The policy the report is tied to */
+ ...policyPropTypes,
+
/** Collection of categories attached to a policy */
policyCategories: PropTypes.objectOf(categoryPropTypes),
@@ -101,14 +105,15 @@ const propTypes = {
const defaultProps = {
parentReport: {},
parentReportActions: {},
- policyCategories: {},
transaction: {
amount: 0,
currency: CONST.CURRENCY.USD,
comment: {comment: ''},
},
transactionViolations: [],
+ policyCategories: {},
policyTags: {},
+ ...policyDefaultProps,
};
function MoneyRequestView({report, parentReport, parentReportActions, policyCategories, shouldShowHorizontalRule, transaction, policyTags, policy, transactionViolations}) {
@@ -147,12 +152,18 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate
// Flags for allowing or disallowing editing a money request
const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID);
const isCancelled = moneyRequestReport && moneyRequestReport.isCancelledIOU;
+
+ // Used for non-restricted fields such as: description, category, tag, billable, etc.
const canEdit = ReportUtils.canEditMoneyRequest(parentReportAction);
- const canEditAmount = ReportUtils.canEditMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.AMOUNT, transaction) && !isSettled && !isCardTransaction;
- const canEditReceipt = ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, moneyRequestReport.reportID, CONST.EDIT_REQUEST_FIELD.RECEIPT);
+ const canEditAmount = ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.AMOUNT);
+ const canEditMerchant = ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.MERCHANT);
+ const canEditDate = ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.DATE);
+ const canEditReceipt = ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.RECEIPT);
+ const canEditDistance = ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.DISTANCE);
// A flag for verifying that the current report is a sub-report of a workspace chat
- const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(ReportUtils.getRootParentReport(report)), [report]);
+ // if the policy of the report is either Collect or Control, then this report must be tied to workspace chat
+ const isPolicyExpenseChat = ReportUtils.isGroupPolicy(report);
// Fetches only the first tag, for now
const policyTag = PolicyUtils.getTag(policyTags);
@@ -193,18 +204,12 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate
}
}
- // A temporary solution to hide the transaction detail
- // This will be removed after we properly add the transaction as a prop
- if (ReportActionsUtils.isDeletedAction(parentReportAction)) {
- return null;
- }
-
const hasReceipt = TransactionUtils.hasReceipt(transaction);
let receiptURIs;
let hasErrors = false;
if (hasReceipt) {
receiptURIs = ReceiptUtils.getThumbnailAndImageURIs(transaction);
- hasErrors = canEdit && TransactionUtils.hasMissingSmartscanFields(transaction);
+ hasErrors = canEditReceipt && TransactionUtils.hasMissingSmartscanFields(transaction);
}
const pendingAction = lodashGet(transaction, 'pendingAction');
@@ -223,11 +228,12 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate
isLocalFile={receiptURIs.isLocalFile}
transaction={transaction}
enablePreviewModal
+ canEditReceipt={canEditReceipt}
/>
)}
- {!hasReceipt && canEditReceipt && !isSettled && canUseViolations && (
+ {!hasReceipt && canEditReceipt && canUseViolations && (
Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.RECEIPT))}
@@ -269,8 +275,8 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate
Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DISTANCE))}
/>
@@ -280,8 +286,8 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate
Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.MERCHANT))}
brickRoadIndicator={hasViolations('merchant') || (hasErrors && isEmptyMerchant) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
@@ -294,8 +300,8 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate
Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DATE))}
brickRoadIndicator={hasViolations('date') || (hasErrors && transactionDate === '') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
diff --git a/src/components/ReportActionItem/ReportActionItemImage.js b/src/components/ReportActionItem/ReportActionItemImage.js
index 4336a5eddd8a..1495dcbd9111 100644
--- a/src/components/ReportActionItem/ReportActionItemImage.js
+++ b/src/components/ReportActionItem/ReportActionItemImage.js
@@ -31,6 +31,9 @@ const propTypes = {
/** whether thumbnail is refer the local file or not */
isLocalFile: PropTypes.bool,
+
+ /** whether the receipt can be replaced */
+ canEditReceipt: PropTypes.bool,
};
const defaultProps = {
@@ -38,6 +41,7 @@ const defaultProps = {
transaction: {},
enablePreviewModal: false,
isLocalFile: false,
+ canEditReceipt: false,
};
/**
@@ -46,7 +50,7 @@ const defaultProps = {
* and optional preview modal as well.
*/
-function ReportActionItemImage({thumbnail, image, enablePreviewModal, transaction, isLocalFile}) {
+function ReportActionItemImage({thumbnail, image, enablePreviewModal, transaction, canEditReceipt, isLocalFile}) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const imageSource = tryResolveUrlFromApiRoot(image || '');
@@ -88,6 +92,7 @@ function ReportActionItemImage({thumbnail, image, enablePreviewModal, transactio
isAuthTokenRequired={!isLocalFile}
report={report}
isReceiptAttachment
+ canEditReceipt={canEditReceipt}
allowToDownload
originalFileName={transaction.filename}
>
diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js
index e88c057a615d..27447a10a32b 100644
--- a/src/components/ReportActionItem/ReportPreview.js
+++ b/src/components/ReportActionItem/ReportPreview.js
@@ -243,10 +243,10 @@ function ReportPreview(props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- const isGroupPolicy = ReportUtils.isGroupPolicyExpenseChat(props.chatReport);
+ const isPaidGroupPolicy = ReportUtils.isPaidGroupPolicyExpenseChat(props.chatReport);
const isPolicyAdmin = policyType !== CONST.POLICY.TYPE.PERSONAL && lodashGet(props.policy, 'role') === CONST.POLICY.ROLE.ADMIN;
- const isPayer = isGroupPolicy
- ? // In a group policy, the admin approver can pay the report directly by skipping the approval step
+ const isPayer = isPaidGroupPolicy
+ ? // In a paid group policy, the admin approver can pay the report directly by skipping the approval step
isPolicyAdmin && (isApproved || isCurrentUserManager)
: isPolicyAdmin || (isMoneyRequestReport && isCurrentUserManager);
const shouldShowPayButton = useMemo(
@@ -254,11 +254,11 @@ function ReportPreview(props) {
[isPayer, isDraftExpenseReport, iouSettled, reimbursableSpend, iouCanceled, props.iouReport],
);
const shouldShowApproveButton = useMemo(() => {
- if (!isGroupPolicy) {
+ if (!isPaidGroupPolicy) {
return false;
}
return isCurrentUserManager && !isDraftExpenseReport && !isApproved && !iouSettled;
- }, [isGroupPolicy, isCurrentUserManager, isDraftExpenseReport, isApproved, iouSettled]);
+ }, [isPaidGroupPolicy, isCurrentUserManager, isDraftExpenseReport, isApproved, iouSettled]);
const shouldShowSettlementButton = shouldShowPayButton || shouldShowApproveButton;
return (
diff --git a/src/components/TextInput/BaseTextInput/baseTextInputPropTypes.js b/src/components/TextInput/BaseTextInput/baseTextInputPropTypes.js
index 78c37e94196a..78f06b4075e0 100644
--- a/src/components/TextInput/BaseTextInput/baseTextInputPropTypes.js
+++ b/src/components/TextInput/BaseTextInput/baseTextInputPropTypes.js
@@ -26,6 +26,9 @@ const propTypes = {
/** Customize the TextInput container */
textInputContainerStyles: PropTypes.arrayOf(PropTypes.object),
+ /** Customizes the touchable wrapper of the TextInput component */
+ touchableInputWrapperStyle: PropTypes.arrayOf(PropTypes.object),
+
/** Customize the main container */
containerStyles: PropTypes.arrayOf(PropTypes.object),
diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx
index d548041b0cf8..9c3899979aaa 100644
--- a/src/components/TextInput/BaseTextInput/index.tsx
+++ b/src/components/TextInput/BaseTextInput/index.tsx
@@ -37,6 +37,7 @@ function BaseTextInput(
errorText = '',
icon = null,
textInputContainerStyles,
+ touchableInputWrapperStyle,
containerStyles,
inputStyle,
forceActiveLabel = false,
@@ -287,7 +288,7 @@ function BaseTextInput(
style={[
autoGrowHeight && styles.autoGrowHeightInputContainer(textInputHeight, variables.componentSizeLarge, typeof maxHeight === 'number' ? maxHeight : 0),
!isMultiline && styles.componentHeightLarge,
- containerStyles,
+ touchableInputWrapperStyle,
]}
>
;
+ /** Customizes the touchable wrapper of the TextInput component */
+ touchableInputWrapperStyle?: StyleProp;
+
/** Customize the main container */
containerStyles?: StyleProp;
diff --git a/src/components/TextPill.tsx b/src/components/TextPill.tsx
new file mode 100644
index 000000000000..6d473b189534
--- /dev/null
+++ b/src/components/TextPill.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import type {StyleProp, TextStyle} from 'react-native';
+// eslint-disable-next-line no-restricted-imports
+import useThemeStyles from '@hooks/useThemeStyles';
+import colors from '@styles/theme/colors';
+import Text from './Text';
+
+type TextPillProps = {
+ /** The color of the text/ */
+ color?: string;
+
+ /** Styles to apply to the text */
+ textStyles: StyleProp;
+
+ children: React.ReactNode;
+};
+
+function TextPill({color, textStyles, children}: TextPillProps) {
+ const styles = useThemeStyles();
+
+ return {children};
+}
+
+TextPill.displayName = 'TextPill';
+
+export default TextPill;
diff --git a/src/components/TimePicker/TimePicker.js b/src/components/TimePicker/TimePicker.js
index 20f52e2d08ee..a9b4566a390c 100644
--- a/src/components/TimePicker/TimePicker.js
+++ b/src/components/TimePicker/TimePicker.js
@@ -469,7 +469,7 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) {
setSelectionHour(e.nativeEvent.selection);
}}
style={styles.timePickerInput}
- containerStyles={[styles.timePickerHeight100]}
+ touchableInputWrapperStyle={styles.timePickerHeight100}
selection={selectionHour}
showSoftInputOnFocus={false}
/>
@@ -497,7 +497,7 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) {
setSelectionMinute(e.nativeEvent.selection);
}}
style={styles.timePickerInput}
- containerStyles={[styles.timePickerHeight100]}
+ touchableInputWrapperStyle={styles.timePickerHeight100}
selection={selectionMinute}
showSoftInputOnFocus={false}
/>
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 6e177c1df141..0b8983a8361b 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -641,6 +641,14 @@ export default {
},
waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `Started settling up, payment is held until ${submitterDisplayName} enables their Wallet`,
enableWallet: 'Enable Wallet',
+ hold: 'Hold',
+ holdEducationalTitle: 'This request is on',
+ whatIsHoldTitle: 'What is hold?',
+ whatIsHoldExplain: 'Hold is our way of streamlining financial collaboration. "Reject" is so harsh!',
+ holdIsTemporaryTitle: 'Hold is usually temporary',
+ holdIsTemporaryExplain: "Because hold is used to clear up confusion or clarify an important detail before payment, it's not permanent.",
+ deleteHoldTitle: "Delete whatever won't be paid",
+ deleteHoldExplain: "In the rare case where something is put on hold and won't be paid, it's on the person requesting payment to delete it.",
set: 'set',
changed: 'changed',
removed: 'removed',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 990554b0b502..a1afde53482b 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -636,6 +636,14 @@ export default {
},
waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `Inició el pago, pero no se procesará hasta que ${submitterDisplayName} active su Billetera`,
enableWallet: 'Habilitar Billetera',
+ hold: 'Hold',
+ holdEducationalTitle: 'Esta solicitud está en',
+ whatIsHoldTitle: '¿Qué es Hold?',
+ whatIsHoldExplain: 'Hold es nuestra forma de agilizar la colaboración financiera. ¡"Rechazar" es tan duro!',
+ holdIsTemporaryTitle: 'Hold suele ser temporal',
+ holdIsTemporaryExplain: 'Debido a que hold se utiliza para aclarar confusión o aclarar un detalle importante antes del pago, no es permanente.',
+ deleteHoldTitle: 'Eliminar lo que no se pagará',
+ deleteHoldExplain: 'En el raro caso de que algo se ponga en hold y no se pague, la persona que solicita el pago debe eliminarlo.',
set: 'estableció',
changed: 'cambió',
removed: 'eliminó',
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
index e177f1c2003d..4be1c988561b 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
@@ -283,6 +283,10 @@ const ReferralModalStackNavigator = createModalStackNavigator require('../../../pages/ReferralDetailsPage').default as React.ComponentType,
});
+const ProcessMoneyRequestHoldStackNavigator = createModalStackNavigator({
+ [SCREENS.PROCESS_MONEY_REQUEST_HOLD_ROOT]: () => require('../../../pages/ProcessMoneyRequestHoldPage').default as React.ComponentType,
+});
+
export {
MoneyRequestModalStackNavigator,
SplitDetailsModalStackNavigator,
@@ -309,4 +313,5 @@ export {
RoomMembersModalStackNavigator,
RoomInviteModalStackNavigator,
ReferralModalStackNavigator,
+ ProcessMoneyRequestHoldStackNavigator,
};
diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
index ca33b32113bb..7721a64adea9 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
@@ -1,6 +1,6 @@
import type {StackScreenProps} from '@react-navigation/stack';
import {createStackNavigator} from '@react-navigation/stack';
-import React, {useMemo} from 'react';
+import React, {useMemo, useRef} from 'react';
import {View} from 'react-native';
import NoDropZone from '@components/DragAndDrop/NoDropZone';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -20,10 +20,21 @@ function RightModalNavigator({navigation}: RightModalNavigatorProps) {
const styles = useThemeStyles();
const {isSmallScreenWidth} = useWindowDimensions();
const screenOptions = useMemo(() => ModalNavigatorScreenOptions(styles), [styles]);
+ const isExecutingRef = useRef(false);
return (
- {!isSmallScreenWidth && }
+ {!isSmallScreenWidth && (
+ {
+ if (isExecutingRef.current) {
+ return;
+ }
+ isExecutingRef.current = true;
+ navigation.goBack();
+ }}
+ />
+ )}
+
diff --git a/src/libs/Navigation/linkingConfig.ts b/src/libs/Navigation/linkingConfig.ts
index 3b2350afcc43..5f2a607b5f78 100644
--- a/src/libs/Navigation/linkingConfig.ts
+++ b/src/libs/Navigation/linkingConfig.ts
@@ -492,6 +492,11 @@ const linkingConfig: LinkingOptions = {
[SCREENS.REFERRAL_DETAILS]: ROUTES.REFERRAL_DETAILS_MODAL.route,
},
},
+ ProcessMoneyRequestHold: {
+ screens: {
+ ProcessMoneyRequestHold_Root: ROUTES.PROCESS_MONEY_REQUEST_HOLD,
+ },
+ },
},
},
},
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 505bc82180f4..90f5361f11f4 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -331,6 +331,10 @@ type ReferralDetailsNavigatorParamList = {
[SCREENS.REFERRAL_DETAILS]: undefined;
};
+type ProcessMoneyRequestHoldNavigatorParamList = {
+ [SCREENS.PROCESS_MONEY_REQUEST_HOLD_ROOT]: undefined;
+};
+
type PrivateNotesNavigatorParamList = {
[SCREENS.PRIVATE_NOTES.VIEW]: {
reportID: string;
@@ -372,6 +376,7 @@ type RightModalNavigatorParamList = {
[SCREENS.RIGHT_MODAL.FLAG_COMMENT]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.EDIT_REQUEST]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.SIGN_IN]: NavigatorScreenParams;
+ [SCREENS.RIGHT_MODAL.PROCESS_MONEY_REQUEST_HOLD]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.REFERRAL]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: NavigatorScreenParams;
};
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 1c7f859f2e12..0e159cf69095 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -546,7 +546,8 @@ function isExpenseReport(report: OnyxEntry | EmptyObject): boolean {
/**
* Checks if a report is an IOU report.
*/
-function isIOUReport(report: OnyxEntry): boolean {
+function isIOUReport(reportOrID: OnyxEntry | string | EmptyObject): boolean {
+ const report = typeof reportOrID === 'string' ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] ?? null : reportOrID;
return report?.type === CONST.REPORT.TYPE.IOU;
}
@@ -602,14 +603,15 @@ function isReportManager(report: OnyxEntry): boolean {
/**
* Checks if the supplied report has been approved
*/
-function isReportApproved(report: OnyxEntry | EmptyObject): boolean {
+function isReportApproved(reportOrID: OnyxEntry | string | EmptyObject): boolean {
+ const report = typeof reportOrID === 'string' ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportOrID}`] ?? null : reportOrID;
return report?.stateNum === CONST.REPORT.STATE_NUM.SUBMITTED && report?.statusNum === CONST.REPORT.STATUS.APPROVED;
}
/**
* Checks if the supplied report is an expense report in Open state and status.
*/
-function isDraftExpenseReport(report: OnyxEntry): boolean {
+function isDraftExpenseReport(report: OnyxEntry | EmptyObject): boolean {
return isExpenseReport(report) && report?.stateNum === CONST.REPORT.STATE_NUM.OPEN && report?.statusNum === CONST.REPORT.STATUS.OPEN;
}
@@ -716,9 +718,17 @@ function isControlPolicyExpenseChat(report: OnyxEntry): boolean {
}
/**
- * Whether the provided report belongs to a Control or Collect policy
+ * Whether the provided report belongs to a Free, Collect or Control policy
*/
function isGroupPolicy(report: OnyxEntry): boolean {
+ const policyType = getPolicyType(report, allPolicies);
+ return policyType === CONST.POLICY.TYPE.CORPORATE || policyType === CONST.POLICY.TYPE.TEAM || policyType === CONST.POLICY.TYPE.FREE;
+}
+
+/**
+ * Whether the provided report belongs to a Control or Collect policy
+ */
+function isPaidGroupPolicy(report: OnyxEntry): boolean {
const policyType = getPolicyType(report, allPolicies);
return policyType === CONST.POLICY.TYPE.CORPORATE || policyType === CONST.POLICY.TYPE.TEAM;
}
@@ -726,8 +736,8 @@ function isGroupPolicy(report: OnyxEntry): boolean {
/**
* Whether the provided report belongs to a Control or Collect policy and is an expense chat
*/
-function isGroupPolicyExpenseChat(report: OnyxEntry): boolean {
- return isPolicyExpenseChat(report) && isGroupPolicy(report);
+function isPaidGroupPolicyExpenseChat(report: OnyxEntry): boolean {
+ return isPolicyExpenseChat(report) && isPaidGroupPolicy(report);
}
/**
@@ -740,8 +750,8 @@ function isControlPolicyExpenseReport(report: OnyxEntry): boolean {
/**
* Whether the provided report belongs to a Control or Collect policy and is an expense report
*/
-function isGroupPolicyExpenseReport(report: OnyxEntry): boolean {
- return isExpenseReport(report) && isGroupPolicy(report);
+function isPaidGroupPolicyExpenseReport(report: OnyxEntry): boolean {
+ return isExpenseReport(report) && isPaidGroupPolicy(report);
}
/**
@@ -819,7 +829,7 @@ function isConciergeChatReport(report: OnyxEntry): boolean {
/**
* Returns true if report is still being processed
*/
-function isProcessingReport(report: OnyxEntry): boolean {
+function isProcessingReport(report: OnyxEntry | EmptyObject): boolean {
return report?.stateNum === CONST.REPORT.STATE_NUM.PROCESSING && report?.statusNum === CONST.REPORT.STATUS.SUBMITTED;
}
@@ -1850,9 +1860,13 @@ function getTransactionDetails(transaction: OnyxEntry, createdDateF
* - the current user is the requestor and is not settled yet
* - in case of expense report
* - the current user is the requestor and is not settled yet
- * - or the user is an admin on the policy the expense report is tied to
+ * - the current user is the manager of the report
+ * - or the current user is an admin on the policy the expense report is tied to
+ *
+ * This is used in conjunction with canEditRestrictedField to control editing of specific fields like amount, currency, created, receipt, and distance.
+ * On its own, it only controls allowing/disallowing navigating to the editing pages or showing/hiding the 'Edit' icon on report actions
*/
-function canEditMoneyRequest(reportAction: OnyxEntry, fieldToEdit = '', transaction?: OnyxEntry): boolean {
+function canEditMoneyRequest(reportAction: OnyxEntry): boolean {
const isDeleted = ReportActionsUtils.isDeletedAction(reportAction);
if (isDeleted) {
@@ -1875,55 +1889,76 @@ function canEditMoneyRequest(reportAction: OnyxEntry, fieldToEdit
}
const moneyRequestReport = getReport(String(moneyRequestReportID));
- const isReportSettled = isSettled(moneyRequestReport?.reportID);
- const isApproved = isReportApproved(moneyRequestReport);
- const isAdmin = isExpenseReport(moneyRequestReport) && (getPolicy(moneyRequestReport?.policyID ?? '')?.role ?? '') === CONST.POLICY.ROLE.ADMIN;
const isRequestor = currentUserAccountID === reportAction?.actorAccountID;
- const isDistanceRequest = !isEmpty(transaction) && TransactionUtils.isDistanceRequest(transaction);
- if (isAdmin && !isRequestor && fieldToEdit === CONST.EDIT_REQUEST_FIELD.RECEIPT) {
- return false;
- }
- if (isDistanceRequest && fieldToEdit === CONST.EDIT_REQUEST_FIELD.AMOUNT) {
- return isAdmin;
+ if (isIOUReport(moneyRequestReport)) {
+ return isProcessingReport(moneyRequestReport) && isRequestor;
}
- if (isAdmin) {
+
+ const policy = getPolicy(moneyRequestReport?.policyID ?? '');
+ const isAdmin = policy.role === CONST.POLICY.ROLE.ADMIN;
+ const isManager = currentUserAccountID === moneyRequestReport?.managerID;
+
+ // Admin & managers can always edit coding fields such as tag, category, billable, etc. As long as the report has a state higher than OPEN.
+ if ((isAdmin || isManager) && !isDraftExpenseReport(moneyRequestReport)) {
return true;
}
- return !isApproved && !isReportSettled && isRequestor;
+ return !isReportApproved(moneyRequestReport) && !isSettled(moneyRequestReport?.reportID) && isRequestor;
}
/**
* Checks if the current user can edit the provided property of a money request
*
*/
-function canEditFieldOfMoneyRequest(
- reportAction: OnyxEntry,
- reportID: string,
- fieldToEdit: ValueOf,
- transaction: OnyxEntry,
-): boolean {
+function canEditFieldOfMoneyRequest(reportAction: OnyxEntry, fieldToEdit: ValueOf): boolean {
// A list of fields that cannot be edited by anyone, once a money request has been settled
- const nonEditableFieldsWhenSettled: string[] = [
+ const restrictedFields: string[] = [
CONST.EDIT_REQUEST_FIELD.AMOUNT,
CONST.EDIT_REQUEST_FIELD.CURRENCY,
+ CONST.EDIT_REQUEST_FIELD.MERCHANT,
CONST.EDIT_REQUEST_FIELD.DATE,
CONST.EDIT_REQUEST_FIELD.RECEIPT,
CONST.EDIT_REQUEST_FIELD.DISTANCE,
];
- // Checks if this user has permissions to edit this money request
- if (!canEditMoneyRequest(reportAction, fieldToEdit, transaction)) {
- return false; // User doesn't have permission to edit
+ if (!canEditMoneyRequest(reportAction)) {
+ return false;
+ }
+
+ // If we're editing fields such as category, tag, description, etc. the check above should be enough for handling the permission
+ if (!restrictedFields.includes(fieldToEdit)) {
+ return true;
}
- if (!isEmpty(transaction) && fieldToEdit === CONST.EDIT_REQUEST_FIELD.RECEIPT && TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction)) {
+
+ const iouMessage = reportAction?.originalMessage as IOUMessage;
+ const moneyRequestReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouMessage?.IOUReportID}`] ?? ({} as Report);
+ const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${iouMessage?.IOUTransactionID}`] ?? ({} as Transaction);
+
+ if (isSettled(String(moneyRequestReport.reportID)) || isReportApproved(String(moneyRequestReport.reportID))) {
return false;
}
- // Checks if the report is settled
- // Checks if the provided property is a restricted one
- return !isSettled(reportID) || !nonEditableFieldsWhenSettled.includes(fieldToEdit);
+ if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.AMOUNT || fieldToEdit === CONST.EDIT_REQUEST_FIELD.CURRENCY) {
+ if (TransactionUtils.isCardTransaction(transaction)) {
+ return false;
+ }
+
+ if (TransactionUtils.isDistanceRequest(transaction)) {
+ const policy = getPolicy(moneyRequestReport?.reportID ?? '');
+ const isAdmin = isExpenseReport(moneyRequestReport) && policy.role === CONST.POLICY.ROLE.ADMIN;
+ const isManager = isExpenseReport(moneyRequestReport) && currentUserAccountID === moneyRequestReport?.managerID;
+
+ return isAdmin || isManager;
+ }
+ }
+
+ if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.RECEIPT) {
+ const isRequestor = currentUserAccountID === reportAction?.actorAccountID;
+ return !TransactionUtils.isReceiptBeingScanned(transaction) && !TransactionUtils.isDistanceRequest(transaction) && isRequestor;
+ }
+
+ return true;
}
/**
@@ -2063,7 +2098,7 @@ function getReportPreviewMessage(
const formattedAmount = CurrencyUtils.convertToDisplayString(totalAmount, report.currency);
- if (isReportApproved(report) && isGroupPolicy(report)) {
+ if (isReportApproved(report) && isPaidGroupPolicy(report)) {
return Localize.translateLocal('iou.managerApprovedAmount', {
manager: payerName ?? '',
amount: formattedAmount,
@@ -4347,10 +4382,12 @@ export {
formatReportLastMessageText,
chatIncludesConcierge,
isPolicyExpenseChat,
+ isGroupPolicy,
+ isPaidGroupPolicy,
isControlPolicyExpenseChat,
isControlPolicyExpenseReport,
- isGroupPolicyExpenseChat,
- isGroupPolicyExpenseReport,
+ isPaidGroupPolicyExpenseChat,
+ isPaidGroupPolicyExpenseReport,
getIconsForParticipants,
getIcons,
getRoomWelcomeMessage,
diff --git a/src/libs/updatePropsPaperWorklet/index.js b/src/libs/updatePropsPaperWorklet/index.js
deleted file mode 100644
index 1bca6ea13cdc..000000000000
--- a/src/libs/updatePropsPaperWorklet/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function () {
- 'worklet';
-}
diff --git a/src/libs/updatePropsPaperWorklet/index.native.js b/src/libs/updatePropsPaperWorklet/index.native.js
deleted file mode 100644
index ed79b38ffab5..000000000000
--- a/src/libs/updatePropsPaperWorklet/index.native.js
+++ /dev/null
@@ -1,13 +0,0 @@
-export default function (viewTag, viewName, updates) {
- 'worklet';
-
- // _updatePropsPaper is a function that is worklet function from react-native-reanimated which is not available on web
- // eslint-disable-next-line no-undef
- _updatePropsPaper([
- {
- tag: viewTag,
- name: viewName,
- updates,
- },
- ]);
-}
diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js
index 54c5202fb205..bc05c110ab2f 100644
--- a/src/pages/EditRequestPage.js
+++ b/src/pages/EditRequestPage.js
@@ -1,7 +1,7 @@
import lodashGet from 'lodash/get';
import lodashValues from 'lodash/values';
import PropTypes from 'prop-types';
-import React, {useCallback, useEffect, useMemo} from 'react';
+import React, {useCallback, useEffect} from 'react';
import {withOnyx} from 'react-native-onyx';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import categoryPropTypes from '@components/categoryPropTypes';
@@ -47,9 +47,6 @@ const propTypes = {
/** The report object for the thread report */
report: reportPropTypes,
- /** The parent report object for the thread report */
- parentReport: reportPropTypes,
-
/** Collection of categories attached to a policy */
policyCategories: PropTypes.objectOf(categoryPropTypes),
@@ -65,14 +62,13 @@ const propTypes = {
const defaultProps = {
report: {},
- parentReport: {},
policyCategories: {},
policyTags: {},
parentReportActions: {},
transaction: {},
};
-function EditRequestPage({report, route, parentReport, policyCategories, policyTags, parentReportActions, transaction}) {
+function EditRequestPage({report, route, policyCategories, policyTags, parentReportActions, transaction}) {
const parentReportActionID = lodashGet(report, 'parentReportActionID', '0');
const parentReportAction = lodashGet(parentReportActions, parentReportActionID, {});
const {
@@ -93,7 +89,7 @@ function EditRequestPage({report, route, parentReport, policyCategories, policyT
const tagListName = PolicyUtils.getTagListName(policyTags);
// A flag for verifying that the current report is a sub-report of a workspace chat
- const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(ReportUtils.getRootParentReport(report)), [report]);
+ const isPolicyExpenseChat = ReportUtils.isGroupPolicy(report);
// A flag for showing the categories page
const shouldShowCategories = isPolicyExpenseChat && (transactionCategory || OptionsListUtils.hasEnabledOptions(lodashValues(policyCategories)));
@@ -104,7 +100,7 @@ function EditRequestPage({report, route, parentReport, policyCategories, policyT
// Decides whether to allow or disallow editing a money request
useEffect(() => {
// Do not dismiss the modal, when a current user can edit this property of the money request.
- if (ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, parentReport.reportID, fieldToEdit, transaction)) {
+ if (ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, fieldToEdit)) {
return;
}
@@ -112,7 +108,7 @@ function EditRequestPage({report, route, parentReport, policyCategories, policyT
Navigation.isNavigationReady().then(() => {
Navigation.dismissModal();
});
- }, [parentReportAction, parentReport.reportID, fieldToEdit, transaction]);
+ }, [parentReportAction, fieldToEdit]);
// Update the transaction object and close the modal
function editMoneyRequest(transactionChanges) {
@@ -303,9 +299,6 @@ export default compose(
policyTags: {
key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report ? report.policyID : '0'}`,
},
- parentReport: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report ? report.parentReportID : '0'}`,
- },
parentReportActions: {
key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '0'}`,
canEvict: false,
diff --git a/src/pages/ProcessMoneyRequestHoldPage.js b/src/pages/ProcessMoneyRequestHoldPage.js
new file mode 100644
index 000000000000..c9de16f874a2
--- /dev/null
+++ b/src/pages/ProcessMoneyRequestHoldPage.js
@@ -0,0 +1,51 @@
+import React, {useCallback, useMemo} from 'react';
+import {View} from 'react-native';
+import Button from '@components/Button';
+import HeaderPageLayout from '@components/HeaderPageLayout';
+import HoldMenuSectionList from '@components/HoldMenuSectionList';
+import Text from '@components/Text';
+import TextPill from '@components/TextPill';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
+
+function ProcessMoneyRequestHoldPage() {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ const onConfirm = useCallback(() => {
+ // Currently only goes back, this will be changed after backends for hold will be merged
+ Navigation.goBack();
+ }, []);
+
+ const footerComponent = useMemo(
+ () => (
+
+ ),
+ [onConfirm, translate],
+ );
+
+ return (
+ Navigation.goBack()}
+ >
+
+
+ {translate('iou.holdEducationalTitle')}
+ {translate('iou.hold')};
+
+
+
+
+ );
+}
+
+ProcessMoneyRequestHoldPage.displayName = 'ProcessMoneyRequestHoldPage';
+
+export default ProcessMoneyRequestHoldPage;
diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js
index da79db6f2ec8..c072666920ae 100644
--- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js
+++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
-import {runOnJS, useAnimatedRef} from 'react-native-reanimated';
+import {runOnJS, setNativeProps, useAnimatedRef} from 'react-native-reanimated';
import _ from 'underscore';
import AttachmentModal from '@components/AttachmentModal';
import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton';
@@ -24,7 +24,6 @@ import getDraftComment from '@libs/ComposerUtils/getDraftComment';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import getModalState from '@libs/getModalState';
import * as ReportUtils from '@libs/ReportUtils';
-import updatePropsPaperWorklet from '@libs/updatePropsPaperWorklet';
import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside';
import ParticipantLocalTime from '@pages/home/report/ParticipantLocalTime';
import reportActionPropTypes from '@pages/home/report/reportActionPropTypes';
@@ -348,13 +347,10 @@ function ReportActionCompose({
return;
}
- const viewTag = animatedRef();
- const viewName = 'RCTMultilineTextInputView';
- const updates = {text: ''};
// We are setting the isCommentEmpty flag to true so the status of it will be in sync of the native text input state
runOnJS(setIsCommentEmpty)(true);
runOnJS(resetFullComposerSize)();
- updatePropsPaperWorklet(viewTag, viewName, updates); // clears native text input on the UI thread
+ setNativeProps(animatedRef, {text: ''}); // clears native text input on the UI thread
runOnJS(submitForm)();
}, [isSendDisabled, resetFullComposerSize, submitForm, animatedRef, isReportReadyForDisplay]);
diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js
index eab9ab5a7510..24833fc96fdc 100644
--- a/src/pages/iou/IOUCurrencySelection.js
+++ b/src/pages/iou/IOUCurrencySelection.js
@@ -86,7 +86,7 @@ function IOUCurrencySelection(props) {
const parentReportAction = ReportActionsUtils.getReportAction(report.parentReportID, report.parentReportActionID);
// Do not dismiss the modal, when a current user can edit this currency of this money request.
- if (ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, report.parentReportID, CONST.EDIT_REQUEST_FIELD.CURRENCY)) {
+ if (ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.CURRENCY)) {
return;
}
diff --git a/src/pages/iou/request/step/IOURequestStepMerchant.js b/src/pages/iou/request/step/IOURequestStepMerchant.js
index 355bb76b89b0..091e7d8023c3 100644
--- a/src/pages/iou/request/step/IOURequestStepMerchant.js
+++ b/src/pages/iou/request/step/IOURequestStepMerchant.js
@@ -36,13 +36,15 @@ function IOURequestStepMerchant({
route: {
params: {transactionID, backTo},
},
- transaction: {merchant},
+ transaction: {merchant, participants},
}) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {inputCallbackRef} = useAutoFocusInput();
const isEmptyMerchant = merchant === '' || merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
+ const isMerchantRequired = _.some(participants, (participant) => Boolean(participant.isPolicyExpenseChat));
+
const navigateBack = () => {
Navigation.goBack(backTo || ROUTES.HOME);
};
@@ -51,15 +53,18 @@ function IOURequestStepMerchant({
* @param {Object} value
* @param {String} value.moneyRequestMerchant
*/
- const validate = useCallback((value) => {
- const errors = {};
+ const validate = useCallback(
+ (value) => {
+ const errors = {};
- if (_.isEmpty(value.moneyRequestMerchant)) {
- errors.moneyRequestMerchant = 'common.error.fieldRequired';
- }
+ if (isMerchantRequired && _.isEmpty(value.moneyRequestMerchant)) {
+ errors.moneyRequestMerchant = 'common.error.fieldRequired';
+ }
- return errors;
- }, []);
+ return errors;
+ },
+ [isMerchantRequired],
+ );
/**
* @param {Object} value
diff --git a/src/styles/index.ts b/src/styles/index.ts
index a0ad7094ab9b..4ef8740fbebb 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -4068,6 +4068,21 @@ const styles = (theme: ThemeColors) =>
marginBottom: 16,
},
+ holdRequestInline: {
+ ...headlineFont,
+ ...whiteSpace.preWrap,
+ color: theme.heading,
+ fontSize: variables.fontSizeXLarge,
+ lineHeight: variables.lineHeightXXLarge,
+
+ backgroundColor: colors.red,
+ borderRadius: variables.componentBorderRadiusMedium,
+ overflow: 'hidden',
+
+ paddingHorizontal: 8,
+ paddingVertical: 4,
+ },
+
walletCard: {
borderRadius: variables.componentBorderRadiusLarge,
position: 'relative',
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index 4d717389cdb6..80834c9a0261 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -196,4 +196,5 @@ export default {
cardPreviewHeight: 148,
cardPreviewWidth: 235,
cardNameWidth: 156,
+ holdMenuIconSize: 64,
} as const;
diff --git a/tests/perf-test/ReportScreen.perf-test.js b/tests/perf-test/ReportScreen.perf-test.js
index a82903762631..88f3fe99b347 100644
--- a/tests/perf-test/ReportScreen.perf-test.js
+++ b/tests/perf-test/ReportScreen.perf-test.js
@@ -153,7 +153,7 @@ function ReportScreenWrapper(args) {
const runs = CONST.PERFORMANCE_TESTS.RUNS;
-test('[ReportScreen] should render ReportScreen with composer interactions', () => {
+test.skip('[ReportScreen] should render ReportScreen with composer interactions', () => {
const {triggerTransitionEnd, addListener} = createAddListenerMock();
const scenario = async () => {
/**
@@ -226,7 +226,7 @@ test('[ReportScreen] should render ReportScreen with composer interactions', ()
);
});
-test('[ReportScreen] should press of the report item', () => {
+test.skip('[ReportScreen] should press of the report item', () => {
const {triggerTransitionEnd, addListener} = createAddListenerMock();
const scenario = async () => {
/**