From 61f49617e333c385131d42e436c22428a8cda27c Mon Sep 17 00:00:00 2001 From: Rayane Djouah <77965000+rayane-djouah@users.noreply.github.com> Date: Thu, 27 Feb 2025 18:21:46 +0100 Subject: [PATCH 01/30] Implement ChangeReportPolicy --- .../emptystate__receiptfairy.svg | 155 ++++++++++++++ src/CONST.ts | 1 + src/ROUTES.ts | 8 + src/SCREENS.ts | 6 + .../ChangePolicyEducationalMenu.tsx | 53 +++++ .../ChangeWorkspaceMenuSectionList.tsx | 63 ++++++ src/components/Icon/Illustrations.ts | 2 + .../ProcessMoneyRequestHoldMenu.tsx | 2 +- src/languages/en.ts | 7 + src/languages/es.ts | 7 + .../parameters/ChangeReportPolicyParams.ts | 7 + src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 4 + .../ModalStackNavigators/index.tsx | 11 + .../FeatureTrainingModalNavigator.tsx | 5 + .../Navigators/RightModalNavigator.tsx | 4 + src/libs/Navigation/linkingConfig/config.ts | 6 + src/libs/Navigation/types.ts | 10 + src/libs/ReportActionsUtils.ts | 5 - src/libs/ReportUtils.ts | 49 +++++ src/libs/actions/Report.ts | 190 ++++++++++++++++++ src/pages/ChangePolicyEducationalModal.tsx | 36 ++++ src/pages/ReportChangeWorkspacePage.tsx | 188 +++++++++++++++++ .../home/report/withReportOrNotFound.tsx | 4 +- src/styles/variables.ts | 1 + src/types/onyx/DismissedProductTraining.ts | 7 + src/types/onyx/OriginalMessage.ts | 12 +- 27 files changed, 836 insertions(+), 8 deletions(-) create mode 100644 assets/images/product-illustrations/emptystate__receiptfairy.svg create mode 100644 src/components/ChangePolicyEducationalMenu.tsx create mode 100644 src/components/ChangeWorkspaceMenuSectionList.tsx create mode 100644 src/libs/API/parameters/ChangeReportPolicyParams.ts create mode 100644 src/pages/ChangePolicyEducationalModal.tsx create mode 100644 src/pages/ReportChangeWorkspacePage.tsx diff --git a/assets/images/product-illustrations/emptystate__receiptfairy.svg b/assets/images/product-illustrations/emptystate__receiptfairy.svg new file mode 100644 index 0000000000000..2c25f7dd11f1e --- /dev/null +++ b/assets/images/product-illustrations/emptystate__receiptfairy.svg @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/CONST.ts b/src/CONST.ts index 29223e598d8cc..3791f20f87f75 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -6686,6 +6686,7 @@ const CONST = { GLOBAL_CREATE_TOOLTIP: 'globalCreateTooltip', SCAN_TEST_TOOLTIP: 'scanTestTooltip', }, + CHANGE_POLICY_TRAINING_MODAL: 'changePolicyModal', SMART_BANNER_HEIGHT: 152, NAVIGATION_TESTS: { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 86504d344c969..00cd0bd16921e 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -396,6 +396,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), @@ -1615,6 +1619,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 5a8b0c75d5c01..bed7cc4b3cbd1 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -164,6 +164,7 @@ const SCREENS = { DETAILS: 'Details', PROFILE: 'Profile', REPORT_DETAILS: 'Report_Details', + REPORT_CHANGE_WORKSPACE: 'ReportChangeWorkspace', WORKSPACE_CONFIRMATION: 'Workspace_Confirmation', REPORT_SETTINGS: 'Report_Settings', REPORT_DESCRIPTION: 'Report_Description', @@ -332,6 +333,10 @@ const SCREENS = { EXPORT: 'Report_Details_Export', }, + REPORT_CHANGE_WORKSPACE: { + ROOT: 'ReportChangeWorkspace_Root', + }, + WORKSPACE_CONFIRMATION: {ROOT: 'Workspace_Confirmation_Root'}, WORKSPACE: { @@ -645,6 +650,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/ChangePolicyEducationalMenu.tsx b/src/components/ChangePolicyEducationalMenu.tsx new file mode 100644 index 0000000000000..20ecbeeffafc7 --- /dev/null +++ b/src/components/ChangePolicyEducationalMenu.tsx @@ -0,0 +1,53 @@ +import {useNavigation} from '@react-navigation/native'; +import React, {useEffect} from 'react'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; +import FeatureTrainingModal from './FeatureTrainingModal'; +import HoldMenuSectionList from './HoldMenuSectionList'; +import * as Illustrations from './Icon/Illustrations'; + + +type ChangePolicyEducationalMenuProps = { + /** Method to trigger when pressing outside of the popover menu to close it */ + onClose: () => void; + + /** Method to trigger when pressing confirm button */ + onConfirm: () => void; +}; + +function ChangePolicyEducationalMenu({onClose, onConfirm}: ChangePolicyEducationalMenuProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const navigation = useNavigation(); + + useEffect(() => { + const unsub = navigation.addListener('beforeRemove', () => { + onClose(); + }); + return unsub; + }, [navigation, onClose]); + + return ( + + + + ); +} + +ChangePolicyEducationalMenu.displayName = 'ChangePolicyEducationalMenu'; + +export default ChangePolicyEducationalMenu; diff --git a/src/components/ChangeWorkspaceMenuSectionList.tsx b/src/components/ChangeWorkspaceMenuSectionList.tsx new file mode 100644 index 0000000000000..846bd61dacb44 --- /dev/null +++ b/src/components/ChangeWorkspaceMenuSectionList.tsx @@ -0,0 +1,63 @@ +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 Icon from './Icon'; +import * as Illustrations from './Icon/Illustrations'; +import Text from './Text'; + +type ChangeWorkspaceMenuSection = { + /** The icon supplied with the section */ + icon: React.FC | ImageSourcePropType; + + /** Translation key for the title */ + titleTranslationKey: TranslationPaths; +}; + +function ChangeWorkspaceMenuSectionList() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const holdMenuSections: ChangeWorkspaceMenuSection[] = [ + { + icon: Illustrations.FolderOpen, + titleTranslationKey: 'iou.changePolicyEducational.reCategorize', + }, + { + icon: Illustrations.Workflows, + titleTranslationKey: 'iou.changePolicyEducational.workflows', + }, + ]; + + return ( + <> + {holdMenuSections.map((section, i) => ( + + + + {translate(section.titleTranslationKey)} + + + ))} + + ); +} + +ChangeWorkspaceMenuSectionList.displayName = 'ChangeWorkspaceMenuSectionList'; + +export type {ChangeWorkspaceMenuSection}; + +export default ChangeWorkspaceMenuSectionList; diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 247958b5b8b2a..aac5875be4863 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -37,6 +37,7 @@ import ConciergeExclamation from '@assets/images/product-illustrations/concierge import CreditCardsBlue from '@assets/images/product-illustrations/credit-cards--blue.svg'; import EmptyStateExpenses from '@assets/images/product-illustrations/emptystate__expenses.svg'; import HoldExpense from '@assets/images/product-illustrations/emptystate__holdexpense.svg'; +import ReceiptFairy from '@assets/images/product-illustrations/emptystate__receiptfairy.svg'; import EmptyStateTravel from '@assets/images/product-illustrations/emptystate__travel.svg'; import FolderWithPapers from '@assets/images/product-illustrations/folder-with-papers.svg'; import GpsTrackOrange from '@assets/images/product-illustrations/gps-track--orange.svg'; @@ -229,6 +230,7 @@ export { QRCode, RealtimeReport, HoldExpense, + ReceiptFairy, ReceiptEnvelope, Approval, WalletAlt, diff --git a/src/components/ProcessMoneyRequestHoldMenu.tsx b/src/components/ProcessMoneyRequestHoldMenu.tsx index 6ace3f5cdd37b..60404819bb02a 100644 --- a/src/components/ProcessMoneyRequestHoldMenu.tsx +++ b/src/components/ProcessMoneyRequestHoldMenu.tsx @@ -49,7 +49,7 @@ function ProcessMoneyRequestHoldMenu({onClose, onConfirm}: ProcessMoneyRequestHo confirmText={translate('common.buttonConfirm')} image={Illustrations.HoldExpense} contentFitImage="cover" - width={variables.holdEducationModalWidth} + width={variables.changePolicyEducationModalWidth} illustrationAspectRatio={39 / 22} contentInnerContainerStyles={styles.mb5} modalInnerContainerStyle={styles.pt0} diff --git a/src/languages/en.ts b/src/languages/en.ts index 73da027938138..7a110e23f3724 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1094,6 +1094,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', diff --git a/src/languages/es.ts b/src/languages/es.ts index 0d54c36a90cf6..9c4e8c5997b42 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1091,6 +1091,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: '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: 'estableció', changed: 'cambió', removed: 'eliminó', 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 38ee3ee710535..d63f32d506302 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -378,3 +378,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 c1a0341c71666..af91ff16ab0e1 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -461,6 +461,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; @@ -933,6 +934,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 91939c86f07fd..4016b548a4c4f 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -18,6 +18,7 @@ import type { ReimbursementAccountNavigatorParamList, ReportDescriptionNavigatorParamList, ReportDetailsNavigatorParamList, + ReportChangeWorkspaceNavigatorParamList, ReportSettingsNavigatorParamList, RoomMembersNavigatorParamList, SearchAdvancedFiltersParamList, @@ -134,6 +135,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, @@ -659,6 +664,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, @@ -734,12 +743,14 @@ export { NewTeachersUniteNavigator, PrivateNotesModalStackNavigator, ProcessMoneyRequestHoldStackNavigator, + ChangePolicyEducationalStackNavigator, ProfileModalStackNavigator, ReferralModalStackNavigator, TravelModalStackNavigator, ReimbursementAccountModalStackNavigator, 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..74e182aacca3e 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/FeatureTrainingModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/FeatureTrainingModalNavigator.tsx @@ -7,6 +7,7 @@ import type {FeatureTrainingNavigatorParamList} from '@libs/Navigation/types'; import ProcessMoneyRequestHoldPage from '@pages/ProcessMoneyRequestHoldPage'; import TrackTrainingPage from '@pages/TrackTrainingPage'; import SCREENS from '@src/SCREENS'; +import ChangePolicyEducationalModal from '@pages/ChangePolicyEducationalModal'; const Stack = createPlatformStackNavigator(); @@ -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 b1d923c69b29c..3b5f96afc0909 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -112,6 +112,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]: { @@ -1011,6 +1012,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 fc73a6db8626a..e776360f851d8 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1029,6 +1029,13 @@ type ReportDetailsNavigatorParamList = { }; }; +type ReportChangeWorkspaceNavigatorParamList = { + [SCREENS.REPORT_CHANGE_WORKSPACE.ROOT]: { + reportID: string; + backTo?: Routes; + }; +}; + type ReportSettingsNavigatorParamList = { [SCREENS.REPORT_SETTINGS.ROOT]: { reportID: string; @@ -1434,6 +1441,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 = { @@ -1501,6 +1509,7 @@ type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.PROFILE]: NavigatorScreenParams; [SCREENS.SETTINGS.SHARE_CODE]: undefined; [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; @@ -1928,6 +1937,7 @@ export type { ReimbursementAccountNavigatorParamList, ReportDescriptionNavigatorParamList, ReportDetailsNavigatorParamList, + ReportChangeWorkspaceNavigatorParamList, ReportSettingsNavigatorParamList, ReportsSplitNavigatorParamList, RestrictedActionParamList, diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index f60a4bb2e668c..52254625bf4ea 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -1302,7 +1302,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, @@ -1358,10 +1357,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, 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 dfa424ba5ef1e..c2320a92fb967 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -5670,6 +5670,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. * @@ -9536,6 +9584,7 @@ export { isHiddenForCurrentUser, prepareOnboardingOnyxData, getReportSubtitlePrefix, + buildOptimisticChangePolicyReportAction, }; export type { diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 63448c89fc8aa..732de93944fc3 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -86,11 +86,13 @@ import type {OptimisticAddCommentReportAction, OptimisticChatReport} from '@libs import { buildOptimisticAddCommentReportAction, buildOptimisticChangeFieldAction, + buildOptimisticChangePolicyReportAction, buildOptimisticChatReport, buildOptimisticCreatedReportAction, buildOptimisticExportIntegrationAction, buildOptimisticGroupChatReport, buildOptimisticRenamedRoomReportAction, + buildOptimisticReportPreview, buildOptimisticRoomDescriptionUpdatedReportAction, buildOptimisticSelfDMReport, buildOptimisticTaskCommentReportAction, @@ -110,6 +112,7 @@ import { getOriginalReportID, getParsedComment, getPendingChatMembers, + getPolicyExpenseChat, getReportFieldKey, getReportIDFromLink, getReportLastMessage, @@ -137,6 +140,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, @@ -354,6 +358,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)); @@ -4667,6 +4677,184 @@ function clearDeleteTransactionNavigateBackUrl() { Onyx.merge(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL, null); } + +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}); +} + + +function changeReportPolicy(reportID: string, policyID: string){ + if (!reportID || !policyID) { + return; + } + const reportToMove = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + if (!reportToMove || reportToMove?.policyID === policyID) { + return; + } + + const optimisticData: OnyxUpdate[] = []; + const successData: OnyxUpdate[] = []; + const failureData: OnyxUpdate[] = []; + + // 1. Optimistically set the policyID on the report (and all its threads) + function processReport(currentReportID: string) { + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${currentReportID}`]; + const originalPolicyID = report?.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}, + }); + } + + // Get child reports IDs for current report + const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${currentReportID}`] ?? {}; + const childReportIDs = Object.values(reportActions) + .filter((action) => action?.childReportID) + .map((action) => action.childReportID?.toString()); + + // Recursively process child reports + childReportIDs.forEach((childReportID) => { + if (!childReportID) { + return; + } + processReport(childReportID); + }); + } + + // Start processing with the initial report + processReport(reportID); + + // 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]; + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: {[reportToMove?.parentReportActionID]: null}, + }); + + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + 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 it as a parent of the moved report + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: {parentReportActionID: optimisticReportPreviewAction.reportActionID}, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: {parentReportActionID: reportToMove.parentReportActionID}, + }); + + // 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}, + }); + + // 5. If the dismissedProductTraining.changeReportModal is not set, + // navigate to CHANGE_POLICY_EDUCATIONAL and a backTo param for the report page. + // Otherwise, return early + + if(nvpDismissedProductTraining?.[CONST.CHANGE_POLICY_TRAINING_MODAL]){ + return; + } + Navigation.navigate(ROUTES.CHANGE_POLICY_EDUCATIONAL.getRoute(ROUTES.REPORT_WITH_ID.getRoute(reportToMove.reportID))); + + // 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}); +} + export type {Video}; export { @@ -4760,4 +4948,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..78e2d952a6f93 --- /dev/null +++ b/src/pages/ChangePolicyEducationalModal.tsx @@ -0,0 +1,36 @@ +import {useFocusEffect} from '@react-navigation/native'; +import React, {useCallback, useRef} from 'react'; +import {InteractionManager} from 'react-native'; +import ChangePolicyEducationalMenu from '@components/ChangePolicyEducationalMenu'; +import blurActiveElement from '@libs/Accessibility/blurActiveElement'; +import {dismissChangePolicyModal} from '@libs/actions/Report'; +import CONST from '@src/CONST'; + +function ChangePolicyEducationalModal() { + const focusTimeoutRef = useRef(null); + useFocusEffect( + useCallback(() => { + focusTimeoutRef.current = setTimeout(() => { + InteractionManager.runAfterInteractions(() => { + blurActiveElement(); + }); + }, CONST.ANIMATED_TRANSITION); + return () => focusTimeoutRef.current && clearTimeout(focusTimeoutRef.current); + }, []), + ); + + const onConfirm = useCallback(() => { + dismissChangePolicyModal(); + }, []); + + 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..b38357c8a2d71 --- /dev/null +++ b/src/pages/ReportChangeWorkspacePage.tsx @@ -0,0 +1,188 @@ +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 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 { changeReportPolicy } from '@libs/actions/Report'; +import type { ReportChangeWorkspaceNavigatorParamList } from '@libs/Navigation/types'; +import type SCREENS from '@src/SCREENS'; +import type { PlatformStackScreenProps } from '@libs/Navigation/PlatformStackNavigation/types'; +import type { WithReportOrNotFoundProps } from './home/report/withReportOrNotFound'; +import withReportOrNotFound from './home/report/withReportOrNotFound'; + +type ReportChangeWorkspacePageProps = WithReportOrNotFoundProps & PlatformStackScreenProps; + +type WorkspaceListItem = { + text: string; + policyID?: string; + isPolicyAdmin?: boolean; + brickRoadIndicator?: BrickRoad; +} & ListItem; + +function ReportChangeWorkspacePage({report}: ReportChangeWorkspacePageProps) { + const reportID = report?.reportID; + const {isOffline} = useNetwork(); + const styles = useThemeStyles(); + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const {translate} = useLocalize(); + const {activeWorkspaceID} = useActiveWorkspace(); + + const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); + const [policies, fetchStatus] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.email}); + const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const brickRoadsForPolicies = useMemo(() => getWorkspacesBrickRoads(reports, policies, reportActions), [reports, policies, reportActions]); + const unreadStatusesForPolicies = useMemo(() => getWorkspacesUnreadStatuses(reports), [reports]); + const shouldShowLoadingIndicator = isLoadingApp && !isOffline; + + const getIndicatorTypeForPolicy = useCallback( + (policyId?: string) => { + if (policyId && policyId !== activeWorkspaceID) { + return brickRoadsForPolicies[policyId]; + } + + if (Object.values(brickRoadsForPolicies).includes(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR)) { + return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; + } + + if (Object.values(brickRoadsForPolicies).includes(CONST.BRICK_ROAD_INDICATOR_STATUS.INFO)) { + return CONST.BRICK_ROAD_INDICATOR_STATUS.INFO; + } + + return undefined; + }, + [activeWorkspaceID, brickRoadsForPolicies], + ); + + const hasUnreadData = useCallback( + // TO DO: Implement checking if policy has some unread data + (policyId?: string) => { + if (policyId) { + return unreadStatusesForPolicies[policyId]; + } + + return Object.values(unreadStatusesForPolicies).some((status) => status); + }, + [unreadStatusesForPolicies], + ); + + const selectPolicy = useCallback( + (policyID?: string) => { + const newPolicyID = policyID === activeWorkspaceID ? undefined : policyID; + + Navigation.goBack(); + // On native platforms, we will see a blank screen if we navigate to a new HomeScreen route while navigating back at the same time. + // Therefore we delay switching the workspace until after back navigation, using the InteractionManager. + changeReportPolicy(reportID, newPolicyID ?? ''); + }, + [activeWorkspaceID, reportID], + ); + + 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 ( + + {({didScreenTransitionEnd}) => ( + <> + + {shouldShowLoadingIndicator ? ( + + ) : ( + + ListItem={UserListItem} + sections={sections} + onSelectRow={(option) => changeReportPolicy(reportID, option.policyID ?? '')} + textInputLabel={usersWorkspaces.length >= CONST.STANDARD_LIST_ITEM_LIMIT ? translate('common.search') : undefined} + textInputValue={searchTerm} + onChangeText={setSearchTerm} + headerMessage={headerMessage} + shouldShowListEmptyContent={shouldShowCreateWorkspace} + initiallyFocusedOptionKey={activeWorkspaceID ?? CONST.WORKSPACE_SWITCHER.NAME} + showLoadingPlaceholder={fetchStatus.status === 'loading' || !didScreenTransitionEnd} + showConfirmButton={!!activeWorkspaceID} + shouldUseDefaultTheme + confirmButtonText={translate('workspace.common.clearFilter')} + onConfirm={() => selectPolicy(undefined)} + /> + )} + + )} + + ); +} + +ReportChangeWorkspacePage.displayName = 'ReportChangeWorkspacePage'; + +export default withReportOrNotFound()(ReportChangeWorkspacePage); diff --git a/src/pages/home/report/withReportOrNotFound.tsx b/src/pages/home/report/withReportOrNotFound.tsx index 55fc168d3d7c0..b361f4e28f1ac 100644 --- a/src/pages/home/report/withReportOrNotFound.tsx +++ b/src/pages/home/report/withReportOrNotFound.tsx @@ -11,6 +11,7 @@ import * as ReportUtils from '@libs/ReportUtils'; import type { ParticipantsNavigatorParamList, PrivateNotesNavigatorParamList, + ReportChangeWorkspaceNavigatorParamList, ReportDescriptionNavigatorParamList, ReportDetailsNavigatorParamList, ReportSettingsNavigatorParamList, @@ -49,7 +50,8 @@ type ScreenProps = | PlatformStackScreenProps | PlatformStackScreenProps | PlatformStackScreenProps - | PlatformStackScreenProps; + | PlatformStackScreenProps + | PlatformStackScreenProps; type WithReportOrNotFoundProps = WithReportOrNotFoundOnyxProps & { route: ScreenProps['route']; diff --git a/src/styles/variables.ts b/src/styles/variables.ts index f087a9a193735..ff986dc40abe6 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -212,6 +212,7 @@ export default { photoUploadPopoverWidth: 335, onboardingModalWidth: 500, holdEducationModalWidth: 400, + changePolicyEducationModalWidth: 400, fontSizeToWidthRatio: getValueUsingPixelRatio(0.8, 1), // Emoji related variables diff --git a/src/types/onyx/DismissedProductTraining.ts b/src/types/onyx/DismissedProductTraining.ts index 0ab9425398bd9..105dd7ba00392 100644 --- a/src/types/onyx/DismissedProductTraining.ts +++ b/src/types/onyx/DismissedProductTraining.ts @@ -64,6 +64,13 @@ type DismissedProductTraining = { * When user dismisses the globalCreateTooltip product training tooltip, we store the timestamp here. */ [SCAN_TEST_TOOLTIP]: 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/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index d123cf646dda9..d67a05c9a45c5 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -541,6 +541,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 */ @@ -726,7 +735,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; @@ -811,4 +820,5 @@ export type { OriginalMessageModifiedExpense, OriginalMessageExportIntegration, IssueNewCardOriginalMessage, + OriginalMessageChangePolicy, }; From 0503fcfd47bdb08f66fb1b813df7dafa6aa642c1 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 28 Feb 2025 18:05:07 +0100 Subject: [PATCH 02/30] Correct ModalStackNavigator name --- .../Navigation/AppNavigator/Navigators/RightModalNavigator.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index 3b5f96afc0909..7632b0fefa008 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -114,7 +114,7 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { /> Date: Fri, 28 Feb 2025 18:20:53 +0100 Subject: [PATCH 03/30] Fix changeReportPolicy action logic --- src/libs/actions/Report.ts | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 732de93944fc3..a8913877dd9ac 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -4707,18 +4707,16 @@ function changeReportPolicy(reportID: string, policyID: string){ const failureData: OnyxUpdate[] = []; // 1. Optimistically set the policyID on the report (and all its threads) - function processReport(currentReportID: string) { + function updatePolicyIdForReportAndThreads(currentReportID: string) { const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${currentReportID}`]; const originalPolicyID = report?.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}`, @@ -4737,25 +4735,24 @@ function changeReportPolicy(reportID: string, policyID: string){ if (!childReportID) { return; } - processReport(childReportID); + updatePolicyIdForReportAndThreads(childReportID); }); } // Start processing with the initial report - processReport(reportID); + updatePolicyIdForReportAndThreads(reportID); // 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]; optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportToMove?.parentReportID}`, value: {[reportToMove?.parentReportActionID]: null}, }); - failureData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportToMove?.parentReportID}`, value: {[reportToMove?.parentReportActionID]: oldReportPreviewAction}, }); } @@ -4838,12 +4835,9 @@ function changeReportPolicy(reportID: string, policyID: string){ // 5. If the dismissedProductTraining.changeReportModal is not set, // navigate to CHANGE_POLICY_EDUCATIONAL and a backTo param for the report page. - // Otherwise, return early - - if(nvpDismissedProductTraining?.[CONST.CHANGE_POLICY_TRAINING_MODAL]){ - return; + if(!nvpDismissedProductTraining?.[CONST.CHANGE_POLICY_TRAINING_MODAL]){ + Navigation.navigate(ROUTES.CHANGE_POLICY_EDUCATIONAL.getRoute(ROUTES.REPORT_WITH_ID.getRoute(reportToMove.reportID)));; } - Navigation.navigate(ROUTES.CHANGE_POLICY_EDUCATIONAL.getRoute(ROUTES.REPORT_WITH_ID.getRoute(reportToMove.reportID))); // Call the ChangeReportPolicy API endpoint const params = { From 5f7bd16c4ea779dbd10f8bf2752af27b880d0e3d Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 28 Feb 2025 18:26:21 +0100 Subject: [PATCH 04/30] add comments to functions --- src/libs/actions/Report.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index a8913877dd9ac..a9cfa49e12b2f 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -4678,6 +4678,9 @@ function clearDeleteTransactionNavigateBackUrl() { } +/** + * Dismisses the change report's policy educational modal so that it doesn't show up again. + */ function dismissChangePolicyModal() { const date = new Date(); const optimisticData = [ @@ -4693,6 +4696,9 @@ function dismissChangePolicyModal() { } +/** + * 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; @@ -4835,7 +4841,7 @@ function changeReportPolicy(reportID: string, policyID: string){ // 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]){ + if (!nvpDismissedProductTraining?.[CONST.CHANGE_POLICY_TRAINING_MODAL]){ Navigation.navigate(ROUTES.CHANGE_POLICY_EDUCATIONAL.getRoute(ROUTES.REPORT_WITH_ID.getRoute(reportToMove.reportID)));; } From 43377ba790b0133fd1cd3fcb531a91815a215ed6 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 28 Feb 2025 18:30:32 +0100 Subject: [PATCH 05/30] revert a change --- src/components/ProcessMoneyRequestHoldMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ProcessMoneyRequestHoldMenu.tsx b/src/components/ProcessMoneyRequestHoldMenu.tsx index 60404819bb02a..6ace3f5cdd37b 100644 --- a/src/components/ProcessMoneyRequestHoldMenu.tsx +++ b/src/components/ProcessMoneyRequestHoldMenu.tsx @@ -49,7 +49,7 @@ function ProcessMoneyRequestHoldMenu({onClose, onConfirm}: ProcessMoneyRequestHo confirmText={translate('common.buttonConfirm')} image={Illustrations.HoldExpense} contentFitImage="cover" - width={variables.changePolicyEducationModalWidth} + width={variables.holdEducationModalWidth} illustrationAspectRatio={39 / 22} contentInnerContainerStyles={styles.mb5} modalInnerContainerStyle={styles.pt0} From cfc5f079038f26f88ae75ec6dc7366d53469b860 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 28 Feb 2025 19:01:48 +0100 Subject: [PATCH 06/30] ReportChangeWorkspacePage --- src/libs/actions/Report.ts | 14 ++--- src/pages/ReportChangeWorkspacePage.tsx | 69 +++---------------------- 2 files changed, 16 insertions(+), 67 deletions(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index a9cfa49e12b2f..d37c3a87983f1 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -4839,12 +4839,6 @@ function changeReportPolicy(reportID: string, policyID: string){ value: {[optimisticMovedReportAction.reportActionID]: null}, }); - // 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)));; - } - // Call the ChangeReportPolicy API endpoint const params = { reportID: reportToMove.reportID, @@ -4853,6 +4847,14 @@ function changeReportPolicy(reportID: string, policyID: string){ 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))); + return; + } + Navigation.goBack(); } export type {Video}; diff --git a/src/pages/ReportChangeWorkspacePage.tsx b/src/pages/ReportChangeWorkspacePage.tsx index b38357c8a2d71..db079a33292b9 100644 --- a/src/pages/ReportChangeWorkspacePage.tsx +++ b/src/pages/ReportChangeWorkspacePage.tsx @@ -7,7 +7,6 @@ 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'; @@ -15,8 +14,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; 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'; @@ -33,7 +30,6 @@ type WorkspaceListItem = { text: string; policyID?: string; isPolicyAdmin?: boolean; - brickRoadIndicator?: BrickRoad; } & ListItem; function ReportChangeWorkspacePage({report}: ReportChangeWorkspacePageProps) { @@ -42,58 +38,17 @@ function ReportChangeWorkspacePage({report}: ReportChangeWorkspacePageProps) { const styles = useThemeStyles(); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const {translate} = useLocalize(); - const {activeWorkspaceID} = useActiveWorkspace(); - const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); - const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); const [policies, fetchStatus] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.email}); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); - const brickRoadsForPolicies = useMemo(() => getWorkspacesBrickRoads(reports, policies, reportActions), [reports, policies, reportActions]); - const unreadStatusesForPolicies = useMemo(() => getWorkspacesUnreadStatuses(reports), [reports]); const shouldShowLoadingIndicator = isLoadingApp && !isOffline; - const getIndicatorTypeForPolicy = useCallback( - (policyId?: string) => { - if (policyId && policyId !== activeWorkspaceID) { - return brickRoadsForPolicies[policyId]; - } - - if (Object.values(brickRoadsForPolicies).includes(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR)) { - return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; - } - - if (Object.values(brickRoadsForPolicies).includes(CONST.BRICK_ROAD_INDICATOR_STATUS.INFO)) { - return CONST.BRICK_ROAD_INDICATOR_STATUS.INFO; - } - - return undefined; - }, - [activeWorkspaceID, brickRoadsForPolicies], - ); - - const hasUnreadData = useCallback( - // TO DO: Implement checking if policy has some unread data - (policyId?: string) => { - if (policyId) { - return unreadStatusesForPolicies[policyId]; - } - - return Object.values(unreadStatusesForPolicies).some((status) => status); - }, - [unreadStatusesForPolicies], - ); - const selectPolicy = useCallback( (policyID?: string) => { - const newPolicyID = policyID === activeWorkspaceID ? undefined : policyID; - - Navigation.goBack(); - // On native platforms, we will see a blank screen if we navigate to a new HomeScreen route while navigating back at the same time. - // Therefore we delay switching the workspace until after back navigation, using the InteractionManager. - changeReportPolicy(reportID, newPolicyID ?? ''); + changeReportPolicy(reportID, policyID ?? ''); }, - [activeWorkspaceID, reportID], + [reportID], ); const usersWorkspaces = useMemo(() => { @@ -106,7 +61,6 @@ function ReportChangeWorkspacePage({report}: ReportChangeWorkspacePageProps) { .map((policy) => ({ text: policy?.name ?? '', policyID: policy?.id, - brickRoadIndicator: getIndicatorTypeForPolicy(policy?.id), icons: [ { source: policy?.avatarURL ? policy.avatarURL : getDefaultWorkspaceAvatar(policy?.name), @@ -116,19 +70,18 @@ function ReportChangeWorkspacePage({report}: ReportChangeWorkspacePageProps) { id: policy?.id, }, ], - isBold: hasUnreadData(policy?.id), keyForList: policy?.id, isPolicyAdmin: isPolicyAdmin(policy), - isSelected: activeWorkspaceID === policy?.id, + isSelected: report.policyID === policy?.id, })); - }, [policies, isOffline, currentUserLogin, getIndicatorTypeForPolicy, hasUnreadData, activeWorkspaceID]); + }, [policies, isOffline, currentUserLogin, report.policyID]); 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], + .sort((policy1, policy2) => sortWorkspacesBySelected({policyID: policy1.policyID, name: policy1.text}, {policyID: policy2.policyID, name: policy2.text}, report.policyID)), + [debouncedSearchTerm, usersWorkspaces, report.policyID], ); const sections = useMemo(() => { @@ -143,7 +96,6 @@ function ReportChangeWorkspacePage({report}: ReportChangeWorkspacePageProps) { }, [filteredAndSortedUserWorkspaces]); const headerMessage = filteredAndSortedUserWorkspaces.length === 0 && usersWorkspaces.length ? translate('common.noResultsFound') : ''; - const shouldShowCreateWorkspace = usersWorkspaces.length === 0; return ( ListItem={UserListItem} sections={sections} - onSelectRow={(option) => changeReportPolicy(reportID, option.policyID ?? '')} + onSelectRow={(option) => selectPolicy(option.policyID ?? '')} textInputLabel={usersWorkspaces.length >= CONST.STANDARD_LIST_ITEM_LIMIT ? translate('common.search') : undefined} textInputValue={searchTerm} onChangeText={setSearchTerm} headerMessage={headerMessage} - shouldShowListEmptyContent={shouldShowCreateWorkspace} - initiallyFocusedOptionKey={activeWorkspaceID ?? CONST.WORKSPACE_SWITCHER.NAME} + initiallyFocusedOptionKey={report.policyID} showLoadingPlaceholder={fetchStatus.status === 'loading' || !didScreenTransitionEnd} - showConfirmButton={!!activeWorkspaceID} - shouldUseDefaultTheme - confirmButtonText={translate('workspace.common.clearFilter')} - onConfirm={() => selectPolicy(undefined)} /> )} From d285d3e0771122aeb93d9f628f24f4c23f1a966a Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 28 Feb 2025 21:00:13 +0100 Subject: [PATCH 07/30] isWorkspaceEligibleForReportChange --- src/libs/PolicyUtils.ts | 24 ++++++++++++++++++++++++ src/pages/ReportChangeWorkspacePage.tsx | 21 +++++++++++++-------- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 92f47b91e8ee3..669d8b31b6114 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -1087,6 +1087,29 @@ const sortWorkspacesBySelected = (workspace1: WorkspaceDetails, workspace2: Work return workspace1.name?.toLowerCase().localeCompare(workspace2.name?.toLowerCase() ?? '') ?? 0; }; +/** + * An eligible workspace is one that meets the following criteria: + * Submitters: workspaces where the submitter is a member of + * Approvers: workspaces where both the approver AND submitter are members of + * 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) + */ +const isWorkspaceEligibleForReportChange = ( + newPolicy: OnyxEntry, + reportOwnerAccountID: number | undefined, + reportManagerID: number | undefined, + currentUserLogin: string | undefined, +): boolean => { + const curretUserAccountID = getCurrentUserAccountID(); + if (curretUserAccountID === reportOwnerAccountID) { + return !!currentUserLogin && !!newPolicy?.employeeList?.[currentUserLogin]; + } + if (curretUserAccountID === reportManagerID) { + const reportSubmitterLogin = (!!reportOwnerAccountID && getLoginsByAccountIDs([reportOwnerAccountID]).at(0)) ?? ''; + return !!currentUserLogin && !!newPolicy?.employeeList?.[currentUserLogin] && !!reportSubmitterLogin && !!newPolicy?.employeeList?.[reportSubmitterLogin]; + } + return isUserPolicyAdmin(newPolicy, currentUserLogin); +}; + /** * Takes removes pendingFields and errorFields from a customUnit */ @@ -1492,6 +1515,7 @@ export { getPolicyNameByID, getMostFrequentEmailDomain, getDescriptionForPolicyDomainCard, + isWorkspaceEligibleForReportChange, }; export type {MemberEmailsToAccountIDs}; diff --git a/src/pages/ReportChangeWorkspacePage.tsx b/src/pages/ReportChangeWorkspacePage.tsx index db079a33292b9..057e6dd62fdc8 100644 --- a/src/pages/ReportChangeWorkspacePage.tsx +++ b/src/pages/ReportChangeWorkspacePage.tsx @@ -11,17 +11,17 @@ import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; +import {changeReportPolicy} from '@libs/actions/Report'; import Navigation from '@libs/Navigation/Navigation'; -import {isPolicyAdmin, shouldShowPolicy, sortWorkspacesBySelected} from '@libs/PolicyUtils'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {ReportChangeWorkspaceNavigatorParamList} from '@libs/Navigation/types'; +import {isPolicyAdmin, isWorkspaceEligibleForReportChange, shouldShowPolicy, sortWorkspacesBySelected} from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import { changeReportPolicy } from '@libs/actions/Report'; -import type { ReportChangeWorkspaceNavigatorParamList } from '@libs/Navigation/types'; import type SCREENS from '@src/SCREENS'; -import type { PlatformStackScreenProps } from '@libs/Navigation/PlatformStackNavigation/types'; -import type { WithReportOrNotFoundProps } from './home/report/withReportOrNotFound'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {WithReportOrNotFoundProps} from './home/report/withReportOrNotFound'; import withReportOrNotFound from './home/report/withReportOrNotFound'; type ReportChangeWorkspacePageProps = WithReportOrNotFoundProps & PlatformStackScreenProps; @@ -57,7 +57,12 @@ function ReportChangeWorkspacePage({report}: ReportChangeWorkspacePageProps) { } return Object.values(policies) - .filter((policy) => shouldShowPolicy(policy, !!isOffline, currentUserLogin) && !policy?.isJoinRequestPending) + .filter( + (policy) => + shouldShowPolicy(policy, !!isOffline, currentUserLogin) && + !policy?.isJoinRequestPending && + isWorkspaceEligibleForReportChange(policy, report?.ownerAccountID, report?.managerID, currentUserLogin), + ) .map((policy) => ({ text: policy?.name ?? '', policyID: policy?.id, @@ -74,7 +79,7 @@ function ReportChangeWorkspacePage({report}: ReportChangeWorkspacePageProps) { isPolicyAdmin: isPolicyAdmin(policy), isSelected: report.policyID === policy?.id, })); - }, [policies, isOffline, currentUserLogin, report.policyID]); + }, [policies, isOffline, currentUserLogin, report.policyID, report?.ownerAccountID, report?.managerID]); const filteredAndSortedUserWorkspaces = useMemo( () => From c80610c8c48683b51b1aa206cce7fa0b2c195cc5 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 28 Feb 2025 21:38:02 +0100 Subject: [PATCH 08/30] action message --- src/languages/en.ts | 2 ++ src/languages/es.ts | 2 ++ src/languages/params.ts | 3 ++ src/libs/ReportUtils.ts | 33 ++++++++++++------- .../home/report/PureReportActionItem.tsx | 3 ++ 5 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 24ae33ad6ed1b..024c82d49ffb5 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -38,6 +38,7 @@ import type { ChangeOwnerHasFailedSettlementsParams, ChangeOwnerSubscriptionParams, ChangePolicyParams, + ChangeReportPolicyParams, ChangeTypeParams, CharacterLengthLimitParams, CharacterLimitParams, @@ -5149,6 +5150,7 @@ const translations = { 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 714f1714da61f..5ab52bd27dd6d 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -37,6 +37,7 @@ import type { ChangeOwnerHasFailedSettlementsParams, ChangeOwnerSubscriptionParams, ChangePolicyParams, + ChangeReportPolicyParams, ChangeTypeParams, CharacterLengthLimitParams, CharacterLimitParams, @@ -5204,6 +5205,7 @@ const translations = { 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) => `changed the workspace to ${toPolicyName}${fromPolicyName ? ` (previously ${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 2b5458fc047c8..dba65e45efbf6 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -293,6 +293,8 @@ 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}; type UpdatedPolicyCurrencyParams = {oldCurrency: string; newCurrency: string}; @@ -828,6 +830,7 @@ export type { ZipCodeExampleFormatParams, ChangeFieldParams, ChangePolicyParams, + ChangeReportPolicyParams, ChangeTypeParams, ExportedToIntegrationParams, DelegateSubmitParams, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 4d0dafa867e41..ecde1af0b7eaa 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, @@ -5394,6 +5395,13 @@ 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: getPolicyNameByID(fromPolicyID ?? ''), 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) @@ -5685,18 +5693,18 @@ function buildOptimisticChangePolicyReportAction(fromPolicyID: string | undefine 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})`, - }, - ] - : []), + }, + ...(fromPolicyID + ? [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + text: `(previously ${fromPolicy?.name})`, + }, + ] + : []), ]; return { @@ -5716,7 +5724,7 @@ function buildOptimisticChangePolicyReportAction(fromPolicyID: string | undefine reportActionID: rand64(), shouldShow: true, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - } + }; } /** @@ -9586,6 +9594,7 @@ export { prepareOnboardingOnyxData, getReportSubtitlePrefix, buildOptimisticChangePolicyReportAction, + getPolicyChangeMessage, }; export type { diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index 8ef1fe4a94426..60741bf5b1156 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, @@ -877,6 +878,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) { From 706c8e23a40ac173fdf2bb0fde20c62c1e90d46c Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Fri, 28 Feb 2025 21:43:46 +0100 Subject: [PATCH 09/30] remove unused translation --- src/languages/en.ts | 1 - src/languages/es.ts | 3 +-- src/languages/params.ts | 3 --- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 024c82d49ffb5..62205024d1c2f 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5149,7 +5149,6 @@ 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`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 5ab52bd27dd6d..52bdab8923dca 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5204,8 +5204,7 @@ 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) => `changed the workspace to ${toPolicyName}${fromPolicyName ? ` (previously ${fromPolicyName}`: ''})`, + 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 dba65e45efbf6..f41273eb6e767 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -291,8 +291,6 @@ 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 +827,6 @@ export type { WelcomeToRoomParams, ZipCodeExampleFormatParams, ChangeFieldParams, - ChangePolicyParams, ChangeReportPolicyParams, ChangeTypeParams, ExportedToIntegrationParams, From cebcba7beb0a1d07c1aa29d0d07fa8a8f785184a Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Sat, 1 Mar 2025 00:37:50 +0100 Subject: [PATCH 10/30] ChangePolicyEducationalModal --- .../emptystate__receiptfairy.svg | 307 +++++++++--------- .../ChangePolicyEducationalMenu.tsx | 18 +- .../ChangeWorkspaceMenuSectionList.tsx | 9 +- src/components/FeatureTrainingModal.tsx | 10 + src/styles/variables.ts | 3 + 5 files changed, 185 insertions(+), 162 deletions(-) diff --git a/assets/images/product-illustrations/emptystate__receiptfairy.svg b/assets/images/product-illustrations/emptystate__receiptfairy.svg index 2c25f7dd11f1e..ccdeda5926f89 100644 --- a/assets/images/product-illustrations/emptystate__receiptfairy.svg +++ b/assets/images/product-illustrations/emptystate__receiptfairy.svg @@ -1,155 +1,154 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/ChangePolicyEducationalMenu.tsx b/src/components/ChangePolicyEducationalMenu.tsx index 20ecbeeffafc7..f4b5900004f97 100644 --- a/src/components/ChangePolicyEducationalMenu.tsx +++ b/src/components/ChangePolicyEducationalMenu.tsx @@ -3,8 +3,11 @@ import React, {useEffect} from 'react'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; +import useStyleUtils from '@hooks/useStyleUtils'; +import colors from '@styles/theme/colors'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import ChangeWorkspaceMenuSectionList from './ChangeWorkspaceMenuSectionList'; import FeatureTrainingModal from './FeatureTrainingModal'; -import HoldMenuSectionList from './HoldMenuSectionList'; import * as Illustrations from './Icon/Illustrations'; @@ -20,6 +23,9 @@ function ChangePolicyEducationalMenu({onClose, onConfirm}: ChangePolicyEducation const {translate} = useLocalize(); const styles = useThemeStyles(); const navigation = useNavigation(); + const StyleUtils = useStyleUtils(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + useEffect(() => { const unsub = navigation.addListener('beforeRemove', () => { @@ -34,16 +40,20 @@ function ChangePolicyEducationalMenu({onClose, onConfirm}: ChangePolicyEducation description={translate('iou.changePolicyEducational.description')} confirmText={translate('common.buttonConfirm')} image={Illustrations.ReceiptFairy} + imageWidth={variables.changePolicyEducationModalIconWidth} + imageHeight={variables.changePolicyEducationModalIconHeight} contentFitImage="cover" width={variables.changePolicyEducationModalWidth} illustrationAspectRatio={39 / 22} - contentInnerContainerStyles={styles.mb5} - modalInnerContainerStyle={styles.pt0} + illustrationInnerContainerStyle={[styles.alignItemsCenter, styles.justifyContentCenter, styles.cardSectionIllustration, StyleUtils.getBackgroundColorStyle(colors.blue700)]} illustrationOuterContainerStyle={styles.p0} + contentInnerContainerStyles={[styles.mb5, styles.gap2]} + contentOuterContainerStyles={!shouldUseNarrowLayout && [styles.mt8, styles.mh8]} + modalInnerContainerStyle={{...styles.pt0, ...(shouldUseNarrowLayout ? {} : styles.pb8)}} onClose={onClose} onConfirm={onConfirm} > - + ); } diff --git a/src/components/ChangeWorkspaceMenuSectionList.tsx b/src/components/ChangeWorkspaceMenuSectionList.tsx index 846bd61dacb44..f2d78b7f4b4d4 100644 --- a/src/components/ChangeWorkspaceMenuSectionList.tsx +++ b/src/components/ChangeWorkspaceMenuSectionList.tsx @@ -4,11 +4,12 @@ import type {ImageSourcePropType} from 'react-native'; import type {SvgProps} from 'react-native-svg'; 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 Icon from './Icon'; import * as Illustrations from './Icon/Illustrations'; -import Text from './Text'; +import RenderHTML from './RenderHTML'; type ChangeWorkspaceMenuSection = { /** The icon supplied with the section */ @@ -22,7 +23,7 @@ function ChangeWorkspaceMenuSectionList() { const {translate} = useLocalize(); const styles = useThemeStyles(); - const holdMenuSections: ChangeWorkspaceMenuSection[] = [ + const changeWorkspaceMenuSections: ChangeWorkspaceMenuSection[] = [ { icon: Illustrations.FolderOpen, titleTranslationKey: 'iou.changePolicyEducational.reCategorize', @@ -35,7 +36,7 @@ function ChangeWorkspaceMenuSectionList() { return ( <> - {holdMenuSections.map((section, i) => ( + {changeWorkspaceMenuSections.map((section, i) => ( - {translate(section.titleTranslationKey)} + ${convertToLTR(translate(section.titleTranslationKey))}`} /> ))} diff --git a/src/components/FeatureTrainingModal.tsx b/src/components/FeatureTrainingModal.tsx index 32c86a0af4767..3bdd3392f702f 100644 --- a/src/components/FeatureTrainingModal.tsx +++ b/src/components/FeatureTrainingModal.tsx @@ -112,6 +112,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 @@ -140,6 +146,8 @@ function FeatureTrainingModal({ contentInnerContainerStyles, contentOuterContainerStyles, modalInnerContainerStyle, + imageWidth, + imageHeight, }: FeatureTrainingModalProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -201,6 +209,8 @@ function FeatureTrainingModal({ )} {!!videoURL && videoStatus === 'video' && ( diff --git a/src/styles/variables.ts b/src/styles/variables.ts index ff986dc40abe6..9f29ac934f131 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -213,6 +213,9 @@ export default { onboardingModalWidth: 500, holdEducationModalWidth: 400, changePolicyEducationModalWidth: 400, + changePolicyEducationModalIconWidth: 147.7, + changePolicyEducationModalIconHeight: 180, + fontSizeToWidthRatio: getValueUsingPixelRatio(0.8, 1), // Emoji related variables From c3292d8928d6b070e079e58f80c592830a939e24 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Sat, 1 Mar 2025 00:39:38 +0100 Subject: [PATCH 11/30] prettier --- src/ROUTES.ts | 4 +-- .../ChangePolicyEducationalMenu.tsx | 8 ++--- src/languages/en.ts | 3 +- src/languages/es.ts | 3 +- .../ModalStackNavigators/index.tsx | 2 +- .../FeatureTrainingModalNavigator.tsx | 2 +- src/libs/actions/Report.ts | 32 ++++++++----------- 7 files changed, 25 insertions(+), 29 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 00cd0bd16921e..6439530f5f259 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -399,7 +399,7 @@ const ROUTES = { 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), @@ -1622,7 +1622,7 @@ const ROUTES = { 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/components/ChangePolicyEducationalMenu.tsx b/src/components/ChangePolicyEducationalMenu.tsx index f4b5900004f97..a54191723426c 100644 --- a/src/components/ChangePolicyEducationalMenu.tsx +++ b/src/components/ChangePolicyEducationalMenu.tsx @@ -1,16 +1,15 @@ import {useNavigation} from '@react-navigation/native'; import React, {useEffect} from 'react'; import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import variables from '@styles/variables'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; import colors from '@styles/theme/colors'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import variables from '@styles/variables'; import ChangeWorkspaceMenuSectionList from './ChangeWorkspaceMenuSectionList'; import FeatureTrainingModal from './FeatureTrainingModal'; import * as Illustrations from './Icon/Illustrations'; - type ChangePolicyEducationalMenuProps = { /** Method to trigger when pressing outside of the popover menu to close it */ onClose: () => void; @@ -25,7 +24,6 @@ function ChangePolicyEducationalMenu({onClose, onConfirm}: ChangePolicyEducation const navigation = useNavigation(); const StyleUtils = useStyleUtils(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - useEffect(() => { const unsub = navigation.addListener('beforeRemove', () => { diff --git a/src/languages/en.ts b/src/languages/en.ts index 62205024d1c2f..962b18d0fef7a 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5149,7 +5149,8 @@ const translations = { type: { changeField: ({oldValue, newValue, fieldName}: ChangeFieldParams) => `changed ${fieldName} from ${oldValue} to ${newValue}`, changeFieldEmpty: ({newValue, fieldName}: ChangeFieldParams) => `changed ${fieldName} to ${newValue}`, - changeReportPolicy: ({fromPolicyName, toPolicyName}: ChangeReportPolicyParams) => `changed the workspace to ${toPolicyName}${fromPolicyName ? ` (previously ${fromPolicyName}`: ''})`, + 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 52bdab8923dca..4d494f01ef73e 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5204,7 +5204,8 @@ const translations = { type: { changeField: ({oldValue, newValue, fieldName}: ChangeFieldParams) => `cambió ${fieldName} de ${oldValue} a ${newValue}`, changeFieldEmpty: ({newValue, fieldName}: ChangeFieldParams) => `cambió ${fieldName} a ${newValue}`, - changeReportPolicy: ({fromPolicyName, toPolicyName}: ChangeReportPolicyParams) => `cambió el espacio de trabajo a ${toPolicyName}${fromPolicyName ? ` (previamente ${fromPolicyName}`: ''})`, + 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/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 4016b548a4c4f..779a022fbd7f6 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -16,9 +16,9 @@ import type { ProfileNavigatorParamList, ReferralDetailsNavigatorParamList, ReimbursementAccountNavigatorParamList, + ReportChangeWorkspaceNavigatorParamList, ReportDescriptionNavigatorParamList, ReportDetailsNavigatorParamList, - ReportChangeWorkspaceNavigatorParamList, ReportSettingsNavigatorParamList, RoomMembersNavigatorParamList, SearchAdvancedFiltersParamList, diff --git a/src/libs/Navigation/AppNavigator/Navigators/FeatureTrainingModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/FeatureTrainingModalNavigator.tsx index 74e182aacca3e..2041f7b0e4a56 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/FeatureTrainingModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/FeatureTrainingModalNavigator.tsx @@ -4,10 +4,10 @@ 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'; -import ChangePolicyEducationalModal from '@pages/ChangePolicyEducationalModal'; const Stack = createPlatformStackNavigator(); diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index d37c3a87983f1..073689852486c 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -4677,7 +4677,6 @@ function clearDeleteTransactionNavigateBackUrl() { Onyx.merge(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL, null); } - /** * Dismisses the change report's policy educational modal so that it doesn't show up again. */ @@ -4695,11 +4694,10 @@ function dismissChangePolicyModal() { API.write(WRITE_COMMANDS.DISMISS_PRODUCT_TRAINING, {name: CONST.CHANGE_POLICY_TRAINING_MODAL}, {optimisticData}); } - /** * 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){ +function changeReportPolicy(reportID: string, policyID: string) { if (!reportID || !policyID) { return; } @@ -4768,29 +4766,27 @@ function changeReportPolicy(reportID: string, policyID: string){ const policyExpenseChat = getPolicyExpenseChat(currentUserAccountID, policyID); const optimisticReportPreviewAction = buildOptimisticReportPreview(policyExpenseChat, reportToMove); - if (policyExpenseChat){ + 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, - }, + 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 it as a parent of the moved report optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -4801,7 +4797,7 @@ function changeReportPolicy(reportID: string, policyID: string){ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: {parentReportActionID: reportToMove.parentReportActionID}, - }); + }); // Set lastVisibleActionCreated optimisticData.push({ @@ -4848,14 +4844,14 @@ function changeReportPolicy(reportID: string, policyID: string){ }; API.write(WRITE_COMMANDS.CHANGE_REPORT_POLICY, params, {optimisticData, successData, failureData}); - // 5. If the dismissedProductTraining.changeReportModal is not set, + // 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]){ + if (!nvpDismissedProductTraining?.[CONST.CHANGE_POLICY_TRAINING_MODAL]) { Navigation.navigate(ROUTES.CHANGE_POLICY_EDUCATIONAL.getRoute(ROUTES.REPORT_WITH_ID.getRoute(reportToMove.reportID))); return; } Navigation.goBack(); -} +} export type {Video}; From c1e1bbe2b3352a7a47972a75605dfa7721290286 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Sat, 1 Mar 2025 00:43:07 +0100 Subject: [PATCH 12/30] fix Eslint errors --- src/languages/en.ts | 1 - src/languages/es.ts | 1 - src/libs/ReportActionsUtils.ts | 2 +- src/types/onyx/OldDotAction.ts | 20 -------------------- 4 files changed, 1 insertion(+), 23 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 962b18d0fef7a..7be159eefb89b 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -37,7 +37,6 @@ import type { ChangeOwnerDuplicateSubscriptionParams, ChangeOwnerHasFailedSettlementsParams, ChangeOwnerSubscriptionParams, - ChangePolicyParams, ChangeReportPolicyParams, ChangeTypeParams, CharacterLengthLimitParams, diff --git a/src/languages/es.ts b/src/languages/es.ts index 4d494f01ef73e..80d43f485d0bc 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -36,7 +36,6 @@ import type { ChangeOwnerDuplicateSubscriptionParams, ChangeOwnerHasFailedSettlementsParams, ChangeOwnerSubscriptionParams, - ChangePolicyParams, ChangeReportPolicyParams, ChangeTypeParams, CharacterLengthLimitParams, diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index d28df923a1910..52254625bf4ea 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -28,7 +28,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'; 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, From 4d553ccffc7f2b01a6da83dae20df880daab5726 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Sat, 1 Mar 2025 00:54:35 +0100 Subject: [PATCH 13/30] fix lint errors --- src/components/FeatureTrainingModal.tsx | 2 ++ src/libs/ReportUtils.ts | 5 ++++- src/pages/ReportChangeWorkspacePage.tsx | 7 +++++-- src/pages/home/report/withReportOrNotFound.tsx | 8 ++++---- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/components/FeatureTrainingModal.tsx b/src/components/FeatureTrainingModal.tsx index 3bdd3392f702f..39f6122babd3a 100644 --- a/src/components/FeatureTrainingModal.tsx +++ b/src/components/FeatureTrainingModal.tsx @@ -241,6 +241,8 @@ function FeatureTrainingModal({ ); }, [ image, + imageHeight, + imageWidth, contentFitImage, illustrationAspectRatio, styles.w100, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index ecde1af0b7eaa..12e4ae5b1f770 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -5398,7 +5398,10 @@ function getDeletedTransactionMessage(action: ReportAction) { function getPolicyChangeMessage(action: ReportAction) { const PolicyChangeOriginalMessage = getOriginalMessage(action as ReportAction) ?? {}; const {fromPolicyID, toPolicyID} = PolicyChangeOriginalMessage as OriginalMessageChangePolicy; - const message = translateLocal('report.actions.type.changeReportPolicy', {fromPolicyName: getPolicyNameByID(fromPolicyID ?? ''), toPolicyName: getPolicyNameByID(toPolicyID)}); + const message = translateLocal('report.actions.type.changeReportPolicy', { + fromPolicyName: !!fromPolicyID ? getPolicyNameByID(fromPolicyID) : undefined, + toPolicyName: getPolicyNameByID(toPolicyID), + }); return message; } diff --git a/src/pages/ReportChangeWorkspacePage.tsx b/src/pages/ReportChangeWorkspacePage.tsx index 057e6dd62fdc8..53b3eec495e9a 100644 --- a/src/pages/ReportChangeWorkspacePage.tsx +++ b/src/pages/ReportChangeWorkspacePage.tsx @@ -46,7 +46,10 @@ function ReportChangeWorkspacePage({report}: ReportChangeWorkspacePageProps) { const selectPolicy = useCallback( (policyID?: string) => { - changeReportPolicy(reportID, policyID ?? ''); + if (!policyID) { + return; + } + changeReportPolicy(reportID, policyID); }, [reportID], ); @@ -120,7 +123,7 @@ function ReportChangeWorkspacePage({report}: ReportChangeWorkspacePageProps) { ListItem={UserListItem} sections={sections} - onSelectRow={(option) => selectPolicy(option.policyID ?? '')} + onSelectRow={(option) => selectPolicy(option.policyID)} textInputLabel={usersWorkspaces.length >= CONST.STANDARD_LIST_ITEM_LIMIT ? translate('common.search') : undefined} textInputValue={searchTerm} onChangeText={setSearchTerm} diff --git a/src/pages/home/report/withReportOrNotFound.tsx b/src/pages/home/report/withReportOrNotFound.tsx index b361f4e28f1ac..9c94d2c3f501e 100644 --- a/src/pages/home/report/withReportOrNotFound.tsx +++ b/src/pages/home/report/withReportOrNotFound.tsx @@ -5,9 +5,10 @@ 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, @@ -18,7 +19,6 @@ import type { 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'; @@ -84,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. From 78205adb8eeadd412fdf39cd0f4e91ce8b33e4c7 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Sat, 1 Mar 2025 00:58:53 +0100 Subject: [PATCH 14/30] fix lint error --- src/libs/ReportUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 12e4ae5b1f770..83ea14ced0920 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -5399,7 +5399,7 @@ 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, + fromPolicyName: fromPolicyID ? getPolicyNameByID(fromPolicyID) : undefined, toPolicyName: getPolicyNameByID(toPolicyID), }); return message; From d8c33117e3306d895fcd6e85cea676b92fade7ef Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Sat, 1 Mar 2025 01:24:38 +0100 Subject: [PATCH 15/30] update changeReportPolicy logic --- src/libs/actions/Report.ts | 9 ++++----- src/pages/ReportChangeWorkspacePage.tsx | 2 ++ 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 073689852486c..012487a53fb95 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -4787,16 +4787,17 @@ function changeReportPolicy(reportID: string, policyID: string) { value: {[optimisticReportPreviewAction.reportActionID]: null}, }); - // Set the new report preview action it as a parent of the moved report + // Set the new report preview action it 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}, + value: {parentReportActionID: optimisticReportPreviewAction.reportActionID, parentReportID: policyExpenseChat.reportID}, }); failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - value: {parentReportActionID: reportToMove.parentReportActionID}, + value: {parentReportActionID: reportToMove.parentReportActionID, parentReportID: reportToMove.parentReportID}, }); // Set lastVisibleActionCreated @@ -4848,9 +4849,7 @@ function changeReportPolicy(reportID: string, policyID: string) { // 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))); - return; } - Navigation.goBack(); } export type {Video}; diff --git a/src/pages/ReportChangeWorkspacePage.tsx b/src/pages/ReportChangeWorkspacePage.tsx index 53b3eec495e9a..28d035be83d93 100644 --- a/src/pages/ReportChangeWorkspacePage.tsx +++ b/src/pages/ReportChangeWorkspacePage.tsx @@ -19,6 +19,7 @@ import {isPolicyAdmin, isWorkspaceEligibleForReportChange, shouldShowPolicy, sor import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {WithReportOrNotFoundProps} from './home/report/withReportOrNotFound'; @@ -49,6 +50,7 @@ function ReportChangeWorkspacePage({report}: ReportChangeWorkspacePageProps) { if (!policyID) { return; } + Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(reportID)); changeReportPolicy(reportID, policyID); }, [reportID], From 26bc7f764414e8ac180bc4b4c77f742fed6d87ba Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Tue, 4 Mar 2025 17:07:14 +0100 Subject: [PATCH 16/30] address review comments --- .../ChangePolicyEducationalMenu.tsx | 15 +-- src/languages/en.ts | 2 +- src/languages/es.ts | 10 +- src/libs/PersonalDetailsUtils.ts | 17 ++- src/libs/PolicyUtils.ts | 4 +- src/libs/actions/Report.ts | 118 ++++++++++++------ src/pages/ChangePolicyEducationalModal.tsx | 7 +- src/styles/variables.ts | 2 +- 8 files changed, 112 insertions(+), 63 deletions(-) diff --git a/src/components/ChangePolicyEducationalMenu.tsx b/src/components/ChangePolicyEducationalMenu.tsx index a54191723426c..389d8d31b0c9d 100644 --- a/src/components/ChangePolicyEducationalMenu.tsx +++ b/src/components/ChangePolicyEducationalMenu.tsx @@ -11,14 +11,11 @@ import FeatureTrainingModal from './FeatureTrainingModal'; import * as Illustrations from './Icon/Illustrations'; type ChangePolicyEducationalMenuProps = { - /** Method to trigger when pressing outside of the popover menu to close it */ - onClose: () => void; - - /** Method to trigger when pressing confirm button */ + /** Method to trigger when pressing confirm button or outside of the popover menu to close it */ onConfirm: () => void; }; -function ChangePolicyEducationalMenu({onClose, onConfirm}: ChangePolicyEducationalMenuProps) { +function ChangePolicyEducationalMenu({onConfirm}: ChangePolicyEducationalMenuProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const navigation = useNavigation(); @@ -27,10 +24,10 @@ function ChangePolicyEducationalMenu({onClose, onConfirm}: ChangePolicyEducation useEffect(() => { const unsub = navigation.addListener('beforeRemove', () => { - onClose(); + onConfirm(); }); return unsub; - }, [navigation, onClose]); + }, [navigation, onConfirm]); return ( diff --git a/src/languages/en.ts b/src/languages/en.ts index 7be159eefb89b..aeaa76bf2d29f 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1099,7 +1099,7 @@ const translations = { 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.', + workflows: 'This report may now be subject to a different approval workflow.', }, changeWorkspace: 'Change workspace', set: 'set', diff --git a/src/languages/es.ts b/src/languages/es.ts index 80d43f485d0bc..738237bebc602 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1093,12 +1093,12 @@ const translations = { 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: '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.', + 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: 'Change workspace', + changeWorkspace: 'Cambiar espacio de trabajo', set: 'estableció', changed: 'cambió', removed: 'eliminó', 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 669d8b31b6114..ea91ded0aeb82 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'; @@ -1104,7 +1104,7 @@ const isWorkspaceEligibleForReportChange = ( return !!currentUserLogin && !!newPolicy?.employeeList?.[currentUserLogin]; } if (curretUserAccountID === reportManagerID) { - const reportSubmitterLogin = (!!reportOwnerAccountID && getLoginsByAccountIDs([reportOwnerAccountID]).at(0)) ?? ''; + const reportSubmitterLogin = (!!reportOwnerAccountID && getLoginByAccountID(reportOwnerAccountID)) ?? ''; return !!currentUserLogin && !!newPolicy?.employeeList?.[currentUserLogin] && !!reportSubmitterLogin && !!newPolicy?.employeeList?.[reportSubmitterLogin]; } return isUserPolicyAdmin(newPolicy, currentUserLogin); diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 9a14398fc511a..1b476f9576328 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -122,6 +122,7 @@ import { getRouteFromLink, isChatThread as isChatThreadReportUtils, isConciergeChatReport, + isExpenseReport, isGroupChat as isGroupChatReportUtils, isHiddenForCurrentUser, isMoneyRequestReport, @@ -4683,6 +4684,58 @@ function dismissChangePolicyModal() { 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. */ @@ -4691,7 +4744,7 @@ function changeReportPolicy(reportID: string, policyID: string) { return; } const reportToMove = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; - if (!reportToMove || reportToMove?.policyID === policyID) { + if (!reportToMove || reportToMove?.policyID === policyID || !isExpenseReport(reportToMove)) { return; } @@ -4700,48 +4753,43 @@ function changeReportPolicy(reportID: string, policyID: string) { const failureData: OnyxUpdate[] = []; // 1. Optimistically set the policyID on the report (and all its threads) - function updatePolicyIdForReportAndThreads(currentReportID: string) { - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${currentReportID}`]; - const originalPolicyID = report?.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}, - }); - } + // Preprocess reports to create a map of parentReportID to child reports list of reportIDs + const reportIDToThreadsReportIDsMap = buildReportIDToThreadsReportIDsMap(); - // Get child reports IDs for current report - const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${currentReportID}`] ?? {}; - const childReportIDs = Object.values(reportActions) - .filter((action) => action?.childReportID) - .map((action) => action.childReportID?.toString()); - - // Recursively process child reports - childReportIDs.forEach((childReportID) => { - if (!childReportID) { - return; - } - updatePolicyIdForReportAndThreads(childReportID); - }); - } - - // Start processing with the initial report - updatePolicyIdForReportAndThreads(reportID); + // 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]: null}, + value: {[reportToMove?.parentReportActionID]: updatedReportPreviewAction}, }); failureData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -4776,7 +4824,7 @@ function changeReportPolicy(reportID: string, policyID: string) { value: {[optimisticReportPreviewAction.reportActionID]: null}, }); - // Set the new report preview action it as a parent of the moved report, + // 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, diff --git a/src/pages/ChangePolicyEducationalModal.tsx b/src/pages/ChangePolicyEducationalModal.tsx index 78e2d952a6f93..ec8225e85741b 100644 --- a/src/pages/ChangePolicyEducationalModal.tsx +++ b/src/pages/ChangePolicyEducationalModal.tsx @@ -23,12 +23,7 @@ function ChangePolicyEducationalModal() { dismissChangePolicyModal(); }, []); - return ( - - ); + return ; } ChangePolicyEducationalModal.displayName = 'ChangePolicyEducationalModal'; diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 9f29ac934f131..84ccc48a23a62 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -213,7 +213,7 @@ export default { onboardingModalWidth: 500, holdEducationModalWidth: 400, changePolicyEducationModalWidth: 400, - changePolicyEducationModalIconWidth: 147.7, + changePolicyEducationModalIconWidth: 147.69, changePolicyEducationModalIconHeight: 180, fontSizeToWidthRatio: getValueUsingPixelRatio(0.8, 1), From 7dc8a2e23c91ec8190b26fe2016b7dbe2a78b059 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Tue, 4 Mar 2025 17:16:16 +0100 Subject: [PATCH 17/30] lint --- src/components/ChangePolicyEducationalMenu.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/ChangePolicyEducationalMenu.tsx b/src/components/ChangePolicyEducationalMenu.tsx index 389d8d31b0c9d..ff840f00c5333 100644 --- a/src/components/ChangePolicyEducationalMenu.tsx +++ b/src/components/ChangePolicyEducationalMenu.tsx @@ -1,7 +1,6 @@ import {useNavigation} from '@react-navigation/native'; import React, {useEffect} from 'react'; import useLocalize from '@hooks/useLocalize'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import colors from '@styles/theme/colors'; @@ -20,7 +19,6 @@ function ChangePolicyEducationalMenu({onConfirm}: ChangePolicyEducationalMenuPro const styles = useThemeStyles(); const navigation = useNavigation(); const StyleUtils = useStyleUtils(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); useEffect(() => { const unsub = navigation.addListener('beforeRemove', () => { From 2ac603a22d6ef57010beca47207fa71f38e1e468 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Tue, 4 Mar 2025 20:11:46 +0100 Subject: [PATCH 18/30] correct formatting in changeReportPolicy translation --- src/languages/en.ts | 2 +- src/languages/es.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 3eafadc47cf2c..bbad1ee2fc699 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5158,7 +5158,7 @@ const translations = { changeField: ({oldValue, newValue, fieldName}: ChangeFieldParams) => `changed ${fieldName} from ${oldValue} to ${newValue}`, changeFieldEmpty: ({newValue, fieldName}: ChangeFieldParams) => `changed ${fieldName} to ${newValue}`, changeReportPolicy: ({fromPolicyName, toPolicyName}: ChangeReportPolicyParams) => - `changed the workspace to ${toPolicyName}${fromPolicyName ? ` (previously ${fromPolicyName}` : ''})`, + `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 148fea36e7a27..c0b8d51d82ac4 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5213,7 +5213,7 @@ const translations = { changeField: ({oldValue, newValue, fieldName}: ChangeFieldParams) => `cambió ${fieldName} de ${oldValue} a ${newValue}`, changeFieldEmpty: ({newValue, fieldName}: ChangeFieldParams) => `cambió ${fieldName} a ${newValue}`, changeReportPolicy: ({fromPolicyName, toPolicyName}: ChangeReportPolicyParams) => - `cambió el espacio de trabajo a ${toPolicyName}${fromPolicyName ? ` (previamente ${fromPolicyName}` : ''})`, + `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`, From 7373efecf688b4e566188583c3f327acfcb0c724 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Tue, 4 Mar 2025 22:49:55 +0100 Subject: [PATCH 19/30] address review comments --- src/CONST.ts | 1 + .../ChangePolicyEducationalMenu.tsx | 18 ++++++-------- .../ChangeWorkspaceMenuSectionList.tsx | 24 +++++++++---------- src/components/HoldMenuSectionList.tsx | 24 +++++++++---------- .../ProcessMoneyRequestHoldMenu.tsx | 15 ++++-------- 5 files changed, 37 insertions(+), 45 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index ae1c7b7f8cb65..1fa1a9e55b055 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -6743,6 +6743,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/components/ChangePolicyEducationalMenu.tsx b/src/components/ChangePolicyEducationalMenu.tsx index ff840f00c5333..f243ba45ad43b 100644 --- a/src/components/ChangePolicyEducationalMenu.tsx +++ b/src/components/ChangePolicyEducationalMenu.tsx @@ -1,10 +1,11 @@ -import {useNavigation} from '@react-navigation/native'; -import React, {useEffect} from 'react'; +import React from 'react'; +import useBeforeRemove from '@hooks/useBeforeRemove'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import colors from '@styles/theme/colors'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import ChangeWorkspaceMenuSectionList from './ChangeWorkspaceMenuSectionList'; import FeatureTrainingModal from './FeatureTrainingModal'; import * as Illustrations from './Icon/Illustrations'; @@ -17,15 +18,9 @@ type ChangePolicyEducationalMenuProps = { function ChangePolicyEducationalMenu({onConfirm}: ChangePolicyEducationalMenuProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const navigation = useNavigation(); const StyleUtils = useStyleUtils(); - useEffect(() => { - const unsub = navigation.addListener('beforeRemove', () => { - onConfirm(); - }); - return unsub; - }, [navigation, onConfirm]); + useBeforeRemove(onConfirm); return ( {changeWorkspaceMenuSections.map((section, i) => ( {holdMenuSections.map((section, i) => ( { - 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} From 9e391abdb2a27703daeae60f37835e28394ea74a Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Wed, 5 Mar 2025 00:28:20 +0100 Subject: [PATCH 20/30] DRY common code --- src/hooks/useWorkspaceList.ts | 115 ++++++++++++++++++++++ src/pages/ReportChangeWorkspacePage.tsx | 83 ++++------------ src/pages/WorkspaceSwitcherPage/index.tsx | 84 ++++------------ 3 files changed, 153 insertions(+), 129 deletions(-) create mode 100644 src/hooks/useWorkspaceList.ts diff --git a/src/hooks/useWorkspaceList.ts b/src/hooks/useWorkspaceList.ts new file mode 100644 index 0000000000000..6cd850daa1db7 --- /dev/null +++ b/src/hooks/useWorkspaceList.ts @@ -0,0 +1,115 @@ +import {useMemo} from 'react'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; +import {shouldShowPolicy, isPolicyAdmin, sortWorkspacesBySelected} from '@libs/PolicyUtils'; +import CONST from '@src/CONST'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {Policy} from '@src/types/onyx'; +import type { ListItem, SectionListDataType } from '@components/SelectionList/types'; +import type { BrickRoad } from '@libs/WorkspacesSettingsUtils'; +import * as Expensicons from '@components/Icon/Expensicons'; + +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}; \ No newline at end of file diff --git a/src/pages/ReportChangeWorkspacePage.tsx b/src/pages/ReportChangeWorkspacePage.tsx index 28d035be83d93..6a614a28460e7 100644 --- a/src/pages/ReportChangeWorkspacePage.tsx +++ b/src/pages/ReportChangeWorkspacePage.tsx @@ -1,11 +1,9 @@ -import React, {useCallback, useMemo} from 'react'; +import React, {useCallback} 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 useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; @@ -15,24 +13,17 @@ 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 {isPolicyAdmin, isWorkspaceEligibleForReportChange, shouldShowPolicy, sortWorkspacesBySelected} from '@libs/PolicyUtils'; -import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; -import CONST from '@src/CONST'; +import {isWorkspaceEligibleForReportChange} from '@libs/PolicyUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type { WorkspaceListItem } from '@hooks/useWorkspaceList'; +import useWorkspaceList from '@hooks/useWorkspaceList'; import type {WithReportOrNotFoundProps} from './home/report/withReportOrNotFound'; import withReportOrNotFound from './home/report/withReportOrNotFound'; type ReportChangeWorkspacePageProps = WithReportOrNotFoundProps & PlatformStackScreenProps; -type WorkspaceListItem = { - text: string; - policyID?: string; - isPolicyAdmin?: boolean; -} & ListItem; - function ReportChangeWorkspacePage({report}: ReportChangeWorkspacePageProps) { const reportID = report?.reportID; const {isOffline} = useNetwork(); @@ -56,56 +47,18 @@ function ReportChangeWorkspacePage({report}: ReportChangeWorkspacePageProps) { [reportID], ); - const usersWorkspaces = useMemo(() => { - if (!policies || isEmptyObject(policies)) { - return []; - } - - return Object.values(policies) - .filter( - (policy) => - shouldShowPolicy(policy, !!isOffline, currentUserLogin) && - !policy?.isJoinRequestPending && - isWorkspaceEligibleForReportChange(policy, report?.ownerAccountID, report?.managerID, currentUserLogin), - ) - .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: report.policyID === policy?.id, - })); - }, [policies, isOffline, currentUserLogin, report.policyID, report?.ownerAccountID, report?.managerID]); - - 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}, report.policyID)), - [debouncedSearchTerm, usersWorkspaces, report.policyID], - ); - - 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 { + sections, + shouldShowNoResultsFoundMessage, + shouldShowSearchInput, + } = useWorkspaceList({ + policies, + currentUserLogin, + isOffline, + selectedPolicyID: report.policyID, + searchTerm: debouncedSearchTerm, + additionalFilter: (policy) => isWorkspaceEligibleForReportChange(policy, report?.ownerAccountID, report?.managerID, currentUserLogin), + }); 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') : ''} initiallyFocusedOptionKey={report.policyID} showLoadingPlaceholder={fetchStatus.status === 'loading' || !didScreenTransitionEnd} /> diff --git a/src/pages/WorkspaceSwitcherPage/index.tsx b/src/pages/WorkspaceSwitcherPage/index.tsx index cd57fb6859f67..86f1aa8859dfc 100644 --- a/src/pages/WorkspaceSwitcherPage/index.tsx +++ b/src/pages/WorkspaceSwitcherPage/index.tsx @@ -2,10 +2,8 @@ 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'; @@ -13,23 +11,14 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; 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 type { WorkspaceListItem } from '@hooks/useWorkspaceList'; +import useWorkspaceList from '@hooks/useWorkspaceList'; 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,22 @@ 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 +96,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} From fea2af7a62e78df6bcfbd7a283ce2053318a2be3 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Wed, 5 Mar 2025 00:49:04 +0100 Subject: [PATCH 21/30] adjust paddings --- src/components/ChangeWorkspaceMenuSectionList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ChangeWorkspaceMenuSectionList.tsx b/src/components/ChangeWorkspaceMenuSectionList.tsx index a21f6c8fd6fe8..efbd73500883b 100644 --- a/src/components/ChangeWorkspaceMenuSectionList.tsx +++ b/src/components/ChangeWorkspaceMenuSectionList.tsx @@ -40,7 +40,7 @@ function ChangeWorkspaceMenuSectionList() { Date: Wed, 5 Mar 2025 00:59:13 +0100 Subject: [PATCH 22/30] prettier --- src/hooks/useWorkspaceList.ts | 70 +++++++++++------------ src/pages/ReportChangeWorkspacePage.tsx | 12 ++-- src/pages/WorkspaceSwitcherPage/index.tsx | 13 ++--- 3 files changed, 42 insertions(+), 53 deletions(-) diff --git a/src/hooks/useWorkspaceList.ts b/src/hooks/useWorkspaceList.ts index 6cd850daa1db7..ed7dbb2e6c826 100644 --- a/src/hooks/useWorkspaceList.ts +++ b/src/hooks/useWorkspaceList.ts @@ -1,13 +1,13 @@ 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 {shouldShowPolicy, isPolicyAdmin, sortWorkspacesBySelected} from '@libs/PolicyUtils'; +import type {BrickRoad} from '@libs/WorkspacesSettingsUtils'; import CONST from '@src/CONST'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {Policy} from '@src/types/onyx'; -import type { ListItem, SectionListDataType } from '@components/SelectionList/types'; -import type { BrickRoad } from '@libs/WorkspacesSettingsUtils'; -import * as Expensicons from '@components/Icon/Expensicons'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; type WorkspaceListItem = { text: string; @@ -23,18 +23,18 @@ type UseWorkspaceListParams = { selectedPolicyID: string | undefined; searchTerm: string; additionalFilter?: (policy: OnyxEntry) => boolean; - } & ( +} & ( | { - isWorkspaceSwitcher: true; - hasUnreadData: (policyID?: string) => boolean; - getIndicatorTypeForPolicy: (policyID?: string) => BrickRoad; + isWorkspaceSwitcher: true; + hasUnreadData: (policyID?: string) => boolean; + getIndicatorTypeForPolicy: (policyID?: string) => BrickRoad; } | { - isWorkspaceSwitcher?: false | undefined; - hasUnreadData?: never; - getIndicatorTypeForPolicy?: never; + isWorkspaceSwitcher?: false | undefined; + hasUnreadData?: never; + getIndicatorTypeForPolicy?: never; } - ); +); function useWorkspaceList({ policies, @@ -47,37 +47,35 @@ function useWorkspaceList({ 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)) + .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 && { + 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( @@ -102,7 +100,7 @@ function useWorkspaceList({ 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, @@ -112,4 +110,4 @@ function useWorkspaceList({ } export default useWorkspaceList; -export type {WorkspaceListItem}; \ No newline at end of file +export type {WorkspaceListItem}; diff --git a/src/pages/ReportChangeWorkspacePage.tsx b/src/pages/ReportChangeWorkspacePage.tsx index 6a614a28460e7..3647d423c2df6 100644 --- a/src/pages/ReportChangeWorkspacePage.tsx +++ b/src/pages/ReportChangeWorkspacePage.tsx @@ -9,6 +9,8 @@ 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'; @@ -17,8 +19,6 @@ import {isWorkspaceEligibleForReportChange} from '@libs/PolicyUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type { WorkspaceListItem } from '@hooks/useWorkspaceList'; -import useWorkspaceList from '@hooks/useWorkspaceList'; import type {WithReportOrNotFoundProps} from './home/report/withReportOrNotFound'; import withReportOrNotFound from './home/report/withReportOrNotFound'; @@ -47,18 +47,14 @@ function ReportChangeWorkspacePage({report}: ReportChangeWorkspacePageProps) { [reportID], ); - const { - sections, - shouldShowNoResultsFoundMessage, - shouldShowSearchInput, - } = useWorkspaceList({ + const {sections, shouldShowNoResultsFoundMessage, shouldShowSearchInput} = useWorkspaceList({ policies, currentUserLogin, isOffline, selectedPolicyID: report.policyID, searchTerm: debouncedSearchTerm, additionalFilter: (policy) => isWorkspaceEligibleForReportChange(policy, report?.ownerAccountID, report?.managerID, currentUserLogin), - }); + }); return ( { From afaecbcc45d7b71c71c864430011a37bb813a2ec Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Wed, 5 Mar 2025 12:15:43 +0100 Subject: [PATCH 23/30] remove unnecessary code --- .../ChangePolicyEducationalMenu.tsx | 14 ++++----- src/pages/ChangePolicyEducationalModal.tsx | 31 ------------------- 2 files changed, 7 insertions(+), 38 deletions(-) delete mode 100644 src/pages/ChangePolicyEducationalModal.tsx diff --git a/src/components/ChangePolicyEducationalMenu.tsx b/src/components/ChangePolicyEducationalMenu.tsx index f243ba45ad43b..749a0a38db9d5 100644 --- a/src/components/ChangePolicyEducationalMenu.tsx +++ b/src/components/ChangePolicyEducationalMenu.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import useBeforeRemove from '@hooks/useBeforeRemove'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -6,20 +6,20 @@ import useThemeStyles from '@hooks/useThemeStyles'; import colors from '@styles/theme/colors'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import { dismissChangePolicyModal } from '@libs/actions/Report'; import ChangeWorkspaceMenuSectionList from './ChangeWorkspaceMenuSectionList'; import FeatureTrainingModal from './FeatureTrainingModal'; import * as Illustrations from './Icon/Illustrations'; -type ChangePolicyEducationalMenuProps = { - /** Method to trigger when pressing confirm button or outside of the popover menu to close it */ - onConfirm: () => void; -}; - -function ChangePolicyEducationalMenu({onConfirm}: ChangePolicyEducationalMenuProps) { +function ChangePolicyEducationalMenu() { const {translate} = useLocalize(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const onConfirm = useCallback(() => { + dismissChangePolicyModal(); + }, []); + useBeforeRemove(onConfirm); return ( diff --git a/src/pages/ChangePolicyEducationalModal.tsx b/src/pages/ChangePolicyEducationalModal.tsx deleted file mode 100644 index ec8225e85741b..0000000000000 --- a/src/pages/ChangePolicyEducationalModal.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useRef} from 'react'; -import {InteractionManager} from 'react-native'; -import ChangePolicyEducationalMenu from '@components/ChangePolicyEducationalMenu'; -import blurActiveElement from '@libs/Accessibility/blurActiveElement'; -import {dismissChangePolicyModal} from '@libs/actions/Report'; -import CONST from '@src/CONST'; - -function ChangePolicyEducationalModal() { - const focusTimeoutRef = useRef(null); - useFocusEffect( - useCallback(() => { - focusTimeoutRef.current = setTimeout(() => { - InteractionManager.runAfterInteractions(() => { - blurActiveElement(); - }); - }, CONST.ANIMATED_TRANSITION); - return () => focusTimeoutRef.current && clearTimeout(focusTimeoutRef.current); - }, []), - ); - - const onConfirm = useCallback(() => { - dismissChangePolicyModal(); - }, []); - - return ; -} - -ChangePolicyEducationalModal.displayName = 'ChangePolicyEducationalModal'; - -export default ChangePolicyEducationalModal; From 29bfa8cdc09627dc11e27e83f8ec38515ffacbd8 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Wed, 5 Mar 2025 12:20:47 +0100 Subject: [PATCH 24/30] rename the component --- .../ChangePolicyEducationalModal.tsx} | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) rename src/{components/ChangePolicyEducationalMenu.tsx => pages/ChangePolicyEducationalModal.tsx} (77%) diff --git a/src/components/ChangePolicyEducationalMenu.tsx b/src/pages/ChangePolicyEducationalModal.tsx similarity index 77% rename from src/components/ChangePolicyEducationalMenu.tsx rename to src/pages/ChangePolicyEducationalModal.tsx index 749a0a38db9d5..ecc8d1fd4a760 100644 --- a/src/components/ChangePolicyEducationalMenu.tsx +++ b/src/pages/ChangePolicyEducationalModal.tsx @@ -1,17 +1,17 @@ -import React, { useCallback } from 'react'; +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'; -import { dismissChangePolicyModal } from '@libs/actions/Report'; -import ChangeWorkspaceMenuSectionList from './ChangeWorkspaceMenuSectionList'; -import FeatureTrainingModal from './FeatureTrainingModal'; -import * as Illustrations from './Icon/Illustrations'; -function ChangePolicyEducationalMenu() { +function ChangePolicyEducationalModal() { const {translate} = useLocalize(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -45,6 +45,6 @@ function ChangePolicyEducationalMenu() { ); } -ChangePolicyEducationalMenu.displayName = 'ChangePolicyEducationalMenu'; +ChangePolicyEducationalModal.displayName = 'ChangePolicyEducationalModal'; -export default ChangePolicyEducationalMenu; +export default ChangePolicyEducationalModal; From c375808ecfb46739eaaf17dc8de5ba630c302047 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Thu, 6 Mar 2025 14:39:30 +0100 Subject: [PATCH 25/30] address review comments --- src/components/ChangeWorkspaceMenuSectionList.tsx | 12 ++++-------- src/components/HoldMenuSectionList.tsx | 11 ++++------- src/libs/ReportUtils.ts | 2 +- src/libs/actions/Report.ts | 2 +- 4 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/components/ChangeWorkspaceMenuSectionList.tsx b/src/components/ChangeWorkspaceMenuSectionList.tsx index efbd73500883b..35566b1ddd47d 100644 --- a/src/components/ChangeWorkspaceMenuSectionList.tsx +++ b/src/components/ChangeWorkspaceMenuSectionList.tsx @@ -1,19 +1,18 @@ 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 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: React.FC | ImageSourcePropType; + icon: IconAsset; /** Translation key for the title */ titleTranslationKey: TranslationPaths; @@ -36,10 +35,10 @@ function ChangeWorkspaceMenuSectionList() { return ( <> - {changeWorkspaceMenuSections.map((section, i) => ( + {changeWorkspaceMenuSections.map((section) => ( | ImageSourcePropType; + icon: IconAsset; /** Translation key for the title */ titleTranslationKey: TranslationPaths; @@ -35,10 +34,10 @@ function HoldMenuSectionList() { return ( <> - {holdMenuSections.map((section, i) => ( + {holdMenuSections.map((section) => ( Date: Thu, 6 Mar 2025 14:40:29 +0100 Subject: [PATCH 26/30] update workspace eligibility logic for report changes --- src/libs/PolicyUtils.ts | 40 +++++++++++++++++-------- src/pages/ReportChangeWorkspacePage.tsx | 2 +- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index bde54678fd587..622bae1ddd583 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -1088,26 +1088,40 @@ const sortWorkspacesBySelected = (workspace1: WorkspaceDetails, workspace2: Work }; /** - * An eligible workspace is one that meets the following criteria: - * Submitters: workspaces where the submitter is a member of - * Approvers: workspaces where both the approver AND submitter are members of - * 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) + * Determines whether the report can be moved to the workspace. */ const isWorkspaceEligibleForReportChange = ( newPolicy: OnyxEntry, - reportOwnerAccountID: number | undefined, - reportManagerID: number | undefined, + report: OnyxEntry, currentUserLogin: string | undefined, ): boolean => { - const curretUserAccountID = getCurrentUserAccountID(); - if (curretUserAccountID === reportOwnerAccountID) { - return !!currentUserLogin && !!newPolicy?.employeeList?.[currentUserLogin]; + 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; } - if (curretUserAccountID === reportManagerID) { - const reportSubmitterLogin = (!!reportOwnerAccountID && getLoginByAccountID(reportOwnerAccountID)) ?? ''; - return !!currentUserLogin && !!newPolicy?.employeeList?.[currentUserLogin] && !!reportSubmitterLogin && !!newPolicy?.employeeList?.[reportSubmitterLogin]; + + // Approvers: workspaces where both the approver AND submitter are members of + const reportApproverAccountID = getManagerAccountID(newPolicy, 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 isUserPolicyAdmin(newPolicy, currentUserLogin); + + return false; }; /** diff --git a/src/pages/ReportChangeWorkspacePage.tsx b/src/pages/ReportChangeWorkspacePage.tsx index 3647d423c2df6..b570c0440fac6 100644 --- a/src/pages/ReportChangeWorkspacePage.tsx +++ b/src/pages/ReportChangeWorkspacePage.tsx @@ -53,7 +53,7 @@ function ReportChangeWorkspacePage({report}: ReportChangeWorkspacePageProps) { isOffline, selectedPolicyID: report.policyID, searchTerm: debouncedSearchTerm, - additionalFilter: (policy) => isWorkspaceEligibleForReportChange(policy, report?.ownerAccountID, report?.managerID, currentUserLogin), + additionalFilter: (policy) => isWorkspaceEligibleForReportChange(policy, report, currentUserLogin), }); return ( From f1d61c7e641c73c98d195a30a289d0154a30d230 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Thu, 6 Mar 2025 14:42:30 +0100 Subject: [PATCH 27/30] prettier --- src/libs/PolicyUtils.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 622bae1ddd583..c593b2499a417 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -1090,14 +1090,10 @@ const sortWorkspacesBySelected = (workspace1: WorkspaceDetails, workspace2: Work /** * Determines whether the report can be moved to the workspace. */ -const isWorkspaceEligibleForReportChange = ( - newPolicy: OnyxEntry, - report: OnyxEntry, - currentUserLogin: string | undefined, -): boolean => { +const isWorkspaceEligibleForReportChange = (newPolicy: OnyxEntry, report: OnyxEntry, currentUserLogin: string | undefined): boolean => { const currentUserAccountID = getCurrentUserAccountID(); const isCurrentUserMember = !!currentUserLogin && !!newPolicy?.employeeList?.[currentUserLogin]; - if (!isCurrentUserMember){ + if (!isCurrentUserMember) { return false; } @@ -1117,7 +1113,7 @@ const isWorkspaceEligibleForReportChange = ( } // 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)){ + if (isPolicyOwner(newPolicy, currentUserAccountID) || isUserPolicyAdmin(newPolicy, currentUserLogin)) { return true; } From 41ff79de409b68c890fc3550546fc0c03b1c7638 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Thu, 6 Mar 2025 14:44:40 +0100 Subject: [PATCH 28/30] remove eslint-disable comment for array index key --- src/components/ChangeWorkspaceMenuSectionList.tsx | 1 - src/components/HoldMenuSectionList.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/src/components/ChangeWorkspaceMenuSectionList.tsx b/src/components/ChangeWorkspaceMenuSectionList.tsx index 35566b1ddd47d..2e159114264b0 100644 --- a/src/components/ChangeWorkspaceMenuSectionList.tsx +++ b/src/components/ChangeWorkspaceMenuSectionList.tsx @@ -37,7 +37,6 @@ function ChangeWorkspaceMenuSectionList() { <> {changeWorkspaceMenuSections.map((section) => ( diff --git a/src/components/HoldMenuSectionList.tsx b/src/components/HoldMenuSectionList.tsx index d709b8a8f1c88..b2dc5b1213e9d 100644 --- a/src/components/HoldMenuSectionList.tsx +++ b/src/components/HoldMenuSectionList.tsx @@ -36,7 +36,6 @@ function HoldMenuSectionList() { <> {holdMenuSections.map((section) => ( From e6ed6544a8ba8f340cbe4bb6fc1a6ab15d5475ab Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Wed, 12 Mar 2025 20:14:54 +0100 Subject: [PATCH 29/30] fix approver logic --- src/libs/PolicyUtils.ts | 4 ++-- src/pages/ReportChangeWorkspacePage.tsx | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 92aa2c24a023a..dedd03bbdcc8c 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -1090,7 +1090,7 @@ const sortWorkspacesBySelected = (workspace1: WorkspaceDetails, workspace2: Work /** * Determines whether the report can be moved to the workspace. */ -const isWorkspaceEligibleForReportChange = (newPolicy: OnyxEntry, report: OnyxEntry, currentUserLogin: string | undefined): boolean => { +const isWorkspaceEligibleForReportChange = (newPolicy: OnyxEntry, report: OnyxEntry, oldPolicy: OnyxEntry, currentUserLogin: string | undefined): boolean => { const currentUserAccountID = getCurrentUserAccountID(); const isCurrentUserMember = !!currentUserLogin && !!newPolicy?.employeeList?.[currentUserLogin]; if (!isCurrentUserMember) { @@ -1104,7 +1104,7 @@ const isWorkspaceEligibleForReportChange = (newPolicy: OnyxEntry, report } // Approvers: workspaces where both the approver AND submitter are members of - const reportApproverAccountID = getManagerAccountID(newPolicy, report); + const reportApproverAccountID = getSubmitToAccountID(oldPolicy, report); const isCurrentUserApprover = currentUserAccountID === reportApproverAccountID; if (isCurrentUserApprover) { const reportSubmitterLogin = report?.ownerAccountID ? getLoginByAccountID(report?.ownerAccountID) : undefined; diff --git a/src/pages/ReportChangeWorkspacePage.tsx b/src/pages/ReportChangeWorkspacePage.tsx index b570c0440fac6..d07ed20513a4b 100644 --- a/src/pages/ReportChangeWorkspacePage.tsx +++ b/src/pages/ReportChangeWorkspacePage.tsx @@ -32,6 +32,7 @@ function ReportChangeWorkspacePage({report}: ReportChangeWorkspacePageProps) { 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; @@ -53,7 +54,7 @@ function ReportChangeWorkspacePage({report}: ReportChangeWorkspacePageProps) { isOffline, selectedPolicyID: report.policyID, searchTerm: debouncedSearchTerm, - additionalFilter: (policy) => isWorkspaceEligibleForReportChange(policy, report, currentUserLogin), + additionalFilter: (newPolicy) => isWorkspaceEligibleForReportChange(newPolicy, report, oldPolicy, currentUserLogin), }); return ( From 2d4ecbd1dff172f6fd00922d97353817e22a8b09 Mon Sep 17 00:00:00 2001 From: Rayane <77965000+rayane-d@users.noreply.github.com> Date: Wed, 12 Mar 2025 20:15:59 +0100 Subject: [PATCH 30/30] Add isWorkspaceEligibleForReportChange tests --- tests/unit/PolicyUtilsTest.ts | 152 +++++++++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 1 deletion(-) 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); + }); + }); });