Skip to content

Commit

Permalink
Merge pull request #33897 from software-mansion-labs/hold-requests/ho…
Browse files Browse the repository at this point in the history
…ld-page

[WAVE 7] Hold Requests: Individual Money Request Page, Reason Interstitial, Banner
  • Loading branch information
robertjchen authored Feb 16, 2024
2 parents 79ce049 + bd1881a commit 7fd529b
Show file tree
Hide file tree
Showing 29 changed files with 540 additions and 32 deletions.
3 changes: 3 additions & 0 deletions assets/images/stopwatch.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,7 @@ const CONST = {
CHRONOSOOOLIST: 'CHRONOSOOOLIST',
CLOSED: 'CLOSED',
CREATED: 'CREATED',
HOLD: 'HOLD',
IOU: 'IOU',
MARKEDREIMBURSED: 'MARKEDREIMBURSED',
MODIFIEDEXPENSE: 'MODIFIEDEXPENSE',
Expand Down Expand Up @@ -648,6 +649,7 @@ const CONST = {
REMOVE_FROM_ROOM: 'REMOVEFROMROOM',
LEAVE_ROOM: 'LEAVEROOM',
},
UNHOLD: 'UNHOLD',
},
THREAD_DISABLED: ['CREATED'],
},
Expand Down
7 changes: 7 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ const ONYXKEYS = {
/** Whether the user has tried focus mode yet */
NVP_TRY_FOCUS_MODE: 'tryFocusMode',

/** Whether the user has been shown the hold educational interstitial yet */
NVP_HOLD_USE_EXPLAINED: 'holdUseExplained',

/** Boolean flag used to display the focus mode notification */
FOCUS_MODE_NOTIFICATION: 'focusModeNotification',

Expand Down Expand Up @@ -355,6 +358,8 @@ const ONYXKEYS = {
MONEY_REQUEST_AMOUNT_FORM_DRAFT: 'moneyRequestAmountFormDraft',
MONEY_REQUEST_DATE_FORM: 'moneyRequestCreatedForm',
MONEY_REQUEST_DATE_FORM_DRAFT: 'moneyRequestCreatedFormDraft',
MONEY_REQUEST_HOLD_FORM: 'moneyHoldReasonForm',
MONEY_REQUEST_HOLD_FORM_DRAFT: 'moneyHoldReasonFormDraft',
NEW_CONTACT_METHOD_FORM: 'newContactMethodForm',
NEW_CONTACT_METHOD_FORM_DRAFT: 'newContactMethodFormDraft',
WAYPOINT_FORM: 'waypointForm',
Expand Down Expand Up @@ -410,6 +415,7 @@ type OnyxFormValuesMapping = {
[ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM]: FormTypes.Form;
[ONYXKEYS.FORMS.MONEY_REQUEST_AMOUNT_FORM]: FormTypes.Form;
[ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM]: FormTypes.Form;
[ONYXKEYS.FORMS.MONEY_REQUEST_HOLD_FORM]: FormTypes.MoneyRequestHoldReasonForm;
[ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM]: FormTypes.Form;
[ONYXKEYS.FORMS.WAYPOINT_FORM]: FormTypes.Form;
[ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM]: FormTypes.Form;
Expand Down Expand Up @@ -499,6 +505,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE]: OnyxTypes.BlockedFromConcierge;
[ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID]: string;
[ONYXKEYS.NVP_TRY_FOCUS_MODE]: boolean;
[ONYXKEYS.NVP_HOLD_USE_EXPLAINED]: boolean;
[ONYXKEYS.FOCUS_MODE_NOTIFICATION]: boolean;
[ONYXKEYS.NVP_LAST_PAYMENT_METHOD]: OnyxTypes.LastPaymentMethod;
[ONYXKEYS.NVP_RECENT_WAYPOINTS]: OnyxTypes.RecentWaypoint[];
Expand Down
4 changes: 4 additions & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,10 @@ const ROUTES = {
route: ':iouType/new/category/:reportID?',
getRoute: (iouType: string, reportID = '') => `${iouType}/new/category/${reportID}` as const,
},
MONEY_REQUEST_HOLD_REASON: {
route: ':iouType/edit/reason/:transactionID?',
getRoute: (iouType: string, transactionID: string, reportID: string, backTo: string) => `${iouType}/edit/reason/${transactionID}?backTo=${backTo}&reportID=${reportID}` as const,
},
MONEY_REQUEST_MERCHANT: {
route: ':iouType/new/merchant/:reportID?',
getRoute: (iouType: string, reportID = '') => `${iouType}/new/merchant/${reportID}` as const,
Expand Down
1 change: 1 addition & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ const SCREENS = {
SCAN_TAB: 'scan',
DISTANCE_TAB: 'distance',
CREATE: 'Money_Request_Create',
HOLD: 'Money_Request_Hold_Reason',
STEP_CONFIRMATION: 'Money_Request_Step_Confirmation',
START: 'Money_Request_Start',
STEP_AMOUNT: 'Money_Request_Step_Amount',
Expand Down
22 changes: 22 additions & 0 deletions src/components/HoldBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from 'react';
import {View} from 'react-native';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import Text from './Text';
import TextPill from './TextPill';

function HoldBanner() {
const styles = useThemeStyles();
const {translate} = useLocalize();

return (
<View style={[styles.dFlex, styles.flexRow, styles.alignItemsCenter, styles.pb3, styles.ph5, styles.borderBottom]}>
<TextPill>{translate('iou.hold')}</TextPill>
<Text style={[styles.textLabel, styles.pl3, styles.mw100, styles.flexShrink1]}>{translate('iou.requestOnHold')}</Text>
</View>
);
}

HoldBanner.displayName = 'HoldBanner';

export default HoldBanner;
2 changes: 2 additions & 0 deletions src/components/Icon/Expensicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ import Linkedin from '@assets/images/social-linkedin.svg';
import Podcast from '@assets/images/social-podcast.svg';
import Twitter from '@assets/images/social-twitter.svg';
import Youtube from '@assets/images/social-youtube.svg';
import Stopwatch from '@assets/images/stopwatch.svg';
import Sync from '@assets/images/sync.svg';
import Task from '@assets/images/task.svg';
import ThreeDots from '@assets/images/three-dots.svg';
Expand Down Expand Up @@ -270,6 +271,7 @@ export {
Scan,
Send,
Shield,
Stopwatch,
Sync,
Task,
ThumbsUp,
Expand Down
83 changes: 79 additions & 4 deletions src/components/MoneyRequestHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ import type {Policy, Report, ReportAction, ReportActions, Session, Transaction}
import type {OriginalMessageIOU} from '@src/types/onyx/OriginalMessage';
import ConfirmModal from './ConfirmModal';
import HeaderWithBackButton from './HeaderWithBackButton';
import HoldBanner from './HoldBanner';
import * as Expensicons from './Icon/Expensicons';
import MoneyRequestHeaderStatusBar from './MoneyRequestHeaderStatusBar';
import {usePersonalDetails} from './OnyxProvider';
import ProcessMoneyRequestHoldMenu from './ProcessMoneyRequestHoldMenu';

type MoneyRequestHeaderOnyxProps = {
/** Session info for the currently logged in user. */
Expand All @@ -36,6 +38,9 @@ type MoneyRequestHeaderOnyxProps = {
/** All report actions */
// eslint-disable-next-line react/no-unused-prop-types
parentReportActions: OnyxEntry<ReportActions>;

/** Whether we should show the Hold Interstitial explaining the feature */
shownHoldUseExplanation: OnyxEntry<boolean>;
};

type MoneyRequestHeaderProps = MoneyRequestHeaderOnyxProps & {
Expand All @@ -49,18 +54,22 @@ type MoneyRequestHeaderProps = MoneyRequestHeaderOnyxProps & {
parentReportAction: ReportAction & OriginalMessageIOU;
};

function MoneyRequestHeader({session, parentReport, report, parentReportAction, transaction, policy}: MoneyRequestHeaderProps) {
function MoneyRequestHeader({session, parentReport, report, parentReportAction, transaction, shownHoldUseExplanation = false, policy}: MoneyRequestHeaderProps) {
const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
const styles = useThemeStyles();
const {translate} = useLocalize();
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
const [shouldShowHoldMenu, setShouldShowHoldMenu] = useState(false);
const moneyRequestReport = parentReport;
const isSettled = ReportUtils.isSettled(moneyRequestReport?.reportID);
const isApproved = ReportUtils.isReportApproved(moneyRequestReport);
const isOnHold = TransactionUtils.isOnHold(transaction);
const {isSmallScreenWidth, windowWidth} = useWindowDimensions();

// Only the requestor can take delete the request, admins can only edit it.
const isActionOwner = typeof parentReportAction?.actorAccountID === 'number' && typeof session?.accountID === 'number' && parentReportAction.actorAccountID === session?.accountID;
const isPolicyAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN;
const isApprover = ReportUtils.isMoneyRequestReport(moneyRequestReport) && (session?.accountID ?? null) === moneyRequestReport?.managerID;

const deleteTransaction = useCallback(() => {
IOU.deleteMoneyRequest(parentReportAction?.originalMessage?.IOUTransactionID ?? '', parentReportAction, true);
Expand All @@ -69,6 +78,8 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction,

const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction);
const isPending = TransactionUtils.isExpensifyCardTransaction(transaction) && TransactionUtils.isPending(transaction);

const isRequestModifiable = !isSettled && !isApproved && !ReportActionsUtils.isDeletedAction(parentReportAction);
const canModifyRequest = isActionOwner && !isSettled && !isApproved && !ReportActionsUtils.isDeletedAction(parentReportAction);
let canDeleteRequest = canModifyRequest;

Expand All @@ -77,14 +88,66 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction,
canDeleteRequest = canDeleteRequest && (ReportUtils.isDraftExpenseReport(moneyRequestReport) || PolicyUtils.isPolicyAdmin(policy));
}

const changeMoneyRequestStatus = () => {
if (isOnHold) {
IOU.unholdRequest(parentReportAction?.originalMessage?.IOUTransactionID ?? '', report?.reportID);
} else {
const activeRoute = encodeURIComponent(Navigation.getActiveRouteWithoutParams());
Navigation.navigate(ROUTES.MONEY_REQUEST_HOLD_REASON.getRoute(policy?.type, parentReportAction?.originalMessage?.IOUTransactionID ?? '', report?.reportID, activeRoute));
}
};

useEffect(() => {
if (canDeleteRequest) {
return;
}

setIsDeleteModalVisible(false);
}, [canDeleteRequest]);

const threeDotsMenuItems = [HeaderUtils.getPinMenuItem(report)];
if (isRequestModifiable) {
const isRequestIOU = parentReport?.type === 'iou';
const isHoldCreator = ReportUtils.isHoldCreator(transaction, report?.reportID) && isRequestIOU;
const canModifyStatus = isPolicyAdmin || isActionOwner || isApprover;
if (isOnHold && (isHoldCreator || (!isRequestIOU && canModifyStatus))) {
threeDotsMenuItems.push({
icon: Expensicons.Stopwatch,
text: translate('iou.unholdRequest'),
onSelected: () => changeMoneyRequestStatus(),
});
}
if (!isOnHold && (isRequestIOU || canModifyStatus)) {
threeDotsMenuItems.push({
icon: Expensicons.Stopwatch,
text: translate('iou.holdRequest'),
onSelected: () => changeMoneyRequestStatus(),
});
}
}

useEffect(() => {
setShouldShowHoldMenu(isOnHold && !shownHoldUseExplanation);
}, [isOnHold, shownHoldUseExplanation]);

useEffect(() => {
if (!shouldShowHoldMenu) {
return;
}

if (isSmallScreenWidth) {
if (Navigation.getActiveRoute().slice(1) === ROUTES.PROCESS_MONEY_REQUEST_HOLD) {
Navigation.goBack();
}
} else {
Navigation.navigate(ROUTES.PROCESS_MONEY_REQUEST_HOLD);
}
}, [isSmallScreenWidth, shouldShowHoldMenu]);

const handleHoldRequestClose = () => {
IOU.setShownHoldUseExplanation();
};

if (canModifyRequest) {
if (!TransactionUtils.hasReceipt(transaction)) {
threeDotsMenuItems.push({
Expand Down Expand Up @@ -115,7 +178,7 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction,
<>
<View style={[styles.pl0]}>
<HeaderWithBackButton
shouldShowBorderBottom={!isScanning && !isPending}
shouldShowBorderBottom={!isScanning && !isPending && !isOnHold}
shouldShowAvatarWithDisplay
shouldShowPinButton={false}
shouldShowThreeDotsButton
Expand Down Expand Up @@ -144,6 +207,7 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction,
shouldShowBorderBottom
/>
)}
{isOnHold && <HoldBanner />}
</View>
<ConfirmModal
title={translate('iou.deleteRequest')}
Expand All @@ -155,22 +219,33 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction,
cancelText={translate('common.cancel')}
danger
/>
{isSmallScreenWidth && shouldShowHoldMenu && (
<ProcessMoneyRequestHoldMenu
onClose={handleHoldRequestClose}
onConfirm={handleHoldRequestClose}
isVisible={shouldShowHoldMenu}
/>
)}
</>
);
}

MoneyRequestHeader.displayName = 'MoneyRequestHeader';

const MoneyRequestHeaderWithTransaction = withOnyx<MoneyRequestHeaderProps, Pick<MoneyRequestHeaderOnyxProps, 'transaction'>>({
const MoneyRequestHeaderWithTransaction = withOnyx<MoneyRequestHeaderProps, Pick<MoneyRequestHeaderOnyxProps, 'transaction' | 'shownHoldUseExplanation'>>({
transaction: {
key: ({report, parentReportActions}) => {
const parentReportAction = (report.parentReportActionID && parentReportActions ? parentReportActions[report.parentReportActionID] : {}) as ReportAction & OriginalMessageIOU;
return `${ONYXKEYS.COLLECTION.TRANSACTION}${parentReportAction.originalMessage.IOUTransactionID ?? 0}`;
},
},
shownHoldUseExplanation: {
key: ONYXKEYS.NVP_HOLD_USE_EXPLAINED,
initWithStoredValues: true,
},
})(MoneyRequestHeader);

export default withOnyx<Omit<MoneyRequestHeaderProps, 'transaction'>, Omit<MoneyRequestHeaderOnyxProps, 'transaction'>>({
export default withOnyx<Omit<MoneyRequestHeaderProps, 'transaction' | 'shownHoldUseExplanation'>, Omit<MoneyRequestHeaderOnyxProps, 'transaction' | 'shownHoldUseExplanation'>>({
session: {
key: ONYXKEYS.SESSION,
},
Expand Down
13 changes: 5 additions & 8 deletions src/components/ProcessMoneyRequestHoldMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type {RefObject} from 'react';
import React from 'react';
import React, {useRef} from 'react';
import {View} from 'react-native';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
Expand All @@ -25,22 +24,20 @@ type ProcessMoneyRequestHoldMenuProps = {
anchorPosition?: PopoverAnchorPosition;

/** The anchor alignment of the popover menu */
anchorAlignment: AnchorAlignment;

/** The anchor ref of the popover menu */
anchorRef: RefObject<View | HTMLDivElement>;
anchorAlignment?: AnchorAlignment;
};

function ProcessMoneyRequestHoldMenu({isVisible, onClose, onConfirm, anchorPosition, anchorAlignment, anchorRef}: ProcessMoneyRequestHoldMenuProps) {
function ProcessMoneyRequestHoldMenu({isVisible, onClose, onConfirm, anchorPosition, anchorAlignment}: ProcessMoneyRequestHoldMenuProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
const popoverRef = useRef(null);

return (
<Popover
isVisible={isVisible}
onClose={onClose}
anchorPosition={anchorPosition}
anchorRef={anchorRef}
anchorRef={popoverRef}
anchorAlignment={anchorAlignment}
disableAnimation={false}
withoutOverlay={false}
Expand Down
9 changes: 6 additions & 3 deletions src/components/TextPill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,18 @@ type TextPillProps = {
color?: string;

/** Styles to apply to the text */
textStyles: StyleProp<TextStyle>;
textStyles?: StyleProp<TextStyle>;

children: React.ReactNode;
};

function TextPill({color, textStyles, children}: TextPillProps) {
const styles = useThemeStyles();

return <Text style={[{backgroundColor: color ?? colors.red, borderRadius: 6}, styles.overflowHidden, styles.textStrong, styles.ph2, styles.pv1, textStyles]}>{children}</Text>;
return (
<Text style={[{backgroundColor: color ?? colors.red, borderRadius: 6}, styles.overflowHidden, styles.textStrong, styles.ph2, styles.pv1, styles.flexShrink0, textStyles]}>
{children}
</Text>
);
}

TextPill.displayName = 'TextPill';
Expand Down
12 changes: 12 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,18 @@ export default {
waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `Started settling up, payment is held until ${submitterDisplayName} enables their Wallet`,
enableWallet: 'Enable Wallet',
hold: 'Hold',
holdRequest: 'Hold Request',
unholdRequest: 'Unhold Request',
explainHold: "Explain why you're holding this request.",
reason: 'Reason',
holdReasonRequired: 'A reason is required when holding.',
requestOnHold: 'This request was put on hold. Review the comments for next steps.',
confirmApprove: 'Confirm what to approve',
confirmApprovalAmount: 'Approve the entire report total or only the amount not on hold.',
confirmPay: 'Confirm what to pay',
confirmPayAmount: 'Pay all out-of-pocket spend or only the amount not on hold.',
payOnly: 'Pay only',
approveOnly: 'Approve only',
holdEducationalTitle: 'This request is on',
whatIsHoldTitle: 'What is hold?',
whatIsHoldExplain: 'Hold is our way of streamlining financial collaboration. "Reject" is so harsh!',
Expand Down
26 changes: 19 additions & 7 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,14 +660,26 @@ 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.',
holdRequest: 'Bloquear solicitud de dinero',
unholdRequest: 'Desbloquear solicitud de dinero',
explainHold: 'Explica la razón para bloquear esta solicitud.',
reason: 'Razón',
holdReasonRequired: 'Se requiere una razón para bloquear.',
requestOnHold: 'Este solicitud está bloqueada. Revisa los comentarios para saber como proceder.',
confirmApprove: 'Confirma que quieres aprobar',
confirmApprovalAmount: 'Aprobar el total o solo la parte no bloqueada.',
confirmPay: 'Confirma que quieres pagar',
confirmPayAmount: 'Pagar todos los gastos por cuenta propia o solo el monto no bloqueado.',
payOnly: 'Solo pagar',
approveOnly: 'Solo aprobar',
hold: 'Bloqueada',
holdEducationalTitle: 'Esta solicitud está',
whatIsHoldTitle: '¿Qué es Bloquear?',
whatIsHoldExplain: 'Bloquear es nuestra forma de agilizar la colaboración financiera. ¡"Rechazar" es tan duro!',
holdIsTemporaryTitle: 'Bloquear suele ser temporal',
holdIsTemporaryExplain: 'Se utiliza bloquear 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.',
deleteHoldExplain: 'En el raro caso de que algo se bloquear y no se pague, la persona que solicita el pago debe eliminarlo.',
set: 'estableció',
changed: 'cambió',
removed: 'eliminó',
Expand Down
Loading

0 comments on commit 7fd529b

Please sign in to comment.