From 8ff20413ceba2bf0e7c76fb511c4387894930ced Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Sat, 11 Apr 2026 05:26:46 +0530 Subject: [PATCH 01/16] Add workspace rules PDF document upload, view, and management Allow workspace admins to upload, replace, and remove a PDF document on the workspace overview page. Non-admins can view and download it. PDFs are stored in a private S3 bucket and served through an authenticated streaming endpoint. --- src/ROUTES.ts | 4 + src/SCREENS.ts | 1 + .../DeleteWorkspaceRulesDocumentParams.ts | 5 + .../UpdateWorkspaceRulesDocumentParams.ts | 6 + src/libs/API/parameters/index.ts | 2 + src/libs/API/types.ts | 4 + .../Navigation/AppNavigator/AuthScreens.tsx | 6 + .../GetStateForActionHandlers.ts | 1 + src/libs/Navigation/linkingConfig/config.ts | 1 + src/libs/Navigation/types.ts | 3 + src/libs/PolicyUtils.ts | 27 +++++ src/libs/actions/Policy/Policy.ts | 93 +++++++++++++++ .../media/AttachmentModalScreen/index.tsx | 10 ++ .../routes/WorkspaceDocumentModalContent.tsx | 49 ++++++++ .../media/AttachmentModalScreen/types.ts | 1 + src/pages/workspace/WorkspaceOverviewPage.tsx | 108 +++++++++++++++++- src/types/onyx/Policy.ts | 3 + 17 files changed, 320 insertions(+), 4 deletions(-) create mode 100644 src/libs/API/parameters/DeleteWorkspaceRulesDocumentParams.ts create mode 100644 src/libs/API/parameters/UpdateWorkspaceRulesDocumentParams.ts create mode 100644 src/pages/media/AttachmentModalScreen/routes/WorkspaceDocumentModalContent.tsx diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 2c30d1f3d2cd..e86dd001ca4d 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2118,6 +2118,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 387edc01db54..118316b6f4f6 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/libs/API/parameters/DeleteWorkspaceRulesDocumentParams.ts b/src/libs/API/parameters/DeleteWorkspaceRulesDocumentParams.ts new file mode 100644 index 000000000000..1cff382e8b76 --- /dev/null +++ b/src/libs/API/parameters/DeleteWorkspaceRulesDocumentParams.ts @@ -0,0 +1,5 @@ +type DeleteWorkspaceRulesDocumentParams = { + policyID: string; +}; + +export default DeleteWorkspaceRulesDocumentParams; diff --git a/src/libs/API/parameters/UpdateWorkspaceRulesDocumentParams.ts b/src/libs/API/parameters/UpdateWorkspaceRulesDocumentParams.ts new file mode 100644 index 000000000000..8539b6dfda84 --- /dev/null +++ b/src/libs/API/parameters/UpdateWorkspaceRulesDocumentParams.ts @@ -0,0 +1,6 @@ +type UpdateWorkspaceRulesDocumentParams = { + policyID: string; + file: File; +}; + +export default UpdateWorkspaceRulesDocumentParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index dfbde3571fcd..f9477e819f10 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 UpdateWorkspaceRulesDocumentParams} from './UpdateWorkspaceRulesDocumentParams'; +export type {default as DeleteWorkspaceRulesDocumentParams} from './DeleteWorkspaceRulesDocumentParams'; 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 0c6a1e5b6e52..592c39eedcea 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_WORKSPACE_RULES_DOCUMENT: 'UpdateWorkspaceRulesDocument', + DELETE_WORKSPACE_RULES_DOCUMENT: 'DeleteWorkspaceRulesDocument', UPDATE_WORKSPACE_GENERAL_SETTINGS: 'UpdateWorkspaceGeneralSettings', UPDATE_WORKSPACE_DESCRIPTION: 'UpdateWorkspaceDescription', UPDATE_WORKSPACE_CLIENT_ID: 'UpdateWorkspaceClientID', @@ -726,6 +728,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_WORKSPACE_RULES_DOCUMENT]: Parameters.UpdateWorkspaceRulesDocumentParams; + [WRITE_COMMANDS.DELETE_WORKSPACE_RULES_DOCUMENT]: Parameters.DeleteWorkspaceRulesDocumentParams; [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 9aba217a1624..90d387442d16 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 4e8cccf47262..b7f386c49ec1 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2987,6 +2987,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 3c725937bd42..6ad6b60bb0f7 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -47,6 +47,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'; @@ -2152,6 +2154,30 @@ 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 GetPolicyDocument 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 getPolicyDocumentSourceURL(policyDocumentURL: string | undefined, policyID: string | undefined, encryptedAuthToken: string): string { + if (!policyDocumentURL || !policyID) { + return ''; + } + + const isLocalFile = policyDocumentURL.startsWith('blob:') || policyDocumentURL.startsWith('file:'); + if (isLocalFile) { + return policyDocumentURL; + } + + return addEncryptedAuthTokenToURL( + `${getApiRoot({shouldUseSecure: false})}api/GetPolicyDocument?policyID=${policyID}&v=${encodeURIComponent(policyDocumentURL)}`, + encryptedAuthToken, + true, + ); +} + export { canEditTaxRate, canPolicyAccessFeature, @@ -2335,6 +2361,7 @@ export { isPolicyTaxEnabled, sortPoliciesByName, isPolicyApprover, + getPolicyDocumentSourceURL, }; export type {MemberEmailsToAccountIDs}; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 95c4bc61c55e..9cbe265a643e 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -16,6 +16,7 @@ import type { CreateWorkspaceParams, DeleteWorkspaceAvatarParams, DeleteWorkspaceParams, + DeleteWorkspaceRulesDocumentParams, DisablePolicyApprovalsParams, DisablePolicyBillableModeParams, DowngradeToTeamParams, @@ -71,6 +72,7 @@ import type { UpdateWorkspaceClientIDParams, UpdateWorkspaceDescriptionParams, UpdateWorkspaceGeneralSettingsParams, + UpdateWorkspaceRulesDocumentParams, UpgradeToCorporateParams, } from '@libs/API/parameters'; import type SetPolicyCashExpenseModeParams from '@libs/API/parameters/SetPolicyCashExpenseModeParams'; @@ -1805,6 +1807,95 @@ function deleteWorkspaceAvatar(policyID: string, currentAvatarURL: string, curre API.write(WRITE_COMMANDS.DELETE_WORKSPACE_AVATAR, params, {optimisticData, finallyData, failureData}); } +function updateWorkspaceRulesDocument(policyID: string, file: File, currentDocumentURL: string | undefined) { + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + policyDocumentURL: file.uri, + errorFields: { + policyDocumentURL: null, + }, + pendingFields: { + policyDocumentURL: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + const finallyData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + policyDocumentURL: null, + }, + }, + }, + ]; + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + policyDocumentURL: currentDocumentURL ?? '', + errorFields: {policyDocumentURL: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + }, + ]; + + const params: UpdateWorkspaceRulesDocumentParams = { + policyID, + file, + }; + + API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_RULES_DOCUMENT, params, {optimisticData, finallyData, failureData}); +} + +function deleteWorkspaceRulesDocument(policyID: string, currentDocumentURL: string) { + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + policyDocumentURL: '', + errorFields: { + policyDocumentURL: null, + }, + pendingFields: { + policyDocumentURL: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + ]; + const finallyData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: { + policyDocumentURL: null, + }, + }, + }, + ]; + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + policyDocumentURL: currentDocumentURL, + errorFields: {policyDocumentURL: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + }, + ]; + + const params: DeleteWorkspaceRulesDocumentParams = {policyID}; + + API.write(WRITE_COMMANDS.DELETE_WORKSPACE_RULES_DOCUMENT, params, {optimisticData, finallyData, failureData}); +} + /** * Clear error and pending fields for the workspace avatar */ @@ -7115,6 +7206,8 @@ export { updateGeneralSettings, deleteWorkspaceAvatar, updateWorkspaceAvatar, + updateWorkspaceRulesDocument, + deleteWorkspaceRulesDocument, clearAvatarErrors, generatePolicyID, createWorkspace, 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 [session] = useOnyx(ONYXKEYS.SESSION); + const policy = usePolicy(policyID); + const [isLoadingApp = false] = useOnyx(ONYXKEYS.IS_LOADING_APP, {initWithStoredValues: false}); + + const policyKeysLength = Object.keys(policy ?? {}).length; + + // eslint-disable-next-line rulesdir/no-negated-variables + const shouldShowNotFoundPage = (policyKeysLength === 0 && !isLoadingApp) || !policy?.policyDocumentURL; + const isLoading = policyKeysLength === 0 && !!isLoadingApp; + + const policyDocumentSourceURL = useMemo( + () => getPolicyDocumentSourceURL(policy?.policyDocumentURL, policyID, session?.encryptedAuthToken ?? ''), + [policy?.policyDocumentURL, policyID, session?.encryptedAuthToken], + ); + + const contentProps = useMemo( + () => ({ + source: policyDocumentSourceURL, + headerTitle: policy?.name ?? '', + originalFileName: `${policyID}-policy-document.pdf`, + shouldShowNotFoundPage, + isLoading, + shouldCloseOnSwipeDown: true, + }), + [policyDocumentSourceURL, policy?.name, policyID, shouldShowNotFoundPage, isLoading], + ); + + 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..c5367aa9bfb6 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'; @@ -41,10 +47,12 @@ import { clearPolicyErrorField, deleteWorkspace, deleteWorkspaceAvatar, + deleteWorkspaceRulesDocument, leaveWorkspace, openPolicyProfilePage, setIsComingFromGlobalReimbursementsFlow, updateWorkspaceAvatar, + updateWorkspaceRulesDocument, } from '@libs/actions/Policy/Policy'; import {filterInactiveCards, getCardSettings} from '@libs/CardUtils'; import {getLatestErrorField, getLatestErrorMessage} from '@libs/ErrorUtils'; @@ -54,6 +62,7 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import { getConnectionExporters, + getPolicyDocumentSourceURL, getUserFriendlyWorkspaceType, goBackFromInvalidPolicy, isPendingDeletePolicy, @@ -75,6 +84,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 +99,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 +195,12 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const {isBetaEnabled} = usePermissions(); const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); const [session] = useOnyx(ONYXKEYS.SESSION); + + const policyDocumentSourceURL = useMemo( + () => getPolicyDocumentSourceURL(policy?.policyDocumentURL, policyID, session?.encryptedAuthToken ?? ''), + [policy?.policyDocumentURL, policyID, session?.encryptedAuthToken], + ); + const personalDetails = usePersonalDetails(); const [accountIDToLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: accountIDToLoginSelector(reportsToArchive)}); const [isCannotLeaveWorkspaceModalOpen, setIsCannotLeaveWorkspaceModalOpen] = useState(false); @@ -423,6 +439,33 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa return translate('common.leaveWorkspaceConfirmation'); }; + const getPolicyDocumentMenuItems = (openPicker: (options: {onPicked: (files: FileObject[]) => void}) => void): PopoverMenuItem[] => [ + { + text: translate('common.replace'), + icon: expensifyIcons.Upload, + onSelected: () => { + openPicker({ + onPicked: (files: FileObject[]) => { + if (!policyID || !files.length) { + return; + } + updateWorkspaceRulesDocument(policyID, files.at(0) as File, policy?.policyDocumentURL); + }, + }); + }, + }, + { + text: translate('common.remove'), + icon: expensifyIcons.Trashcan, + onSelected: () => { + if (!policyID || !policy?.policyDocumentURL) { + return; + } + deleteWorkspaceRulesDocument(policyID, policy.policyDocumentURL); + }, + }, + ]; + const handleInvitePress = () => { if (isAccountLocked) { showLockedAccountModal(); @@ -757,13 +800,70 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa {isBetaEnabled(CONST.BETAS.CUSTOM_RULES) ? (
+ + {translate('workspace.rules.customRules.policyDocument')} + + {({openPicker}) => { + if (policy?.policyDocumentURL) { + return ( + + { + if (!policyID) { + return; + } + Navigation.navigate(ROUTES.WORKSPACE_DOCUMENT.getRoute(policyID)); + }} + role={CONST.ROLE.BUTTON} + accessibilityLabel={translate('workspace.rules.customRules.policyDocument')} + style={[styles.border, styles.borderRadiusComponentLarge, styles.overflowHidden, {width: 200, height: 260}]} + > + + + {isPolicyAdmin && ( + + )} + + ); + } + + if (!isPolicyAdmin) { + return null; + } + + return ( +
- {isBetaEnabled(CONST.BETAS.CUSTOM_RULES) ? ( + {isBetaEnabled(CONST.BETAS.CUSTOM_RULES) && (isPolicyAdmin || !!policy?.rulesDocumentURL || !StringUtils.isEmptyString(policy?.customRules ?? '')) ? (
- { - if (!policyID) { - return; - } - clearPolicyErrorField(policyID, 'rulesDocumentURL'); - }} - > - {translate('workspace.rules.customRules.policyDocument')} - - {({openPicker}) => { - if (policy?.rulesDocumentURL) { + {(isPolicyAdmin || !!policy?.rulesDocumentURL) && ( + { + 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} From 8a12a4fdc996832339c2b4742eaefb0a94a049d7 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Thu, 16 Apr 2026 05:04:30 +0530 Subject: [PATCH 12/16] Fix prettier formatting in WorkspaceOverviewPage --- src/pages/workspace/WorkspaceOverviewPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index fc23b60d076d..6182e6dc592b 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -813,7 +813,7 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa title={translate('workspace.rules.customRules.title')} titleStyles={[styles.textHeadline, styles.cardSectionTitle, styles.accountSettingsSectionTitle, styles.mb0]} subtitle={translate('workspace.rules.customRules.cardSubtitle')} - subtitleStyles={[(isPolicyAdmin || !!policy?.rulesDocumentURL) ? styles.mb6 : styles.mb2]} + subtitleStyles={[isPolicyAdmin || !!policy?.rulesDocumentURL ? styles.mb6 : styles.mb2]} subtitleTextStyles={[styles.textNormal, styles.colorMuted, styles.mr5]} containerStyles={shouldUseNarrowLayout ? styles.p5 : styles.p8} > From f2553e6d2ba8b06ac00a4c31675d1e33e5d8ea62 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Thu, 16 Apr 2026 17:47:05 +0530 Subject: [PATCH 13/16] Wire download action into workspace document modal --- .../routes/WorkspaceDocumentModalContent.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/media/AttachmentModalScreen/routes/WorkspaceDocumentModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/WorkspaceDocumentModalContent.tsx index 05f96c7bc54e..b1e538119571 100644 --- a/src/pages/media/AttachmentModalScreen/routes/WorkspaceDocumentModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/WorkspaceDocumentModalContent.tsx @@ -8,6 +8,7 @@ import AttachmentModalContainer from '@pages/media/AttachmentModalScreen/Attachm import type {AttachmentModalScreenProps} from '@pages/media/AttachmentModalScreen/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; +import useDownloadAttachment from './hooks/useDownloadAttachment'; function WorkspaceDocumentModalContent({navigation, route}: AttachmentModalScreenProps) { const {policyID} = route.params; @@ -28,6 +29,8 @@ function WorkspaceDocumentModalContent({navigation, route}: AttachmentModalScree [policy?.rulesDocumentURL, policyID, session?.encryptedAuthToken], ); + const onDownloadAttachment = useDownloadAttachment(); + const contentProps = useMemo( () => ({ source: rulesDocumentSourceURL, @@ -35,9 +38,10 @@ function WorkspaceDocumentModalContent({navigation, route}: AttachmentModalScree originalFileName: `${policyID}-policy-document.pdf`, shouldShowNotFoundPage, isLoading, + onDownloadAttachment, shouldCloseOnSwipeDown: true, }), - [rulesDocumentSourceURL, translate, policyID, shouldShowNotFoundPage, isLoading], + [rulesDocumentSourceURL, translate, policyID, shouldShowNotFoundPage, isLoading, onDownloadAttachment], ); return ( From 00b1593810866df6323a6a8bf31a8f4dad331781 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Thu, 16 Apr 2026 22:22:02 +0530 Subject: [PATCH 14/16] Extract named booleans for readability, remove eslint-disable --- .../routes/WorkspaceDocumentModalContent.tsx | 7 +++---- src/pages/workspace/WorkspaceOverviewPage.tsx | 15 ++++++++++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/pages/media/AttachmentModalScreen/routes/WorkspaceDocumentModalContent.tsx b/src/pages/media/AttachmentModalScreen/routes/WorkspaceDocumentModalContent.tsx index b1e538119571..74bfa2e8feb3 100644 --- a/src/pages/media/AttachmentModalScreen/routes/WorkspaceDocumentModalContent.tsx +++ b/src/pages/media/AttachmentModalScreen/routes/WorkspaceDocumentModalContent.tsx @@ -20,8 +20,7 @@ function WorkspaceDocumentModalContent({navigation, route}: AttachmentModalScree const policyKeysLength = Object.keys(policy ?? {}).length; - // eslint-disable-next-line rulesdir/no-negated-variables - const shouldShowNotFoundPage = (policyKeysLength === 0 && !isLoadingApp) || !policy?.rulesDocumentURL; + const isDocumentAvailable = (policyKeysLength > 0 || !!isLoadingApp) && !!policy?.rulesDocumentURL; const isLoading = policyKeysLength === 0 && !!isLoadingApp; const rulesDocumentSourceURL = useMemo( @@ -36,12 +35,12 @@ function WorkspaceDocumentModalContent({navigation, route}: AttachmentModalScree source: rulesDocumentSourceURL, headerTitle: translate('workspace.rules.customRules.policyDocument'), originalFileName: `${policyID}-policy-document.pdf`, - shouldShowNotFoundPage, + shouldShowNotFoundPage: !isDocumentAvailable, isLoading, onDownloadAttachment, shouldCloseOnSwipeDown: true, }), - [rulesDocumentSourceURL, translate, policyID, shouldShowNotFoundPage, isLoading, onDownloadAttachment], + [rulesDocumentSourceURL, translate, policyID, isDocumentAvailable, isLoading, onDownloadAttachment], ); return ( diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index 6182e6dc592b..503b083c751f 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -204,6 +204,11 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa [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 rulesDocumentMenuIconStyle = useMemo(() => ({borderRadius: variables.componentSizeNormal / 2, backgroundColor: theme.cardBG}), [theme.cardBG]); @@ -807,17 +812,17 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa )} - {isBetaEnabled(CONST.BETAS.CUSTOM_RULES) && (isPolicyAdmin || !!policy?.rulesDocumentURL || !StringUtils.isEmptyString(policy?.customRules ?? '')) ? ( + {shouldShowExpensePolicySection ? (
- {(isPolicyAdmin || !!policy?.rulesDocumentURL) && ( + {shouldShowRulesDocumentSubSection && ( )} - {(isPolicyAdmin || !StringUtils.isEmptyString(policy?.customRules ?? '')) && ( + {(isPolicyAdmin || hasCustomRulesText) && ( Navigation.navigate(ROUTES.RULES_CUSTOM.getRoute(route.params.policyID))} shouldRenderAsHTML /> From 77e8490782f41bae1acfffa3869be52b14ef7de9 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Wed, 22 Apr 2026 21:16:46 +0530 Subject: [PATCH 15/16] Fix download button, three-dots styling, and iOS replace on workspace document - Show download button in workspace document modal by including onDownloadAttachment in isValidContext check - Use receiptActionButton style for three-dots menu to match receipt preview icon buttons - Add shouldCallAfterModalHide to Replace menu item so native picker opens after popover closes --- .../AttachmentModalBaseContent/index.tsx | 2 +- src/pages/workspace/WorkspaceOverviewPage.tsx | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) 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/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index 503b083c751f..e217299b3fdc 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -35,7 +35,6 @@ import usePrevious from '@hooks/usePrevious'; import usePrivateSubscription from '@hooks/usePrivateSubscription'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useShouldBlockCurrencyChange from '@hooks/useShouldBlockCurrencyChange'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionViolationOfWorkspace from '@hooks/useTransactionViolationOfWorkspace'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; @@ -95,7 +94,6 @@ import WorkspacePageWithSections from './WorkspacePageWithSections'; type WorkspaceOverviewPageProps = WithPolicyProps & PlatformStackScreenProps; function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: WorkspaceOverviewPageProps) { - const theme = useTheme(); const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -211,7 +209,6 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const rulesDocumentThumbnailStyle = useMemo(() => ({maxWidth: variables.rulesDocumentThumbnailMaxWidth, height: variables.rulesDocumentThumbnailHeight}), []); const rulesDocumentMenuPositionStyle = useMemo(() => ({top: variables.spacing2, right: variables.spacing2}), []); - const rulesDocumentMenuIconStyle = useMemo(() => ({borderRadius: variables.componentSizeNormal / 2, backgroundColor: theme.cardBG}), [theme.cardBG]); const personalDetails = usePersonalDetails(); const [accountIDToLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: accountIDToLoginSelector(reportsToArchive)}); @@ -463,6 +460,7 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa { text: translate('common.replace'), icon: expensifyIcons.Upload, + shouldCallAfterModalHide: true, onSelected: () => { openPicker({ onPicked: handleRulesDocumentPicked, @@ -861,7 +859,7 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa )} From e6450faf5495bbddf5da13981b81f93863ee66bf Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Wed, 22 Apr 2026 21:39:52 +0530 Subject: [PATCH 16/16] Forward acceptedFileTypes to native picker, skip attachment type modal - Map acceptedFileTypes extensions to native picker types (UTIs on iOS, MIME on Android) - Add shouldSkipAttachmentTypeModal prop to bypass Camera/Gallery/Document modal - Use shouldSkipAttachmentTypeModal for workspace rules document picker - Remove all type assertions in favor of String() coercion --- .../AttachmentPicker/index.native.tsx | 79 +++++++++++++++---- src/components/AttachmentPicker/types.ts | 3 + src/pages/workspace/WorkspaceOverviewPage.tsx | 5 +- 3 files changed, 71 insertions(+), 16 deletions(-) diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 61f9f0d5270a..072814182b75 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), + msword: String(types.doc), + zip: String(types.zip), + text: 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/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index e217299b3fdc..ca908d0506b0 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -832,7 +832,10 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa }} > {translate('workspace.rules.customRules.policyDocument')} - + {({openPicker}) => { if (policy?.rulesDocumentURL) { return (