Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8353,6 +8353,8 @@ const CONST = {
},
RECEIPT: {
IMAGE: 'Receipt-Image',
ENLARGE_BUTTON: 'Receipt-EnlargeButton',
ADD_ATTACHMENT_BUTTON: 'Receipt-AddAttachmentButton',
},
RECEIPT_MODAL: {
REPLACE_RECEIPT: 'ReceiptModal-ReplaceReceipt',
Expand Down
82 changes: 80 additions & 2 deletions src/components/ReportActionItem/MoneyRequestReceiptView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,32 @@ import {View} from 'react-native';
import type {StyleProp, ViewStyle} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import AttachmentPicker from '@components/AttachmentPicker';
import Icon from '@components/Icon';
import {ModalActions} from '@components/Modal/Global/ModalContext';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
import ReceiptAudit, {ReceiptAuditMessages} from '@components/ReceiptAudit';
import ReceiptEmptyState from '@components/ReceiptEmptyState';
import useActiveRoute from '@hooks/useActiveRoute';
import useAncestors from '@hooks/useAncestors';
import useCardFeedErrors from '@hooks/useCardFeedErrors';
import useConfirmModal from '@hooks/useConfirmModal';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useEnvironment from '@hooks/useEnvironment';
import useGetIOUReportFromReportAction from '@hooks/useGetIOUReportFromReportAction';
import useHover from '@hooks/useHover';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import useOriginalReportID from '@hooks/useOriginalReportID';
import useReportIsArchived from '@hooks/useReportIsArchived';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useTransactionViolations from '@hooks/useTransactionViolations';
import {getBrokenConnectionUrlToFixPersonalCard} from '@libs/CardUtils';
import {canUseTouchScreen} from '@libs/DeviceCapabilities';
import {getMicroSecondOnyxErrorWithTranslationKey, isReceiptError} from '@libs/ErrorUtils';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils';
Expand All @@ -46,9 +54,10 @@ import {
} from '@libs/TransactionUtils';
import ViolationsUtils, {filterReceiptViolations} from '@libs/Violations/ViolationsUtils';
import Navigation from '@navigation/Navigation';
import variables from '@styles/variables';
import {clearAllRelatedReportActionErrors} from '@userActions/ClearReportActionErrors';
import {cleanUpMoneyRequest, replaceReceipt} from '@userActions/IOU';
import {navigateToConciergeChatAndDeleteReport} from '@userActions/Report';
import {addAttachmentWithComment, navigateToConciergeChatAndDeleteReport} from '@userActions/Report';
import {clearError, getLastModifiedExpense, revert} from '@userActions/Transaction';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
Expand Down Expand Up @@ -131,7 +140,12 @@ function MoneyRequestReceiptView({report, readonly = false, updatedTransaction,
const didReceiptScanSucceed = hasReceipt && didReceiptScanSucceedTransactionUtils(transaction);
const isInvoice = isInvoiceReport(moneyRequestReport);
const isChatReportArchived = useReportIsArchived(moneyRequestReport?.chatReportID);
const {login: currentUserLogin, accountID: currentUserAccountID} = useCurrentUserPersonalDetails();
const {login: currentUserLogin, accountID: currentUserAccountID, timezone: currentUserTimezone} = useCurrentUserPersonalDetails();
const theme = useTheme();
const ancestors = useAncestors(report);
const {hovered, bind: hoverBind} = useHover();
const isTouchScreen = canUseTouchScreen();
const lazyIcons = useMemoizedLazyExpensifyIcons(['Expand', 'ReceiptPlus']);

// Flags for allowing or disallowing editing an expense
// Used for non-restricted fields such as: description, category, tag, billable, etc...
Expand Down Expand Up @@ -322,6 +336,8 @@ function MoneyRequestReceiptView({report, readonly = false, updatedTransaction,

const isMapDistanceRequest = !!transaction && isDistanceRequest && !isManualDistanceRequest(transaction);

const canShowReceiptActions = hasReceipt && !isLoading && isEditable && !isMapDistanceRequest && !mergeTransactionID;

const receiptAuditMessagesRow = (
<View style={[styles.mt3, isEmptyObject(errors) && isDisplayedInWideRHP && styles.mb3]}>
<ReceiptAuditMessages notes={receiptImageViolations} />
Expand Down Expand Up @@ -423,6 +439,8 @@ function MoneyRequestReceiptView({report, readonly = false, updatedTransaction,
showBorderlessLoading && styles.flex1,
fillSpace && !shouldShowReceiptEmptyState && isMapDistanceRequest && styles.flex1,
]}
onMouseEnter={() => !isLoading && hoverBind.onMouseEnter()}
onMouseLeave={hoverBind.onMouseLeave}
>
<ReportActionItemImage
shouldUseThumbnailImage={!fillSpace}
Expand All @@ -441,6 +459,66 @@ function MoneyRequestReceiptView({report, readonly = false, updatedTransaction,
onLoad={() => setIsLoading(false)}
onLoadFailure={() => setIsLoading(false)}
/>
{canShowReceiptActions && (
<View style={[styles.receiptActionButtonsContainer, styles.pointerEventsBoxNone, !hovered && !isTouchScreen && styles.opacity0]}>
<AttachmentPicker>
{({openPicker}) => (
<PressableWithoutFeedback
onPress={() => {
openPicker({
onPicked: (files) => {
if (!report?.reportID) {
return;
}
addAttachmentWithComment({
report,
notifyReportID: moneyRequestReport?.reportID ?? report.reportID,
ancestors,
attachments: files,
currentUserAccountID,
timezone: currentUserTimezone,
});
},
});
}}
style={styles.receiptActionButton}
accessibilityLabel={translate('reportActionCompose.addAttachment')}
role={CONST.ROLE.BUTTON}
sentryLabel={CONST.SENTRY_LABEL.RECEIPT.ADD_ATTACHMENT_BUTTON}
>
<View style={styles.primaryMediumIcon}>
<Icon
src={lazyIcons.ReceiptPlus}
height={variables.iconSizeSmall}
width={variables.iconSizeSmall}
fill={theme.icon}
/>
</View>
</PressableWithoutFeedback>
)}
</AttachmentPicker>
<PressableWithoutFeedback
onPress={() =>
Navigation.navigate(
ROUTES.TRANSACTION_RECEIPT.getRoute(report?.reportID, (updatedTransaction ?? transaction)?.transactionID, readonly || !canEditReceipt),
)
}
style={styles.receiptActionButton}
accessibilityLabel={translate('accessibilityHints.viewAttachment')}
role={CONST.ROLE.BUTTON}
sentryLabel={CONST.SENTRY_LABEL.RECEIPT.ENLARGE_BUTTON}
>
<View style={styles.primaryMediumIcon}>
<Icon
src={lazyIcons.Expand}
height={variables.iconSizeSmall}
width={variables.iconSizeSmall}
fill={theme.icon}
/>
</View>
</PressableWithoutFeedback>
</View>
)}
</View>
)}
{/* For WideRHP (fillSpace is true), we need to wait for the image to load to get the correct size, then display the violation message to avoid the jumping issue.
Expand Down
15 changes: 15 additions & 0 deletions src/styles/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3622,6 +3622,21 @@ const staticStyles = (theme: ThemeColors) =>
right: 20,
},

receiptActionButtonsContainer: {
position: 'absolute',
top: 16,
right: 16,
flexDirection: 'row',
gap: 8,
},

receiptActionButton: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},

bgGreenSuccess: {
backgroundColor: colors.green400,
},
Expand Down
129 changes: 129 additions & 0 deletions tests/ui/components/MoneyRequestReceiptViewTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,51 @@ jest.mock(
},
);

jest.mock('@components/ReportActionItem/ReportActionItemImage', () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-assignment
const {useEffect} = require('react');
function MockReportActionItemImage({onLoad}: {onLoad?: () => void}) {
(useEffect as typeof React.useEffect)(() => {
onLoad?.();
}, [onLoad]);
return null;
}
return MockReportActionItemImage;
});

jest.mock('@src/languages/IntlStore', () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const en: Record<string, unknown> = require('@src/languages/en').default;
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const flatten: (obj: Record<string, unknown>) => Record<string, unknown> = require('@src/languages/flattenObject').default;
const cache = new Map<string, Record<string, unknown>>();
cache.set('en', flatten(en));
return {
getCurrentLocale: jest.fn(() => 'en'),
load: jest.fn(() => Promise.resolve()),
get: jest.fn((key: string, locale?: string) => {
const translations = cache.get(locale ?? 'en');
return translations?.[key] ?? null;
}),
};
});

jest.mock('@assets/emojis', () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const actual = jest.requireActual('@assets/emojis');
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return {
...actual,
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
default: actual.default,
importEmojiLocale: jest.fn(() => Promise.resolve()),
};
});

jest.mock('@libs/EmojiTrie', () => ({
buildEmojisTrie: jest.fn(),
}));

// Override IDs so we control Onyx keys and can use evictableKeys for REPORT_ACTIONS
const TEST_PARENT_REPORT_ID = 'testParentReportID';
const TEST_REPORT_ID = 'testReportID';
Expand Down Expand Up @@ -122,6 +167,22 @@ const transactionWithoutReceipt: Transaction = {
originalCurrency: '',
};

const transactionWithReceipt: Transaction = {
...transactionWithoutReceipt,
receipt: {
state: CONST.IOU.RECEIPT_STATE.OPEN,
source: 'https://example.com/receipt.jpg',
},
};

const transactionWithScanningReceipt: Transaction = {
...transactionWithoutReceipt,
receipt: {
state: CONST.IOU.RECEIPT_STATE.SCANNING,
source: 'https://example.com/receipt.jpg',
},
};

function Wrapper({children}: {children: React.ReactNode}) {
return <ComposeProviders components={[OnyxListItemProvider, LocaleContextProvider]}>{children}</ComposeProviders>;
}
Expand Down Expand Up @@ -175,4 +236,72 @@ describe('MoneyRequestReceiptView', () => {
expect(onPicked).toBeDefined();
});
});

describe('receipt action buttons visibility', () => {
it('does not show action buttons when transaction has no receipt', async () => {
render(
<Wrapper>
<MoneyRequestReceiptView report={testReport} />
</Wrapper>,
);
await waitForBatchedUpdatesWithAct();

expect(screen.queryByLabelText(translateLocal('accessibilityHints.viewAttachment'))).toBeNull();
expect(screen.queryByLabelText(translateLocal('reportActionCompose.addAttachment'))).toBeNull();
});

it('shows action buttons when transaction has a receipt', async () => {
await act(async () => {
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TEST_TRANSACTION_ID}`, transactionWithReceipt);
});
await waitForBatchedUpdatesWithAct();

render(
<Wrapper>
<MoneyRequestReceiptView report={testReport} />
</Wrapper>,
);
await waitForBatchedUpdatesWithAct();

expect(screen.getByLabelText(translateLocal('accessibilityHints.viewAttachment'))).toBeTruthy();
expect(screen.getByLabelText(translateLocal('reportActionCompose.addAttachment'))).toBeTruthy();
});

it('shows action buttons when receipt is scanning', async () => {
await act(async () => {
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TEST_TRANSACTION_ID}`, transactionWithScanningReceipt);
});
await waitForBatchedUpdatesWithAct();

render(
<Wrapper>
<MoneyRequestReceiptView report={testReport} />
</Wrapper>,
);
await waitForBatchedUpdatesWithAct();

expect(screen.getByLabelText(translateLocal('accessibilityHints.viewAttachment'))).toBeTruthy();
expect(screen.getByLabelText(translateLocal('reportActionCompose.addAttachment'))).toBeTruthy();
});

it('does not show action buttons in readonly mode', async () => {
await act(async () => {
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TEST_TRANSACTION_ID}`, transactionWithReceipt);
});
await waitForBatchedUpdatesWithAct();

render(
<Wrapper>
<MoneyRequestReceiptView
report={testReport}
readonly
/>
</Wrapper>,
);
await waitForBatchedUpdatesWithAct();

expect(screen.queryByLabelText(translateLocal('accessibilityHints.viewAttachment'))).toBeNull();
expect(screen.queryByLabelText(translateLocal('reportActionCompose.addAttachment'))).toBeNull();
});
});
});
Loading