diff --git a/src/CONST/index.ts b/src/CONST/index.ts index a6b1a6113bd9..a12682fd4fb3 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1655,6 +1655,7 @@ const CONST = { HOLD: 'hold', DOWNLOAD_PDF: 'downloadPDF', DOWNLOAD_RECEIPTS: 'downloadReceipts', + SHARE: 'share', PRINT: 'print', CHANGE_WORKSPACE: 'changeWorkspace', CHANGE_APPROVER: 'changeApprover', @@ -9760,6 +9761,11 @@ const CONST = { EXPORT_FILE: 'MoreMenu-ExportFile', DOWNLOAD_PDF: 'MoreMenu-DownloadPDF', DOWNLOAD_RECEIPTS: 'MoreMenu-DownloadReceipts', + SHARE: 'MoreMenu-Share', + SHARE_REPORT: 'MoreMenu-ShareReport', + SHARE_CODE: 'MoreMenu-ShareCode', + REFER_FRIEND: 'MoreMenu-ReferFriend', + COPY_URL: 'MoreMenu-CopyURL', PRINT: 'MoreMenu-Print', CLOSE_PDF_MODAL: 'MoreMenu-ClosePDFModal', SUBMIT: 'MoreMenu-Submit', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index e1a5cfe91f08..8dba378b951d 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1583,6 +1583,8 @@ const DYNAMIC_ROUTES = { SCREENS.SEARCH.ROOT, SCREENS.REPORT_DETAILS.DYNAMIC_ROOT, ], + getRoute: (shouldShowQRCodeOnly?: boolean) => getUrlWithParams('share-code', {shouldShowQRCodeOnly: shouldShowQRCodeOnly ? 'true' : undefined}), + queryParams: ['shouldShowQRCodeOnly'], }, REFERRAL_DETAILS: { path: 'referral/:contentType', @@ -1594,6 +1596,10 @@ const DYNAMIC_ROUTES = { entryScreens: [SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, SCREENS.SEARCH.ROOT], getRoute: (connectionName: ConnectionName) => `details/export/${connectionName as string}` as const, }, + REPORT_DETAILS_SHARE_REPORT: { + path: 'share-report', + entryScreens: [SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, SCREENS.SEARCH.ROOT], + }, REPORT_CHANGE_WORKSPACE: { path: 'change-workspace', entryScreens: [SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, SCREENS.SEARCH.ROOT], diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 1871d9a129b5..50c1c99d897d 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -506,6 +506,7 @@ const SCREENS = { DYNAMIC_ROOT: 'Dynamic_Report_Details_Root', DYNAMIC_SHARE_CODE: 'Dynamic_Report_Details_Share_Code', DYNAMIC_EXPORT: 'Dynamic_Report_Details_Export', + DYNAMIC_SHARE_REPORT: 'Dynamic_Report_Details_Share_Report', }, REPORT_CHANGE_WORKSPACE: { diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 941fbf534f52..0d5b6d3881ea 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -35,6 +35,7 @@ import usePolicy from '@hooks/usePolicy'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; +import useShareActions from '@hooks/useShareActions'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; @@ -388,6 +389,8 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo onPDFModalOpen: openPDFDownload, }); + const shareActions = useShareActions({reportID, policy}); + // Compute list of applicable secondary action keys const secondaryActions = moneyRequestReport ? getSecondaryReportActions({ @@ -425,6 +428,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo }, }, ...exportActionEntries, + ...shareActions, ...lifecycleActionEntries, ...expenseActions, ...holdRejectActions, diff --git a/src/hooks/useShareActions.ts b/src/hooks/useShareActions.ts new file mode 100644 index 000000000000..1781f7c85b8f --- /dev/null +++ b/src/hooks/useShareActions.ts @@ -0,0 +1,91 @@ +/** + * Builds the Share option of the report More menu and the options under it. + */ +import type {SecondaryActionEntry} from '@components/MoneyReportHeaderActions/types'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; + +import Clipboard from '@libs/Clipboard'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; +import {canShareReport} from '@libs/ReportUtils'; +import addTrailingForwardSlash from '@libs/UrlUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useEnvironment from './useEnvironment'; +import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; +import useLocalize from './useLocalize'; +import useOnyx from './useOnyx'; + +type UseShareActionsParams = { + reportID: string | undefined; + policy?: OnyxEntry; +}; + +type UseShareActionsReturn = Pick, SecondaryActionEntry>, typeof CONST.REPORT.SECONDARY_ACTIONS.SHARE>; + +function useShareActions({reportID, policy}: UseShareActionsParams): UseShareActionsReturn { + const {translate} = useLocalize(); + const {environmentURL} = useEnvironment(); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Mail', 'QrCode', 'Cash', 'Copy', 'ArrowRight'] as const); + + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); + + const shareSubMenuItems: PopoverMenuItem[] = [ + { + text: translate('common.shareCode'), + icon: expensifyIcons.QrCode, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.SHARE_CODE, + onSelected: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_CODE.getRoute(true))), + }, + { + text: translate(`referralProgram.${CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND}.header`), + icon: expensifyIcons.Cash, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.REFER_FRIEND, + onSelected: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REFERRAL_DETAILS.getRoute(CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND))), + }, + { + text: translate('qrCodes.copy'), + icon: expensifyIcons.Copy, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.COPY_URL, + onSelected: () => { + if (!moneyRequestReport?.reportID) { + return; + } + Clipboard.setString(`${addTrailingForwardSlash(environmentURL)}${ROUTES.REPORT_WITH_ID.getRoute(moneyRequestReport.reportID)}`); + }, + }, + ]; + + if (canShareReport(moneyRequestReport, policy, currentUserAccountID)) { + shareSubMenuItems.unshift({ + text: translate('common.shareReport'), + icon: expensifyIcons.Mail, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.SHARE_REPORT, + onSelected: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_REPORT.path)), + }); + } + + return { + [CONST.REPORT.SECONDARY_ACTIONS.SHARE]: { + value: CONST.REPORT.SECONDARY_ACTIONS.SHARE, + text: translate('common.share'), + backButtonText: translate('common.share'), + icon: expensifyIcons.Mail, + rightIcon: expensifyIcons.ArrowRight, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.SHARE, + subMenuItems: shareSubMenuItems, + }, + }; +} + +export default useShareActions; diff --git a/src/languages/de.ts b/src/languages/de.ts index 7c899591f6bf..e8328119c135 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -528,6 +528,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: 'Das Diagramm konnte nicht geladen werden. Bitte aktualisieren Sie die Seite und versuchen Sie es erneut.', apiKey: 'API-Schlüssel', exportsTo: 'Exportiert nach', + shareReport: 'Bericht teilen', + reportShared: 'Bericht geteilt', }, socials: { podcast: 'Folgen Sie uns auf Podcast', diff --git a/src/languages/el.ts b/src/languages/el.ts index b988893bfa82..4ae8648aed47 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -528,6 +528,8 @@ const translations: TranslationDeepObject = { other: 'Λήψη αποδείξεων', }), commuter: 'επιβάτης καθημερινών μετακινήσεων', + shareReport: 'Κοινοποιήστε την αναφορά', + reportShared: 'Η αναφορά κοινοποιήθηκε', }, socials: { podcast: 'Ακολουθήστε μας στο Podcast', diff --git a/src/languages/en.ts b/src/languages/en.ts index 2f5134b89260..df36036b31a1 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -347,6 +347,8 @@ const translations = { assignee: 'Assignee', with: 'with', shareCode: 'Share code', + shareReport: 'Share report', + reportShared: 'Report shared', share: 'Share', per: 'per', // @context Unit label for “mile.” Should be treated as a measurement unit and may require capitalization depending on locale conventions. diff --git a/src/languages/es.ts b/src/languages/es.ts index 93cf2ad6894b..9dc47874f0fd 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -515,6 +515,8 @@ const translations: TranslationDeepObject = { commuter: 'viajero diario', noResultsFoundSubtitle: 'Sin resultados. Intenta ajustar tus filtros o la búsqueda.', exportsTo: 'Exporta a', + shareReport: 'Compartir informe', + reportShared: 'Informe compartido', }, socials: { podcast: 'Síguenos en Podcast', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 1acbd6ecd956..a2248cb03ee5 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -527,6 +527,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: 'Le graphique n’a pas pu être chargé. Veuillez actualiser la page et réessayer.', apiKey: 'Clé API', exportsTo: 'Exports vers', + shareReport: 'Partager la note de frais', + reportShared: 'Note de frais partagée', }, socials: { podcast: 'Suivez-nous sur Podcast', diff --git a/src/languages/it.ts b/src/languages/it.ts index f848f8657d0e..e208cedd849b 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -527,6 +527,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: 'Impossibile caricare il grafico. Aggiorna la pagina e riprova.', apiKey: 'Chiave API', exportsTo: 'Esporta in', + shareReport: 'Condividi report', + reportShared: 'Report condiviso', }, socials: { podcast: 'Seguici su Podcast', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index a4d43dcd8a96..45e9b9f67933 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -526,6 +526,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: 'グラフを読み込めませんでした。ページを再読み込みして、もう一度お試しください。', apiKey: 'API キー', exportsTo: 'エクスポート先', + shareReport: 'レポートを共有', + reportShared: 'レポートを共有しました', }, socials: { podcast: 'ポッドキャストでフォロー', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d755c3f8a4d8..45f1f707240c 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -527,6 +527,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: 'De grafiek kon niet worden geladen. Vernieuw de pagina en probeer het opnieuw.', apiKey: 'API-sleutel', exportsTo: 'Exporteert naar', + shareReport: 'Rapport delen', + reportShared: 'Rapport gedeeld', }, socials: { podcast: 'Volg ons op Podcast', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 18f7582c6b9c..8054281cafe1 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -530,6 +530,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: 'Nie udało się załadować wykresu. Odśwież stronę i spróbuj ponownie.', apiKey: 'Klucz API', exportsTo: 'Eksporty do', + shareReport: 'Udostępnij raport', + reportShared: 'Udostępniono raport', }, socials: { podcast: 'Śledź nas na Podcast', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 2b6f09018c6f..408d296336d5 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -526,6 +526,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: 'Não foi possível carregar o gráfico. Atualize a página e tente novamente.', apiKey: 'Chave de API', exportsTo: 'Exportações para', + shareReport: 'Compartilhar relatório', + reportShared: 'Relatório compartilhado', }, socials: { podcast: 'Siga-nos no Podcast', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 3159f2578400..27f203f0c2ea 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -522,6 +522,8 @@ const translations: TranslationDeepObject = { chartFailedToLoad: '无法加载图表。请刷新页面后重试。', apiKey: 'API 密钥', exportsTo: '导出到', + shareReport: '分享报表', + reportShared: '报表已分享', }, socials: { podcast: '在播客上关注我们', diff --git a/src/libs/API/parameters/ShareReportParams.ts b/src/libs/API/parameters/ShareReportParams.ts new file mode 100644 index 000000000000..33338659745c --- /dev/null +++ b/src/libs/API/parameters/ShareReportParams.ts @@ -0,0 +1,7 @@ +type ShareReportParams = { + reportID: string; + shareToEmail: string; + permissions: string; +}; + +export default ShareReportParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index d4bebad19fc0..fe73c3c198f3 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -593,6 +593,7 @@ export type {default as ImportPerDiemRatesParams} from './ImportPerDiemRatesPara export type {default as ExportPerDiemCSVParams} from './ExportPerDiemCSVParams'; export type {default as ExportReportPDFParams} from './ExportReportPDFParams'; export type {default as ExportReportsToPDFParams} from './ExportReportsToPDFParams'; +export type {default as ShareReportParams} from './ShareReportParams'; export type {default as ExportReceiptsToZipParams} from './ExportReceiptsToZipParams'; export type {default as UpdateWorkspaceCustomUnitParams} from './UpdateWorkspaceCustomUnitParams'; export type {default as DismissProductTrainingParams} from './DismissProductTraining'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 9c3a10501357..8e2d8741ecb7 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -190,6 +190,7 @@ const WRITE_COMMANDS = { MOVE_IOU_REPORT_TO_EXISTING_POLICY: 'MoveIOUReportToExistingPolicy', EXPORT_REPORT_TO_PDF: 'ExportReportToPDF', EXPORT_REPORTS_TO_PDF: 'ExportReportsToPDF', + SHARE_REPORT: 'ShareReport', EXPORT_RECEIPTS_TO_ZIP: 'ExportReceiptsToZip', RENAME_WORKSPACE_CATEGORY: 'RenameWorkspaceCategory', CREATE_POLICY_TAG: 'CreatePolicyTag', @@ -954,6 +955,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.EXPORT_PER_DIEM_CSV]: Parameters.ExportPerDiemCSVParams; [WRITE_COMMANDS.EXPORT_REPORT_TO_PDF]: Parameters.ExportReportPDFParams; [WRITE_COMMANDS.EXPORT_REPORTS_TO_PDF]: Parameters.ExportReportsToPDFParams; + [WRITE_COMMANDS.SHARE_REPORT]: Parameters.ShareReportParams; [WRITE_COMMANDS.EXPORT_RECEIPTS_TO_ZIP]: Parameters.ExportReceiptsToZipParams; [WRITE_COMMANDS.RENAME_WORKSPACE_CATEGORY]: Parameters.RenameWorkspaceCategoriesParams; [WRITE_COMMANDS.SET_WORKSPACE_REQUIRES_CATEGORY]: Parameters.SetWorkspaceRequiresCategoryParams; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 23752a46e5e1..189c73266d44 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -284,6 +284,7 @@ const ReportDetailsModalStackNavigator = createModalStackNavigator require('../../../../pages/DynamicReportDetailsPage').default, [SCREENS.REPORT_DETAILS.DYNAMIC_SHARE_CODE]: () => require('../../../../pages/inbox/report/DynamicReportDetailsShareCodePage').default, [SCREENS.REPORT_DETAILS.DYNAMIC_EXPORT]: () => require('../../../../pages/inbox/report/DynamicReportDetailsExportPage').default, + [SCREENS.REPORT_DETAILS.DYNAMIC_SHARE_REPORT]: () => require('../../../../pages/inbox/report/DynamicReportDetailsShareReportPage').default, }); const ReportCardActivateStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 153dedb7daa3..4dc98999a8d6 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1697,6 +1697,7 @@ const config: LinkingOptions['config'] = { [SCREENS.REPORT_DETAILS.DYNAMIC_ROOT]: DYNAMIC_ROUTES.REPORT_DETAILS.path, [SCREENS.REPORT_DETAILS.DYNAMIC_SHARE_CODE]: DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_CODE.path, [SCREENS.REPORT_DETAILS.DYNAMIC_EXPORT]: DYNAMIC_ROUTES.REPORT_DETAILS_EXPORT.path, + [SCREENS.REPORT_DETAILS.DYNAMIC_SHARE_REPORT]: DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_REPORT.path, }, }, [SCREENS.RIGHT_MODAL.REPORT_CARD_ACTIVATE]: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 9cb3ce446598..dc907a06eb84 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2285,12 +2285,16 @@ type ReportDetailsNavigatorParamList = { }; [SCREENS.REPORT_DETAILS.DYNAMIC_SHARE_CODE]: { reportID: string; + shouldShowQRCodeOnly?: string; }; [SCREENS.REPORT_DETAILS.DYNAMIC_EXPORT]: { reportID: string; policyID: string; connectionName: ConnectionName; }; + [SCREENS.REPORT_DETAILS.DYNAMIC_SHARE_REPORT]: { + reportID: string; + }; }; type ReportCardActivateNavigatorParamList = { diff --git a/src/libs/PopoverMenuSections.ts b/src/libs/PopoverMenuSections.ts index 1baddf312db4..41d4ec38e1f4 100644 --- a/src/libs/PopoverMenuSections.ts +++ b/src/libs/PopoverMenuSections.ts @@ -23,7 +23,7 @@ const REPORT_MORE_MENU_SECTIONS = [ ], [REPORT_SECONDARY_ACTIONS.SPLIT, REPORT_SECONDARY_ACTIONS.MERGE, REPORT_SECONDARY_ACTIONS.DUPLICATE_EXPENSE, REPORT_SECONDARY_ACTIONS.MOVE_EXPENSE], [REPORT_SECONDARY_ACTIONS.DUPLICATE_REPORT, REPORT_SECONDARY_ACTIONS.CHANGE_WORKSPACE, REPORT_SECONDARY_ACTIONS.CHANGE_APPROVER], - [REPORT_SECONDARY_ACTIONS.EXPORT, REPORT_SECONDARY_ACTIONS.DOWNLOAD_PDF, REPORT_SECONDARY_ACTIONS.DOWNLOAD_RECEIPTS, REPORT_SECONDARY_ACTIONS.PRINT], + [REPORT_SECONDARY_ACTIONS.EXPORT, REPORT_SECONDARY_ACTIONS.DOWNLOAD_PDF, REPORT_SECONDARY_ACTIONS.DOWNLOAD_RECEIPTS, REPORT_SECONDARY_ACTIONS.SHARE, REPORT_SECONDARY_ACTIONS.PRINT], [REPORT_SECONDARY_ACTIONS.VIEW_DETAILS, REPORT_SECONDARY_ACTIONS.DELETE], ]; diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index 8bc716e0f830..11baada8cb50 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -1171,6 +1171,8 @@ function getSecondaryReportActions({ options.push(CONST.REPORT.SECONDARY_ACTIONS.DOWNLOAD_RECEIPTS); } + options.push(CONST.REPORT.SECONDARY_ACTIONS.SHARE); + if (!isOpenReportUtils(report)) { options.push(CONST.REPORT.SECONDARY_ACTIONS.PRINT); } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 1ad33c915d77..837eacc4dec9 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -13918,6 +13918,16 @@ function canBeExported(report: OnyxEntry) { return isExpenseReport(report) && isCorrectState; } +/** + * Whether the current user can email the report to someone. The submitter, the approver and workspace admins can once the report is submitted. + */ +function canShareReport(report: OnyxEntry, policy: OnyxEntry, currentUserAccountID: number): boolean { + if (!isExpenseReport(report) || isOpenReport(report)) { + return false; + } + return isCurrentUserSubmitter(report, currentUserAccountID) || report?.managerID === currentUserAccountID || isPolicyAdmin(policy); +} + function getIntegrationNameFromExportMessage(reportActions: OnyxEntry | ReportAction[]) { if (!reportActions) { return ''; @@ -15316,6 +15326,7 @@ export { findPolicyExpenseChatByPolicyID, getIntegrationIcon, canBeExported, + canShareReport, isExported, hasExpensifyGuidesEmails, hasExportError, diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index feae9942d704..63c6b2928621 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -40,6 +40,7 @@ import type { SearchForReportsParams, SearchForRoomsToMentionParams, SearchForUsersParams, + ShareReportParams, TogglePinnedChatParams, TransactionThreadInfo, UpdateChatNameParams, @@ -93,6 +94,7 @@ import Parser from '@libs/Parser'; import {getParsedMessageWithShortMentions} from '@libs/ParsingUtils'; import {getAllPersonalDetails} from '@libs/PersonalDetailsStore'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import {isMapOrGPSRequired} from '@libs/PolicyDistanceRatesUtils'; import { getDefaultApprover, @@ -7005,6 +7007,17 @@ async function exportReportToPDF({reportID}: ExportReportPDFParams) { return API.write(WRITE_COMMANDS.EXPORT_REPORT_TO_PDF, params, {optimisticData, failureData}); } +/** Emails the report to the given login and gives them access to it. */ +function shareReport(reportID: string, shareToLogin: string) { + const params = { + reportID, + shareToEmail: addSMSDomainIfPhoneNumber(shareToLogin), + permissions: [CONST.REPORT.PERMISSIONS.READ, CONST.REPORT.PERMISSIONS.WRITE].join(', '), + } satisfies ShareReportParams; + + API.write(WRITE_COMMANDS.SHARE_REPORT, params); +} + function downloadReportPDF(fileName: string, reportName: string, translate: LocalizedTranslate, currentUserLogin: string, encryptedAuthToken: string) { const baseURL = getOldDotURLFromEnvironment(environment); const downloadFileName = `${reportName}.pdf`; @@ -9133,6 +9146,7 @@ export { expandURLPreview, exportReportToCSV, exportReportToPDF, + shareReport, exportToIntegration, joinReportViaSecureLink, flagComment, diff --git a/src/pages/ShareCodePage.tsx b/src/pages/ShareCodePage.tsx index da1ab57c8f81..5e7fb738b008 100644 --- a/src/pages/ShareCodePage.tsx +++ b/src/pages/ShareCodePage.tsx @@ -58,6 +58,9 @@ type ShareCodePageOnyxProps = { /** The policy for the report currently being looked at */ policy?: OnyxEntry; + + /** Whether to show only the QR code, without the rows under it */ + shouldShowQRCodeOnly?: boolean; }; type ShareCodePageProps = ShareCodePageOnyxProps & BackToParams; @@ -98,7 +101,7 @@ function createAvatarQRLogo(LocalAvatarLogo: React.FC): React.FC - - Clipboard.setString(url)} - shouldLimitWidth={false} - /> - {/* Remove this platform specific condition once https://github.com/Expensify/App/issues/19834 is done. - We shouldn't introduce platform specific code in our codebase. - This is a temporary solution while Web is not supported for the QR code download feature */} - {shouldAllowDownloadQRCode && ( - qrCodeRef.current?.download?.()}> - - - - - - {translate('common.download')} - - - - )} - - { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REFERRAL_DETAILS.getRoute(CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE))); - }} - /> - + {!shouldShowQRCodeOnly && ( + + Clipboard.setString(url)} + shouldLimitWidth={false} + /> + {/* Remove this platform specific condition once https://github.com/Expensify/App/issues/19834 is done. + We shouldn't introduce platform specific code in our codebase. + This is a temporary solution while Web is not supported for the QR code download feature */} + {shouldAllowDownloadQRCode && ( + qrCodeRef.current?.download?.()}> + + + + + + {translate('common.download')} + + + + )} + + { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REFERRAL_DETAILS.getRoute(CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE))); + }} + /> + + )} ); diff --git a/src/pages/inbox/report/DynamicReportDetailsShareCodePage.tsx b/src/pages/inbox/report/DynamicReportDetailsShareCodePage.tsx index 11c5d7b12c95..f77da2477a38 100644 --- a/src/pages/inbox/report/DynamicReportDetailsShareCodePage.tsx +++ b/src/pages/inbox/report/DynamicReportDetailsShareCodePage.tsx @@ -18,7 +18,7 @@ import withReportOrNotFound from './withReportOrNotFound'; type DynamicReportDetailsShareCodePageProps = WithReportOrNotFoundProps & PlatformStackScreenProps; -function DynamicReportDetailsShareCodePage({report, policy}: DynamicReportDetailsShareCodePageProps) { +function DynamicReportDetailsShareCodePage({report, policy, route}: DynamicReportDetailsShareCodePageProps) { const navigateBackRoute = useDynamicBackPath(DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_CODE.path); if (isSelfDM(report)) { @@ -30,6 +30,7 @@ function DynamicReportDetailsShareCodePage({report, policy}: DynamicReportDetail backTo={navigateBackRoute} report={report} policy={policy} + shouldShowQRCodeOnly={route.params?.shouldShowQRCodeOnly === 'true'} /> ); } diff --git a/src/pages/inbox/report/DynamicReportDetailsShareReportPage.tsx b/src/pages/inbox/report/DynamicReportDetailsShareReportPage.tsx new file mode 100644 index 000000000000..464a9cb4bb8c --- /dev/null +++ b/src/pages/inbox/report/DynamicReportDetailsShareReportPage.tsx @@ -0,0 +1,168 @@ +/** + * Picks who to email an expense report to. Opens from Share report in the report's More menu. + */ +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SingleSelectWithAvatarListItem from '@components/SelectionList/ListItem/SingleSelectWithAvatarListItem'; +import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; +import type {Section} from '@components/SelectionList/SelectionListWithSections/types'; + +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePersonalDetailSearchSelector from '@hooks/usePersonalDetailSearchSelector'; +import usePressLoading from '@hooks/usePressLoading'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {searchUserInServer, shareReport} from '@libs/actions/Report'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import getPlatform from '@libs/getPlatform'; +import Growl from '@libs/Growl'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {ReportDetailsNavigatorParamList} from '@libs/Navigation/types'; +import {getHeaderMessage} from '@libs/PersonalDetailOptionsListUtils'; +import type {OptionData} from '@libs/PersonalDetailOptionsListUtils'; +import {getReportName} from '@libs/ReportNameUtils'; +import {canShareReport} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import React, {useEffect, useState} from 'react'; + +import type {WithReportOrNotFoundProps} from './withReportOrNotFound'; + +import withReportOrNotFound from './withReportOrNotFound'; + +type DynamicReportDetailsShareReportPageProps = WithReportOrNotFoundProps & PlatformStackScreenProps; + +function DynamicReportDetailsShareReportPage({report, policy}: DynamicReportDetailsShareReportPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_REPORT.path); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + + const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); + const [isSearchingForUsers] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_USERS); + + const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); + const {isLoading, startWithLoading} = usePressLoading(); + + const {searchTerm, debouncedSearchTerm, setSearchTerm, selectedOptions, availableOptions, toggleSelection, areOptionsInitialized} = usePersonalDetailSearchSelector({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, + includeUserToInvite: true, + includeRecentReports: false, + shouldUpdateSelectedOptionsOnSingleSelect: true, + excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, + shouldInitialize: didScreenTransitionEnd, + }); + + useEffect(() => { + searchUserInServer(debouncedSearchTerm); + }, [debouncedSearchTerm]); + + const selectedLogin = selectedOptions.at(0)?.login; + + const shareWithSelectedUser = () => { + if (!selectedLogin) { + return; + } + startWithLoading(() => { + shareReport(report.reportID, selectedLogin); + Navigation.goBack(backPath); + Growl.success(translate('common.reportShared')); + }); + }; + + const sections: Array> = []; + if (areOptionsInitialized) { + if (availableOptions.selectedOptions.length > 0) { + sections.push({ + title: undefined, + data: availableOptions.selectedOptions, + sectionIndex: 0, + }); + } + + if (availableOptions.personalDetails.length > 0) { + sections.push({ + title: translate('common.contacts'), + data: availableOptions.personalDetails, + sectionIndex: 1, + }); + } + + if (availableOptions.userToInvite) { + sections.push({ + title: undefined, + data: [availableOptions.userToInvite], + sectionIndex: 2, + }); + } + } + + const textInputOptions = { + label: translate('selectionList.nameEmailOrPhoneNumber'), + value: searchTerm, + onChangeText: setSearchTerm, + headerMessage: sections.length > 0 ? '' : getHeaderMessage(translate, debouncedSearchTerm.trim().toLowerCase(), countryCode), + }; + + const footerContent = ( + + ); + + return ( + setDidScreenTransitionEnd(true)} + > + + Navigation.goBack(backPath)} + /> + + + + ); +} + +export default withReportOrNotFound()(DynamicReportDetailsShareReportPage); diff --git a/src/pages/inbox/report/withReportOrNotFound.tsx b/src/pages/inbox/report/withReportOrNotFound.tsx index baee6ca53277..4fff348181e9 100644 --- a/src/pages/inbox/report/withReportOrNotFound.tsx +++ b/src/pages/inbox/report/withReportOrNotFound.tsx @@ -56,6 +56,7 @@ type ScreenProps = | PlatformStackScreenProps | PlatformStackScreenProps | PlatformStackScreenProps + | PlatformStackScreenProps | PlatformStackScreenProps | PlatformStackScreenProps | PlatformStackScreenProps diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 7a6a36a460fd..a58a2d214013 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -6077,6 +6077,19 @@ describe('actions/Report', () => { }); }); + describe('shareReport', () => { + it('sends the recipient and the permissions the API expects', () => { + // Given a recipient entered as a phone number + const phoneNumber = '+15005550006'; + + // When the report is shared with them + Report.shareReport('1', phoneNumber); + + // Then the request names them by their SMS login and gives read and write access + expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.SHARE_REPORT, {reportID: '1', shareToEmail: `${phoneNumber}${CONST.SMS.DOMAIN}`, permissions: 'read, write'}); + }); + }); + describe('openReport with introSelected', () => { it('should call OpenReport API with introSelected parameter', async () => { global.fetch = TestHelper.createGlobalFetchMock(); diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 16471032dbf1..74e45ba7f9d4 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -150,6 +150,7 @@ describe('getSecondaryAction', () => { const result = [ CONST.REPORT.SECONDARY_ACTIONS.EXPORT, CONST.REPORT.SECONDARY_ACTIONS.DOWNLOAD_PDF, + CONST.REPORT.SECONDARY_ACTIONS.SHARE, CONST.REPORT.SECONDARY_ACTIONS.PRINT, CONST.REPORT.SECONDARY_ACTIONS.VIEW_DETAILS, ]; diff --git a/tests/unit/ShareCodePageTranslateTest.tsx b/tests/unit/ShareCodePageTranslateTest.tsx index 94d9f446909c..07299510fa6c 100644 --- a/tests/unit/ShareCodePageTranslateTest.tsx +++ b/tests/unit/ShareCodePageTranslateTest.tsx @@ -1,5 +1,6 @@ import {render} from '@testing-library/react-native'; +import ContextMenuItem from '@components/ContextMenuItem'; import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import QRShareWithDownload from '@components/QRShare/QRShareWithDownload'; @@ -54,6 +55,7 @@ jest.mock('@libs/ReportUtils', () => { const mockGetDisplayNameForParticipant = jest.mocked(getDisplayNameForParticipant); const mockFindLocalAvatarForURL = jest.mocked(findLocalAvatarForURL); const mockQRShareWithDownload = jest.mocked(QRShareWithDownload); +const mockContextMenuItem = jest.mocked(ContextMenuItem); const PARTICIPANT_A = 717001; const PARTICIPANT_B = 717002; @@ -90,6 +92,34 @@ describe('ShareCodePage', () => { expect(mockGetDisplayNameForParticipant).toHaveBeenCalledWith(expect.objectContaining({hiddenTranslation: 'common.hidden'})); }); + it('hides the rows under the QR code when only the QR code is asked for', async () => { + // Given a report, whose share code page normally lists Copy URL under the QR code + const report: Report = {reportID: '717200', type: CONST.REPORT.TYPE.EXPENSE}; + mockContextMenuItem.mockClear(); + render( + + + , + ); + await waitForBatchedUpdates(); + expect(mockContextMenuItem).toHaveBeenCalled(); + + // When the page opens from the Share menu, which already has Copy URL and Refer a friend + mockContextMenuItem.mockClear(); + render( + + + , + ); + await waitForBatchedUpdates(); + + // Then only the QR code shows + expect(mockContextMenuItem).not.toHaveBeenCalled(); + }); + describe('profile QR code avatar logo', () => { it('wraps catalog-backed avatars in an SVG logo that only forwards an explicitly set fill', async () => { const localAvatar: React.FC = jest.fn(() => null); diff --git a/tests/unit/hooks/useShareActionsTest.ts b/tests/unit/hooks/useShareActionsTest.ts new file mode 100644 index 000000000000..d92ded15706d --- /dev/null +++ b/tests/unit/hooks/useShareActionsTest.ts @@ -0,0 +1,143 @@ +import {renderHook} from '@testing-library/react-native'; + +import useShareActions from '@hooks/useShareActions'; + +import Clipboard from '@libs/Clipboard'; +import Navigation from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {Policy, Report} from '@src/types/onyx'; + +import createMock from '../../utils/createMock'; + +const REPORT_ID = 'report1'; +const CURRENT_USER_ACCOUNT_ID = 1; +const OTHER_ACCOUNT_ID = 2; +const ENVIRONMENT_URL = 'https://new.expensify.com'; + +const submittedReport: Report = { + reportID: REPORT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + managerID: OTHER_ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, +}; + +let mockReport: Report | undefined = submittedReport; + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), +})); + +jest.mock('@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute', () => ({ + __esModule: true, + default: (path: string) => path, +})); + +jest.mock('@libs/Clipboard', () => ({ + setString: jest.fn(), +})); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({translate: (key: string) => key}), +})); + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: () => ({}), +})); + +jest.mock('@hooks/useEnvironment', () => ({ + __esModule: true, + default: () => ({environmentURL: ENVIRONMENT_URL}), +})); + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ + __esModule: true, + default: () => ({accountID: CURRENT_USER_ACCOUNT_ID}), +})); + +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + default: () => [mockReport], +})); + +function getShareOptions(policy?: Policy) { + const {result} = renderHook(() => useShareActions({reportID: REPORT_ID, policy})); + return result.current[CONST.REPORT.SECONDARY_ACTIONS.SHARE].subMenuItems ?? []; +} + +describe('useShareActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockReport = submittedReport; + }); + + it('lists share report first for the submitter of a submitted report', () => { + // Given a submitted expense report owned by the current user + // When the share options are built + const options = getShareOptions(); + + // Then share report comes before the options every report has + expect(options.map((option) => option.text)).toEqual(['common.shareReport', 'common.shareCode', 'referralProgram.referralFriend.header', 'qrCodes.copy']); + }); + + it('hides share report on an open report', () => { + // Given the same report before it is submitted + mockReport = {...submittedReport, stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN}; + + // When the share options are built + const options = getShareOptions(); + + // Then a report that was never submitted cannot be emailed + expect(options.map((option) => option.text)).not.toContain('common.shareReport'); + }); + + it('hides share report from a member who is not the submitter, the approver or an admin', () => { + // Given a submitted report that someone else owns and approves + mockReport = {...submittedReport, ownerAccountID: OTHER_ACCOUNT_ID}; + + // When the share options are built for a regular workspace member + const memberOptions = getShareOptions(createMock({id: 'policy1', role: CONST.POLICY.ROLE.USER})); + + // Then they cannot share it, while a workspace admin can + expect(memberOptions.map((option) => option.text)).not.toContain('common.shareReport'); + const adminOptions = getShareOptions(createMock({id: 'policy1', role: CONST.POLICY.ROLE.ADMIN})); + expect(adminOptions.at(0)?.text).toBe('common.shareReport'); + }); + + it('opens the share report page', () => { + // Given the share options of a report the current user can share + const options = getShareOptions(); + + // When share report is selected + options.at(0)?.onSelected?.(); + + // Then the picker for the recipient opens + expect(Navigation.navigate).toHaveBeenCalledWith(DYNAMIC_ROUTES.REPORT_DETAILS_SHARE_REPORT.path); + }); + + it('opens share code with only the QR code', () => { + // Given the share options of a report + const options = getShareOptions(); + + // When share code is selected + options.at(1)?.onSelected?.(); + + // Then the share code page opens without the copy and referral rows the share menu already has + expect(Navigation.navigate).toHaveBeenCalledWith('share-code?shouldShowQRCodeOnly=true'); + }); + + it('copies the report URL', () => { + // Given the share options of a report + const options = getShareOptions(); + + // When copy URL is selected + options.at(-1)?.onSelected?.(); + + // Then the link to the report is on the clipboard + expect(Clipboard.setString).toHaveBeenCalledWith(`${ENVIRONMENT_URL}/${ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)}`); + }); +});