Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8ff2041
Add workspace rules PDF document upload, view, and management
ishpaul777 Apr 10, 2026
c1d9b25
Use cacheBuster param name for policy document URL uniqueness
ishpaul777 Apr 11, 2026
3289b24
Rename policyDocumentURL to rulesDocumentURL, align API commands
ishpaul777 Apr 13, 2026
7f4d8e6
Rename action functions to align with API command names
ishpaul777 Apr 15, 2026
3aa5f75
Fix thumbnail styling, three-dots menu, and add translation keys
ishpaul777 Apr 15, 2026
a9532c4
Fix CI: add translation keys to all locales, add sentryLabel
ishpaul777 Apr 15, 2026
c6b5887
Memoize inline style objects for rules document thumbnail
ishpaul777 Apr 15, 2026
9959723
Update rules document thumbnail styling and variable naming
ishpaul777 Apr 15, 2026
d66d79b
Implement translation for policy document header in WorkspaceDocument…
ishpaul777 Apr 15, 2026
4eb8454
Enhance PDF document handling in policy updates
ishpaul777 Apr 15, 2026
f312353
Refactor custom rules section in WorkspaceOverviewPage
ishpaul777 Apr 15, 2026
8a12a4f
Fix prettier formatting in WorkspaceOverviewPage
ishpaul777 Apr 15, 2026
f2553e6
Wire download action into workspace document modal
ishpaul777 Apr 16, 2026
00b1593
Extract named booleans for readability, remove eslint-disable
ishpaul777 Apr 16, 2026
77e8490
Fix download button, three-dots styling, and iOS replace on workspace…
ishpaul777 Apr 22, 2026
e6450fa
Forward acceptedFileTypes to native picker, skip attachment type modal
ishpaul777 Apr 22, 2026
df53f99
Merge main into ishpaul/workspace-rules-document-upload to resolve co…
MelvinBot Apr 22, 2026
1de1497
Merge branch 'Expensify:main' into ishpaul/workspace-rules-document-u…
ishpaul777 Apr 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down
4 changes: 4 additions & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
79 changes: 64 additions & 15 deletions src/components/AttachmentPicker/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think msword and text are not file extensions .. i think we can remove the msword

const EXTENSION_TO_NATIVE_TYPE: Record<string, string> = {
    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),
};

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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -251,8 +272,22 @@ function AttachmentPicker({
* Launch the DocumentPicker. Results are in the same format as ImagePicker
*/
const showDocumentPicker = useCallback(async (): Promise<LocalCopy[]> => {
let pickerTypes: string[];
if (acceptedFileTypes && acceptedFileTypes.length > 0) {
const mappedTypes = acceptedFileTypes.reduce<string[]>((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,
});

Expand Down Expand Up @@ -280,7 +315,7 @@ function AttachmentPicker({
type: file.type,
};
});
}, [fileLimit, type]);
}, [acceptedFileTypes, fileLimit, type]);

const menuItemData: Item[] = useMemo(() => {
const data: Item[] = [
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
*
Expand Down
3 changes: 3 additions & 0 deletions src/components/AttachmentPicker/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
2 changes: 2 additions & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6748,6 +6748,8 @@ ${reportName}
customRules: {
title: '経費ポリシー',
cardSubtitle: 'ここはチームの経費ポリシーが保存されている場所です。何が対象になるか、全員が同じ認識を持てます。',
policyDocument: 'ポリシー文書',
policyText: 'ポリシーテキスト',
},
spendRules: {
title: '支出',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6630,6 +6630,8 @@ ${reportName}
customRules: {
title: '报销政策',
cardSubtitle: '这是你们团队的报销政策所在之处,让所有人都清楚哪些内容在报销范围之内。',
policyDocument: '政策文件',
policyText: '政策文本',
},
spendRules: {
title: '支出',
Expand Down
5 changes: 5 additions & 0 deletions src/libs/API/parameters/DeletePolicyRulesDocumentParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
type DeletePolicyRulesDocumentParams = {
policyID: string;
};

export default DeletePolicyRulesDocumentParams;
6 changes: 6 additions & 0 deletions src/libs/API/parameters/UpdatePolicyRulesDocumentParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
type UpdatePolicyRulesDocumentParams = {
policyID: string;
file: File;
};

export default UpdatePolicyRulesDocumentParams;
2 changes: 2 additions & 0 deletions src/libs/API/parameters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 4 additions & 0 deletions src/libs/API/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions src/libs/Navigation/AppNavigator/AuthScreens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,12 @@ function AuthScreens() {
getComponent={loadAttachmentModalScreen}
listeners={modalScreenListeners}
/>
<RootStack.Screen
name={SCREENS.WORKSPACE_DOCUMENT}
options={attachmentModalScreenOptions}
getComponent={loadAttachmentModalScreen}
listeners={modalScreenListeners}
/>
<RootStack.Screen
name={SCREENS.TRANSACTION_RECEIPT}
options={attachmentModalScreenOptions}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const MODAL_ROUTES_TO_DISMISS = new Set<string>([
SCREENS.MONEY_REQUEST.ODOMETER_PREVIEW,
SCREENS.PROFILE_AVATAR,
SCREENS.WORKSPACE_AVATAR,
SCREENS.WORKSPACE_DOCUMENT,
SCREENS.REPORT_AVATAR,
SCREENS.CONCIERGE,
SCREENS.SEARCH_ROUTER.ROOT,
Expand Down
1 change: 1 addition & 0 deletions src/libs/Navigation/linkingConfig/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const config: LinkingOptions<RootNavigatorParamList>['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,
Expand Down
3 changes: 3 additions & 0 deletions src/libs/Navigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2999,6 +2999,9 @@ type AttachmentModalScreensParamList = {
policyID: string;
letter?: UpperCaseCharacters;
};
[SCREENS.WORKSPACE_DOCUMENT]: AttachmentModalContainerModalProps & {
policyID: string;
};
[SCREENS.REPORT_AVATAR]: AttachmentModalContainerModalProps & {
reportID: string;
policyID?: string;
Expand Down
29 changes: 29 additions & 0 deletions src/libs/PolicyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
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';
Expand Down Expand Up @@ -74,7 +76,7 @@

let allPolicies: OnyxCollection<Policy>;

Onyx.connect({

Check warning on line 79 in src/libs/PolicyUtils.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.POLICY,
waitForCollectionCallback: true,
callback: (value) => (allPolicies = value),
Expand Down Expand Up @@ -2126,6 +2128,32 @@
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,
Expand Down Expand Up @@ -2299,6 +2327,7 @@
isPolicyTaxEnabled,
sortPoliciesByName,
isPolicyApprover,
getRulesDocumentSourceURL,
getHRConnectionNames,
isGustoConnected,
};
Expand Down
Loading
Loading