diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 5ece2222e909..c59d5120d080 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9312,6 +9312,7 @@ const CONST = { PLAN_TYPE: 'WorkspaceOverview-PlanType', SHARE: 'WorkspaceOverview-Share', CUSTOM_RULES: 'WorkspaceOverview-CustomRules', + RULES_DOCUMENT: 'WorkspaceOverview-RulesDocument', INVITE_BUTTON: 'WorkspaceOverview-InviteButton', MORE_DROPDOWN: 'WorkspaceOverview-MoreDropdown', }, diff --git a/src/ROUTES.ts b/src/ROUTES.ts index e261008e310d..6ee3e38055ba 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2122,6 +2122,10 @@ const ROUTES = { route: 'workspaces/:policyID/avatar', getRoute: (policyID: string, fallbackLetter?: UpperCaseCharacters) => `workspaces/${policyID}/avatar${fallbackLetter ? `?letter=${fallbackLetter}` : ''}` as const, }, + WORKSPACE_DOCUMENT: { + route: 'workspaces/:policyID/document', + getRoute: (policyID: string) => `workspaces/${policyID}/document` as const, + }, WORKSPACE_JOIN_USER: { route: 'workspaces/:policyID/join', getRoute: (policyID: string, inviterEmail: string) => `workspaces/${policyID}/join?email=${inviterEmail}` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index b675d7d4cc1a..b9135816bc0b 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -19,6 +19,7 @@ const SCREENS = { REPORT: 'Report', PROFILE_AVATAR: 'ProfileAvatar', WORKSPACE_AVATAR: 'WorkspaceAvatar', + WORKSPACE_DOCUMENT: 'WorkspaceDocument', REPORT_AVATAR: 'ReportAvatar', NOT_FOUND: 'not-found', TRANSITION_BETWEEN_APPS: 'TransitionBetweenApps', diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 61f9f0d5270a..e446831a1244 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -27,6 +27,25 @@ import type IconAsset from '@src/types/utils/IconAsset'; import launchCamera from './launchCamera/launchCamera'; import type AttachmentPickerProps from './types'; +const EXTENSION_TO_NATIVE_TYPE: Record = { + pdf: String(types.pdf), + doc: String(types.doc), + docx: String(types.docx), + zip: String(types.zip), + txt: String(types.plainText), + json: String(types.json), + xls: String(types.xls), + xlsx: String(types.xlsx), + jpg: String(types.images), + jpeg: String(types.images), + png: String(types.images), + gif: String(types.images), + heif: String(types.images), + heic: String(types.images), + tif: String(types.images), + tiff: String(types.images), +}; + type LocalCopy = { name: string | null; uri: string; @@ -121,8 +140,10 @@ function AttachmentPicker({ shouldHideCameraOption = false, shouldValidateImage = true, shouldHideGalleryOption = false, + acceptedFileTypes, fileLimit = 1, onOpenPicker, + shouldSkipAttachmentTypeModal = false, }: AttachmentPickerProps) { const icons = useMemoizedLazyExpensifyIcons(['Camera', 'Gallery', 'Paperclip']); const styles = useThemeStyles(); @@ -251,8 +272,22 @@ function AttachmentPicker({ * Launch the DocumentPicker. Results are in the same format as ImagePicker */ const showDocumentPicker = useCallback(async (): Promise => { + let pickerTypes: string[]; + if (acceptedFileTypes && acceptedFileTypes.length > 0) { + const mappedTypes = acceptedFileTypes.reduce((result, extension) => { + const nativeType = EXTENSION_TO_NATIVE_TYPE[String(extension)]; + if (nativeType !== undefined && !result.includes(nativeType)) { + result.push(nativeType); + } + return result; + }, []); + pickerTypes = mappedTypes.length > 0 ? mappedTypes : [types.allFiles]; + } else { + pickerTypes = [type === CONST.ATTACHMENT_PICKER_TYPE.IMAGE ? types.images : types.allFiles]; + } + const pickedFiles = await pick({ - type: [type === CONST.ATTACHMENT_PICKER_TYPE.IMAGE ? types.images : types.allFiles], + type: pickerTypes, allowMultiSelection: fileLimit !== 1, }); @@ -280,7 +315,7 @@ function AttachmentPicker({ type: file.type, }; }); - }, [fileLimit, type]); + }, [acceptedFileTypes, fileLimit, type]); const menuItemData: Item[] = useMemo(() => { const data: Item[] = [ @@ -336,19 +371,6 @@ function AttachmentPicker({ [showGeneralAlert, showImageCorruptionAlert, translate], ); - /** - * Opens the attachment modal - * - * @param onPickedHandler A callback that will be called with the selected attachment - * @param onCanceledHandler A callback that will be called without a selected attachment - */ - const open = (onPickedHandler: (files: FileObject[]) => void, onCanceledHandler: () => void = () => {}, onClosedHandler: () => void = () => {}) => { - completeAttachmentSelection.current = onPickedHandler; - onCanceled.current = onCanceledHandler; - onClosed.current = onClosedHandler; - setIsVisible(true); - }; - /** * Closes the attachment modal */ @@ -442,6 +464,33 @@ function AttachmentPicker({ [handleImageProcessingError, shouldValidateImage, showGeneralAlert, showImageCorruptionAlert], ); + /** + * Opens the attachment modal, or directly launches the document picker when shouldSkipAttachmentTypeModal is true. + */ + const open = (onPickedHandler: (files: FileObject[]) => void, onCanceledHandler: () => void = () => {}, onClosedHandler: () => void = () => {}) => { + completeAttachmentSelection.current = onPickedHandler; + onCanceled.current = onCanceledHandler; + onClosed.current = onClosedHandler; + + if (shouldSkipAttachmentTypeModal) { + onOpenPicker?.(); + showDocumentPicker() + .catch((error: Error) => { + if (JSON.stringify(error).includes('OPERATION_CANCELED')) { + return; + } + showGeneralAlert(error.message); + throw error; + }) + .then((result) => pickAttachment(result)) + .catch(console.error) + .finally(() => onClosedHandler()); + return; + } + + setIsVisible(true); + }; + /** * Setup native attachment selection to start after this popover closes * diff --git a/src/components/AttachmentPicker/types.ts b/src/components/AttachmentPicker/types.ts index 362eb6759fb3..03ec003c9d66 100644 --- a/src/components/AttachmentPicker/types.ts +++ b/src/components/AttachmentPicker/types.ts @@ -60,6 +60,9 @@ type AttachmentPickerProps = { /** A callback that will be called when the picker is opened. */ onOpenPicker?: () => void; + + /** When true, skip the Camera/Gallery/Document modal and open the document picker directly (native only). */ + shouldSkipAttachmentTypeModal?: boolean; }; export default AttachmentPickerProps; diff --git a/src/languages/de.ts b/src/languages/de.ts index a8ce5a694f63..cab7e8ce9bfd 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6840,6 +6840,8 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und customRules: { title: 'Spesenrichtlinie', cardSubtitle: 'Hier ist die Spesenrichtlinie deines Teams hinterlegt, damit alle denselben Stand haben, was abgedeckt ist.', + policyDocument: 'Richtliniendokument', + policyText: 'Richtlinientext', }, spendRules: { title: 'Ausgaben', diff --git a/src/languages/en.ts b/src/languages/en.ts index abcc4f46ec56..545d3e4b0e9c 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6854,6 +6854,8 @@ const translations = { customRules: { title: 'Expense policy', cardSubtitle: "Here's where your team's expense policy lives, so everyone's on the same page about what's covered.", + policyDocument: 'Policy document', + policyText: 'Policy text', }, spendRules: { title: 'Spend', diff --git a/src/languages/es.ts b/src/languages/es.ts index 77c080462661..d0a81c213f34 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6741,6 +6741,8 @@ ${amount} para ${merchant} - ${date}`, customRules: { title: 'Reglas personalizadas', cardSubtitle: 'Aquí es donde se definen las reglas de tu equipo, para que todos sepan lo que esta cubierto.', + policyDocument: 'Documento de política', + policyText: 'Texto de política', }, spendRules: { title: 'Gastos', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 28200673ce3c..93e1d9fdac9e 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6862,6 +6862,8 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip customRules: { title: 'Politique de dépenses', cardSubtitle: 'C’est ici que se trouve la politique de dépenses de votre équipe, pour que tout le monde soit d’accord sur ce qui est couvert.', + policyDocument: 'Document de politique', + policyText: 'Texte de politique', }, spendRules: { title: 'Dépenser', diff --git a/src/languages/it.ts b/src/languages/it.ts index c92797e4e987..a65c71ca4793 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6825,6 +6825,8 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo customRules: { title: 'Politica di spesa', cardSubtitle: 'Qui trovi il regolamento spese del tuo team, così tutti sono allineati su cosa è coperto.', + policyDocument: 'Documento di politica', + policyText: 'Testo della politica', }, spendRules: { title: 'Spesa', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 1bc54276c894..6b7e73ab1499 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6748,6 +6748,8 @@ ${reportName} customRules: { title: '経費ポリシー', cardSubtitle: 'ここはチームの経費ポリシーが保存されている場所です。何が対象になるか、全員が同じ認識を持てます。', + policyDocument: 'ポリシー文書', + policyText: 'ポリシーテキスト', }, spendRules: { title: '支出', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 4e420cbbfd03..297a2803a0e3 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6805,6 +6805,8 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar customRules: { title: 'Declaratiebeleid', cardSubtitle: 'Hier staat het declaratiebeleid van je team, zodat iedereen hetzelfde beeld heeft van wat er wordt vergoed.', + policyDocument: 'Beleidsdocument', + policyText: 'Beleidstekst', }, spendRules: { title: 'Uitgaven', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index d715a797e093..35631fc56ac5 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6797,6 +6797,8 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i customRules: { title: 'Polityka wydatków', cardSubtitle: 'To tutaj znajduje się polityka wydatków Twojego zespołu, aby wszyscy mieli jasność co do tego, co jest objęte.', + policyDocument: 'Dokument polityki', + policyText: 'Tekst polityki', }, spendRules: { title: 'Wydatki', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 34d7eec5d349..da7ee01af0a0 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6803,6 +6803,8 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e customRules: { title: 'Política de despesas', cardSubtitle: 'Aqui é onde fica a política de despesas da sua equipe, para que todo mundo esteja alinhado sobre o que é coberto.', + policyDocument: 'Documento de política', + policyText: 'Texto da política', }, spendRules: { title: 'Gasto', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 49510c6c6296..266f36b96294 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6630,6 +6630,8 @@ ${reportName} customRules: { title: '报销政策', cardSubtitle: '这是你们团队的报销政策所在之处,让所有人都清楚哪些内容在报销范围之内。', + policyDocument: '政策文件', + policyText: '政策文本', }, spendRules: { title: '支出', diff --git a/src/libs/API/parameters/DeletePolicyRulesDocumentParams.ts b/src/libs/API/parameters/DeletePolicyRulesDocumentParams.ts new file mode 100644 index 000000000000..8b51b7a37f0e --- /dev/null +++ b/src/libs/API/parameters/DeletePolicyRulesDocumentParams.ts @@ -0,0 +1,5 @@ +type DeletePolicyRulesDocumentParams = { + policyID: string; +}; + +export default DeletePolicyRulesDocumentParams; diff --git a/src/libs/API/parameters/UpdatePolicyRulesDocumentParams.ts b/src/libs/API/parameters/UpdatePolicyRulesDocumentParams.ts new file mode 100644 index 000000000000..c2a0873283df --- /dev/null +++ b/src/libs/API/parameters/UpdatePolicyRulesDocumentParams.ts @@ -0,0 +1,6 @@ +type UpdatePolicyRulesDocumentParams = { + policyID: string; + file: File; +}; + +export default UpdatePolicyRulesDocumentParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 5a6be0464fd8..4baf3531d7fe 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -164,6 +164,8 @@ export type {default as CreateWorkspaceParams} from './CreateWorkspaceParams'; export type {default as UpdateWorkspaceGeneralSettingsParams} from './UpdateWorkspaceGeneralSettingsParams'; export type {default as DeleteWorkspaceAvatarParams} from './DeleteWorkspaceAvatarParams'; export type {default as UpdateWorkspaceAvatarParams} from './UpdateWorkspaceAvatarParams'; +export type {default as UpdatePolicyRulesDocumentParams} from './UpdatePolicyRulesDocumentParams'; +export type {default as DeletePolicyRulesDocumentParams} from './DeletePolicyRulesDocumentParams'; export type {default as AddMembersToWorkspaceParams} from './AddMembersToWorkspaceParams'; export type {default as DeleteMembersFromWorkspaceParams} from './DeleteMembersFromWorkspaceParams'; export type {default as OpenWorkspaceParams} from './OpenWorkspaceParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index d3274ef9a103..3169aea66275 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -154,6 +154,8 @@ const WRITE_COMMANDS = { ADD_MEMBERS_TO_WORKSPACE: 'AddMembersToWorkspace', UPDATE_WORKSPACE_AVATAR: 'UpdateWorkspaceAvatar', DELETE_WORKSPACE_AVATAR: 'DeleteWorkspaceAvatar', + UPDATE_POLICY_RULES_DOCUMENT: 'UpdatePolicyRulesDocument', + DELETE_POLICY_RULES_DOCUMENT: 'DeletePolicyRulesDocument', UPDATE_WORKSPACE_GENERAL_SETTINGS: 'UpdateWorkspaceGeneralSettings', UPDATE_WORKSPACE_DESCRIPTION: 'UpdateWorkspaceDescription', UPDATE_WORKSPACE_CLIENT_ID: 'UpdateWorkspaceClientID', @@ -727,6 +729,8 @@ type WriteCommandParameters = { [WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE]: Parameters.AddMembersToWorkspaceParams; [WRITE_COMMANDS.UPDATE_WORKSPACE_AVATAR]: Parameters.UpdateWorkspaceAvatarParams; [WRITE_COMMANDS.DELETE_WORKSPACE_AVATAR]: Parameters.DeleteWorkspaceAvatarParams; + [WRITE_COMMANDS.UPDATE_POLICY_RULES_DOCUMENT]: Parameters.UpdatePolicyRulesDocumentParams; + [WRITE_COMMANDS.DELETE_POLICY_RULES_DOCUMENT]: Parameters.DeletePolicyRulesDocumentParams; [WRITE_COMMANDS.UPDATE_WORKSPACE_GENERAL_SETTINGS]: Parameters.UpdateWorkspaceGeneralSettingsParams; [WRITE_COMMANDS.UPDATE_WORKSPACE_DESCRIPTION]: Parameters.UpdateWorkspaceDescriptionParams; [WRITE_COMMANDS.UPDATE_WORKSPACE_CLIENT_ID]: Parameters.UpdateWorkspaceClientIDParams; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 9b381ec63314..078809a912c5 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -283,6 +283,12 @@ function AuthScreens() { getComponent={loadAttachmentModalScreen} listeners={modalScreenListeners} /> + ([ SCREENS.MONEY_REQUEST.ODOMETER_PREVIEW, SCREENS.PROFILE_AVATAR, SCREENS.WORKSPACE_AVATAR, + SCREENS.WORKSPACE_DOCUMENT, SCREENS.REPORT_AVATAR, SCREENS.CONCIERGE, SCREENS.SEARCH_ROUTER.ROOT, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 03d6d7478a48..0841315ca1ad 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -33,6 +33,7 @@ const config: LinkingOptions['config'] = { }, }, [SCREENS.WORKSPACE_AVATAR]: ROUTES.WORKSPACE_AVATAR.route, + [SCREENS.WORKSPACE_DOCUMENT]: ROUTES.WORKSPACE_DOCUMENT.route, [SCREENS.REPORT_AVATAR]: ROUTES.REPORT_AVATAR.route, [SCREENS.TRANSACTION_RECEIPT]: ROUTES.TRANSACTION_RECEIPT.route, [SCREENS.MONEY_REQUEST.RECEIPT_PREVIEW]: ROUTES.MONEY_REQUEST_RECEIPT_PREVIEW.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 0458f31c005d..c7ad29a032d2 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2999,6 +2999,9 @@ type AttachmentModalScreensParamList = { policyID: string; letter?: UpperCaseCharacters; }; + [SCREENS.WORKSPACE_DOCUMENT]: AttachmentModalContainerModalProps & { + policyID: string; + }; [SCREENS.REPORT_AVATAR]: AttachmentModalContainerModalProps & { reportID: string; policyID?: string; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 8e3866794b98..28fab9d6d4af 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -45,6 +45,8 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {getBankAccountFromID} from './actions/BankAccounts'; import {hasSynchronizationErrorMessage, isConnectionUnverified} from './actions/connections'; import {shouldShowQBOReimbursableExportDestinationAccountError} from './actions/connections/QuickbooksOnline'; +import addEncryptedAuthTokenToURL from './addEncryptedAuthTokenToURL'; +import {getApiRoot} from './ApiUtils'; import {getCategoryApproverRule} from './CategoryUtils'; import {convertToBackendAmount} from './CurrencyUtils'; import Navigation from './Navigation/Navigation'; @@ -2126,6 +2128,32 @@ function sortPoliciesByName(policies: Policy[], localeCompare: (a: string, b: st return policies.sort((a, b) => localeCompare(a.name || '', b.name || '')); } +/** + * Builds a source URL for rendering a policy document PDF. + * Local blob/file URIs (from optimistic uploads) are returned directly. + * Remote URLs are routed through the authenticated GetPolicyRulesDocument streaming endpoint. + * The stored URL (which contains a unique timestamp per upload) is appended as a version + * parameter so the browser treats each replacement as a distinct resource. + */ +function getRulesDocumentSourceURL(rulesDocumentURL: string | undefined, policyID: string | undefined, encryptedAuthToken: string): string { + if (!rulesDocumentURL || !policyID) { + return ''; + } + + const isLocalFile = rulesDocumentURL.startsWith('blob:') || rulesDocumentURL.startsWith('file:'); + if (isLocalFile) { + return rulesDocumentURL; + } + + return addEncryptedAuthTokenToURL( + // Each PDF upload gets a unique S3 key, so rulesDocumentURL changes on every replacement. + // Encoding it as cacheBuster ensures the full streaming URL is also unique, preventing stale browser/pdfjs cache. + `${getApiRoot({shouldUseSecure: false})}api/GetPolicyRulesDocument?policyID=${policyID}&cacheBuster=${encodeURIComponent(rulesDocumentURL)}`, + encryptedAuthToken, + true, + ); +} + export { canEditTaxRate, canPolicyAccessFeature, @@ -2299,6 +2327,7 @@ export { isPolicyTaxEnabled, sortPoliciesByName, isPolicyApprover, + getRulesDocumentSourceURL, getHRConnectionNames, isGustoConnected, }; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index e3ae881fc9fb..66174939c206 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -14,6 +14,7 @@ import type { ChangePolicyUberBillingAccountPageParams, CreateWorkspaceFromIOUPaymentParams, CreateWorkspaceParams, + DeletePolicyRulesDocumentParams, DeleteWorkspaceAvatarParams, DeleteWorkspaceParams, DisablePolicyApprovalsParams, @@ -67,6 +68,7 @@ import type { UpdateInvoiceCompanyNameParams, UpdateInvoiceCompanyWebsiteParams, UpdatePolicyAddressParams, + UpdatePolicyRulesDocumentParams, UpdateWorkspaceAvatarParams, UpdateWorkspaceClientIDParams, UpdateWorkspaceDescriptionParams, @@ -80,7 +82,7 @@ import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types import * as CurrencyUtils from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; -import {createFile} from '@libs/fileDownload/FileUtils'; +import {createFile, splitExtensionFromFileName} from '@libs/fileDownload/FileUtils'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import GoogleTagManager from '@libs/GoogleTagManager'; // eslint-disable-next-line @typescript-eslint/no-deprecated @@ -1805,6 +1807,103 @@ function deleteWorkspaceAvatar(policyID: string, currentAvatarURL: string, curre API.write(WRITE_COMMANDS.DELETE_WORKSPACE_AVATAR, params, {optimisticData, finallyData, failureData}); } +function updatePolicyRulesDocument(policyID: string, file: File, currentDocumentURL: string | undefined) { + const isPDF = file.type === 'application/pdf' || splitExtensionFromFileName(file.name ?? '').fileExtension.toLowerCase() === 'pdf'; + if (!isPDF) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + errorFields: {rulesDocumentURL: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('attachmentPicker.notAllowedExtension')}, + }); + return; + } + + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rulesDocumentURL: file.uri, + errorFields: { + rulesDocumentURL: null, + }, + pendingFields: { + rulesDocumentURL: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + const finallyData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + rulesDocumentURL: null, + }, + }, + }, + ]; + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rulesDocumentURL: currentDocumentURL ?? '', + errorFields: {rulesDocumentURL: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + }, + ]; + + const params: UpdatePolicyRulesDocumentParams = { + policyID, + file, + }; + + API.write(WRITE_COMMANDS.UPDATE_POLICY_RULES_DOCUMENT, params, {optimisticData, finallyData, failureData}); +} + +function deletePolicyRulesDocument(policyID: string, currentDocumentURL: string) { + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rulesDocumentURL: '', + errorFields: { + rulesDocumentURL: null, + }, + pendingFields: { + rulesDocumentURL: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + const finallyData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + rulesDocumentURL: null, + }, + }, + }, + ]; + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + rulesDocumentURL: currentDocumentURL, + errorFields: {rulesDocumentURL: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + }, + ]; + + const params: DeletePolicyRulesDocumentParams = {policyID}; + + API.write(WRITE_COMMANDS.DELETE_POLICY_RULES_DOCUMENT, params, {optimisticData, finallyData, failureData}); +} + /** * Clear error and pending fields for the workspace avatar */ @@ -7114,6 +7213,8 @@ export { updateGeneralSettings, deleteWorkspaceAvatar, updateWorkspaceAvatar, + updatePolicyRulesDocument, + deletePolicyRulesDocument, clearAvatarErrors, generatePolicyID, createWorkspace, diff --git a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx index 44a79444b5d1..5ae30502c965 100644 --- a/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx +++ b/src/pages/media/AttachmentModalScreen/AttachmentModalBaseContent/index.tsx @@ -188,7 +188,7 @@ function AttachmentModalBaseContent({ const {isAttachmentLoaded} = useContext(AttachmentStateContext); const isEReceipt = transaction && !hasReceiptSource(transaction) && hasEReceipt(transaction); const shouldShowDownloadButton = useMemo(() => { - const isValidContext = !isEmptyObject(report) || type === CONST.ATTACHMENT_TYPE.SEARCH; + const isValidContext = !isEmptyObject(report) || type === CONST.ATTACHMENT_TYPE.SEARCH || !!onDownloadAttachment; if (!isValidContext || isErrorInAttachment(source) || isEReceipt) { return false; } diff --git a/src/pages/media/AttachmentModalScreen/index.tsx b/src/pages/media/AttachmentModalScreen/index.tsx index 52ce70838483..531ee1eeb0af 100644 --- a/src/pages/media/AttachmentModalScreen/index.tsx +++ b/src/pages/media/AttachmentModalScreen/index.tsx @@ -9,6 +9,7 @@ import ReportAvatarModalContent from './routes/report/ReportAvatarModalContent'; import ShareDetailsAttachmentModalContent from './routes/ShareDetailsAttachmentModalContent'; import TransactionReceiptModalContent from './routes/TransactionReceiptModalContent'; import WorkspaceAvatarModalContent from './routes/WorkspaceAvatarModalContent'; +import WorkspaceDocumentModalContent from './routes/WorkspaceDocumentModalContent'; import type {AttachmentModalScreenProps, AttachmentModalScreenType} from './types'; type RouteType = AttachmentModalScreenProps['route']; @@ -74,6 +75,15 @@ function AttachmentModalScreen({route, ); } + if (route.name === SCREENS.WORKSPACE_DOCUMENT) { + return ( + } + navigation={navigation as NavigationType} + /> + ); + } + if (route.name === SCREENS.REPORT_AVATAR) { return ( ) { + const {policyID} = route.params; + const {translate} = useLocalize(); + + const [session] = useOnyx(ONYXKEYS.SESSION); + const policy = usePolicy(policyID); + const [isLoadingApp = false] = useOnyx(ONYXKEYS.IS_LOADING_APP, {initWithStoredValues: false}); + + const policyKeysLength = Object.keys(policy ?? {}).length; + + const isDocumentAvailable = (policyKeysLength > 0 || !!isLoadingApp) && !!policy?.rulesDocumentURL; + const isLoading = policyKeysLength === 0 && !!isLoadingApp; + + const rulesDocumentSourceURL = useMemo( + () => getRulesDocumentSourceURL(policy?.rulesDocumentURL, policyID, session?.encryptedAuthToken ?? ''), + [policy?.rulesDocumentURL, policyID, session?.encryptedAuthToken], + ); + + const onDownloadAttachment = useDownloadAttachment(); + + const contentProps = useMemo( + () => ({ + source: rulesDocumentSourceURL, + headerTitle: translate('workspace.rules.customRules.policyDocument'), + originalFileName: `${policyID}-policy-document.pdf`, + shouldShowNotFoundPage: !isDocumentAvailable, + isLoading, + onDownloadAttachment, + shouldCloseOnSwipeDown: true, + }), + [rulesDocumentSourceURL, translate, policyID, isDocumentAvailable, isLoading, onDownloadAttachment], + ); + + return ( + + ); +} + +export default WorkspaceDocumentModalContent; diff --git a/src/pages/media/AttachmentModalScreen/types.ts b/src/pages/media/AttachmentModalScreen/types.ts index 09360f0fa2c0..0fd6cfd60ada 100644 --- a/src/pages/media/AttachmentModalScreen/types.ts +++ b/src/pages/media/AttachmentModalScreen/types.ts @@ -27,6 +27,7 @@ type AttachmentModalScreenType = | typeof SCREENS.REPORT_AVATAR | typeof SCREENS.PROFILE_AVATAR | typeof SCREENS.WORKSPACE_AVATAR + | typeof SCREENS.WORKSPACE_DOCUMENT | typeof SCREENS.TRANSACTION_RECEIPT | typeof SCREENS.MONEY_REQUEST.RECEIPT_PREVIEW | typeof SCREENS.MONEY_REQUEST.ODOMETER_PREVIEW diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index f104e84f9421..ca908d0506b0 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -1,7 +1,8 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; +import AttachmentPicker from '@components/AttachmentPicker'; import Avatar from '@components/Avatar'; import AvatarWithImagePicker from '@components/AvatarWithImagePicker'; import Button from '@components/Button'; @@ -13,7 +14,12 @@ import {useLockedAccountActions, useLockedAccountState} from '@components/Locked import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import PDFThumbnail from '@components/PDFThumbnail'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; +import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import Section from '@components/Section'; +import Text from '@components/Text'; +import ThreeDotsMenu from '@components/ThreeDotsMenu'; import useCardFeeds from '@hooks/useCardFeeds'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -39,11 +45,13 @@ import { clearAvatarErrors, clearDeleteWorkspaceError, clearPolicyErrorField, + deletePolicyRulesDocument, deleteWorkspace, deleteWorkspaceAvatar, leaveWorkspace, openPolicyProfilePage, setIsComingFromGlobalReimbursementsFlow, + updatePolicyRulesDocument, updateWorkspaceAvatar, } from '@libs/actions/Policy/Policy'; import {filterInactiveCards, getCardSettings} from '@libs/CardUtils'; @@ -54,6 +62,7 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import { getConnectionExporters, + getRulesDocumentSourceURL, getUserFriendlyWorkspaceType, goBackFromInvalidPolicy, isPendingDeletePolicy, @@ -68,6 +77,7 @@ import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButton'; import StringUtils from '@libs/StringUtils'; import {isSubscriptionTypeOfInvoicing, shouldCalculateBillNewDot} from '@libs/SubscriptionUtils'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -75,6 +85,7 @@ import type SCREENS from '@src/SCREENS'; import {accountIDToLoginSelector} from '@src/selectors/PersonalDetails'; import {ownerPoliciesSelector} from '@src/selectors/Policy'; import {reimbursementAccountErrorSelector} from '@src/selectors/ReimbursementAccount'; +import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {WithPolicyProps} from './withPolicy'; import withPolicy from './withPolicy'; @@ -89,7 +100,7 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {getCurrencySymbol} = useCurrencyListActions(); const illustrationIcons = useMemoizedLazyIllustrations(['Building']); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Exit', 'FallbackWorkspaceAvatar', 'ImageCropSquareMask', 'QrCode', 'Transfer', 'Trashcan', 'UserPlus']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Exit', 'FallbackWorkspaceAvatar', 'ImageCropSquareMask', 'QrCode', 'Transfer', 'Trashcan', 'Upload', 'UserPlus']); const backTo = route.params.backTo; const [account] = useOnyx(ONYXKEYS.ACCOUNT); @@ -185,6 +196,20 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const {isBetaEnabled} = usePermissions(); const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); const [session] = useOnyx(ONYXKEYS.SESSION); + + const rulesDocumentSourceURL = useMemo( + () => getRulesDocumentSourceURL(policy?.rulesDocumentURL, policyID, session?.encryptedAuthToken ?? ''), + [policy?.rulesDocumentURL, policyID, session?.encryptedAuthToken], + ); + + const hasRulesDocument = !!policy?.rulesDocumentURL; + const hasCustomRulesText = !StringUtils.isEmptyString(policy?.customRules ?? ''); + const shouldShowExpensePolicySection = isBetaEnabled(CONST.BETAS.CUSTOM_RULES) && (isPolicyAdmin || hasRulesDocument || hasCustomRulesText); + const shouldShowRulesDocumentSubSection = isPolicyAdmin || hasRulesDocument; + + const rulesDocumentThumbnailStyle = useMemo(() => ({maxWidth: variables.rulesDocumentThumbnailMaxWidth, height: variables.rulesDocumentThumbnailHeight}), []); + const rulesDocumentMenuPositionStyle = useMemo(() => ({top: variables.spacing2, right: variables.spacing2}), []); + const personalDetails = usePersonalDetails(); const [accountIDToLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: accountIDToLoginSelector(reportsToArchive)}); const [isCannotLeaveWorkspaceModalOpen, setIsCannotLeaveWorkspaceModalOpen] = useState(false); @@ -423,6 +448,37 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa return translate('common.leaveWorkspaceConfirmation'); }; + const handleRulesDocumentPicked = (files: FileObject[]) => { + const file = files.at(0); + if (!policyID || !file) { + return; + } + updatePolicyRulesDocument(policyID, file as File, policy?.rulesDocumentURL); + }; + + const getRulesDocumentMenuItems = (openPicker: (options: {onPicked: (files: FileObject[]) => void}) => void): PopoverMenuItem[] => [ + { + text: translate('common.replace'), + icon: expensifyIcons.Upload, + shouldCallAfterModalHide: true, + onSelected: () => { + openPicker({ + onPicked: handleRulesDocumentPicked, + }); + }, + }, + { + text: translate('common.remove'), + icon: expensifyIcons.Trashcan, + onSelected: () => { + if (!policyID || !policy?.rulesDocumentURL) { + return; + } + deletePolicyRulesDocument(policyID, policy.rulesDocumentURL); + }, + }, + ]; + const handleInvitePress = () => { if (isAccountLocked) { showLockedAccountModal(); @@ -754,28 +810,102 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa )} - {isBetaEnabled(CONST.BETAS.CUSTOM_RULES) ? ( + {shouldShowExpensePolicySection ? (
- - Navigation.navigate(ROUTES.RULES_CUSTOM.getRoute(route.params.policyID))} - shouldRenderAsHTML - /> - + {shouldShowRulesDocumentSubSection && ( + { + if (!policyID) { + return; + } + clearPolicyErrorField(policyID, 'rulesDocumentURL'); + }} + > + {translate('workspace.rules.customRules.policyDocument')} + + {({openPicker}) => { + if (policy?.rulesDocumentURL) { + return ( + + { + if (!policyID) { + return; + } + Navigation.navigate(ROUTES.WORKSPACE_DOCUMENT.getRoute(policyID)); + }} + role={CONST.ROLE.BUTTON} + accessibilityLabel={translate('workspace.rules.customRules.policyDocument')} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.OVERVIEW.RULES_DOCUMENT} + style={[styles.border, styles.borderRadiusComponentLarge, styles.overflowHidden, styles.flex1]} + > + + + {isPolicyAdmin && ( + + + + )} + + ); + } + + if (!isPolicyAdmin) { + return null; + } + + return ( + +
) : null} diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 7b897e288fe6..fece2bcab300 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -22,6 +22,8 @@ export default { inputComponentSizeNormal: 40, componentSizeLarge: 52, spacing2: 8, + rulesDocumentThumbnailMaxWidth: 368, + rulesDocumentThumbnailHeight: 200, componentBorderRadius: 8, componentBorderRadiusSmall: 4, componentBorderRadiusMedium: 6, diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index c1b6ddcf64f1..6e73a8a3ce95 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -2007,6 +2007,9 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< /** A set of custom rules defined with natural language */ customRules?: string; + /** URL of the workspace rules PDF document stored in a private S3 bucket */ + rulesDocumentURL?: string; + /** ReportID of the admins room for this workspace - This should be a string, we are keeping the number for backward compatibility */ chatReportIDAdmins?: string | number;