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