diff --git a/assets/images/product-illustrations/emptystate__receiptfairy.svg b/assets/images/product-illustrations/emptystate__receiptfairy.svg
new file mode 100644
index 0000000000000..ccdeda5926f89
--- /dev/null
+++ b/assets/images/product-illustrations/emptystate__receiptfairy.svg
@@ -0,0 +1,154 @@
+
diff --git a/src/CONST.ts b/src/CONST.ts
index b790bb5cdcba8..b461177e0d047 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -6790,6 +6790,7 @@ const CONST = {
SCAN_TEST_TOOLTIP_MANAGER: 'scanTestTooltipManager',
SCAN_TEST_CONFIRMATION: 'scanTestConfirmation',
},
+ CHANGE_POLICY_TRAINING_MODAL: 'changePolicyModal',
SMART_BANNER_HEIGHT: 152,
NAVIGATION_TESTS: {
@@ -6821,6 +6822,7 @@ const CONST = {
},
SKIPPABLE_COLLECTION_MEMBER_IDS: [String(DEFAULT_NUMBER_ID), '-1', 'undefined', 'null', 'NaN'] as string[],
SETUP_SPECIALIST_LOGIN: 'Setup Specialist',
+ ILLUSTRATION_ASPECT_RATIO: 39 / 22,
} as const;
type Country = keyof typeof CONST.ALL_COUNTRIES;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 561885c583bb3..6069b3efcf13e 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -410,6 +410,10 @@ const ROUTES = {
route: 'r/:reportID/details/export/:connectionName',
getRoute: (reportID: string, connectionName: ConnectionName, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/details/export/${connectionName as string}` as const, backTo),
},
+ REPORT_WITH_ID_CHANGE_WORKSPACE: {
+ route: 'r/:reportID/change-workspace',
+ getRoute: (reportID: string, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/change-workspace` as const, backTo),
+ },
REPORT_SETTINGS: {
route: 'r/:reportID/settings',
getRoute: (reportID: string, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/settings` as const, backTo),
@@ -1685,6 +1689,10 @@ const ROUTES = {
route: 'hold-expense-educational',
getRoute: (backTo?: string) => getUrlWithBackToParam('hold-expense-educational', backTo),
},
+ CHANGE_POLICY_EDUCATIONAL: {
+ route: 'change-workspace-educational',
+ getRoute: (backTo?: string) => getUrlWithBackToParam('change-workspace-educational', backTo),
+ },
TRAVEL_MY_TRIPS: 'travel',
TRAVEL_TCS: {
route: 'travel/terms/:domain/accept',
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 77ecd1920ceaa..48e334910a372 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -166,6 +166,7 @@ const SCREENS = {
PROFILE: 'Profile',
NEW_REPORT_WORKSPACE_SELECTION: 'New_Report_Workspace_Selection',
REPORT_DETAILS: 'Report_Details',
+ REPORT_CHANGE_WORKSPACE: 'ReportChangeWorkspace',
WORKSPACE_CONFIRMATION: 'Workspace_Confirmation',
REPORT_SETTINGS: 'Report_Settings',
REPORT_DESCRIPTION: 'Report_Description',
@@ -339,6 +340,10 @@ const SCREENS = {
EXPORT: 'Report_Details_Export',
},
+ REPORT_CHANGE_WORKSPACE: {
+ ROOT: 'ReportChangeWorkspace_Root',
+ },
+
WORKSPACE_CONFIRMATION: {ROOT: 'Workspace_Confirmation_Root'},
WORKSPACE: {
@@ -652,6 +657,7 @@ const SCREENS = {
DETAILS_ROOT: 'Details_Root',
PROFILE_ROOT: 'Profile_Root',
PROCESS_MONEY_REQUEST_HOLD_ROOT: 'ProcessMoneyRequestHold_Root',
+ CHANGE_POLICY_EDUCATIONAL_ROOT: 'ChangePolicyEducational_Root',
REPORT_DESCRIPTION_ROOT: 'Report_Description_Root',
REPORT_PARTICIPANTS: {
ROOT: 'ReportParticipants_Root',
diff --git a/src/components/ChangeWorkspaceMenuSectionList.tsx b/src/components/ChangeWorkspaceMenuSectionList.tsx
new file mode 100644
index 0000000000000..2e159114264b0
--- /dev/null
+++ b/src/components/ChangeWorkspaceMenuSectionList.tsx
@@ -0,0 +1,59 @@
+import React from 'react';
+import {View} from 'react-native';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import convertToLTR from '@libs/convertToLTR';
+import variables from '@styles/variables';
+import type {TranslationPaths} from '@src/languages/types';
+import type IconAsset from '@src/types/utils/IconAsset';
+import Icon from './Icon';
+import * as Illustrations from './Icon/Illustrations';
+import RenderHTML from './RenderHTML';
+
+type ChangeWorkspaceMenuSection = {
+ /** The icon supplied with the section */
+ icon: IconAsset;
+
+ /** Translation key for the title */
+ titleTranslationKey: TranslationPaths;
+};
+
+const changeWorkspaceMenuSections: ChangeWorkspaceMenuSection[] = [
+ {
+ icon: Illustrations.FolderOpen,
+ titleTranslationKey: 'iou.changePolicyEducational.reCategorize',
+ },
+ {
+ icon: Illustrations.Workflows,
+ titleTranslationKey: 'iou.changePolicyEducational.workflows',
+ },
+];
+
+function ChangeWorkspaceMenuSectionList() {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ return (
+ <>
+ {changeWorkspaceMenuSections.map((section) => (
+
+
+
+ ${convertToLTR(translate(section.titleTranslationKey))}`} />
+
+
+ ))}
+ >
+ );
+}
+
+ChangeWorkspaceMenuSectionList.displayName = 'ChangeWorkspaceMenuSectionList';
+export default ChangeWorkspaceMenuSectionList;
diff --git a/src/components/FeatureTrainingModal.tsx b/src/components/FeatureTrainingModal.tsx
index 613d786230f25..0307fdb902846 100644
--- a/src/components/FeatureTrainingModal.tsx
+++ b/src/components/FeatureTrainingModal.tsx
@@ -115,6 +115,12 @@ type FeatureTrainingModalSVGProps = {
/** Determines how the image should be resized to fit its container */
contentFitImage?: ImageContentFit;
+
+ /** The width of the image */
+ imageWidth?: number;
+
+ /** The height of the image */
+ imageHeight?: number;
};
// This page requires either an icon or a video/animation, but not both
@@ -143,6 +149,8 @@ function FeatureTrainingModal({
contentInnerContainerStyles,
contentOuterContainerStyles,
modalInnerContainerStyle,
+ imageWidth,
+ imageHeight,
isModalDisabled = true,
}: FeatureTrainingModalProps) {
const styles = useThemeStyles();
@@ -211,6 +219,8 @@ function FeatureTrainingModal({
)}
{!!videoURL && videoStatus === 'video' && (
@@ -241,6 +251,8 @@ function FeatureTrainingModal({
);
}, [
image,
+ imageHeight,
+ imageWidth,
contentFitImage,
illustrationAspectRatio,
styles.w100,
diff --git a/src/components/HoldMenuSectionList.tsx b/src/components/HoldMenuSectionList.tsx
index 180b801c3f3cc..b2dc5b1213e9d 100644
--- a/src/components/HoldMenuSectionList.tsx
+++ b/src/components/HoldMenuSectionList.tsx
@@ -1,44 +1,42 @@
import React from 'react';
import {View} from 'react-native';
-import type {ImageSourcePropType} from 'react-native';
-import type {SvgProps} from 'react-native-svg';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
import type {TranslationPaths} from '@src/languages/types';
+import type IconAsset from '@src/types/utils/IconAsset';
import Icon from './Icon';
import * as Illustrations from './Icon/Illustrations';
import Text from './Text';
type HoldMenuSection = {
/** The icon supplied with the section */
- icon: React.FC | ImageSourcePropType;
+ icon: IconAsset;
/** Translation key for the title */
titleTranslationKey: TranslationPaths;
};
+const holdMenuSections: HoldMenuSection[] = [
+ {
+ icon: Illustrations.Stopwatch,
+ titleTranslationKey: 'iou.holdIsLeftBehind',
+ },
+ {
+ icon: Illustrations.RealtimeReport,
+ titleTranslationKey: 'iou.unholdWhenReady',
+ },
+];
+
function HoldMenuSectionList() {
const {translate} = useLocalize();
const styles = useThemeStyles();
- const holdMenuSections: HoldMenuSection[] = [
- {
- icon: Illustrations.Stopwatch,
- titleTranslationKey: 'iou.holdIsLeftBehind',
- },
- {
- icon: Illustrations.RealtimeReport,
- titleTranslationKey: 'iou.unholdWhenReady',
- },
- ];
-
return (
<>
- {holdMenuSections.map((section, i) => (
+ {holdMenuSections.map((section) => (
{
- const unsub = navigation.addListener('beforeRemove', () => {
- onClose();
- });
- return unsub;
- }, [navigation, onClose]);
+ useBeforeRemove(onClose);
const title = useMemo(
() => (
@@ -50,7 +45,7 @@ function ProcessMoneyRequestHoldMenu({onClose, onConfirm}: ProcessMoneyRequestHo
image={Illustrations.HoldExpense}
contentFitImage="cover"
width={variables.holdEducationModalWidth}
- illustrationAspectRatio={39 / 22}
+ illustrationAspectRatio={CONST.ILLUSTRATION_ASPECT_RATIO}
contentInnerContainerStyles={styles.mb5}
modalInnerContainerStyle={styles.pt0}
illustrationOuterContainerStyle={styles.p0}
diff --git a/src/hooks/useWorkspaceList.ts b/src/hooks/useWorkspaceList.ts
new file mode 100644
index 0000000000000..ed7dbb2e6c826
--- /dev/null
+++ b/src/hooks/useWorkspaceList.ts
@@ -0,0 +1,113 @@
+import {useMemo} from 'react';
+import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
+import * as Expensicons from '@components/Icon/Expensicons';
+import type {ListItem, SectionListDataType} from '@components/SelectionList/types';
+import {isPolicyAdmin, shouldShowPolicy, sortWorkspacesBySelected} from '@libs/PolicyUtils';
+import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils';
+import type {BrickRoad} from '@libs/WorkspacesSettingsUtils';
+import CONST from '@src/CONST';
+import type {Policy} from '@src/types/onyx';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+
+type WorkspaceListItem = {
+ text: string;
+ policyID?: string;
+ isPolicyAdmin?: boolean;
+ brickRoadIndicator?: BrickRoad;
+} & ListItem;
+
+type UseWorkspaceListParams = {
+ policies: OnyxCollection;
+ currentUserLogin: string | undefined;
+ isOffline: boolean;
+ selectedPolicyID: string | undefined;
+ searchTerm: string;
+ additionalFilter?: (policy: OnyxEntry) => boolean;
+} & (
+ | {
+ isWorkspaceSwitcher: true;
+ hasUnreadData: (policyID?: string) => boolean;
+ getIndicatorTypeForPolicy: (policyID?: string) => BrickRoad;
+ }
+ | {
+ isWorkspaceSwitcher?: false | undefined;
+ hasUnreadData?: never;
+ getIndicatorTypeForPolicy?: never;
+ }
+);
+
+function useWorkspaceList({
+ policies,
+ currentUserLogin,
+ selectedPolicyID,
+ searchTerm,
+ isOffline,
+ isWorkspaceSwitcher = false,
+ hasUnreadData,
+ getIndicatorTypeForPolicy,
+ additionalFilter,
+}: UseWorkspaceListParams) {
+ const usersWorkspaces = useMemo(() => {
+ if (!policies || isEmptyObject(policies)) {
+ return [];
+ }
+
+ return Object.values(policies)
+ .filter((policy) => !!policy && shouldShowPolicy(policy, !!isOffline, currentUserLogin) && !policy?.isJoinRequestPending && (additionalFilter ? additionalFilter(policy) : true))
+ .map((policy) => ({
+ text: policy?.name ?? '',
+ policyID: policy?.id,
+ icons: [
+ {
+ source: policy?.avatarURL ? policy.avatarURL : getDefaultWorkspaceAvatar(policy?.name),
+ fallbackIcon: Expensicons.FallbackWorkspaceAvatar,
+ name: policy?.name,
+ type: CONST.ICON_TYPE_WORKSPACE,
+ id: policy?.id,
+ },
+ ],
+ keyForList: policy?.id,
+ isPolicyAdmin: isPolicyAdmin(policy),
+ isSelected: selectedPolicyID === policy?.id,
+ ...(isWorkspaceSwitcher &&
+ hasUnreadData &&
+ getIndicatorTypeForPolicy && {
+ isBold: hasUnreadData(policy?.id),
+ brickRoadIndicator: getIndicatorTypeForPolicy(policy?.id),
+ }),
+ }));
+ }, [policies, isOffline, currentUserLogin, additionalFilter, selectedPolicyID, getIndicatorTypeForPolicy, hasUnreadData, isWorkspaceSwitcher]);
+
+ const filteredAndSortedUserWorkspaces = useMemo(
+ () =>
+ usersWorkspaces
+ .filter((policy) => policy.text?.toLowerCase().includes(searchTerm?.toLowerCase() ?? ''))
+ .sort((policy1, policy2) => sortWorkspacesBySelected({policyID: policy1.policyID, name: policy1.text}, {policyID: policy2.policyID, name: policy2.text}, selectedPolicyID)),
+ [searchTerm, usersWorkspaces, selectedPolicyID],
+ );
+
+ const sections = useMemo(() => {
+ const options: Array> = [
+ {
+ data: filteredAndSortedUserWorkspaces,
+ shouldShow: true,
+ indexOffset: 1,
+ },
+ ];
+ return options;
+ }, [filteredAndSortedUserWorkspaces]);
+
+ const shouldShowNoResultsFoundMessage = filteredAndSortedUserWorkspaces.length === 0 && usersWorkspaces.length;
+ const shouldShowSearchInput = usersWorkspaces.length >= CONST.STANDARD_LIST_ITEM_LIMIT;
+ const shouldShowCreateWorkspace = isWorkspaceSwitcher && usersWorkspaces.length === 0;
+
+ return {
+ sections,
+ shouldShowNoResultsFoundMessage,
+ shouldShowSearchInput,
+ shouldShowCreateWorkspace,
+ };
+}
+
+export default useWorkspaceList;
+export type {WorkspaceListItem};
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 5f16846159026..61918c10bfdba 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -37,7 +37,7 @@ import type {
ChangeOwnerDuplicateSubscriptionParams,
ChangeOwnerHasFailedSettlementsParams,
ChangeOwnerSubscriptionParams,
- ChangePolicyParams,
+ ChangeReportPolicyParams,
ChangeTypeParams,
CharacterLengthLimitParams,
CharacterLimitParams,
@@ -1099,6 +1099,13 @@ const translations = {
whatIsHoldExplain: 'Hold is like hitting “pause” on an expense to ask for more details before approval or payment.',
holdIsLeftBehind: 'Held expenses are left behind even if you approve an entire report.',
unholdWhenReady: "Unhold expenses when you're ready to approve or pay.",
+ changePolicyEducational: {
+ title: 'You moved this report!',
+ description: 'Double-check these items, which tend to change when moving reports to a new workspace.',
+ reCategorize: 'Re-categorize any expenses to comply with workspace rules.',
+ workflows: 'This report may now be subject to a different approval workflow.',
+ },
+ changeWorkspace: 'Change workspace',
set: 'set',
changed: 'changed',
removed: 'removed',
@@ -5159,7 +5166,8 @@ const translations = {
type: {
changeField: ({oldValue, newValue, fieldName}: ChangeFieldParams) => `changed ${fieldName} from ${oldValue} to ${newValue}`,
changeFieldEmpty: ({newValue, fieldName}: ChangeFieldParams) => `changed ${fieldName} to ${newValue}`,
- changePolicy: ({fromPolicy, toPolicy}: ChangePolicyParams) => `changed the workspace to ${toPolicy} (previously ${fromPolicy})`,
+ changeReportPolicy: ({fromPolicyName, toPolicyName}: ChangeReportPolicyParams) =>
+ `changed the workspace to ${toPolicyName}${fromPolicyName ? ` (previously ${fromPolicyName})` : ''}`,
changeType: ({oldType, newType}: ChangeTypeParams) => `changed type from ${oldType} to ${newType}`,
delegateSubmit: ({delegateUser, originalManager}: DelegateSubmitParams) => `sent this report to ${delegateUser} since ${originalManager} is on vacation`,
exportedToCSV: `exported this report to CSV`,
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 481beb87d053b..d49a8a96346b3 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -36,7 +36,7 @@ import type {
ChangeOwnerDuplicateSubscriptionParams,
ChangeOwnerHasFailedSettlementsParams,
ChangeOwnerSubscriptionParams,
- ChangePolicyParams,
+ ChangeReportPolicyParams,
ChangeTypeParams,
CharacterLengthLimitParams,
CharacterLimitParams,
@@ -1096,6 +1096,13 @@ const translations = {
whatIsHoldExplain: 'Retener es como "pausar" un gasto para solicitar más detalles antes de aprobarlo o pagarlo.',
holdIsLeftBehind: 'Si apruebas un informe, los gastos retenidos se quedan fuera de esa aprobación.',
unholdWhenReady: 'Desbloquea los gastos cuando estés listo para aprobarlos o pagarlos.',
+ changePolicyEducational: {
+ title: '¡Has movido este informe!',
+ description: 'Revisa cuidadosamente estos elementos, que tienden a cambiar al trasladar informes a un nuevo espacio de trabajo.',
+ reCategorize: 'Vuelve a categorizar los gastos para cumplir con las reglas del espacio de trabajo.',
+ workflows: 'Este informe ahora puede estar sujeto a un flujo de aprobación diferente.',
+ },
+ changeWorkspace: 'Cambiar espacio de trabajo',
set: 'estableció',
changed: 'cambió',
removed: 'eliminó',
@@ -5214,7 +5221,8 @@ const translations = {
type: {
changeField: ({oldValue, newValue, fieldName}: ChangeFieldParams) => `cambió ${fieldName} de ${oldValue} a ${newValue}`,
changeFieldEmpty: ({newValue, fieldName}: ChangeFieldParams) => `cambió ${fieldName} a ${newValue}`,
- changePolicy: ({fromPolicy, toPolicy}: ChangePolicyParams) => `cambió el espacio de trabajo a ${toPolicy} (previamente ${fromPolicy})`,
+ changeReportPolicy: ({fromPolicyName, toPolicyName}: ChangeReportPolicyParams) =>
+ `cambió el espacio de trabajo a ${toPolicyName}${fromPolicyName ? ` (previamente ${fromPolicyName})` : ''}`,
changeType: ({oldType, newType}: ChangeTypeParams) => `cambió type de ${oldType} a ${newType}`,
delegateSubmit: ({delegateUser, originalManager}: DelegateSubmitParams) => `envié este informe a ${delegateUser} ya que ${originalManager} está de vacaciones`,
exportedToCSV: `exportó este informe a CSV`,
diff --git a/src/languages/params.ts b/src/languages/params.ts
index b8f1f34ff3a0b..d1c6751e123c7 100644
--- a/src/languages/params.ts
+++ b/src/languages/params.ts
@@ -293,7 +293,7 @@ type HeldRequestParams = {comment: string};
type ChangeFieldParams = {oldValue?: string; newValue: string; fieldName: string};
-type ChangePolicyParams = {fromPolicy: string; toPolicy: string};
+type ChangeReportPolicyParams = {fromPolicyName?: string; toPolicyName: string};
type UpdatedPolicyDescriptionParams = {oldDescription: string; newDescription: string};
@@ -829,7 +829,7 @@ export type {
WelcomeToRoomParams,
ZipCodeExampleFormatParams,
ChangeFieldParams,
- ChangePolicyParams,
+ ChangeReportPolicyParams,
ChangeTypeParams,
ExportedToIntegrationParams,
DelegateSubmitParams,
diff --git a/src/libs/API/parameters/ChangeReportPolicyParams.ts b/src/libs/API/parameters/ChangeReportPolicyParams.ts
new file mode 100644
index 0000000000000..d91e3409b699a
--- /dev/null
+++ b/src/libs/API/parameters/ChangeReportPolicyParams.ts
@@ -0,0 +1,7 @@
+type ChangeReportPolicyParams = {
+ reportID: string;
+ policyID: string;
+ reportPreviewReportActionID: string;
+ changePolicyReportActionID: string;
+};
+export default ChangeReportPolicyParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index 049de664f15f3..7ab1ab36f86ec 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -380,3 +380,4 @@ export type {default as GetCorpayOnboardingFieldsParams} from './GetCorpayOnboar
export type {SaveCorpayOnboardingCompanyDetailsParams} from './SaveCorpayOnboardingCompanyDetailsParams';
export type {default as AcceptSpotnanaTermsParams} from './AcceptSpotnanaTermsParams';
export type {default as SaveCorpayOnboardingBeneficialOwnerParams} from './SaveCorpayOnboardingBeneficialOwnerParams';
+export type {default as ChangeReportPolicyParams} from './ChangeReportPolicyParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index 987a1d0ac4f58..c6abb1b881af9 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -462,6 +462,7 @@ const WRITE_COMMANDS = {
RESET_SMS_DELIVERY_FAILURE_STATUS: 'ResetSMSDeliveryFailureStatus',
SAVE_CORPAY_ONBOARDING_COMPANY_DETAILS: 'SaveCorpayOnboardingCompanyDetails',
SAVE_CORPAY_ONBOARDING_BENEFICIAL_OWNER: 'SaveCorpayOnboardingBeneficialOwner',
+ CHANGE_REPORT_POLICY: 'ChangeReportPolicy',
} as const;
type WriteCommand = ValueOf;
@@ -935,6 +936,9 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.JOIN_ACCESSIBLE_POLICY]: Parameters.JoinAccessiblePolicyParams;
// Dismis Product Training
[WRITE_COMMANDS.DISMISS_PRODUCT_TRAINING]: Parameters.DismissProductTrainingParams;
+
+ // Change report policy
+ [WRITE_COMMANDS.CHANGE_REPORT_POLICY]: Parameters.ChangeReportPolicyParams;
};
const READ_COMMANDS = {
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
index 85610928a8632..d6efddaa55c8a 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
@@ -18,6 +18,7 @@ import type {
ProfileNavigatorParamList,
ReferralDetailsNavigatorParamList,
ReimbursementAccountNavigatorParamList,
+ ReportChangeWorkspaceNavigatorParamList,
ReportDescriptionNavigatorParamList,
ReportDetailsNavigatorParamList,
ReportSettingsNavigatorParamList,
@@ -140,6 +141,10 @@ const ReportDetailsModalStackNavigator = createModalStackNavigator require('../../../../pages/home/report/ReportDetailsExportPage').default,
});
+const ReportChangeWorkspaceModalStackNavigator = createModalStackNavigator({
+ [SCREENS.REPORT_CHANGE_WORKSPACE.ROOT]: () => require('../../../../pages/ReportChangeWorkspacePage').default,
+});
+
const ReportSettingsModalStackNavigator = createModalStackNavigator({
[SCREENS.REPORT_SETTINGS.ROOT]: () => require('../../../../pages/settings/Report/ReportSettingsPage').default,
[SCREENS.REPORT_SETTINGS.NAME]: () => require('../../../../pages/settings/Report/NamePage').default,
@@ -668,6 +673,10 @@ const ProcessMoneyRequestHoldStackNavigator = createModalStackNavigator({
[SCREENS.PROCESS_MONEY_REQUEST_HOLD_ROOT]: () => require('../../../../pages/ProcessMoneyRequestHoldPage').default,
});
+const ChangePolicyEducationalStackNavigator = createModalStackNavigator({
+ [SCREENS.CHANGE_POLICY_EDUCATIONAL_ROOT]: () => require('../../../../pages/ChangePolicyEducationalModal').default,
+});
+
const TransactionDuplicateStackNavigator = createModalStackNavigator({
[SCREENS.TRANSACTION_DUPLICATE.REVIEW]: () => require('../../../../pages/TransactionDuplicate/Review').default,
[SCREENS.TRANSACTION_DUPLICATE.MERCHANT]: () => require('../../../../pages/TransactionDuplicate/ReviewMerchant').default,
@@ -743,6 +752,7 @@ export {
NewTeachersUniteNavigator,
PrivateNotesModalStackNavigator,
ProcessMoneyRequestHoldStackNavigator,
+ ChangePolicyEducationalStackNavigator,
ProfileModalStackNavigator,
ReferralModalStackNavigator,
TravelModalStackNavigator,
@@ -750,6 +760,7 @@ export {
NewReportWorkspaceSelectionModalStackNavigator,
ReportDescriptionModalStackNavigator,
ReportDetailsModalStackNavigator,
+ ReportChangeWorkspaceModalStackNavigator,
ReportParticipantsModalStackNavigator,
ReportSettingsModalStackNavigator,
RoomMembersModalStackNavigator,
diff --git a/src/libs/Navigation/AppNavigator/Navigators/FeatureTrainingModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/FeatureTrainingModalNavigator.tsx
index db110d53bc633..2041f7b0e4a56 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/FeatureTrainingModalNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/FeatureTrainingModalNavigator.tsx
@@ -4,6 +4,7 @@ import NoDropZone from '@components/DragAndDrop/NoDropZone';
import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator';
import Animations from '@libs/Navigation/PlatformStackNavigation/navigationOptions/animation';
import type {FeatureTrainingNavigatorParamList} from '@libs/Navigation/types';
+import ChangePolicyEducationalModal from '@pages/ChangePolicyEducationalModal';
import ProcessMoneyRequestHoldPage from '@pages/ProcessMoneyRequestHoldPage';
import TrackTrainingPage from '@pages/TrackTrainingPage';
import SCREENS from '@src/SCREENS';
@@ -23,6 +24,10 @@ function FeatureTrainingModalNavigator() {
name={SCREENS.PROCESS_MONEY_REQUEST_HOLD_ROOT}
component={ProcessMoneyRequestHoldPage}
/>
+
diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
index 9df081d7b554c..d41864904711a 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
@@ -98,6 +98,10 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) {
name={SCREENS.RIGHT_MODAL.REPORT_DETAILS}
component={ModalStackNavigators.ReportDetailsModalStackNavigator}
/>
+
['config'] = {
exact: true,
},
[SCREENS.PROCESS_MONEY_REQUEST_HOLD_ROOT]: ROUTES.PROCESS_MONEY_REQUEST_HOLD.route,
+ [SCREENS.CHANGE_POLICY_EDUCATIONAL_ROOT]: ROUTES.CHANGE_POLICY_EDUCATIONAL.route,
},
},
[NAVIGATORS.WELCOME_VIDEO_MODAL_NAVIGATOR]: {
@@ -1018,6 +1019,11 @@ const config: LinkingOptions['config'] = {
[SCREENS.REPORT_DETAILS.EXPORT]: ROUTES.REPORT_WITH_ID_DETAILS_EXPORT.route,
},
},
+ [SCREENS.RIGHT_MODAL.REPORT_CHANGE_WORKSPACE]: {
+ screens: {
+ [SCREENS.REPORT_CHANGE_WORKSPACE.ROOT]: ROUTES.REPORT_WITH_ID_CHANGE_WORKSPACE.route,
+ },
+ },
[SCREENS.RIGHT_MODAL.REPORT_SETTINGS]: {
screens: {
[SCREENS.REPORT_SETTINGS.ROOT]: {
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 1013212fa009b..10a862f52cd34 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -1065,6 +1065,13 @@ type ReportDetailsNavigatorParamList = {
};
};
+type ReportChangeWorkspaceNavigatorParamList = {
+ [SCREENS.REPORT_CHANGE_WORKSPACE.ROOT]: {
+ reportID: string;
+ backTo?: Routes;
+ };
+};
+
type ReportSettingsNavigatorParamList = {
[SCREENS.REPORT_SETTINGS.ROOT]: {
reportID: string;
@@ -1470,6 +1477,7 @@ type SignInNavigatorParamList = {
type FeatureTrainingNavigatorParamList = {
[SCREENS.FEATURE_TRAINING_ROOT]: undefined;
[SCREENS.PROCESS_MONEY_REQUEST_HOLD_ROOT]: undefined;
+ [SCREENS.CHANGE_POLICY_EDUCATIONAL_ROOT]: undefined;
};
type ReferralDetailsNavigatorParamList = {
@@ -1538,6 +1546,7 @@ type RightModalNavigatorParamList = {
[SCREENS.SETTINGS.SHARE_CODE]: undefined;
[SCREENS.RIGHT_MODAL.NEW_REPORT_WORKSPACE_SELECTION]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.REPORT_DETAILS]: NavigatorScreenParams;
+ [SCREENS.RIGHT_MODAL.REPORT_CHANGE_WORKSPACE]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.REPORT_SETTINGS]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.SETTINGS_CATEGORIES]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.SETTINGS_TAGS]: NavigatorScreenParams;
@@ -1973,6 +1982,7 @@ export type {
NewReportWorkspaceSelectionNavigatorParamList,
ReportDescriptionNavigatorParamList,
ReportDetailsNavigatorParamList,
+ ReportChangeWorkspaceNavigatorParamList,
ReportSettingsNavigatorParamList,
ReportsSplitNavigatorParamList,
RestrictedActionParamList,
diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts
index dc35a2532727d..2c413722e2ebe 100644
--- a/src/libs/PersonalDetailsUtils.ts
+++ b/src/libs/PersonalDetailsUtils.ts
@@ -163,6 +163,16 @@ function getAccountIDsByLogins(logins: string[]): number[] {
}, []);
}
+/**
+ * Given an accountID, find the associated personal detail and return related login.
+ *
+ * @param accountID User accountID
+ * @returns Login according to passed accountID
+ */
+function getLoginByAccountID(accountID: number): string | undefined {
+ return allPersonalDetails?.[accountID]?.login;
+}
+
/**
* Given a list of accountIDs, find the associated personal detail and return related logins.
*
@@ -171,9 +181,9 @@ function getAccountIDsByLogins(logins: string[]): number[] {
*/
function getLoginsByAccountIDs(accountIDs: number[]): string[] {
return accountIDs.reduce((foundLogins: string[], accountID) => {
- const currentDetail: Partial = allPersonalDetails?.[accountID] ?? {};
- if (currentDetail.login) {
- foundLogins.push(currentDetail.login);
+ const currentLogin = getLoginByAccountID(accountID);
+ if (currentLogin) {
+ foundLogins.push(currentLogin);
}
return foundLogins;
}, []);
@@ -405,4 +415,5 @@ export {
getPersonalDetailsLength,
getUserNameByEmail,
getDefaultCountry,
+ getLoginByAccountID,
};
diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts
index af4bd03fe93fd..06e3d6ea6e1b1 100644
--- a/src/libs/PolicyUtils.ts
+++ b/src/libs/PolicyUtils.ts
@@ -38,7 +38,7 @@ import {getCategoryApproverRule} from './CategoryUtils';
import {translateLocal} from './Localize';
import Navigation from './Navigation/Navigation';
import {isOffline as isOfflineNetworkStore} from './Network/NetworkStore';
-import {getAccountIDsByLogins, getLoginsByAccountIDs, getPersonalDetailByEmail} from './PersonalDetailsUtils';
+import {getAccountIDsByLogins, getLoginByAccountID, getLoginsByAccountIDs, getPersonalDetailByEmail} from './PersonalDetailsUtils';
import {getAllSortedTransactions, getCategory, getTag} from './TransactionUtils';
import {isPublicDomain} from './ValidationUtils';
@@ -1087,6 +1087,39 @@ const sortWorkspacesBySelected = (workspace1: WorkspaceDetails, workspace2: Work
return workspace1.name?.toLowerCase().localeCompare(workspace2.name?.toLowerCase() ?? '') ?? 0;
};
+/**
+ * Determines whether the report can be moved to the workspace.
+ */
+const isWorkspaceEligibleForReportChange = (newPolicy: OnyxEntry, report: OnyxEntry, oldPolicy: OnyxEntry, currentUserLogin: string | undefined): boolean => {
+ const currentUserAccountID = getCurrentUserAccountID();
+ const isCurrentUserMember = !!currentUserLogin && !!newPolicy?.employeeList?.[currentUserLogin];
+ if (!isCurrentUserMember) {
+ return false;
+ }
+
+ // Submitters: workspaces where the submitter is a member of
+ const isCurrentUserSubmitter = report?.ownerAccountID === currentUserAccountID;
+ if (isCurrentUserSubmitter) {
+ return true;
+ }
+
+ // Approvers: workspaces where both the approver AND submitter are members of
+ const reportApproverAccountID = getSubmitToAccountID(oldPolicy, report);
+ const isCurrentUserApprover = currentUserAccountID === reportApproverAccountID;
+ if (isCurrentUserApprover) {
+ const reportSubmitterLogin = report?.ownerAccountID ? getLoginByAccountID(report?.ownerAccountID) : undefined;
+ const isReportSubmitterMember = !!reportSubmitterLogin && !!newPolicy?.employeeList?.[reportSubmitterLogin];
+ return isCurrentUserApprover && isReportSubmitterMember;
+ }
+
+ // Admins: same as approvers OR workspaces where the admin is an admin of (note that the submitter is invited to the workspace in this case)
+ if (isPolicyOwner(newPolicy, currentUserAccountID) || isUserPolicyAdmin(newPolicy, currentUserLogin)) {
+ return true;
+ }
+
+ return false;
+};
+
/**
* Takes removes pendingFields and errorFields from a customUnit
*/
@@ -1487,6 +1520,7 @@ export {
getPolicyNameByID,
getMostFrequentEmailDomain,
getDescriptionForPolicyDomainCard,
+ isWorkspaceEligibleForReportChange,
getManagerAccountID,
isPrefferedExporter,
isAutoSyncEnabled,
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index 592cae57daf3d..1eb9e4d8859ed 100644
--- a/src/libs/ReportActionsUtils.ts
+++ b/src/libs/ReportActionsUtils.ts
@@ -30,7 +30,7 @@ import Log from './Log';
import type {MessageElementBase, MessageTextElement} from './MessageElement';
import Parser from './Parser';
import {getEffectiveDisplayName, getPersonalDetailsByIDs} from './PersonalDetailsUtils';
-import {getPolicy, getPolicyNameByID, isPolicyAdmin as isPolicyAdminPolicyUtils} from './PolicyUtils';
+import {getPolicy, isPolicyAdmin as isPolicyAdminPolicyUtils} from './PolicyUtils';
import type {getReportName, OptimisticIOUReportAction, PartialReportAction} from './ReportUtils';
import StringUtils from './StringUtils';
import {isOnHoldByTransactionID} from './TransactionUtils';
@@ -1280,7 +1280,6 @@ function isOldDotReportAction(action: ReportAction | OldDotReportAction) {
}
return [
CONST.REPORT.ACTIONS.TYPE.CHANGE_FIELD,
- CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY,
CONST.REPORT.ACTIONS.TYPE.CHANGE_TYPE,
CONST.REPORT.ACTIONS.TYPE.DELEGATE_SUBMIT,
CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_CSV,
@@ -1336,10 +1335,6 @@ function getMessageOfOldDotReportAction(oldDotAction: PartialReportAction | OldD
}
return translateLocal('report.actions.type.changeField', {oldValue, newValue, fieldName});
}
- case CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY: {
- const {fromPolicy, toPolicy} = originalMessage;
- return translateLocal('report.actions.type.changePolicy', {fromPolicy: getPolicyNameByID(fromPolicy), toPolicy: getPolicyNameByID(toPolicy)});
- }
case CONST.REPORT.ACTIONS.TYPE.DELEGATE_SUBMIT: {
const {delegateUser, originalManager} = originalMessage;
return translateLocal('report.actions.type.delegateSubmit', {delegateUser, originalManager});
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index df13c7728ef29..5ab1c7986cf2a 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -9,7 +9,7 @@ import lodashMaxBy from 'lodash/maxBy';
import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {SvgProps} from 'react-native-svg';
-import type {OriginalMessageIOU, OriginalMessageModifiedExpense} from 'src/types/onyx/OriginalMessage';
+import type {OriginalMessageChangePolicy, OriginalMessageIOU, OriginalMessageModifiedExpense} from 'src/types/onyx/OriginalMessage';
import type {SetRequired, TupleToUnion, ValueOf} from 'type-fest';
import type {FileObject} from '@components/AttachmentModal';
import {FallbackAvatar, IntacctSquare, NetSuiteSquare, NSQSSquare, QBOSquare, XeroSquare} from '@components/Icon/Expensicons';
@@ -91,6 +91,7 @@ import {
getForwardsToAccount,
getManagerAccountEmail,
getPolicyEmployeeListByIdWithoutCurrentUser,
+ getPolicyNameByID,
getRuleApprovers,
getSubmitToAccountID,
isExpensifyTeam,
@@ -5437,6 +5438,16 @@ function getDeletedTransactionMessage(action: ReportAction) {
return message;
}
+function getPolicyChangeMessage(action: ReportAction) {
+ const PolicyChangeOriginalMessage = getOriginalMessage(action as ReportAction) ?? {};
+ const {fromPolicyID, toPolicyID} = PolicyChangeOriginalMessage as OriginalMessageChangePolicy;
+ const message = translateLocal('report.actions.type.changeReportPolicy', {
+ fromPolicyName: fromPolicyID ? getPolicyNameByID(fromPolicyID) : undefined,
+ toPolicyName: getPolicyNameByID(toPolicyID),
+ });
+ return message;
+}
+
/**
* @param iouReportID - the report ID of the IOU report the action belongs to
* @param type - IOUReportAction type. Can be oneOf(create, decline, cancel, pay, split)
@@ -5714,6 +5725,54 @@ function buildOptimisticMovedReportAction(fromPolicyID: string | undefined, toPo
};
}
+/**
+ * Builds an optimistic CHANGEPOLICY report action with a randomly generated reportActionID.
+ * This action is used when we change the workspace of a report.
+ */
+function buildOptimisticChangePolicyReportAction(fromPolicyID: string | undefined, toPolicyID: string): ReportAction {
+ const originalMessage = {
+ fromPolicyID,
+ toPolicyID,
+ };
+
+ const fromPolicy = getPolicy(fromPolicyID);
+ const toPolicy = getPolicy(toPolicyID);
+
+ const changePolicyReportActionMessage = [
+ {
+ type: CONST.REPORT.MESSAGE.TYPE.TEXT,
+ text: `changed the workspace to ${toPolicy?.name}`,
+ },
+ ...(fromPolicyID
+ ? [
+ {
+ type: CONST.REPORT.MESSAGE.TYPE.TEXT,
+ text: ` (previously ${fromPolicy?.name})`,
+ },
+ ]
+ : []),
+ ];
+
+ return {
+ actionName: CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY,
+ actorAccountID: currentUserAccountID,
+ avatar: getCurrentUserAvatar(),
+ created: DateUtils.getDBTime(),
+ originalMessage,
+ message: changePolicyReportActionMessage,
+ person: [
+ {
+ style: 'strong',
+ text: getCurrentUserDisplayNameOrEmail(),
+ type: 'TEXT',
+ },
+ ],
+ reportActionID: rand64(),
+ shouldShow: true,
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ };
+}
+
/**
* Builds an optimistic SUBMITTED report action with a randomly generated reportActionID.
*
@@ -9608,6 +9667,8 @@ export {
isHiddenForCurrentUser,
prepareOnboardingOnyxData,
getReportSubtitlePrefix,
+ buildOptimisticChangePolicyReportAction,
+ getPolicyChangeMessage,
getExpenseReportStateAndStatus,
};
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index 50af6ec3987f6..9d0d092cf312a 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -91,11 +91,13 @@ import type {OptimisticAddCommentReportAction, OptimisticChatReport, OptimisticN
import {
buildOptimisticAddCommentReportAction,
buildOptimisticChangeFieldAction,
+ buildOptimisticChangePolicyReportAction,
buildOptimisticChatReport,
buildOptimisticCreatedReportAction,
buildOptimisticExportIntegrationAction,
buildOptimisticGroupChatReport,
buildOptimisticRenamedRoomReportAction,
+ buildOptimisticReportPreview,
buildOptimisticRoomDescriptionUpdatedReportAction,
buildOptimisticSelfDMReport,
buildOptimisticTaskCommentReportAction,
@@ -128,6 +130,7 @@ import {
getRouteFromLink,
isChatThread as isChatThreadReportUtils,
isConciergeChatReport,
+ isExpenseReport,
isGroupChat as isGroupChatReportUtils,
isHiddenForCurrentUser,
isMoneyRequestReport,
@@ -147,6 +150,7 @@ import type {Route} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
import INPUT_IDS from '@src/types/form/NewRoomForm';
import type {
+ DismissedProductTraining,
IntroSelected,
InvitedEmailsToAccountIDs,
NewGroupChatDraft,
@@ -366,6 +370,12 @@ Onyx.connect({
callback: (value) => (allReportDraftComments = value),
});
+let nvpDismissedProductTraining: OnyxEntry;
+Onyx.connect({
+ key: ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING,
+ callback: (value) => (nvpDismissedProductTraining = value),
+});
+
let environmentURL: string;
Environment.getEnvironmentURL().then((url: string) => (environmentURL = url));
@@ -4852,6 +4862,228 @@ function clearDeleteTransactionNavigateBackUrl() {
Onyx.merge(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL, null);
}
+/**
+ * Dismisses the change report policy educational modal so that it doesn't show up again.
+ */
+function dismissChangePolicyModal() {
+ const date = new Date();
+ const optimisticData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING,
+ value: {
+ [CONST.CHANGE_POLICY_TRAINING_MODAL]: DateUtils.getDBTime(date.valueOf()),
+ },
+ },
+ ];
+ API.write(WRITE_COMMANDS.DISMISS_PRODUCT_TRAINING, {name: CONST.CHANGE_POLICY_TRAINING_MODAL}, {optimisticData});
+}
+
+/**
+ * @private
+ * Builds a map of parentReportID to child report IDs for efficient traversal.
+ */
+function buildReportIDToThreadsReportIDsMap(): Record {
+ const reportIDToThreadsReportIDsMap: Record = {};
+ Object.values(allReports ?? {}).forEach((report) => {
+ if (!report?.parentReportID) {
+ return;
+ }
+ if (!reportIDToThreadsReportIDsMap[report.parentReportID]) {
+ reportIDToThreadsReportIDsMap[report.parentReportID] = [];
+ }
+ reportIDToThreadsReportIDsMap[report.parentReportID].push(report.reportID);
+ });
+ return reportIDToThreadsReportIDsMap;
+}
+
+/**
+ * @private
+ * Recursively updates the policyID for a report and all its child reports.
+ */
+function updatePolicyIdForReportAndThreads(
+ currentReportID: string,
+ policyID: string,
+ reportIDToThreadsReportIDsMap: Record,
+ optimisticData: OnyxUpdate[],
+ failureData: OnyxUpdate[],
+) {
+ const currentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${currentReportID}`];
+ const originalPolicyID = currentReport?.policyID;
+
+ if (originalPolicyID) {
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${currentReportID}`,
+ value: {policyID},
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${currentReportID}`,
+ value: {policyID: originalPolicyID},
+ });
+ }
+
+ // Recursively process child reports for the current report
+ const childReportIDs = reportIDToThreadsReportIDsMap[currentReportID] || [];
+ childReportIDs.forEach((childReportID) => {
+ updatePolicyIdForReportAndThreads(childReportID, policyID, reportIDToThreadsReportIDsMap, optimisticData, failureData);
+ });
+}
+
+/**
+ * Changes the policy of a report and all its child reports, and moves the report to the new policy's workspace chat.
+ */
+function changeReportPolicy(reportID: string, policyID: string) {
+ if (!reportID || !policyID) {
+ return;
+ }
+ const reportToMove = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`];
+ if (!reportToMove || reportToMove?.policyID === policyID || !isExpenseReport(reportToMove)) {
+ return;
+ }
+
+ const optimisticData: OnyxUpdate[] = [];
+ const successData: OnyxUpdate[] = [];
+ const failureData: OnyxUpdate[] = [];
+
+ // 1. Optimistically set the policyID on the report (and all its threads)
+
+ // Preprocess reports to create a map of parentReportID to child reports list of reportIDs
+ const reportIDToThreadsReportIDsMap = buildReportIDToThreadsReportIDsMap();
+
+ // Recursively update the policyID of the report and all its child reports
+ updatePolicyIdForReportAndThreads(reportID, policyID, reportIDToThreadsReportIDsMap, optimisticData, failureData);
+
+ // 2. If the old workspace had a workspace chat, mark the report preview action as deleted
+ if (reportToMove?.parentReportID && reportToMove?.parentReportActionID) {
+ const oldReportPreviewAction = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportToMove?.parentReportID}`]?.[reportToMove?.parentReportActionID];
+ const deletedTime = DateUtils.getDBTime();
+ const firstMessage = Array.isArray(oldReportPreviewAction?.message) ? oldReportPreviewAction.message.at(0) : null;
+ const updatedReportPreviewAction = {
+ ...oldReportPreviewAction,
+ originalMessage: {
+ deleted: deletedTime,
+ },
+ ...(firstMessage && {
+ message: [
+ {
+ ...firstMessage,
+ deleted: deletedTime,
+ },
+ ...(Array.isArray(oldReportPreviewAction?.message) ? oldReportPreviewAction.message.slice(1) : []),
+ ],
+ }),
+ ...(!Array.isArray(oldReportPreviewAction?.message) && {
+ message: {
+ deleted: deletedTime,
+ },
+ }),
+ };
+
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportToMove?.parentReportID}`,
+ value: {[reportToMove?.parentReportActionID]: updatedReportPreviewAction},
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportToMove?.parentReportID}`,
+ value: {[reportToMove?.parentReportActionID]: oldReportPreviewAction},
+ });
+ }
+
+ // 3. Optimistically create a new REPORTPREVIEW reportAction with the newReportPreviewActionID
+ // and set it as a parent of the moved report
+ const policyExpenseChat = getPolicyExpenseChat(currentUserAccountID, policyID);
+ const optimisticReportPreviewAction = buildOptimisticReportPreview(policyExpenseChat, reportToMove);
+
+ if (policyExpenseChat) {
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${policyExpenseChat.reportID}`,
+ value: {[optimisticReportPreviewAction.reportActionID]: optimisticReportPreviewAction},
+ });
+ successData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${policyExpenseChat.reportID}`,
+ value: {
+ [optimisticReportPreviewAction.reportActionID]: {
+ pendingAction: null,
+ },
+ },
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${policyExpenseChat.reportID}`,
+ value: {[optimisticReportPreviewAction.reportActionID]: null},
+ });
+
+ // Set the new report preview action as a parent of the moved report,
+ // and set the parentReportID on the moved report as the workspace chat reportID
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ value: {parentReportActionID: optimisticReportPreviewAction.reportActionID, parentReportID: policyExpenseChat.reportID},
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ value: {parentReportActionID: reportToMove.parentReportActionID, parentReportID: reportToMove.parentReportID},
+ });
+
+ // Set lastVisibleActionCreated
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`,
+ value: {lastVisibleActionCreated: optimisticReportPreviewAction?.created},
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`,
+ value: {lastVisibleActionCreated: policyExpenseChat.lastVisibleActionCreated},
+ });
+ }
+
+ // 4. Optimistically create a CHANGEPOLICY reportAction on the report using the reportActionID
+ const optimisticMovedReportAction = buildOptimisticChangePolicyReportAction(reportToMove.policyID, policyID);
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportToMove.reportID}`,
+ value: {[optimisticMovedReportAction.reportActionID]: optimisticMovedReportAction},
+ });
+ successData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportToMove.reportID}`,
+ value: {
+ [optimisticMovedReportAction.reportActionID]: {
+ ...optimisticMovedReportAction,
+ pendingAction: null,
+ },
+ },
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportToMove.reportID}`,
+ value: {[optimisticMovedReportAction.reportActionID]: null},
+ });
+
+ // Call the ChangeReportPolicy API endpoint
+ const params = {
+ reportID: reportToMove.reportID,
+ policyID,
+ reportPreviewReportActionID: optimisticReportPreviewAction.reportActionID,
+ changePolicyReportActionID: optimisticMovedReportAction.reportActionID,
+ };
+ API.write(WRITE_COMMANDS.CHANGE_REPORT_POLICY, params, {optimisticData, successData, failureData});
+
+ // 5. If the dismissedProductTraining.changeReportModal is not set,
+ // navigate to CHANGE_POLICY_EDUCATIONAL and a backTo param for the report page.
+ if (!nvpDismissedProductTraining?.[CONST.CHANGE_POLICY_TRAINING_MODAL]) {
+ Navigation.navigate(ROUTES.CHANGE_POLICY_EDUCATIONAL.getRoute(ROUTES.REPORT_WITH_ID.getRoute(reportToMove.reportID)));
+ }
+}
+
export type {Video};
export {
@@ -4948,4 +5180,6 @@ export {
updateRoomVisibility,
updateWriteCapability,
prepareOnboardingOnyxData,
+ dismissChangePolicyModal,
+ changeReportPolicy,
};
diff --git a/src/pages/ChangePolicyEducationalModal.tsx b/src/pages/ChangePolicyEducationalModal.tsx
new file mode 100644
index 0000000000000..ecc8d1fd4a760
--- /dev/null
+++ b/src/pages/ChangePolicyEducationalModal.tsx
@@ -0,0 +1,50 @@
+import React, {useCallback} from 'react';
+import ChangeWorkspaceMenuSectionList from '@components/ChangeWorkspaceMenuSectionList';
+import FeatureTrainingModal from '@components/FeatureTrainingModal';
+import * as Illustrations from '@components/Icon/Illustrations';
+import useBeforeRemove from '@hooks/useBeforeRemove';
+import useLocalize from '@hooks/useLocalize';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {dismissChangePolicyModal} from '@libs/actions/Report';
+import colors from '@styles/theme/colors';
+import variables from '@styles/variables';
+import CONST from '@src/CONST';
+
+function ChangePolicyEducationalModal() {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+
+ const onConfirm = useCallback(() => {
+ dismissChangePolicyModal();
+ }, []);
+
+ useBeforeRemove(onConfirm);
+
+ return (
+
+
+
+ );
+}
+
+ChangePolicyEducationalModal.displayName = 'ChangePolicyEducationalModal';
+
+export default ChangePolicyEducationalModal;
diff --git a/src/pages/ReportChangeWorkspacePage.tsx b/src/pages/ReportChangeWorkspacePage.tsx
new file mode 100644
index 0000000000000..d07ed20513a4b
--- /dev/null
+++ b/src/pages/ReportChangeWorkspacePage.tsx
@@ -0,0 +1,95 @@
+import React, {useCallback} from 'react';
+import {useOnyx} from 'react-native-onyx';
+import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import SelectionList from '@components/SelectionList';
+import UserListItem from '@components/SelectionList/UserListItem';
+import useDebouncedState from '@hooks/useDebouncedState';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
+import useThemeStyles from '@hooks/useThemeStyles';
+import type {WorkspaceListItem} from '@hooks/useWorkspaceList';
+import useWorkspaceList from '@hooks/useWorkspaceList';
+import {changeReportPolicy} from '@libs/actions/Report';
+import Navigation from '@libs/Navigation/Navigation';
+import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
+import type {ReportChangeWorkspaceNavigatorParamList} from '@libs/Navigation/types';
+import {isWorkspaceEligibleForReportChange} from '@libs/PolicyUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import type {WithReportOrNotFoundProps} from './home/report/withReportOrNotFound';
+import withReportOrNotFound from './home/report/withReportOrNotFound';
+
+type ReportChangeWorkspacePageProps = WithReportOrNotFoundProps & PlatformStackScreenProps;
+
+function ReportChangeWorkspacePage({report}: ReportChangeWorkspacePageProps) {
+ const reportID = report?.reportID;
+ const {isOffline} = useNetwork();
+ const styles = useThemeStyles();
+ const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState('');
+ const {translate} = useLocalize();
+
+ const [policies, fetchStatus] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
+ const oldPolicy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`];
+ const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.email});
+ const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP);
+ const shouldShowLoadingIndicator = isLoadingApp && !isOffline;
+
+ const selectPolicy = useCallback(
+ (policyID?: string) => {
+ if (!policyID) {
+ return;
+ }
+ Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(reportID));
+ changeReportPolicy(reportID, policyID);
+ },
+ [reportID],
+ );
+
+ const {sections, shouldShowNoResultsFoundMessage, shouldShowSearchInput} = useWorkspaceList({
+ policies,
+ currentUserLogin,
+ isOffline,
+ selectedPolicyID: report.policyID,
+ searchTerm: debouncedSearchTerm,
+ additionalFilter: (newPolicy) => isWorkspaceEligibleForReportChange(newPolicy, report, oldPolicy, currentUserLogin),
+ });
+
+ return (
+
+ {({didScreenTransitionEnd}) => (
+ <>
+
+ {shouldShowLoadingIndicator ? (
+
+ ) : (
+
+ ListItem={UserListItem}
+ sections={sections}
+ onSelectRow={(option) => selectPolicy(option.policyID)}
+ textInputLabel={shouldShowSearchInput ? translate('common.search') : undefined}
+ textInputValue={searchTerm}
+ onChangeText={setSearchTerm}
+ headerMessage={shouldShowNoResultsFoundMessage ? translate('common.noResultsFound') : ''}
+ initiallyFocusedOptionKey={report.policyID}
+ showLoadingPlaceholder={fetchStatus.status === 'loading' || !didScreenTransitionEnd}
+ />
+ )}
+ >
+ )}
+
+ );
+}
+
+ReportChangeWorkspacePage.displayName = 'ReportChangeWorkspacePage';
+
+export default withReportOrNotFound()(ReportChangeWorkspacePage);
diff --git a/src/pages/WorkspaceSwitcherPage/index.tsx b/src/pages/WorkspaceSwitcherPage/index.tsx
index e9dc78b56c1f4..a18a50073cebb 100644
--- a/src/pages/WorkspaceSwitcherPage/index.tsx
+++ b/src/pages/WorkspaceSwitcherPage/index.tsx
@@ -2,34 +2,23 @@ import React, {useCallback, useMemo} from 'react';
import {useOnyx} from 'react-native-onyx';
import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import * as Expensicons from '@components/Icon/Expensicons';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
-import type {ListItem, SectionListDataType} from '@components/SelectionList/types';
import UserListItem from '@components/SelectionList/UserListItem';
import useActiveWorkspace from '@hooks/useActiveWorkspace';
import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
+import type {WorkspaceListItem} from '@hooks/useWorkspaceList';
+import useWorkspaceList from '@hooks/useWorkspaceList';
import Navigation from '@libs/Navigation/Navigation';
-import {isPolicyAdmin, shouldShowPolicy, sortWorkspacesBySelected} from '@libs/PolicyUtils';
-import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils';
import {getWorkspacesBrickRoads, getWorkspacesUnreadStatuses} from '@libs/WorkspacesSettingsUtils';
-import type {BrickRoad} from '@libs/WorkspacesSettingsUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import {isEmptyObject} from '@src/types/utils/EmptyObject';
import switchPolicyAfterInteractions from './switchPolicyAfterInteractions';
import WorkspaceCardCreateAWorkspace from './WorkspaceCardCreateAWorkspace';
-type WorkspaceListItem = {
- text: string;
- policyID?: string;
- isPolicyAdmin?: boolean;
- brickRoadIndicator?: BrickRoad;
-} & ListItem;
-
const WorkspaceCardCreateAWorkspaceInstance = ;
function WorkspaceSwitcherPage() {
@@ -79,6 +68,17 @@ function WorkspaceSwitcherPage() {
[unreadStatusesForPolicies],
);
+ const {sections, shouldShowNoResultsFoundMessage, shouldShowSearchInput, shouldShowCreateWorkspace} = useWorkspaceList({
+ policies,
+ isOffline,
+ currentUserLogin,
+ selectedPolicyID: activeWorkspaceID,
+ searchTerm: debouncedSearchTerm,
+ isWorkspaceSwitcher: true,
+ hasUnreadData,
+ getIndicatorTypeForPolicy,
+ });
+
const selectPolicy = useCallback(
(policyID?: string) => {
const newPolicyID = policyID === activeWorkspaceID ? undefined : policyID;
@@ -91,55 +91,6 @@ function WorkspaceSwitcherPage() {
[activeWorkspaceID],
);
- const usersWorkspaces = useMemo(() => {
- if (!policies || isEmptyObject(policies)) {
- return [];
- }
-
- return Object.values(policies)
- .filter((policy) => shouldShowPolicy(policy, !!isOffline, currentUserLogin) && !policy?.isJoinRequestPending)
- .map((policy) => ({
- text: policy?.name ?? '',
- policyID: policy?.id,
- brickRoadIndicator: getIndicatorTypeForPolicy(policy?.id),
- icons: [
- {
- source: policy?.avatarURL ? policy.avatarURL : getDefaultWorkspaceAvatar(policy?.name),
- fallbackIcon: Expensicons.FallbackWorkspaceAvatar,
- name: policy?.name,
- type: CONST.ICON_TYPE_WORKSPACE,
- id: policy?.id,
- },
- ],
- isBold: hasUnreadData(policy?.id),
- keyForList: policy?.id,
- isPolicyAdmin: isPolicyAdmin(policy),
- isSelected: activeWorkspaceID === policy?.id,
- }));
- }, [policies, isOffline, currentUserLogin, getIndicatorTypeForPolicy, hasUnreadData, activeWorkspaceID]);
-
- const filteredAndSortedUserWorkspaces = useMemo(
- () =>
- usersWorkspaces
- .filter((policy) => policy.text?.toLowerCase().includes(debouncedSearchTerm?.toLowerCase() ?? ''))
- .sort((policy1, policy2) => sortWorkspacesBySelected({policyID: policy1.policyID, name: policy1.text}, {policyID: policy2.policyID, name: policy2.text}, activeWorkspaceID)),
- [debouncedSearchTerm, usersWorkspaces, activeWorkspaceID],
- );
-
- const sections = useMemo(() => {
- const options: Array> = [
- {
- data: filteredAndSortedUserWorkspaces,
- shouldShow: true,
- indexOffset: 1,
- },
- ];
- return options;
- }, [filteredAndSortedUserWorkspaces]);
-
- const headerMessage = filteredAndSortedUserWorkspaces.length === 0 && usersWorkspaces.length ? translate('common.noResultsFound') : '';
- const shouldShowCreateWorkspace = usersWorkspaces.length === 0;
-
return (
selectPolicy(option.policyID)}
- textInputLabel={usersWorkspaces.length >= CONST.STANDARD_LIST_ITEM_LIMIT ? translate('common.search') : undefined}
+ textInputLabel={shouldShowSearchInput ? translate('common.search') : undefined}
textInputValue={searchTerm}
onChangeText={setSearchTerm}
- headerMessage={headerMessage}
+ headerMessage={shouldShowNoResultsFoundMessage ? translate('common.noResultsFound') : ''}
listEmptyContent={WorkspaceCardCreateAWorkspaceInstance}
shouldShowListEmptyContent={shouldShowCreateWorkspace}
initiallyFocusedOptionKey={activeWorkspaceID ?? CONST.WORKSPACE_SWITCHER.NAME}
diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx
index 72e44234a093f..74d4db8b7213c 100644
--- a/src/pages/home/report/PureReportActionItem.tsx
+++ b/src/pages/home/report/PureReportActionItem.tsx
@@ -110,6 +110,7 @@ import {
getIOUForwardedMessage,
getIOUSubmittedMessage,
getIOUUnapprovedMessage,
+ getPolicyChangeMessage,
getReportAutomaticallyApprovedMessage,
getReportAutomaticallySubmittedMessage,
getWhisperDisplayNames,
@@ -873,6 +874,8 @@ function PureReportActionItem({
children = ;
} else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.UNHOLD) {
children = ;
+ } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY) {
+ children = ;
} else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.DELETED_TRANSACTION) {
children = ;
} else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION) {
diff --git a/src/pages/home/report/withReportOrNotFound.tsx b/src/pages/home/report/withReportOrNotFound.tsx
index 55fc168d3d7c0..9c94d2c3f501e 100644
--- a/src/pages/home/report/withReportOrNotFound.tsx
+++ b/src/pages/home/report/withReportOrNotFound.tsx
@@ -5,19 +5,20 @@ import React, {useEffect} from 'react';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import {useOnyx} from 'react-native-onyx';
import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
+import {openReport} from '@libs/actions/Report';
import getComponentDisplayName from '@libs/getComponentDisplayName';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
-import * as ReportUtils from '@libs/ReportUtils';
+import {canAccessReport} from '@libs/ReportUtils';
import type {
ParticipantsNavigatorParamList,
PrivateNotesNavigatorParamList,
+ ReportChangeWorkspaceNavigatorParamList,
ReportDescriptionNavigatorParamList,
ReportDetailsNavigatorParamList,
ReportSettingsNavigatorParamList,
RoomMembersNavigatorParamList,
} from '@navigation/types';
import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
-import * as Report from '@userActions/Report';
import ONYXKEYS from '@src/ONYXKEYS';
import type SCREENS from '@src/SCREENS';
import type * as OnyxTypes from '@src/types/onyx';
@@ -49,7 +50,8 @@ type ScreenProps =
| PlatformStackScreenProps
| PlatformStackScreenProps
| PlatformStackScreenProps
- | PlatformStackScreenProps;
+ | PlatformStackScreenProps
+ | PlatformStackScreenProps;
type WithReportOrNotFoundProps = WithReportOrNotFoundOnyxProps & {
route: ScreenProps['route'];
@@ -82,13 +84,13 @@ export default function (
return;
}
- Report.openReport(props.route.params.reportID);
+ openReport(props.route.params.reportID);
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, [shouldFetchReport, isReportLoaded, props.route.params.reportID]);
if (shouldRequireReportID || isReportIdInRoute) {
const shouldShowFullScreenLoadingIndicator = !isReportLoaded && (isLoadingReportData !== false || shouldFetchReport);
- const shouldShowNotFoundPage = !isReportLoaded || !ReportUtils.canAccessReport(report, policies, betas);
+ const shouldShowNotFoundPage = !isReportLoaded || !canAccessReport(report, policies, betas);
// If the content was shown, but it's not anymore, that means the report was deleted, and we are probably navigating out of this screen.
// Return null for this case to avoid rendering FullScreenLoadingIndicator or NotFoundPage when animating transition.
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index 22b60bd51d2e2..2b39f2320b91f 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -213,6 +213,10 @@ export default {
photoUploadPopoverWidth: 335,
onboardingModalWidth: 500,
holdEducationModalWidth: 400,
+ changePolicyEducationModalWidth: 400,
+ changePolicyEducationModalIconWidth: 147.69,
+ changePolicyEducationModalIconHeight: 180,
+
fontSizeToWidthRatio: getValueUsingPixelRatio(0.8, 1),
// Emoji related variables
diff --git a/src/types/onyx/DismissedProductTraining.ts b/src/types/onyx/DismissedProductTraining.ts
index ae6079b4b632d..283c660be2e11 100644
--- a/src/types/onyx/DismissedProductTraining.ts
+++ b/src/types/onyx/DismissedProductTraining.ts
@@ -76,6 +76,11 @@ type DismissedProductTraining = {
* When user dismisses the test manager on confirmantion page product training tooltip, we store the timestamp here.
*/
[SCAN_TEST_CONFIRMATION]: string;
+
+ /**
+ * When user dismisses the ChangeReportPolicy feature training modal, we store the timestamp here.
+ */
+ [CONST.CHANGE_POLICY_TRAINING_MODAL]: string;
};
export default DismissedProductTraining;
diff --git a/src/types/onyx/OldDotAction.ts b/src/types/onyx/OldDotAction.ts
index 88c3126d0c52e..11cee93ae0a09 100644
--- a/src/types/onyx/OldDotAction.ts
+++ b/src/types/onyx/OldDotAction.ts
@@ -2,7 +2,6 @@ import type {ValueOf} from 'type-fest';
import type CONST from '@src/CONST';
import type {
ChangeFieldParams,
- ChangePolicyParams,
DelegateSubmitParams,
ExportedToIntegrationParams,
IntegrationsMessageParams,
@@ -52,20 +51,6 @@ type OriginalMessageChangeField = {
originalMessage: Record & ChangeFieldParams;
};
-/**
- *
- */
-type OriginalMessageChangePolicy = {
- /**
- *
- */
- actionName: typeof CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY;
- /**
- *
- */
- originalMessage: ChangePolicyParams & Record;
-};
-
// Currently lacking Params
// type OriginalMessageChangeType = {
// /**
@@ -346,10 +331,6 @@ type OldDotOriginalMessageMap = {
*
*/
[CONST.REPORT.ACTIONS.TYPE.CHANGE_FIELD]: OriginalMessageChangeField;
- /**
- *
- */
- [CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY]: OriginalMessageChangePolicy;
/**
*
*/
@@ -449,7 +430,6 @@ export default OldDotAction;
export type {
OriginalMessageChangeField,
OldDotOriginalMessageActionName,
- OriginalMessageChangePolicy,
OriginalMessageDelegateSubmit,
OriginalMessageExportedToCSV,
OriginalMessageExportedToIntegration,
diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts
index 35fad9b57c111..9bab0aed5e70f 100644
--- a/src/types/onyx/OriginalMessage.ts
+++ b/src/types/onyx/OriginalMessage.ts
@@ -544,6 +544,15 @@ type OriginalMessageReimbursementDequeued = {
currency: string;
};
+/** Model of `CHANGEPOLICY` report action */
+type OriginalMessageChangePolicy = {
+ /** ID of the old policy */
+ fromPolicyID: string | undefined;
+
+ /** ID of the new policy */
+ toPolicyID: string;
+};
+
/** Model of `moved` report action */
type OriginalMessageMoved = {
/** ID of the old policy */
@@ -729,7 +738,7 @@ type OriginalMessageMap = {
[CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT]: OriginalMessageAddComment;
[CONST.REPORT.ACTIONS.TYPE.APPROVED]: OriginalMessageApproved;
[CONST.REPORT.ACTIONS.TYPE.CHANGE_FIELD]: never;
- [CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY]: never;
+ [CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY]: OriginalMessageChangePolicy;
[CONST.REPORT.ACTIONS.TYPE.CHANGE_TYPE]: never;
[CONST.REPORT.ACTIONS.TYPE.CHRONOS_OOO_LIST]: OriginalMessageChronosOOOList;
[CONST.REPORT.ACTIONS.TYPE.CLOSED]: OriginalMessageClosed;
@@ -814,4 +823,5 @@ export type {
OriginalMessageModifiedExpense,
OriginalMessageExportIntegration,
IssueNewCardOriginalMessage,
+ OriginalMessageChangePolicy,
};
diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts
index ac386c68a2d6b..f3b277ca5d7f6 100644
--- a/tests/unit/PolicyUtilsTest.ts
+++ b/tests/unit/PolicyUtilsTest.ts
@@ -2,7 +2,16 @@
import Onyx from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import DateUtils from '@libs/DateUtils';
-import {getActivePolicies, getManagerAccountID, getPolicyNameByID, getRateDisplayValue, getSubmitToAccountID, getUnitRateValue, shouldShowPolicy} from '@libs/PolicyUtils';
+import {
+ getActivePolicies,
+ getManagerAccountID,
+ getPolicyNameByID,
+ getRateDisplayValue,
+ getSubmitToAccountID,
+ getUnitRateValue,
+ isWorkspaceEligibleForReportChange,
+ shouldShowPolicy,
+} from '@libs/PolicyUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {PersonalDetailsList, Policy, PolicyEmployeeList, Report, Transaction} from '@src/types/onyx';
@@ -73,9 +82,11 @@ const categoryapprover2AccountID = 4;
const tagapprover1AccountID = 5;
const tagapprover2AccountID = 6;
const ownerAccountID = 7;
+const approverAccountID = 8;
const employeeEmail = 'employee@test.com';
const adminEmail = 'admin@test.com';
const categoryApprover1Email = 'categoryapprover1@test.com';
+const approverEmail = 'approver@test.com';
const personalDetails: PersonalDetailsList = {
'1': {
@@ -106,6 +117,10 @@ const personalDetails: PersonalDetailsList = {
accountID: ownerAccountID,
login: 'owner@test.com',
},
+ '8': {
+ accountID: approverAccountID,
+ login: approverEmail,
+ },
};
const rules = {
@@ -589,4 +604,139 @@ describe('PolicyUtils', () => {
expect(result).toBe(categoryapprover1AccountID);
});
});
+
+ describe('isWorkspaceEligibleForReportChange', () => {
+ beforeEach(() => {
+ wrapOnyxWithWaitForBatchedUpdates(Onyx);
+ Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails);
+ });
+ afterEach(async () => {
+ await Onyx.clear();
+ await waitForBatchedUpdatesWithAct();
+ });
+
+ it('returns false if current user is not a member of the new policy', async () => {
+ const newPolicy = {
+ ...createRandomPolicy(1),
+ employeeList: {},
+ };
+ const report = createRandomReport(0);
+ const oldPolicy = createRandomPolicy(0);
+ const currentUserLogin = 'nonmember@tests.com';
+ await Onyx.set(ONYXKEYS.SESSION, {email: currentUserLogin, accountID: 0});
+
+ const result = isWorkspaceEligibleForReportChange(newPolicy, report, oldPolicy, currentUserLogin);
+ expect(result).toBe(false);
+ });
+
+ it('returns true if current user is the submitter', async () => {
+ const currentUserLogin = employeeEmail;
+ const currentUserAccountID = employeeAccountID;
+ await Onyx.set(ONYXKEYS.SESSION, {email: currentUserLogin, accountID: currentUserAccountID});
+
+ const newPolicy = {
+ ...createRandomPolicy(1),
+ employeeList: {
+ [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.USER},
+ },
+ };
+ const oldPolicy = createRandomPolicy(0);
+ const report = {
+ ...createRandomReport(0),
+ ownerAccountID: currentUserAccountID,
+ };
+
+ const result = isWorkspaceEligibleForReportChange(newPolicy, report, oldPolicy, currentUserLogin);
+ expect(result).toBe(true);
+ });
+
+ it('returns true if current user is a policy admin', async () => {
+ const currentUserLogin = adminEmail;
+ const currentUserAccountID = adminAccountID;
+ await Onyx.set(ONYXKEYS.SESSION, {email: currentUserLogin, accountID: currentUserAccountID});
+
+ const newPolicy = {
+ ...createRandomPolicy(1),
+ employeeList: {
+ [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.ADMIN},
+ },
+ };
+ const oldPolicy = createRandomPolicy(0);
+ const report = createRandomReport(0);
+
+ const result = isWorkspaceEligibleForReportChange(newPolicy, report, oldPolicy, currentUserLogin);
+ expect(result).toBe(true);
+ });
+
+ it('returns true if current user is the policy owner', async () => {
+ const currentUserLogin = 'owner@test.com';
+ const currentUserAccountID = ownerAccountID;
+ await Onyx.set(ONYXKEYS.SESSION, {email: currentUserLogin, accountID: currentUserAccountID});
+
+ const newPolicy = {
+ ...createRandomPolicy(1),
+ ownerAccountID: currentUserAccountID,
+ employeeList: {
+ [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.ADMIN},
+ },
+ };
+ const oldPolicy = createRandomPolicy(0);
+ const report = createRandomReport(0);
+
+ const result = isWorkspaceEligibleForReportChange(newPolicy, report, oldPolicy, currentUserLogin);
+ expect(result).toBe(true);
+ });
+
+ it('returns true if current user is the approver and submitter is a member', async () => {
+ const currentUserLogin = approverEmail;
+ const currentUserAccountID = approverAccountID;
+ await Onyx.set(ONYXKEYS.SESSION, {email: currentUserLogin, accountID: currentUserAccountID});
+
+ const submitterLogin = employeeEmail;
+ const submitterAccountID = employeeAccountID;
+
+ const newPolicy = {
+ ...createRandomPolicy(1),
+ employeeList: {
+ [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.USER},
+ [submitterLogin]: {email: submitterLogin, role: CONST.POLICY.ROLE.USER},
+ },
+ };
+ const oldPolicy = {
+ ...createRandomPolicy(0),
+ employeeList: {
+ [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.USER},
+ [submitterLogin]: {email: submitterLogin, role: CONST.POLICY.ROLE.USER, submitsTo: currentUserLogin},
+ },
+ approver: currentUserLogin,
+ };
+ const report = {
+ ...createRandomReport(0),
+ ownerAccountID: submitterAccountID,
+ };
+
+ const result = isWorkspaceEligibleForReportChange(newPolicy, report, oldPolicy, currentUserLogin);
+ expect(result).toBe(true);
+ });
+
+ it('returns false if current user is approver but submitter not member', async () => {
+ const currentUserLogin = approverEmail;
+ const currentUserAccountID = approverAccountID;
+ await Onyx.set(ONYXKEYS.SESSION, {email: currentUserLogin, accountID: currentUserAccountID});
+
+ const newPolicy = {
+ ...createRandomPolicy(1),
+ employeeList: {
+ [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.USER},
+ },
+ };
+ const report = {
+ ...createRandomReport(0),
+ ownerAccountID: employeeAccountID,
+ };
+ const oldPolicy = createRandomPolicy(0);
+
+ expect(isWorkspaceEligibleForReportChange(newPolicy, report, oldPolicy, currentUserLogin)).toBe(false);
+ });
+ });
});