-
Notifications
You must be signed in to change notification settings - Fork 3.9k
[No QA] [Bulk workspace edits] Add CopyPolicySettings action file and tests #89963
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
francoisl
merged 31 commits into
Expensify:main
from
fedirjh:copy-policy-settings-actions
May 19, 2026
Merged
Changes from all commits
Commits
Show all changes
31 commits
Select commit
Hold shift + click to select a range
8d1888c
Add workspace.copyPolicySettings.error translation key
fedirjh 9424295
Add CopyPolicySettings action file
fedirjh a48557e
Add unit tests for buildCopyPolicySettingsData
fedirjh 87c6860
Merge branch 'Expensify:main' into copy-policy-settings-actions
fedirjh 861c2b2
Merge branch 'Expensify:main' into copy-policy-settings-actions
fedirjh 486f960
Use named type import in CopyPolicySettings
fedirjh b9e8d88
Use named imports in CopyPolicySettings action file
fedirjh 4f46baf
Replace test custom unit IDs with hex digits to satisfy cspell
fedirjh fe3643f
Merge branch 'Expensify:main' into copy-policy-settings-actions
fedirjh 51fdba8
Merge branch 'Expensify:main' into copy-policy-settings-actions
fedirjh 0f3a8ae
Add documentation for sourcePolicyID in CopyPolicySettingsParams
fedirjh 66bd695
Merge remote-tracking branch 'upstream' into copy-policy-settings-act…
fedirjh 99e0b03
Merge remote-tracking branch 'upstream' into copy-policy-settings-act…
fedirjh a02be50
fix: update copyPolicySettings translations
fedirjh f4b1516
Merge branch 'Expensify:main' into copy-policy-settings-actions
fedirjh 496ee58
Merge branch 'Expensify:main' into copy-policy-settings-actions
fedirjh 502c5c0
Merge branch 'Expensify:main' into copy-policy-settings-actions
fedirjh b4e295a
Merge branch 'Expensify:main' into copy-policy-settings-actions
fedirjh 0540b92
Merge branch 'Expensify:main' into copy-policy-settings-actions
fedirjh fb87736
Update Spanish translation for copy policy settings error message to …
fedirjh a2a17c7
chore: add source policy RBR error on failure in CopyPolicySettings
fedirjh a9c5913
chore: add description field to overview part in CopyPolicySettings
fedirjh cb3e661
refactor: clean up customUnits snapshot and add lifecycle comment
fedirjh 3652170
test: add distanceRates and perDiem to per-part field patch test
fedirjh 9fee396
test: add multi-target and empty source collection edge cases
fedirjh ad5dfc9
style: use consistent .at(0) array access in customUnits test
fedirjh 883216b
Merge remote-tracking branch 'upstream' into copy-policy-settings-act…
fedirjh 5d48595
fix: use null instead of undefined to clear currentStep in failureData
fedirjh 06ac496
refactor: address PR review feedback for CopyPolicySettings
fedirjh 8e749a2
Merge branch 'Expensify:main' into copy-policy-settings-actions
fedirjh e004d1a
Merge branch 'Expensify:main' into copy-policy-settings-actions
fedirjh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,301 @@ | ||
| import type {OnyxCollection, OnyxUpdate} from 'react-native-onyx'; | ||
| import Onyx from 'react-native-onyx'; | ||
| import {write} from '@libs/API'; | ||
| import type {CopyPolicySettingsParams} from '@libs/API/parameters'; | ||
| import {WRITE_COMMANDS} from '@libs/API/types'; | ||
| import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; | ||
| import {generateHexadecimalValue} from '@libs/NumberUtils'; | ||
| import CONST from '@src/CONST'; | ||
| import ONYXKEYS from '@src/ONYXKEYS'; | ||
| import type {CopyPolicySettings as CopyPolicySettingsState, Policy, PolicyCategories, PolicyTagLists} from '@src/types/onyx'; | ||
| import type {CustomUnit} from '@src/types/onyx/Policy'; | ||
|
|
||
| type Part = 'overview' | 'members' | 'reports' | 'accounting' | 'categories' | 'tags' | 'taxes' | 'workflows' | 'rules' | 'distanceRates' | 'perDiem' | 'invoices' | 'travel'; | ||
|
|
||
| const PARTS_TO_POLICY_FIELDS = { | ||
| overview: ['outputCurrency', 'address', 'description'], | ||
| members: ['employeeList'], | ||
| reports: ['fieldList', 'areReportFieldsEnabled'], | ||
| accounting: ['connections', 'areConnectionsEnabled'], | ||
| categories: ['areCategoriesEnabled'], | ||
| tags: ['areTagsEnabled'], | ||
| taxes: ['tax', 'taxRates'], | ||
| // achAccount is intentionally excluded — the backend remaps bankAccountID per-caller | ||
| // (see Auth PR #21638). We rely on the server push for that field. | ||
| workflows: ['areWorkflowsEnabled', 'autoReportingFrequency', 'autoReporting', 'autoReportingOffset', 'harvesting', 'approvalMode', 'autoApproval', 'reimbursementChoice'], | ||
| rules: [ | ||
| 'areRulesEnabled', | ||
| 'maxExpenseAmount', | ||
| 'maxExpenseAge', | ||
| 'maxExpenseAmountNoReceipt', | ||
| 'maxExpenseAmountNoItemizedReceipt', | ||
| 'defaultBillable', | ||
| 'prohibitedExpenses', | ||
| 'eReceipts', | ||
| 'isAttendeeTrackingEnabled', | ||
| 'preventSelfApproval', | ||
| 'shouldShowAutoApprovalOptions', | ||
| 'shouldShowAutoReimbursementLimitOption', | ||
| ], | ||
| distanceRates: ['areDistanceRatesEnabled', 'customUnits'], | ||
| perDiem: ['arePerDiemRatesEnabled', 'customUnits'], | ||
| invoices: ['areInvoicesEnabled', 'invoice'], | ||
| travel: ['isTravelEnabled', 'travelSettings'], | ||
| } as const satisfies Record<Part, ReadonlyArray<keyof Policy>>; | ||
|
|
||
| type PolicyFieldsForPart = (typeof PARTS_TO_POLICY_FIELDS)[Part][number]; | ||
|
|
||
| function setCopyPolicySettingsData(data: Partial<CopyPolicySettingsState>): void { | ||
| Onyx.merge(ONYXKEYS.COPY_POLICY_SETTINGS, data); | ||
| } | ||
|
|
||
| function clearCopyPolicySettings(): void { | ||
| Onyx.set(ONYXKEYS.COPY_POLICY_SETTINGS, {}); | ||
| } | ||
|
|
||
| function requestCopyPolicySettingsNotification(): void { | ||
| write(WRITE_COMMANDS.COPY_POLICY_SETTINGS_NOTIFY, {}); | ||
| } | ||
|
|
||
| function findCustomUnitByName(policy: Policy | undefined, unitName: string): CustomUnit | undefined { | ||
| if (!policy?.customUnits) { | ||
| return undefined; | ||
| } | ||
| return Object.values(policy.customUnits).find((unit) => unit.name === unitName); | ||
| } | ||
|
|
||
| /** | ||
| * Returns the customUnits patch to merge into the target policy when distanceRates and/or perDiem are | ||
| * being copied. The source unit data is written under the target's existing unit ID — a new ID is | ||
| * generated only when the target has no unit of that type yet. | ||
| */ | ||
| function buildCustomUnitsPatch(sourcePolicy: Policy, targetPolicy: Policy, isDistanceSelected: boolean, isPerDiemSelected: boolean): {customUnits: Record<string, CustomUnit>} | undefined { | ||
| if (!isDistanceSelected && !isPerDiemSelected) { | ||
| return undefined; | ||
| } | ||
|
|
||
| const patch: Record<string, CustomUnit> = {}; | ||
|
|
||
| if (isDistanceSelected) { | ||
| const sourceDistance = findCustomUnitByName(sourcePolicy, CONST.CUSTOM_UNITS.NAME_DISTANCE); | ||
| if (sourceDistance) { | ||
| const targetDistance = findCustomUnitByName(targetPolicy, CONST.CUSTOM_UNITS.NAME_DISTANCE); | ||
| const targetUnitID = targetDistance?.customUnitID ?? generateHexadecimalValue(13); | ||
| patch[targetUnitID] = {...sourceDistance, customUnitID: targetUnitID}; | ||
| } | ||
| } | ||
|
|
||
| if (isPerDiemSelected) { | ||
| const sourcePerDiem = findCustomUnitByName(sourcePolicy, CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL); | ||
| if (sourcePerDiem) { | ||
| const targetPerDiem = findCustomUnitByName(targetPolicy, CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL); | ||
| const targetUnitID = targetPerDiem?.customUnitID ?? generateHexadecimalValue(13); | ||
| patch[targetUnitID] = {...sourcePerDiem, customUnitID: targetUnitID}; | ||
| } | ||
| } | ||
|
|
||
| if (Object.keys(patch).length === 0) { | ||
| return undefined; | ||
| } | ||
| return {customUnits: patch}; | ||
| } | ||
|
|
||
| /** | ||
| * Returns the partial Policy patch derived from the selected `parts`, excluding fields whose | ||
| * mapping is handled separately (customUnits, categories, tags collection keys). | ||
| */ | ||
| function buildPolicyFieldPatch(sourcePolicy: Policy, parts: Part[]): Partial<Policy> { | ||
| const patch: Partial<Policy> = {}; | ||
| for (const part of parts) { | ||
| for (const field of PARTS_TO_POLICY_FIELDS[part]) { | ||
| if (field === 'customUnits') { | ||
| continue; | ||
| } | ||
| // The PARTS_TO_POLICY_FIELDS values are typed as keyof Policy, so this assignment is safe. | ||
| (patch as Record<string, unknown>)[field] = sourcePolicy[field as keyof Policy]; | ||
| } | ||
| } | ||
| return patch; | ||
| } | ||
|
|
||
| function buildExpandedPendingFields(parts: Part[]): Partial<Record<PolicyFieldsForPart, typeof CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE>> { | ||
| const pendingFields: Partial<Record<PolicyFieldsForPart, typeof CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE>> = {}; | ||
| for (const part of parts) { | ||
| for (const field of PARTS_TO_POLICY_FIELDS[part]) { | ||
| pendingFields[field] = CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE; | ||
| } | ||
| } | ||
| return pendingFields; | ||
| } | ||
|
|
||
| function buildClearedPendingFields(parts: Part[]): Partial<Record<PolicyFieldsForPart, null>> { | ||
| const cleared: Partial<Record<PolicyFieldsForPart, null>> = {}; | ||
| for (const part of parts) { | ||
| for (const field of PARTS_TO_POLICY_FIELDS[part]) { | ||
| cleared[field] = null; | ||
| } | ||
| } | ||
| return cleared; | ||
| } | ||
|
|
||
| type CopyPolicySettingsOnyxKeys = | ||
| | typeof ONYXKEYS.COLLECTION.POLICY | ||
| | typeof ONYXKEYS.COLLECTION.POLICY_CATEGORIES | ||
| | typeof ONYXKEYS.COLLECTION.POLICY_TAGS | ||
| | typeof ONYXKEYS.COPY_POLICY_SETTINGS; | ||
|
|
||
| function buildCopyPolicySettingsData( | ||
| sourcePolicy: Policy, | ||
| targetPolicies: Policy[], | ||
| parts: Part[], | ||
| allPolicyCategories: OnyxCollection<PolicyCategories>, | ||
| allPolicyTags: OnyxCollection<PolicyTagLists>, | ||
| ): { | ||
| optimisticData: Array<OnyxUpdate<CopyPolicySettingsOnyxKeys>>; | ||
| successData: Array<OnyxUpdate<CopyPolicySettingsOnyxKeys>>; | ||
| failureData: Array<OnyxUpdate<CopyPolicySettingsOnyxKeys>>; | ||
| } { | ||
| const optimisticData: Array<OnyxUpdate<CopyPolicySettingsOnyxKeys>> = []; | ||
| const successData: Array<OnyxUpdate<CopyPolicySettingsOnyxKeys>> = []; | ||
| const failureData: Array<OnyxUpdate<CopyPolicySettingsOnyxKeys>> = []; | ||
|
|
||
| const policyFieldPatch = buildPolicyFieldPatch(sourcePolicy, parts); | ||
| const pendingFields = buildExpandedPendingFields(parts); | ||
| const clearedPendingFields = buildClearedPendingFields(parts); | ||
|
|
||
| const isCategoriesSelected = parts.includes('categories'); | ||
| const isTagsSelected = parts.includes('tags'); | ||
| const isDistanceSelected = parts.includes('distanceRates'); | ||
| const isPerDiemSelected = parts.includes('perDiem'); | ||
|
|
||
| const sourceCategoriesKey = `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${sourcePolicy.id}` as const; | ||
| const sourceTagsKey = `${ONYXKEYS.COLLECTION.POLICY_TAGS}${sourcePolicy.id}` as const; | ||
| const sourceCategories = allPolicyCategories?.[sourceCategoriesKey] ?? {}; | ||
| const sourceTags = allPolicyTags?.[sourceTagsKey] ?? {}; | ||
|
|
||
| for (const targetPolicy of targetPolicies) { | ||
| const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${targetPolicy.id}` as const; | ||
| const customUnitsPatch = buildCustomUnitsPatch(sourcePolicy, targetPolicy, isDistanceSelected, isPerDiemSelected); | ||
|
|
||
| // Step 1+2: SET the full policy with patched fields overlaid. | ||
| // We use SET (not MERGE) because Onyx.merge deep-merges nested objects — source | ||
| // values would be merged into target's, leaving stale nested keys behind. | ||
| optimisticData.push({ | ||
| onyxMethod: Onyx.METHOD.SET, | ||
| key: policyKey, | ||
| value: { | ||
| ...targetPolicy, | ||
| ...policyFieldPatch, | ||
| ...(customUnitsPatch ? {customUnits: {...targetPolicy.customUnits, ...customUnitsPatch.customUnits}} : {}), | ||
| pendingFields: {...targetPolicy.pendingFields, ...pendingFields}, | ||
| }, | ||
| }); | ||
|
fedirjh marked this conversation as resolved.
|
||
|
|
||
| // Success: clear pending markers and any leftover errors from a prior failure | ||
| successData.push({ | ||
| onyxMethod: Onyx.METHOD.MERGE, | ||
| key: policyKey, | ||
| value: { | ||
| pendingFields: clearedPendingFields, | ||
| errors: null, | ||
| }, | ||
| }); | ||
|
fedirjh marked this conversation as resolved.
|
||
|
|
||
| // Failure: restore the original target policy in full, surface RBR | ||
| failureData.push({ | ||
| onyxMethod: Onyx.METHOD.SET, | ||
| key: policyKey, | ||
| value: { | ||
| ...targetPolicy, | ||
| errors: getMicroSecondOnyxErrorWithTranslationKey('workspace.copyPolicySettings.error'), | ||
| }, | ||
| }); | ||
|
|
||
| // Step 3: collection keys (categories / tags) — SET-level overwrite with snapshot rollback | ||
| if (isCategoriesSelected) { | ||
| const targetCategoriesKey = `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${targetPolicy.id}` as const; | ||
| const previousCategories = allPolicyCategories?.[targetCategoriesKey] ?? {}; | ||
| optimisticData.push({ | ||
| onyxMethod: Onyx.METHOD.SET, | ||
| key: targetCategoriesKey, | ||
| value: sourceCategories, | ||
| }); | ||
| failureData.push({ | ||
| onyxMethod: Onyx.METHOD.SET, | ||
| key: targetCategoriesKey, | ||
| value: previousCategories, | ||
| }); | ||
| } | ||
|
|
||
| if (isTagsSelected) { | ||
| const targetTagsKey = `${ONYXKEYS.COLLECTION.POLICY_TAGS}${targetPolicy.id}` as const; | ||
| const previousTags = allPolicyTags?.[targetTagsKey] ?? {}; | ||
| optimisticData.push({ | ||
| onyxMethod: Onyx.METHOD.SET, | ||
| key: targetTagsKey, | ||
| value: sourceTags, | ||
| }); | ||
| failureData.push({ | ||
| onyxMethod: Onyx.METHOD.SET, | ||
| key: targetTagsKey, | ||
| value: previousTags, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| // Surface an RBR on the source policy row so the admin knows the bulk copy failed | ||
| failureData.push({ | ||
| onyxMethod: Onyx.METHOD.MERGE, | ||
| key: `${ONYXKEYS.COLLECTION.POLICY}${sourcePolicy.id}` as const, | ||
| value: { | ||
| errors: getMicroSecondOnyxErrorWithTranslationKey('workspace.copyPolicySettings.error'), | ||
| }, | ||
| }); | ||
|
|
||
| // Clear source policy errors on success (in case this is a retry after failure) | ||
| successData.push({ | ||
| onyxMethod: Onyx.METHOD.MERGE, | ||
| key: `${ONYXKEYS.COLLECTION.POLICY}${sourcePolicy.id}` as const, | ||
| value: { | ||
| errors: null, | ||
| }, | ||
| }); | ||
|
|
||
| // Step 4: drive currentStep on the COPY_POLICY_SETTINGS key itself. | ||
| // Success intentionally omits this key — the backend transitions currentStep | ||
| // to 'complete' via the bulkCopySettings NVP push. | ||
| optimisticData.push({ | ||
| onyxMethod: Onyx.METHOD.MERGE, | ||
| key: ONYXKEYS.COPY_POLICY_SETTINGS, | ||
| value: {currentStep: 'loading'}, | ||
| }); | ||
|
|
||
| failureData.push({ | ||
| onyxMethod: Onyx.METHOD.MERGE, | ||
| key: ONYXKEYS.COPY_POLICY_SETTINGS, | ||
| value: {currentStep: null}, | ||
| }); | ||
|
|
||
| return {optimisticData, successData, failureData}; | ||
| } | ||
|
|
||
| function copyPolicySettings( | ||
| sourcePolicy: Policy, | ||
| targetPolicies: Policy[], | ||
| parts: Part[], | ||
| allPolicyCategories: OnyxCollection<PolicyCategories>, | ||
| allPolicyTags: OnyxCollection<PolicyTagLists>, | ||
| ): void { | ||
| const {optimisticData, successData, failureData} = buildCopyPolicySettingsData(sourcePolicy, targetPolicies, parts, allPolicyCategories, allPolicyTags); | ||
|
|
||
| const params: CopyPolicySettingsParams = { | ||
| sourcePolicyID: sourcePolicy.id, | ||
| policyIDList: targetPolicies.map((policy) => policy.id).join(','), | ||
| parts: parts.join(','), | ||
| }; | ||
|
|
||
| write(WRITE_COMMANDS.COPY_POLICY_SETTINGS, params, {optimisticData, successData, failureData}); | ||
| } | ||
|
|
||
| export {PARTS_TO_POLICY_FIELDS, setCopyPolicySettingsData, clearCopyPolicySettings, requestCopyPolicySettingsNotification, buildCopyPolicySettingsData, copyPolicySettings}; | ||
| export type {Part}; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Quick question: is codingRules intentionally not exposed as its own part?
The Auth treats codingRules as a separate part from rules (which covers workspace-wide violation thresholds like maxExpenseAmount, eReceipts, prohibitedExpenses). (Auth LOC ref)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, intentional per the design doc. The detailed implementation section does not list
codingRulesas a separate frontend part — it is folded intorules.