diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 60a350410ce31..da76fb0bb6405 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -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', diff --git a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx index 79b541baf632f..299da8b18bafd 100644 --- a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx +++ b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx @@ -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'; @@ -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'; @@ -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... @@ -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 = ( @@ -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} > setIsLoading(false)} onLoadFailure={() => setIsLoading(false)} /> + {canShowReceiptActions && ( + + + {({openPicker}) => ( + { + 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} + > + + + + + )} + + + 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} + > + + + + + + )} )} {/* 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. diff --git a/src/styles/index.ts b/src/styles/index.ts index 8db662549d447..f839bd0fa2511 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -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, }, diff --git a/tests/ui/components/MoneyRequestReceiptViewTest.tsx b/tests/ui/components/MoneyRequestReceiptViewTest.tsx index e4e81de60e4e3..6a14647d407e4 100644 --- a/tests/ui/components/MoneyRequestReceiptViewTest.tsx +++ b/tests/ui/components/MoneyRequestReceiptViewTest.tsx @@ -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 = 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) => Record = require('@src/languages/flattenObject').default; + const cache = new Map>(); + 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'; @@ -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 {children}; } @@ -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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.queryByLabelText(translateLocal('accessibilityHints.viewAttachment'))).toBeNull(); + expect(screen.queryByLabelText(translateLocal('reportActionCompose.addAttachment'))).toBeNull(); + }); + }); });