Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
362 changes: 78 additions & 284 deletions src/pages/iou/request/step/IOURequestStepConfirmation.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {useEffect} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import {setMoneyRequestCategory} from '@userActions/IOU';
import CONST from '@src/CONST';
import type {Policy, PolicyCategories, Transaction} from '@src/types/onyx';

type CategoryDefaultsSetterProps = {
transactions: Transaction[];
transactionIDs: string[];
existingTransaction: OnyxEntry<Transaction>;
policyCategories: OnyxEntry<PolicyCategories>;
policy: OnyxEntry<Policy>;
isDistanceRequest: boolean;
requestType: string | undefined;
isMovingTransactionFromTrackExpense: boolean;
};

/**
* Side-effect-only component that handles two category-related effects:
* 1. Resets cleared categories back to their last saved value
* 2. Sets the default distance category for distance requests
*/
function CategoryDefaultsSetter({
transactions,
transactionIDs,
existingTransaction,
policyCategories,
policy,
isDistanceRequest,
requestType,
isMovingTransactionFromTrackExpense,
}: CategoryDefaultsSetterProps) {
useEffect(() => {
for (const item of transactions) {
if (!item.category) {
// If the expense had his category cleared due to unsaved changes (i.e. changing to recipient to one that does not have category)
// then we should reset the category to it's last saved value
const existingCategory = existingTransaction?.category;
if (existingCategory) {
const isExistingCategoryEnabled = policyCategories?.[existingCategory]?.enabled;
if (isExistingCategoryEnabled) {
setMoneyRequestCategory(item.transactionID, existingCategory, policy);
}
}
continue;
}
}
// We don't want to clear out category every time the transactions change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [policy?.id, policyCategories, transactions.length]);

const policyDistance = Object.values(policy?.customUnits ?? {}).find((customUnit) => customUnit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE);
const defaultCategory = policyDistance?.defaultCategory ?? '';

useEffect(() => {
for (const item of transactions) {
if (!isDistanceRequest || !!item?.category) {
continue;
}
setMoneyRequestCategory(item.transactionID, defaultCategory, policy, isMovingTransactionFromTrackExpense);
}
// Prevent resetting to default when unselect category
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [transactionIDs, requestType, defaultCategory, policy?.id]);

return null;
}

CategoryDefaultsSetter.displayName = 'CategoryDefaultsSetter';

export default CategoryDefaultsSetter;
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {useEffect} from 'react';
import {openDraftWorkspaceRequest} from '@userActions/Policy/Policy';
import CONST from '@src/CONST';
import type {PendingAction} from '@src/types/onyx/OnyxCommon';

type DraftWorkspaceOpenerProps = {
isCreatingTrackExpense: boolean;
policyID: string | undefined;
policyPendingAction: PendingAction | undefined;
policyExpenseChatPolicyID: string | undefined;
senderPolicyID: string | undefined;
isOffline: boolean;
};

/**
* Side-effect-only component that opens draft workspace requests when needed.
* Handles two cases:
* 1. When creating a track expense, opens workspace for the policy
* 2. When a policy expense chat or sender policy is present, opens that workspace
*/
function DraftWorkspaceOpener({isCreatingTrackExpense, policyID, policyPendingAction, policyExpenseChatPolicyID, senderPolicyID, isOffline}: DraftWorkspaceOpenerProps) {
useEffect(() => {
if (!isCreatingTrackExpense || policyID === undefined) {
return;
}

openDraftWorkspaceRequest(policyID);
}, [isCreatingTrackExpense, policyPendingAction, policyID]);

useEffect(() => {
if (policyExpenseChatPolicyID && policyPendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) {
openDraftWorkspaceRequest(policyExpenseChatPolicyID);
return;
}
if (senderPolicyID && policyPendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) {
openDraftWorkspaceRequest(senderPolicyID);
}
}, [isOffline, policyPendingAction, policyExpenseChatPolicyID, senderPolicyID]);

return null;
}

DraftWorkspaceOpener.displayName = 'DraftWorkspaceOpener';

export default DraftWorkspaceOpener;
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {useEffect} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import {isPaidGroupPolicy} from '@libs/PolicyUtils';
import {setMoneyRequestBillable, setMoneyRequestReimbursable} from '@userActions/IOU';
import type {Policy} from '@src/types/onyx';

type ExpenseDefaultsSetterProps = {
transactionIDs: string[];
policy: OnyxEntry<Policy>;
isPolicyExpenseChat: boolean | undefined;
isMovingTransactionFromTrackExpense: boolean;
isCreatingTrackExpense: boolean;
};

/**
* Side-effect-only component that sets default billable and reimbursable values
* on transactions based on the policy configuration.
*/
function ExpenseDefaultsSetter({transactionIDs, policy, isPolicyExpenseChat, isMovingTransactionFromTrackExpense, isCreatingTrackExpense}: ExpenseDefaultsSetterProps) {
const defaultBillable = !!policy?.defaultBillable;

useEffect(() => {
if (isMovingTransactionFromTrackExpense) {
return;
}
for (const transactionID of transactionIDs) {
setMoneyRequestBillable(transactionID, defaultBillable);
}
}, [transactionIDs, defaultBillable, isMovingTransactionFromTrackExpense]);

useEffect(() => {
if (isMovingTransactionFromTrackExpense) {
return;
}
const defaultReimbursable = (isPolicyExpenseChat && isPaidGroupPolicy(policy)) || isCreatingTrackExpense ? (policy?.defaultReimbursable ?? true) : true;
for (const transactionID of transactionIDs) {
setMoneyRequestReimbursable(transactionID, defaultReimbursable);
}
}, [transactionIDs, policy, isPolicyExpenseChat, isMovingTransactionFromTrackExpense, isCreatingTrackExpense]);

return null;
}

ExpenseDefaultsSetter.displayName = 'ExpenseDefaultsSetter';

export default ExpenseDefaultsSetter;
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {useEffect} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import {generateReportID} from '@libs/ReportUtils';
import {startMoneyRequest} from '@userActions/IOU';
import type {IOUType} from '@src/CONST';
import CONST from '@src/CONST';
import type {Transaction} from '@src/types/onyx';
import {isEmptyObject} from '@src/types/utils/EmptyObject';

type MoneyRequestInitializerProps = {
isLoadingTransaction: boolean;
transaction: OnyxEntry<Transaction>;
iouType: IOUType;
reportID: string;
draftTransactionIDs: string[] | undefined;
};

/**
* Side-effect-only component that initializes a money request (calls startMoneyRequest)
* when the transaction loads for the first time and has no participants yet.
*/
function MoneyRequestInitializer({isLoadingTransaction, transaction, iouType, reportID, draftTransactionIDs}: MoneyRequestInitializerProps) {
useEffect(() => {
// Exit early if the transaction is still loading
if (!!isLoadingTransaction || (transaction?.transactionID && (!transaction?.isFromGlobalCreate || !isEmptyObject(transaction?.participants)))) {
return;
}

startMoneyRequest(
iouType ?? CONST.IOU.TYPE.CREATE,
// When starting to create an expense from the global FAB, If there is not an existing report yet, a random optimistic reportID is generated and used
// for all of the routes in the creation flow.
reportID ?? generateReportID(),
draftTransactionIDs,
);
// eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want this effect to run again
}, [isLoadingTransaction]);

return null;
}

MoneyRequestInitializer.displayName = 'MoneyRequestInitializer';

export default MoneyRequestInitializer;
123 changes: 123 additions & 0 deletions src/pages/iou/request/step/confirmation/OdometerReceiptStitcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import {useIsFocused} from '@react-navigation/native';
import {useEffect, useRef} from 'react';
import useLocalize from '@hooks/useLocalize';
import {shouldUseTransactionDraft} from '@libs/IOUUtils';
import Log from '@libs/Log';
import {getOdometerImageName, getOdometerImageType, getOdometerImageUri} from '@libs/OdometerImageUtils';
import stitchOdometerImages from '@libs/stitchOdometerImages';
import {setMoneyRequestReceipt} from '@userActions/IOU/Receipt';
import type {IOUAction, IOUType} from '@src/CONST';
import type {FileObject} from '@src/types/utils/Attachment';

type OdometerReceiptStitcherProps = {
isOdometerDistanceRequest: boolean;
currentTransactionID: string;
odometerStartImage: FileObject | string | null | undefined;
odometerEndImage: FileObject | string | null | undefined;
action: IOUAction;
iouType: IOUType;
onStitchingChange: (isStitching: boolean) => void;
onStitchError: (error: string) => void;
};

/**
* Side-effect-only component that stitches two odometer images into a single
* receipt, or sets a single odometer image as the receipt when only one exists.
* Skips stitching when source images haven't changed (compares by URI, not reference,
* because Onyx may create new object instances when restoring a backup transaction).
*/
function OdometerReceiptStitcher({
isOdometerDistanceRequest,
currentTransactionID,
odometerStartImage,
odometerEndImage,
action,
iouType,
onStitchingChange,
onStitchError,
}: OdometerReceiptStitcherProps) {
const {translate} = useLocalize();
const isFocused = useIsFocused();
const lastStitchedImages = useRef<{
startImage: FileObject | string | null | undefined;
endImage: FileObject | string | null | undefined;
} | null>(null);

useEffect(() => {
if (!isOdometerDistanceRequest || !isFocused) {
return;
}

// Skip stitching when source images haven't changed (compare by URI not reference
// because Onyx may create new object instances when restoring a backup transaction)
const startUri = getOdometerImageUri(odometerStartImage);
const endUri = getOdometerImageUri(odometerEndImage);
if (
lastStitchedImages.current !== null &&
getOdometerImageUri(lastStitchedImages.current.startImage) === startUri &&
getOdometerImageUri(lastStitchedImages.current.endImage) === endUri
) {
return;
}

if (!odometerStartImage || !odometerEndImage) {
const singleImage = odometerStartImage ?? odometerEndImage;

if (!singleImage) {
return;
}

setMoneyRequestReceipt(
currentTransactionID,
getOdometerImageUri(singleImage),
getOdometerImageName(singleImage),
shouldUseTransactionDraft(action, iouType),
getOdometerImageType(singleImage),
);
lastStitchedImages.current = {startImage: odometerStartImage, endImage: odometerEndImage};
return;
}

let ignore = false;
onStitchingChange(true);
onStitchError('');

stitchOdometerImages(odometerStartImage, odometerEndImage)
.then((stitchedImage) => {
if (ignore || !stitchedImage) {
return;
}
setMoneyRequestReceipt(
currentTransactionID,
getOdometerImageUri(stitchedImage),
getOdometerImageName(stitchedImage),
shouldUseTransactionDraft(action, iouType),
getOdometerImageType(stitchedImage),
);
lastStitchedImages.current = {startImage: odometerStartImage, endImage: odometerEndImage};
})
.catch((error: unknown) => {
if (ignore) {
return;
}
Log.warn('stitchOdometerImages failed', {error});
onStitchError(translate('iou.error.stitchOdometerImagesFailed'));
})
.finally(() => {
if (ignore) {
return;
}
onStitchingChange(false);
});

return () => {
ignore = true;
};
}, [isOdometerDistanceRequest, isFocused, currentTransactionID, odometerStartImage, odometerEndImage, action, translate, iouType, onStitchingChange, onStitchError]);

return null;
}

OdometerReceiptStitcher.displayName = 'OdometerReceiptStitcher';

export default OdometerReceiptStitcher;
Loading
Loading