From dcdc5d61ef4613a77cd1c056f1f0a7da0e6967f2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 8 May 2026 14:35:18 +0300 Subject: [PATCH 01/11] Revert "Merge pull request #89808 from Expensify/cm-revert-87283" This reverts commit 2b05c8dcfaff75ef7510da65ac13f8e2705cca93, reversing changes made to b2b39fa913ebd4a5892814560be7d520ed257230. --- src/CONST/index.ts | 4 + .../SidePanel/SidePanelContextProvider.tsx | 4 +- src/components/WorkspaceMemberRoleList.tsx | 19 +- src/hooks/useAutoCreateSubmitWorkspace.ts | 115 ++++++ src/hooks/useAutoCreateTrackWorkspace.ts | 70 ++-- .../useOnboardingWorkspaceCreationState.ts | 66 ++++ src/languages/de.ts | 4 + src/languages/en.ts | 4 + src/languages/es.ts | 4 + src/languages/fr.ts | 4 + src/languages/it.ts | 4 + src/languages/ja.ts | 4 + src/languages/nl.ts | 4 + src/languages/pl.ts | 4 + src/languages/pt-BR.ts | 4 + src/languages/zh-hans.ts | 4 + src/libs/PolicyUtils.ts | 30 ++ src/libs/SubscriptionUtils.ts | 2 +- src/libs/actions/Policy/Member.ts | 35 +- src/libs/actions/Policy/Policy.ts | 55 ++- src/libs/actions/Welcome/index.ts | 6 + src/libs/navigateAfterOnboarding.ts | 32 +- .../BaseOnboardingPersonalDetails.tsx | 15 + .../BaseOnboardingPurpose.tsx | 26 ++ .../BaseOnboardingWorkspaces.tsx | 31 +- .../workspace/AccessOrNotFoundWrapper.tsx | 9 +- .../DynamicWorkspaceOverviewPlanTypePage.tsx | 20 +- src/pages/workspace/WorkspaceInitialPage.tsx | 17 +- src/pages/workspace/WorkspaceMembersPage.tsx | 4 +- src/pages/workspace/WorkspaceOverviewPage.tsx | 5 +- .../workspace/WorkspacePageWithSections.tsx | 4 +- .../upgrade/WorkspaceUpgradePage.tsx | 9 +- .../WorkspaceAutoReportingFrequencyPage.tsx | 4 +- ...orkspaceAutoReportingMonthlyOffsetPage.tsx | 4 +- .../workflows/WorkspaceWorkflowsPage.tsx | 7 +- ...aceWorkflowsApprovalsApprovalLimitPage.tsx | 4 +- .../WorkspaceWorkflowsApprovalsCreatePage.tsx | 4 +- .../WorkspaceWorkflowsApprovalsEditPage.tsx | 4 +- ...paceWorkflowsApprovalsExpensesFromPage.tsx | 4 +- tests/actions/PolicyTest.ts | 37 ++ tests/ui/OnboardingPurpose.tsx | 141 ++++++++ tests/ui/PersonalDetailsOnboarding.tsx | 108 ++++++ tests/ui/WorkspaceOnboarding.tsx | 156 +++++++++ .../useAutoCreateSubmitWorkspace.test.ts | 330 ++++++++++++++++++ 44 files changed, 1321 insertions(+), 100 deletions(-) create mode 100644 src/hooks/useAutoCreateSubmitWorkspace.ts create mode 100644 src/hooks/useOnboardingWorkspaceCreationState.ts create mode 100644 tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index b6f608eaf597..bd40f8dfe004 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -948,6 +948,7 @@ const CONST = { BULK_DUPLICATE_REPORT: 'bulkDuplicateReport', BULK_EDIT: 'bulkEdit', NEW_MANUAL_EXPENSE_FLOW: 'newManualExpenseFlow', + SUBMIT_2026: 'submit2026', BULK_SUBMIT_APPROVE_PAY: 'bulkSubmitApprovePay', }, BUTTON_STATES: { @@ -3580,6 +3581,8 @@ const CONST = { // Often referred to as "collect" workspaces TEAM: 'team', + + SUBMIT: 'submit2026', }, RULE_CONDITIONS: { MATCHES: 'matches', @@ -3598,6 +3601,7 @@ const CONST = { ADMIN: 'admin', AUDITOR: 'auditor', USER: 'user', + EDITOR: 'editor', }, AUTO_REIMBURSEMENT_MAX_LIMIT_CENTS: 2000000, diff --git a/src/components/SidePanel/SidePanelContextProvider.tsx b/src/components/SidePanel/SidePanelContextProvider.tsx index 87fb63d098a4..c1dee29c6d69 100644 --- a/src/components/SidePanel/SidePanelContextProvider.tsx +++ b/src/components/SidePanel/SidePanelContextProvider.tsx @@ -10,7 +10,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import SidePanelActions from '@libs/actions/SidePanel'; import DateUtils from '@libs/DateUtils'; import focusComposerWithDelay from '@libs/focusComposerWithDelay'; -import {isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, shouldShowPolicy} from '@libs/PolicyUtils'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -82,7 +82,7 @@ function SidePanelContextProvider({children}: PropsWithChildren) { const isRHPAdminsRoom = onboardingRHPVariant === CONST.ONBOARDING_RHP_VARIANT.RHP_ADMINS_ROOM; const isRHPHomePage = onboardingRHPVariant === CONST.ONBOARDING_RHP_VARIANT.RHP_HOME_PAGE; - const isUserAdmin = isPolicyAdmin(activePolicy, sessionEmail); + const isUserAdmin = canEditWorkspaceSettings(activePolicy); const isPolicyActive = shouldShowPolicy(activePolicy, false, sessionEmail ?? ''); const adminsChatReportID = activePolicy?.chatReportIDAdmins?.toString(); diff --git a/src/components/WorkspaceMemberRoleList.tsx b/src/components/WorkspaceMemberRoleList.tsx index 432dd71ff5ae..801a8698faba 100644 --- a/src/components/WorkspaceMemberRoleList.tsx +++ b/src/components/WorkspaceMemberRoleList.tsx @@ -1,12 +1,15 @@ +import {emailSelector} from '@selectors/Session'; import React from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; -import {isControlPolicy} from '@libs/PolicyUtils'; +import {isControlPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import type {Policy} from '@src/types/onyx'; import HeaderWithBackButton from './HeaderWithBackButton'; @@ -32,6 +35,7 @@ type WorkspaceMemberRoleListProps = { function WorkspaceMemberRoleList({role, policy, navigateBackTo = undefined, isLoading = false, onSelectRole = () => {}}: WorkspaceMemberRoleListProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + const [currentUserEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); const workspaceRoles: ListItemType[] = [ { @@ -58,7 +62,18 @@ function WorkspaceMemberRoleList({role, policy, navigateBackTo = undefined, isLo ]; const isPolicyControl = isControlPolicy(policy); - const availableRoleItems: ListItemType[] = workspaceRoles.filter((item) => isPolicyControl || item.value !== CONST.POLICY.ROLE.AUDITOR); + // Only strict admins can assign the ADMIN role. Editors (e.g. Submit workspace owners) can + // invite/manage members but must not be able to escalate anyone to admin. + const canAssignAdminRole = isPolicyAdmin(policy, currentUserEmail); + const availableRoleItems: ListItemType[] = workspaceRoles.filter((item) => { + if (item.value === CONST.POLICY.ROLE.AUDITOR && !isPolicyControl) { + return false; + } + if (item.value === CONST.POLICY.ROLE.ADMIN && !canAssignAdminRole) { + return false; + } + return true; + }); return ( <> diff --git a/src/hooks/useAutoCreateSubmitWorkspace.ts b/src/hooks/useAutoCreateSubmitWorkspace.ts new file mode 100644 index 000000000000..7d97960bffcb --- /dev/null +++ b/src/hooks/useAutoCreateSubmitWorkspace.ts @@ -0,0 +1,115 @@ +import {useCallback, useMemo} from 'react'; +import type {OnyxCollection} from 'react-native-onyx'; +import {navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; +import {createDisplayName} from '@libs/PersonalDetailsUtils'; +import {canEditWorkspaceSettings, isGroupPolicy} from '@libs/PolicyUtils'; +import {createWorkspace, generateDefaultWorkspaceName, generatePolicyID} from '@userActions/Policy/Policy'; +import {completeOnboarding} from '@userActions/Report'; +import {setOnboardingAdminsChatReportID, setOnboardingPolicyID} from '@userActions/Welcome'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; +import useOnboardingWorkspaceCreationState from './useOnboardingWorkspaceCreationState'; +import useOnyx from './useOnyx'; + +/** + * Hook that provides a function to auto-create a Submit workspace for EMPLOYER + * users during onboarding and complete the onboarding flow. + * + * Shared by BaseOnboardingPersonalDetails, BaseOnboardingPurpose, and BaseOnboardingWorkspaces. + */ +function useAutoCreateSubmitWorkspace() { + const { + onboardingPolicyID, + onboardingAdminsChatReportID, + introSelected, + isSelfTourViewed, + betas, + currentUserEmail, + currentUserAccountID, + localCurrencyCode, + activePolicy, + translate, + formatPhoneNumber, + isRestrictedPolicyCreation, + hasActiveAdminPolicies, + onboardingMessages, + lastWorkspaceNumber, + isSmallScreenWidth, + } = useOnboardingWorkspaceCreationState(); + + const groupPolicySelector = useMemo( + () => (policies: OnyxCollection) => Object.values(policies ?? {}).some((policy) => isGroupPolicy(policy) && canEditWorkspaceSettings(policy)), + [], + ); + const [hasEditableGroupPolicy] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: groupPolicySelector}); + + const autoCreateSubmitWorkspace = useCallback( + (firstName: string, lastName: string) => { + const shouldCreateWorkspace = !isRestrictedPolicyCreation && !onboardingPolicyID && !hasEditableGroupPolicy; + const displayName = createDisplayName(currentUserEmail, {firstName, lastName}, formatPhoneNumber); + + const {adminsChatReportID: newAdminsChatReportID, policyID: newPolicyID} = shouldCreateWorkspace + ? createWorkspace({ + policyOwnerEmail: undefined, + makeMeAdmin: true, + policyName: generateDefaultWorkspaceName(currentUserEmail, lastWorkspaceNumber, translate, displayName), + policyID: generatePolicyID(), + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + currency: localCurrencyCode, + file: undefined, + shouldAddOnboardingTasks: false, + introSelected, + activePolicy, + currentUserAccountIDParam: currentUserAccountID, + currentUserEmailParam: currentUserEmail, + shouldAddGuideWelcomeMessage: false, + type: CONST.POLICY.TYPE.SUBMIT, + betas, + isSelfTourViewed, + hasActiveAdminPolicies, + }) + : {adminsChatReportID: onboardingAdminsChatReportID, policyID: onboardingPolicyID}; + + completeOnboarding({ + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + onboardingMessage: onboardingMessages[CONST.ONBOARDING_CHOICES.EMPLOYER], + firstName, + lastName, + adminsChatReportID: newAdminsChatReportID, + onboardingPolicyID: newPolicyID, + introSelected, + isSelfTourViewed, + betas, + }); + + setOnboardingAdminsChatReportID(); + setOnboardingPolicyID(); + + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(newPolicyID, isSmallScreenWidth); + }, + [ + currentUserEmail, + currentUserAccountID, + lastWorkspaceNumber, + translate, + formatPhoneNumber, + isRestrictedPolicyCreation, + onboardingPolicyID, + hasEditableGroupPolicy, + onboardingAdminsChatReportID, + localCurrencyCode, + introSelected, + activePolicy, + isSelfTourViewed, + onboardingMessages, + betas, + hasActiveAdminPolicies, + isSmallScreenWidth, + ], + ); + + return autoCreateSubmitWorkspace; +} + +export default useAutoCreateSubmitWorkspace; diff --git a/src/hooks/useAutoCreateTrackWorkspace.ts b/src/hooks/useAutoCreateTrackWorkspace.ts index 78dc14f3edb9..99cf11eef483 100644 --- a/src/hooks/useAutoCreateTrackWorkspace.ts +++ b/src/hooks/useAutoCreateTrackWorkspace.ts @@ -1,4 +1,3 @@ -import {hasSeenTourSelector} from '@selectors/Onboarding'; import {useCallback, useMemo} from 'react'; import type {OnyxCollection} from 'react-native-onyx'; import isSidePanelReportSupported from '@components/SidePanel/isSidePanelReportSupported'; @@ -12,17 +11,10 @@ import {setOnboardingAdminsChatReportID, setOnboardingPolicyID} from '@userActio import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {OnboardingPurpose, OnboardingRHPVariant, Policy} from '@src/types/onyx'; -import useActivePolicy from './useActivePolicy'; import useArchivedReportsIdSet from './useArchivedReportsIdSet'; -import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; -import useHasActiveAdminPolicies from './useHasActiveAdminPolicies'; -import useLastWorkspaceNumber from './useLastWorkspaceNumber'; -import useLocalize from './useLocalize'; -import useOnboardingMessages from './useOnboardingMessages'; +import useOnboardingWorkspaceCreationState from './useOnboardingWorkspaceCreationState'; import useOnyx from './useOnyx'; import usePermissions from './usePermissions'; -import usePreferredPolicy from './usePreferredPolicy'; -import useResponsiveLayout from './useResponsiveLayout'; /** * Hook that provides a function to auto-create a workspace for Track (PERSONAL_SPEND) @@ -31,57 +23,57 @@ import useResponsiveLayout from './useResponsiveLayout'; * Shared by BaseOnboardingPersonalDetails and BaseOnboardingPurpose. */ function useAutoCreateTrackWorkspace() { - const [onboardingPolicyID] = useOnyx(ONYXKEYS.ONBOARDING_POLICY_ID); - const [onboardingAdminsChatReportID] = useOnyx(ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID); - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); - const [betas] = useOnyx(ONYXKEYS.BETAS); - const [session] = useOnyx(ONYXKEYS.SESSION); + const { + onboardingPolicyID, + onboardingAdminsChatReportID, + introSelected, + isSelfTourViewed, + betas, + currentUserEmail, + currentUserAccountID, + localCurrencyCode, + activePolicy, + translate, + formatPhoneNumber, + isRestrictedPolicyCreation, + hasActiveAdminPolicies, + onboardingMessages, + lastWorkspaceNumber, + isSmallScreenWidth, + } = useOnboardingWorkspaceCreationState(); + const paidGroupPolicySelector = useMemo( - () => (policies: OnyxCollection) => Object.values(policies ?? {}).some((policy) => isPaidGroupPolicy(policy) && isPolicyAdmin(policy, session?.email)), - [session?.email], + () => (policies: OnyxCollection) => Object.values(policies ?? {}).some((policy) => isPaidGroupPolicy(policy) && isPolicyAdmin(policy, currentUserEmail)), + [currentUserEmail], ); const [hasPaidGroupAdminPolicy] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: paidGroupPolicySelector}); + const [conciergeChatReportID = ''] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [onboardingValues] = useOnyx(ONYXKEYS.NVP_ONBOARDING); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const archivedReportsIdSet = useArchivedReportsIdSet(); const {isBetaEnabled} = usePermissions(); - const {translate, formatPhoneNumber} = useLocalize(); - const activePolicy = useActivePolicy(); - const {isRestrictedPolicyCreation} = usePreferredPolicy(); - const hasActiveAdminPolicies = useHasActiveAdminPolicies(); - const lastWorkspaceNumber = useLastWorkspaceNumber(); - const {onboardingMessages} = useOnboardingMessages(); - - // We use isSmallScreenWidth instead of shouldUseNarrowLayout because navigateAfterOnboarding - // relies on actual device screen width to handle navigation stack differences: on small screens, - // removing OnboardingModalNavigator redirects to HOME, requiring explicit navigation to the last - // accessed report. This behavior is tied to screen size, not responsive layout mode. - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth} = useResponsiveLayout(); const mergedAccountConciergeReportID = !onboardingValues?.shouldRedirectToClassicAfterMerge && onboardingValues?.shouldValidate ? conciergeChatReportID : undefined; const autoCreateTrackWorkspace = useCallback( async (firstName: string, lastName: string, onboardingPurposeSelected: OnboardingPurpose) => { const shouldCreateWorkspace = !isRestrictedPolicyCreation && !onboardingPolicyID && !hasPaidGroupAdminPolicy; - const displayName = createDisplayName(session?.email ?? '', {firstName, lastName}, formatPhoneNumber); + const displayName = createDisplayName(currentUserEmail, {firstName, lastName}, formatPhoneNumber); const {adminsChatReportID: newAdminsChatReportID, policyID: newPolicyID} = shouldCreateWorkspace ? createWorkspace({ policyOwnerEmail: undefined, makeMeAdmin: true, - policyName: generateDefaultWorkspaceName(session?.email ?? '', lastWorkspaceNumber, translate, displayName), + policyName: generateDefaultWorkspaceName(currentUserEmail, lastWorkspaceNumber, translate, displayName), policyID: generatePolicyID(), engagementChoice: CONST.ONBOARDING_CHOICES.TRACK_WORKSPACE, - currency: currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD, + currency: localCurrencyCode, file: undefined, shouldAddOnboardingTasks: false, introSelected, activePolicy, - currentUserAccountIDParam: session?.accountID ?? CONST.DEFAULT_NUMBER_ID, - currentUserEmailParam: session?.email ?? '', + currentUserAccountIDParam: currentUserAccountID, + currentUserEmailParam: currentUserEmail, shouldAddGuideWelcomeMessage: false, onboardingPurposeSelected, betas, @@ -129,8 +121,8 @@ function useAutoCreateTrackWorkspace() { } }, [ - session?.email, - session?.accountID, + currentUserEmail, + currentUserAccountID, lastWorkspaceNumber, translate, formatPhoneNumber, @@ -138,7 +130,7 @@ function useAutoCreateTrackWorkspace() { onboardingPolicyID, hasPaidGroupAdminPolicy, onboardingAdminsChatReportID, - currentUserPersonalDetails.localCurrencyCode, + localCurrencyCode, introSelected, activePolicy, isSelfTourViewed, diff --git a/src/hooks/useOnboardingWorkspaceCreationState.ts b/src/hooks/useOnboardingWorkspaceCreationState.ts new file mode 100644 index 000000000000..cb09fb0b9a29 --- /dev/null +++ b/src/hooks/useOnboardingWorkspaceCreationState.ts @@ -0,0 +1,66 @@ +import {hasSeenTourSelector} from '@selectors/Onboarding'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useActivePolicy from './useActivePolicy'; +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useHasActiveAdminPolicies from './useHasActiveAdminPolicies'; +import useLastWorkspaceNumber from './useLastWorkspaceNumber'; +import useLocalize from './useLocalize'; +import useOnboardingMessages from './useOnboardingMessages'; +import useOnyx from './useOnyx'; +import usePreferredPolicy from './usePreferredPolicy'; +import useResponsiveLayout from './useResponsiveLayout'; + +/** + * Shared state for the onboarding workspace auto-creation hooks + * (`useAutoCreateSubmitWorkspace`, `useAutoCreateTrackWorkspace`). + * + * Email and accountID come from `ONYXKEYS.SESSION` because session is hydrated + * earlier in onboarding than personal details. + */ +function useOnboardingWorkspaceCreationState() { + const [onboardingPolicyID] = useOnyx(ONYXKEYS.ONBOARDING_POLICY_ID); + const [onboardingAdminsChatReportID] = useOnyx(ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [session] = useOnyx(ONYXKEYS.SESSION); + + const currentUserEmail = session?.email ?? ''; + const currentUserAccountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; + + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const localCurrencyCode = currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD; + + const activePolicy = useActivePolicy(); + const {translate, formatPhoneNumber} = useLocalize(); + const {isRestrictedPolicyCreation} = usePreferredPolicy(); + const hasActiveAdminPolicies = useHasActiveAdminPolicies(); + const {onboardingMessages} = useOnboardingMessages(); + const lastWorkspaceNumber = useLastWorkspaceNumber(); + + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + + return { + onboardingPolicyID, + onboardingAdminsChatReportID, + introSelected, + isSelfTourViewed, + betas, + currentUserPersonalDetails, + currentUserEmail, + currentUserAccountID, + localCurrencyCode, + activePolicy, + translate, + formatPhoneNumber, + isRestrictedPolicyCreation, + hasActiveAdminPolicies, + onboardingMessages, + lastWorkspaceNumber, + isSmallScreenWidth, + }; +} + +export default useOnboardingWorkspaceCreationState; diff --git a/src/languages/de.ts b/src/languages/de.ts index 87a42b5637db..501d5595e817 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7015,6 +7015,10 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc label: 'Steuerung', description: 'Für Organisationen mit erweiterten Anforderungen.', }, + submit2026: { + label: 'Einreichen', + description: 'Für Mitarbeiter, die Ausgaben bei ihrem Arbeitgeber einreichen möchten.', + }, }, description: 'Wähle ein passendes Abo für dich. Eine detaillierte Liste der Funktionen und Preise findest du in unserem', subscriptionLink: 'Hilfeseite zu Plantypen und Preisen', diff --git a/src/languages/en.ts b/src/languages/en.ts index fb307b9fe16d..485ad74e8b80 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7052,6 +7052,10 @@ const translations = { label: 'Control', description: 'For organizations with advanced requirements.', }, + submit2026: { + label: 'Submit', + description: 'For employees looking to submit expenses to their employer.', + }, }, description: "Choose a plan that's right for you. For a detailed list of features and pricing, check out our", subscriptionLink: 'plan types and pricing help page', diff --git a/src/languages/es.ts b/src/languages/es.ts index 88d656ca69a6..ab66f62a16e2 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6472,6 +6472,10 @@ ${amount} para ${merchant} - ${date}`, label: 'Controlar', description: 'Para organizaciones con requisitos avanzados.', }, + submit2026: { + label: 'Enviar', + description: 'Para empleados que buscan enviar gastos a su empleador.', + }, }, description: 'Elige el plan adecuado para ti. Para ver una lista detallada de funciones y precios, consulta nuestra', subscriptionLink: 'página de ayuda sobre tipos de planes y precios', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 6e27d7d3ed15..330e23d61ee2 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7037,6 +7037,10 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e label: 'Contrôle', description: 'Pour les organisations ayant des exigences avancées.', }, + submit2026: { + label: 'Soumettre', + description: 'Pour les employés souhaitant soumettre des dépenses à leur employeur.', + }, }, description: 'Choisissez l’offre qui vous convient. Pour une liste détaillée des fonctionnalités et des tarifs, consultez notre', subscriptionLink: "page d'aide sur les types de forfaits et les tarifs", diff --git a/src/languages/it.ts b/src/languages/it.ts index 806ca5b15b24..39ab448dd3e3 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7001,6 +7001,10 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, label: 'Controllo', description: 'Per le organizzazioni con requisiti avanzati.', }, + submit2026: { + label: 'Invia', + description: 'Per i dipendenti che desiderano inviare le spese al proprio datore di lavoro.', + }, }, description: 'Scegli il piano più adatto a te. Per un elenco dettagliato di funzionalità e prezzi, consulta la nostra', subscriptionLink: 'pagina di aiuto su tipi di piano e prezzi', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 45e08ac7d474..07217a8e7390 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6924,6 +6924,10 @@ ${reportName} label: 'コントロール', description: '高度な要件を持つ組織向け。', }, + submit2026: { + label: '提出', + description: '雇用主に経費を提出したい従業員向け。', + }, }, description: '自分に合ったプランをお選びください。機能と料金の詳細な一覧は、こちらのページをご覧ください', subscriptionLink: 'プランの種類と料金のヘルプページ', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index aa821eb069d1..de99b07ff977 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6979,6 +6979,10 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, label: 'Beheer', description: 'Voor organisaties met geavanceerde vereisten.', }, + submit2026: { + label: 'Indienen', + description: 'Voor werknemers die onkosten bij hun werkgever willen indienen.', + }, }, description: 'Kies een pakket dat bij je past. Voor een gedetailleerd overzicht van functies en prijzen, bekijk onze', subscriptionLink: 'hulppagina voor abonnementstypen en prijzen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 837bf6f9b53a..ef4fc52f35e4 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6973,6 +6973,10 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, label: 'Sterowanie', description: 'Dla organizacji z zaawansowanymi wymaganiami.', }, + submit2026: { + label: 'Prześlij', + description: 'Dla pracowników, którzy chcą przesyłać wydatki do pracodawcy.', + }, }, description: 'Wybierz plan odpowiedni dla siebie. Szczegółową listę funkcji i cen znajdziesz w naszej', subscriptionLink: 'strona pomocy dotycząca typów planów i cen', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index d3913e1b3388..97c72f4cbbb7 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6979,6 +6979,10 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, label: 'Controle', description: 'Para organizações com requisitos avançados.', }, + submit2026: { + label: 'Enviar', + description: 'Para funcionários que desejam enviar despesas ao empregador.', + }, }, description: 'Escolha o plano ideal para você. Para ver a lista detalhada de recursos e preços, confira nosso', subscriptionLink: 'página de ajuda sobre tipos de plano e preços', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index bb88bc204cf1..9b9ba7341b14 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6806,6 +6806,10 @@ ${reportName} label: '控制', description: '适用于具有高级需求的组织。', }, + submit2026: { + label: '提交', + description: '适用于希望向雇主提交费用的员工。', + }, }, description: '选择适合您的方案。要查看详细的功能和价格列表,请访问我们的', subscriptionLink: '方案类型和价格帮助页面', diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index cedaf258bf0d..0bd30f3d28d2 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -929,6 +929,32 @@ function isPaidGroupPolicy(policy: OnyxInputOrEntry): boolean { return policy?.type === CONST.POLICY.TYPE.TEAM || policy?.type === CONST.POLICY.TYPE.CORPORATE; } +function isSubmitPolicy(policy: OnyxInputOrEntry): boolean { + return policy?.type === CONST.POLICY.TYPE.SUBMIT; +} + +function isPolicyEditor(policy: OnyxEntry): boolean { + return policy?.role === CONST.POLICY.ROLE.EDITOR; +} + +/** + * Returns true if the user can edit workspace settings — admins on any workspace, or editors on Submit workspaces. + */ +function canEditWorkspaceSettings(policy: OnyxEntry): boolean { + return isPolicyAdmin(policy) || isPolicyEditor(policy); +} + +/** + * Returns true for any group workspace: paid (Team/Corporate) or Submit. + * + * Note: not to be confused with `ReportUtils.isGroupPolicy(policyType: string)`, + * which excludes Submit. Use this helper when Submit workspaces should be treated + * like paid workspaces (e.g. access gating for shared workspace pages). + */ +function isGroupPolicy(policy: OnyxInputOrEntry): boolean { + return isPaidGroupPolicy(policy) || isSubmitPolicy(policy); +} + function getOwnedPaidPolicies(policies: OnyxCollection | null, currentUserAccountID: number | undefined): Policy[] { return Object.values(policies ?? {}).filter((policy): policy is Policy => isPolicyOwner(policy, currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID) && isPaidGroupPolicy(policy)); } @@ -2212,6 +2238,10 @@ export { isDelayedSubmissionEnabled, getCorrectedAutoReportingFrequency, isPaidGroupPolicy, + isSubmitPolicy, + isPolicyEditor, + canEditWorkspaceSettings, + isGroupPolicy, isPendingDeletePolicy, isPolicyAdmin, isPolicyUser, diff --git a/src/libs/SubscriptionUtils.ts b/src/libs/SubscriptionUtils.ts index 7193ad676243..3c26d554354d 100644 --- a/src/libs/SubscriptionUtils.ts +++ b/src/libs/SubscriptionUtils.ts @@ -539,7 +539,7 @@ function getSubscriptionPrice( privateSubscriptionType: SubscriptionType | undefined, hasTeam2025Pricing: boolean, ): number { - if (!privateSubscriptionType || !plan) { + if (!privateSubscriptionType || !plan || plan === CONST.POLICY.TYPE.SUBMIT) { return 0; } diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 4c8704f37d83..a59721afb0ca 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -1083,28 +1083,55 @@ function inviteMemberToWorkspace(policyID: string, inviterEmail?: string) { } /** - * Add member to the selected private domain workspace based on policyID + * Add member to the selected private domain workspace based on policyID. + * + * The optimistic merge is intentionally limited to `isLoading: true`. We can't + * tell the policy's real type/role from the joinable-policy payload (it's not + * exposed on `JoinablePolicy`), so writing speculative `type: SUBMIT` / + * `role: EDITOR` would corrupt Team/Corporate policies joined via the same + * private-domain flow. The `isLoading` flag is enough to suppress the brief + * NotFoundPage flash in `WorkspaceInitialPage` / `AccessOrNotFoundWrapper` + * until the backend response hydrates the policy with its actual shape. */ function joinAccessiblePolicy(policyID: string) { const memberJoinKey = `${ONYXKEYS.COLLECTION.POLICY_JOIN_MEMBER}${policyID}` as const; + const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; - const optimisticData: Array> = [ + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: memberJoinKey, value: {policyID}, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: {isLoading: true}, + }, ]; - const failureData: Array> = [ + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: {isLoading: false}, + }, + ]; + + const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: memberJoinKey, value: {policyID, errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.people.error.genericAdd')}, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: {isLoading: false}, + }, ]; - API.write(WRITE_COMMANDS.JOIN_ACCESSIBLE_POLICY, {policyID}, {optimisticData, failureData}); + API.write(WRITE_COMMANDS.JOIN_ACCESSIBLE_POLICY, {policyID}, {optimisticData, successData, failureData}); } /** diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 028edfc1e996..45e94f796cdc 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -214,7 +214,7 @@ type BuildPolicyDataOptions = { onboardingPurposeSelected?: OnboardingPurpose; shouldAddGuideWelcomeMessage?: boolean; shouldCreateControlPolicy?: boolean; - type?: typeof CONST.POLICY.TYPE.TEAM | typeof CONST.POLICY.TYPE.CORPORATE; + type?: typeof CONST.POLICY.TYPE.TEAM | typeof CONST.POLICY.TYPE.CORPORATE | typeof CONST.POLICY.TYPE.SUBMIT; // TODO: Make it required once we complete refactoring the buildPolicyData function to use isSelfTourViewed. Refactor issue: https://github.com/Expensify/App/issues/66424 isSelfTourViewed?: boolean; betas: OnyxEntry; @@ -2288,7 +2288,7 @@ function createDraftInitialWorkspace( makeMeAdmin = false, currency = '', file?: File, - type: typeof CONST.POLICY.TYPE.TEAM | typeof CONST.POLICY.TYPE.CORPORATE = CONST.POLICY.TYPE.TEAM, + type: typeof CONST.POLICY.TYPE.TEAM | typeof CONST.POLICY.TYPE.CORPORATE | typeof CONST.POLICY.TYPE.SUBMIT = CONST.POLICY.TYPE.TEAM, isAnnualSubscription = false, ) { const {customUnits, outputCurrency} = buildOptimisticDistanceRateCustomUnits(currency); @@ -2365,6 +2365,38 @@ type BuildPolicyDataKeys = | typeof ONYXKEYS.NVP_LAST_PAYMENT_METHOD | typeof ONYXKEYS.PERSONAL_DETAILS_LIST; +function getRoleForNewWorkspaceMember(isSubmitWorkspace: boolean, makeMeAdmin: boolean): ValueOf { + if (isSubmitWorkspace) { + return CONST.POLICY.ROLE.EDITOR; + } + return makeMeAdmin ? CONST.POLICY.ROLE.ADMIN : CONST.POLICY.ROLE.USER; +} + +function getRoleForCallerOnNewPolicy(isSubmitWorkspace: boolean, makeMeAdmin: boolean, policyOwnerEmail: string, currentUserEmail: string): ValueOf | undefined { + if (isSubmitWorkspace) { + return CONST.POLICY.ROLE.EDITOR; + } + // Caller is not on the workspace when creating it for a different owner without keeping admin. + if (policyOwnerEmail && policyOwnerEmail !== currentUserEmail && !makeMeAdmin) { + return undefined; + } + return CONST.POLICY.ROLE.ADMIN; +} + +function getApprovalModeForNewWorkspace( + isSubmitWorkspace: boolean, + shouldEnableWorkflowsByDefault: boolean, + engagementChoice?: OnboardingPurpose, +): ValueOf { + if (isSubmitWorkspace) { + return CONST.POLICY.APPROVAL_MODE.ADVANCED; + } + if (shouldEnableWorkflowsByDefault && engagementChoice !== CONST.ONBOARDING_CHOICES.TRACK_WORKSPACE) { + return CONST.POLICY.APPROVAL_MODE.BASIC; + } + return CONST.POLICY.APPROVAL_MODE.OPTIONAL; +} + /** * Generates onyx data for creating a new workspace * @@ -2431,7 +2463,9 @@ function buildPolicyData(options: BuildPolicyDataOptions): OnyxData feature.id === CONST.POLICY.MORE_FEATURES.ARE_DISTANCE_RATES_ENABLED && feature.enabled); + const areDistanceRatesEnabled = isSubmitWorkspace || !!featuresMap?.find((feature) => feature.id === CONST.POLICY.MORE_FEATURES.ARE_DISTANCE_RATES_ENABLED && feature.enabled); // WARNING: The data below should be kept in sync with the API so we create the policy with the correct configuration. const optimisticData: Array< @@ -2471,7 +2505,7 @@ function buildPolicyData(options: BuildPolicyDataOptions): OnyxData Categories with the side panel open so + * the #admins room is visible in Concierge Anywhere. + */ +function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, isSmallScreenWidth = false) { + setDisableDismissOnEscape(false); + + if (!policyID) { + Navigation.navigate(ROUTES.HOME); + return; + } + + setOnboardingRHPVariant(CONST.ONBOARDING_RHP_VARIANT.RHP_ADMINS_ROOM); + Navigation.navigate(ROUTES.WORKSPACES_LIST.route); + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate(ROUTES.WORKSPACE_CATEGORIES.getRoute(policyID)); + SidePanelActions.openSidePanel(!isSmallScreenWidth); + }); +} + +function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: string, isSmallScreenWidth = false) { + Navigation.dismissModal(); + Navigation.setNavigationActionToMicrotaskQueue(() => { + navigateToSubmitWorkspaceAfterOnboarding(policyID, isSmallScreenWidth); + }); +} + +export {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue}; diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx index 85b3e67b4bc8..956f91be9169 100644 --- a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx +++ b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx @@ -10,6 +10,7 @@ import Text from '@components/Text'; import TextInput from '@components/TextInput'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import useArchivedReportsIdSet from '@hooks/useArchivedReportsIdSet'; +import useAutoCreateSubmitWorkspace from '@hooks/useAutoCreateSubmitWorkspace'; import useAutoCreateTrackWorkspace from '@hooks/useAutoCreateTrackWorkspace'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; @@ -54,6 +55,7 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat const [onboardingPersonalDetailsForm] = useOnyx(ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const autoCreateTrackWorkspace = useAutoCreateTrackWorkspace(); + const autoCreateSubmitWorkspace = useAutoCreateSubmitWorkspace(); // When we merge public email with work email, we now want to navigate to the // concierge chat report of the new work email and not the last accessed report. @@ -64,6 +66,7 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat const {inputCallbackRef} = useAutoFocusInput(); const [shouldValidateOnChange, setShouldValidateOnChange] = useState(false); const {isBetaEnabled} = usePermissions(); + const canUseSubmit2026 = isBetaEnabled(CONST.BETAS.SUBMIT_2026); const onboardingStep = useOnboardingStepCounter(SCREENS.ONBOARDING.PERSONAL_DETAILS); const isPrivateDomainAndHasAccessiblePolicies = !account?.isFromPublicDomain && !!account?.hasAccessibleDomainPolicies; @@ -131,6 +134,16 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat clearPersonalDetailsDraft(); setPersonalDetails(firstName, lastName); + if (onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.EMPLOYER && canUseSubmit2026) { + if (isPrivateDomainAndHasAccessiblePolicies && isValidated) { + Navigation.navigate(ROUTES.ONBOARDING_WORKSPACES.getRoute(route.params?.backTo)); + return; + } + updateDisplayName(firstName, lastName, formatPhoneNumber, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, session?.email ?? ''); + autoCreateSubmitWorkspace(firstName, lastName); + return; + } + if (isPrivateDomainAndHasAccessiblePolicies && (!onboardingPurposeSelected || isVsb || isSmb)) { const nextRoute = isValidated ? ROUTES.ONBOARDING_WORKSPACES : ROUTES.ONBOARDING_PRIVATE_DOMAIN; Navigation.navigate(nextRoute.getRoute(route.params?.backTo)); @@ -157,12 +170,14 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat session?.email, isPrivateDomainAndHasAccessiblePolicies, onboardingPurposeSelected, + canUseSubmit2026, isVsb, isSmb, completeOnboarding, isValidated, route.params?.backTo, autoCreateTrackWorkspace, + autoCreateSubmitWorkspace, ], ); diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx index 2643a641699a..c33ec373f627 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -9,18 +9,21 @@ import type {MenuItemProps} from '@components/MenuItem'; import MenuItemList from '@components/MenuItemList'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; +import useAutoCreateSubmitWorkspace from '@hooks/useAutoCreateSubmitWorkspace'; import useAutoCreateTrackWorkspace from '@hooks/useAutoCreateTrackWorkspace'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnboardingMessages from '@hooks/useOnboardingMessages'; import useOnboardingStepCounter from '@hooks/useOnboardingStepCounter'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import OnboardingRefManager from '@libs/OnboardingRefManager'; import type {TOnboardingRef} from '@libs/OnboardingRefManager'; +import {isCurrentUserValidated} from '@libs/UserUtils'; import variables from '@styles/variables'; import {completeOnboarding} from '@userActions/Report'; import {setOnboardingErrorMessage, setOnboardingPurposeSelected} from '@userActions/Welcome'; @@ -74,6 +77,12 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const [betas] = useOnyx(ONYXKEYS.BETAS); + const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); + const [session] = useOnyx(ONYXKEYS.SESSION); + const {isBetaEnabled} = usePermissions(); + const canUseSubmit2026 = isBetaEnabled(CONST.BETAS.SUBMIT_2026); + const isValidated = isCurrentUserValidated(loginList, session?.email); + const autoCreateSubmitWorkspace = useAutoCreateSubmitWorkspace(); const autoCreateTrackWorkspace = useAutoCreateTrackWorkspace(); const paddingHorizontal = onboardingIsMediumOrLargerScreenWidth ? styles.ph8 : styles.ph5; @@ -102,6 +111,23 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro return; } + if (choice === CONST.ONBOARDING_CHOICES.EMPLOYER && canUseSubmit2026) { + if (isPrivateDomainAndHasAccessiblePolicies && isValidated) { + Navigation.navigate( + personalDetailsForm?.firstName ? ROUTES.ONBOARDING_WORKSPACES.getRoute(route.params?.backTo) : ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(route.params?.backTo), + ); + return; + } + + if (personalDetailsForm?.firstName) { + autoCreateSubmitWorkspace(personalDetailsForm.firstName, personalDetailsForm.lastName ?? ''); + return; + } + + Navigation.navigate(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(route.params?.backTo)); + return; + } + if (isPrivateDomainAndHasAccessiblePolicies && personalDetailsForm?.firstName) { if (choice === CONST.ONBOARDING_CHOICES.PERSONAL_SPEND) { autoCreateTrackWorkspace(personalDetailsForm.firstName, personalDetailsForm.lastName ?? '', choice); diff --git a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx index 1814b46388d6..6d81539a268d 100644 --- a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx +++ b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx @@ -9,6 +9,7 @@ import SelectionList from '@components/SelectionList'; import BareUserListItem from '@components/SelectionList/ListItem/BareUserListItem'; import Text from '@components/Text'; import useArchivedReportsIdSet from '@hooks/useArchivedReportsIdSet'; +import useAutoCreateSubmitWorkspace from '@hooks/useAutoCreateSubmitWorkspace'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -19,7 +20,7 @@ import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {navigateAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboardingWithMicrotaskQueue, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import {isCurrentUserValidated} from '@libs/UserUtils'; @@ -69,20 +70,34 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding const [onboardingValues] = useOnyx(ONYXKEYS.NVP_ONBOARDING); const isVsb = onboardingValues?.signupQualifier === CONST.ONBOARDING_SIGNUP_QUALIFIERS.VSB; const isSmb = onboardingValues?.signupQualifier === CONST.ONBOARDING_SIGNUP_QUALIFIERS.SMB; + const [onboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED); + const canUseSubmit2026 = isBetaEnabled(CONST.BETAS.SUBMIT_2026); + const isEmployerWithSubmit = onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.EMPLOYER && canUseSubmit2026; + const autoCreateSubmitWorkspace = useAutoCreateSubmitWorkspace(); const shouldHideBackButton = onboardingValues?.shouldValidate === false && route.params?.backTo === ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(); const onboardingStep = useOnboardingStepCounter(SCREENS.ONBOARDING.WORKSPACES); const handleJoinWorkspace = (policy: JoinablePolicy) => { + // Only mirror the EMPLOYER ("Get paid back by my employer") + Submit-2026 onboarding flow + // when the user actually picked EMPLOYER, or when no Purpose was selected (private-domain + // users who reach this screen without going through the Purpose step). Users on the Submit + // beta who picked a different Purpose (e.g. MANAGE_TEAM) must not be re-routed through + // the Submit flow. + const shouldUseSubmitFlow = canUseSubmit2026 && (!onboardingPurposeSelected || onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.EMPLOYER); + if (policy.automaticJoiningEnabled) { joinAccessiblePolicy(policy.policyID); } else { askToJoinPolicy(policy.policyID); } + + const engagementChoice = shouldUseSubmitFlow ? CONST.ONBOARDING_CHOICES.EMPLOYER : CONST.ONBOARDING_CHOICES.LOOKING_AROUND; completeOnboarding({ - engagementChoice: CONST.ONBOARDING_CHOICES.LOOKING_AROUND, - onboardingMessage: onboardingMessages[CONST.ONBOARDING_CHOICES.LOOKING_AROUND], + engagementChoice, + onboardingMessage: onboardingMessages[engagementChoice], firstName: onboardingPersonalDetails?.firstName ?? '', lastName: onboardingPersonalDetails?.lastName ?? '', + onboardingPolicyID: shouldUseSubmitFlow && policy.automaticJoiningEnabled ? policy.policyID : undefined, companySize: onboardingCompanySize, introSelected, isSelfTourViewed, @@ -91,6 +106,11 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding setOnboardingAdminsChatReportID(); setOnboardingPolicyID(policy.policyID); + if (shouldUseSubmitFlow && policy.automaticJoiningEnabled) { + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policy.policyID, isSmallScreenWidth); + return; + } + navigateAfterOnboardingWithMicrotaskQueue( isSmallScreenWidth, isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), @@ -146,6 +166,11 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding }); const skipJoiningWorkspaces = () => { + if (isEmployerWithSubmit) { + autoCreateSubmitWorkspace(onboardingPersonalDetails?.firstName ?? '', onboardingPersonalDetails?.lastName ?? ''); + return; + } + if (isVsb) { Navigation.navigate(ROUTES.ONBOARDING_ACCOUNTING.getRoute(route.params?.backTo)); return; diff --git a/src/pages/workspace/AccessOrNotFoundWrapper.tsx b/src/pages/workspace/AccessOrNotFoundWrapper.tsx index 87fab021f830..636662d1062d 100644 --- a/src/pages/workspace/AccessOrNotFoundWrapper.tsx +++ b/src/pages/workspace/AccessOrNotFoundWrapper.tsx @@ -14,7 +14,7 @@ import {openWorkspace} from '@libs/actions/Policy/Policy'; import {isValidMoneyRequestType} from '@libs/IOUUtils'; import goBackFromWorkspaceSettingPages from '@libs/Navigation/helpers/goBackFromWorkspaceSettingPages'; import Navigation from '@libs/Navigation/Navigation'; -import {canSendInvoice, isControlPolicy, isPaidGroupPolicy, isPolicyAccessible, isPolicyAdmin, isPolicyFeatureEnabled as isPolicyFeatureEnabledUtil} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, canSendInvoice, isControlPolicy, isGroupPolicy, isPolicyAccessible, isPolicyFeatureEnabled as isPolicyFeatureEnabledUtil} from '@libs/PolicyUtils'; import {canCreateRequest} from '@libs/ReportUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; @@ -30,9 +30,9 @@ import callOrReturn from '@src/types/utils/callOrReturn'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; const ACCESS_VARIANTS = { - [CONST.POLICY.ACCESS_VARIANTS.PAID]: (policy: OnyxEntry) => isPaidGroupPolicy(policy), + [CONST.POLICY.ACCESS_VARIANTS.PAID]: (policy: OnyxEntry) => isGroupPolicy(policy), [CONST.POLICY.ACCESS_VARIANTS.CONTROL]: (policy: OnyxEntry) => isControlPolicy(policy), - [CONST.POLICY.ACCESS_VARIANTS.ADMIN]: (policy: OnyxEntry, login: string) => isPolicyAdmin(policy, login), + [CONST.POLICY.ACCESS_VARIANTS.ADMIN]: (policy: OnyxEntry) => canEditWorkspaceSettings(policy), [CONST.IOU.ACCESS_VARIANTS.CREATE]: ( policy: OnyxEntry, login: string, @@ -164,7 +164,8 @@ function AccessOrNotFoundWrapper({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isPolicyIDInRoute, policyID]); - const shouldShowFullScreenLoadingIndicator = !isMoneyRequest && isLoadingReportData !== false && (!Object.entries(policy ?? {}).length || !policy?.id); + const isPolicyEmpty = !Object.entries(policy ?? {}).length || !policy?.id; + const shouldShowFullScreenLoadingIndicator = !isMoneyRequest && (isLoadingReportData !== false || !!policy?.isLoading) && isPolicyEmpty; const isFeatureEnabled = featureName ? isPolicyFeatureEnabledUtil(policy, featureName) : true; diff --git a/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx b/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx index d5de898ef1c1..cb97854f9c0b 100644 --- a/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx +++ b/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx @@ -55,7 +55,17 @@ function DynamicWorkspaceOverviewPlanTypePage({policy}: WithPolicyProps) { }, [policy?.type]); const workspacePlanTypes = Object.values(CONST.POLICY.TYPE) - .filter((type) => type !== CONST.POLICY.TYPE.PERSONAL) + .filter((type) => { + if (type === CONST.POLICY.TYPE.PERSONAL) { + return false; + } + // Guard: don't leak the SUBMIT plan type into the plan-type list for paid workspaces. + // Submit-specific plan-type UX (exposing SUBMIT for Submit policies) ships in #87263. + if (type === CONST.POLICY.TYPE.SUBMIT) { + return false; + } + return true; + }) .map((policyType) => ({ value: policyType, text: translate(`workspace.planTypePage.planTypes.${policyType as PersonalPolicyTypeExcludedProps}.label`), @@ -81,6 +91,14 @@ function DynamicWorkspaceOverviewPlanTypePage({policy}: WithPolicyProps) { ) : null; const handleUpdatePlan = () => { + // Submit policies don't expose SUBMIT in the option list, but the editor can + // still pick Team/Corporate. Route any selection from a Submit policy to the + // upgrade screen — the polished Submit-specific upgrade UX ships in #87263. + if (policyID && policy?.type === CONST.POLICY.TYPE.SUBMIT && (currentPlan === CONST.POLICY.TYPE.TEAM || currentPlan === CONST.POLICY.TYPE.CORPORATE)) { + Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policyID)); + return; + } + if (policyID && policy?.type === CONST.POLICY.TYPE.TEAM && currentPlan === CONST.POLICY.TYPE.CORPORATE) { Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policyID)); return; diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index ad9bc4901466..19d3961f93bf 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -14,7 +14,6 @@ import ScrollView from '@components/ScrollView'; import useCardFeedErrors from '@hooks/useCardFeedErrors'; import useConfirmReadyToOpenApp from '@hooks/useConfirmReadyToOpenApp'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -33,14 +32,14 @@ import {clearErrors, openPolicyInitialPage, removeWorkspace} from '@libs/actions import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import { + canEditWorkspaceSettings, canPolicyAccessFeature, shouldShowPolicy as checkIfShouldShowPolicy, goBackFromInvalidPolicy, hasAccountingFeatureConnection, hasPolicyCategoriesError, - isPaidGroupPolicy, + isGroupPolicy, isPendingDeletePolicy, - isPolicyAdmin, isTimeTrackingEnabled, shouldShowEmployeeListError, shouldShowSyncError, @@ -95,7 +94,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const {translate} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); const {isBetaEnabled} = usePermissions(); - const {login} = useCurrentUserPersonalDetails(); + const isFocused = useIsFocused(); const activeRoute = useNavigationState((state) => findFocusedRoute(state)?.name); const waitForNavigate = useWaitForNavigation(); @@ -134,7 +133,8 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const policyName = policy?.name ?? ''; const hasPolicyCreationError = policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && !isEmptyObject(policy.errors); - const shouldShowProtectedItems = isPolicyAdmin(policy, login); + const shouldShowProtectedItems = canEditWorkspaceSettings(policy); + const accountingConnectionNames = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES; const hasSyncError = shouldShowSyncError(policy, isConnectionInProgress(connectionSyncProgress, policy), accountingConnectionNames); const hasMembersError = shouldShowEmployeeListError(policy); @@ -184,8 +184,9 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const shouldShowPolicy = checkIfShouldShowPolicy(policy, true, currentUserLogin); const isPendingDelete = isPendingDeletePolicy(policy); const prevIsPendingDelete = isPendingDeletePolicy(prevPolicy); - - const shouldShowNotFoundPage = !shouldShowPolicy && (!isPendingDelete || prevIsPendingDelete); + // While the policy is being fetched (e.g., right after joinAccessiblePolicy), the role is not yet populated, + // so checkIfShouldShowPolicy returns false. Suppress NotFound during this loading window. + const shouldShowNotFoundPage = !shouldShowPolicy && !policy?.isLoading && (!isPendingDelete || prevIsPendingDelete); const fetchPolicyData = () => { if (policyDraft?.id || !isFocused) { return; @@ -227,7 +228,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac }, ]; - if (isPaidGroupPolicy(policy) && shouldShowProtectedItems) { + if (isGroupPolicy(policy) && shouldShowProtectedItems) { workspaceMenuItems.push({ translationKey: 'common.reports', icon: expensifyIcons.Document, diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index a05b20423b53..33c2d563ea04 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -62,13 +62,13 @@ import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import {isPersonalDetailsReady, sortAlphabetically} from '@libs/OptionsListUtils'; import {getDisplayNameOrDefault, getPersonalDetailsByIDs} from '@libs/PersonalDetailsUtils'; import { + canEditWorkspaceSettings, getConnectionExporters, getMemberAccountIDsForWorkspace, isControlPolicy, isDeletedPolicyEmployee, isExpensifyTeam, isPaidGroupPolicy, - isPolicyAdmin as isPolicyAdminUtils, isPolicyApprover, } from '@libs/PolicyUtils'; import {getDisplayNameForParticipant} from '@libs/ReportUtils'; @@ -155,7 +155,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type for the decision modal // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); - const isPolicyAdmin = isPolicyAdminUtils(policy); + const isPolicyAdmin = canEditWorkspaceSettings(policy); const isLoading = useMemo( () => !isOfflineAndNoMemberDataAvailable && (!isPersonalDetailsReady(personalDetails) || isEmptyObject(policy?.employeeList)), [isOfflineAndNoMemberDataAvailable, personalDetails, policy?.employeeList], diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index 445882b87b8b..4f8d0cec2cc7 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -53,6 +53,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import { + canEditWorkspaceSettings, getConnectionExporters, getUserFriendlyWorkspaceType, goBackFromInvalidPolicy, @@ -109,7 +110,7 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const isBankAccountVerified = !!settings?.paymentBankAccountID; const shouldBlockCurrencyChange = useShouldBlockCurrencyChange(policyID); - const isPolicyAdmin = isPolicyAdminPolicyUtils(policy); + const isPolicyAdmin = canEditWorkspaceSettings(policy); const outputCurrency = policy?.outputCurrency ?? ''; const currencySymbol = getCurrencySymbol(outputCurrency) ?? ''; const formattedCurrency = !isEmptyObject(policy) ? `${outputCurrency} - ${currencySymbol}` : ''; @@ -177,7 +178,7 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const policyName = policy?.name ?? ''; const policyDescription = policy?.description ?? translate('workspace.common.defaultDescription'); const policyCurrency = policy?.outputCurrency ?? ''; - const readOnly = !isPolicyAdminPolicyUtils(policy); + const readOnly = !canEditWorkspaceSettings(policy); const currencyReadOnly = readOnly || isBankAccountVerified; const isOwner = isPolicyOwner(policy, currentUserPersonalDetails.accountID); const shouldShowAddress = !readOnly || !!formattedAddress; diff --git a/src/pages/workspace/WorkspacePageWithSections.tsx b/src/pages/workspace/WorkspacePageWithSections.tsx index a2e1c8846792..1d90f6414c89 100644 --- a/src/pages/workspace/WorkspacePageWithSections.tsx +++ b/src/pages/workspace/WorkspacePageWithSections.tsx @@ -19,7 +19,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {openWorkspaceView} from '@libs/actions/BankAccounts'; import goBackFromWorkspaceSettingPages from '@libs/Navigation/helpers/goBackFromWorkspaceSettingPages'; import Navigation from '@libs/Navigation/Navigation'; -import {isPendingDeletePolicy, isPolicyAdmin, shouldShowPolicy as shouldShowPolicyUtil} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, isPendingDeletePolicy, shouldShowPolicy as shouldShowPolicyUtil} from '@libs/PolicyUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -172,7 +172,7 @@ function WorkspacePageWithSections({ } // We check isPendingDelete and prevIsPendingDelete to prevent the NotFound view from showing right after we delete the workspace - return (!isEmptyObject(policy) && !isPolicyAdmin(policy) && !shouldShowNonAdmin) || (!shouldShowPolicy && !(isPendingDelete && !prevIsPendingDelete)); + return (!isEmptyObject(policy) && !canEditWorkspaceSettings(policy) && !shouldShowNonAdmin) || (!shouldShowPolicy && !(isPendingDelete && !prevIsPendingDelete)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [policy, shouldShowNonAdmin, shouldShowPolicy]); diff --git a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx index 8661e2e0a8b4..576f4af16821 100644 --- a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx +++ b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx @@ -15,7 +15,7 @@ import {updateXeroMappings} from '@libs/actions/connections/Xero'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {canModifyPlan, getDefaultApprover, getPerDiemCustomUnit, isControlPolicy} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, canModifyPlan, getDefaultApprover, getPerDiemCustomUnit, isControlPolicy} from '@libs/PolicyUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import {enablePerDiem} from '@userActions/Policy/PerDiem'; import CONST from '@src/CONST'; @@ -231,7 +231,10 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { }, [isUpgraded, canPerformUpgrade, confirmUpgrade]), ); - if (!canPerformUpgrade) { + // Gate the page to users who can edit workspace settings (admins on any policy, + // or editors on Submit policies). `canPerformUpgrade` (strict admin) still controls + // whether the upgrade button is active, so editors see the intro but can't upgrade. + if (!canEditWorkspaceSettings(policy)) { return ; } @@ -264,7 +267,7 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { policyID={policyID} feature={feature} onUpgrade={onUpgradeToCorporate} - buttonDisabled={isOffline} + buttonDisabled={isOffline || !canPerformUpgrade} loading={policy?.isPendingUpgrade} backTo={route.params.backTo} /> diff --git a/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx b/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx index 17cbe5df9fc7..72e265ee83dd 100644 --- a/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx @@ -14,7 +14,7 @@ import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import {getCorrectedAutoReportingFrequency, goBackFromInvalidPolicy, isPaidGroupPolicy, isPendingDeletePolicy, isPolicyAdmin} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, getCorrectedAutoReportingFrequency, goBackFromInvalidPolicy, isGroupPolicy, isPendingDeletePolicy} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicy from '@pages/workspace/withPolicy'; import type {WithPolicyOnyxProps} from '@pages/workspace/withPolicy'; @@ -118,7 +118,7 @@ function WorkspaceAutoReportingFrequencyPage({policy, route}: WorkspaceAutoRepor diff --git a/src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx b/src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx index c45a8debd9f3..32e450265fe0 100644 --- a/src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx @@ -9,7 +9,7 @@ import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import {goBackFromInvalidPolicy, isPaidGroupPolicy, isPendingDeletePolicy, isPolicyAdmin} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, goBackFromInvalidPolicy, isGroupPolicy, isPendingDeletePolicy} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicy from '@pages/workspace/withPolicy'; import type {WithPolicyOnyxProps} from '@pages/workspace/withPolicy'; @@ -94,7 +94,7 @@ function WorkspaceAutoReportingMonthlyOffsetPage({policy, route}: WorkspaceAutoR diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index 234e9d747a57..a9e5cdd270bc 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -49,10 +49,11 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import {getPaymentMethodDescription} from '@libs/PaymentUtils'; import {getDisplayNameOrDefault, getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import { + canEditWorkspaceSettings, getCorrectedAutoReportingFrequency, hasDynamicExternalWorkflow, isControlPolicy, - isPaidGroupPolicy as isPaidGroupPolicyUtil, + isGroupPolicy as isGroupPolicyUtil, isPolicyAdmin as isPolicyAdminUtil, } from '@libs/PolicyUtils'; import {hasInProgressVBBA} from '@libs/ReimbursementAccountUtils'; @@ -629,7 +630,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { ); - const isPaidGroupPolicy = isPaidGroupPolicyUtil(policy); + const isGroupPolicy = isGroupPolicyUtil(policy); const isLoading = !!(policy?.isLoading && policy?.reimbursementChoice === undefined); return ( @@ -642,7 +643,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { icon={illustrations.Workflows} route={route} shouldShowOfflineIndicatorInWideScreen - shouldShowNotFoundPage={!isPaidGroupPolicy || !isPolicyAdmin} + shouldShowNotFoundPage={!isGroupPolicy || !canEditWorkspaceSettings(policy)} isLoading={isLoading} shouldShowLoading={isLoading} shouldUseScrollView diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApprovalLimitPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApprovalLimitPage.tsx index 5e14701cac06..f7822beb595f 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApprovalLimitPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApprovalLimitPage.tsx @@ -23,7 +23,7 @@ import {convertToBackendAmount, convertToFrontendAmountAsString} from '@libs/Cur import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import {goBackFromInvalidPolicy, isPendingDeletePolicy, isPolicyAdmin} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, goBackFromInvalidPolicy, isPendingDeletePolicy} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -65,7 +65,7 @@ function WorkspaceWorkflowsApprovalsApprovalLimitPage({policy, isLoadingReportDa const selectedApproverPersonalDetails = selectedApproverEmail ? personalDetailsByEmail?.[selectedApproverEmail] : undefined; const selectedApproverDisplayName = selectedApproverEmail ? Str.removeSMSDomain(selectedApproverPersonalDetails?.displayName ?? selectedApproverEmail) : ''; - const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !isPolicyAdmin(policy) || isPendingDeletePolicy(policy); + const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !canEditWorkspaceSettings(policy) || isPendingDeletePolicy(policy); const approverDisplayName = currentApprover ? Str.removeSMSDomain(currentApprover.displayName) : ''; const isApproverSelected = isEditFlow ? approverDisplayName.length > 0 : true; diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx index 15da8a38321d..2c81ebc003c4 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx @@ -13,7 +13,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import {goBackFromInvalidPolicy, isPendingDeletePolicy, isPolicyAdmin} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, goBackFromInvalidPolicy, isPendingDeletePolicy} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -36,7 +36,7 @@ function WorkspaceWorkflowsApprovalsCreatePage({policy, isLoadingReportData = tr const [addExpenseApprovalsTaskReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${addExpenseApprovalsTaskReportID}`); const formRef = useRef(null); - const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !isPolicyAdmin(policy) || isPendingDeletePolicy(policy); + const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !canEditWorkspaceSettings(policy) || isPendingDeletePolicy(policy); const createApprovalWorkflow = useCallback(() => { if (!approvalWorkflow) { diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx index 9e530d9aec48..af9b430bd53e 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx @@ -18,7 +18,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import {goBackFromInvalidPolicy, isPendingDeletePolicy, isPolicyAdmin} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, goBackFromInvalidPolicy, isPendingDeletePolicy} from '@libs/PolicyUtils'; import {convertPolicyEmployeesToApprovalWorkflows, mergeWorkflowMembersWithAvailableMembers} from '@libs/WorkflowUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -100,7 +100,7 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true const {currentApprovalWorkflow, defaultWorkflowMembers, usedApproverEmails} = getApprovalWorkflowData(); - const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !isPolicyAdmin(policy) || isPendingDeletePolicy(policy) || !currentApprovalWorkflow; + const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !canEditWorkspaceSettings(policy) || isPendingDeletePolicy(policy) || !currentApprovalWorkflow; // Set the initial approval workflow when the page is loaded useEffect(() => { diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx index cc9f680177a7..dcb9a4639f43 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx @@ -15,7 +15,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; -import {getDefaultApprover, getMemberAccountIDsForWorkspace, isPendingDeletePolicy, isPolicyAdmin} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, getDefaultApprover, getMemberAccountIDsForWorkspace, isPendingDeletePolicy} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import MemberRightIcon from '@pages/workspace/MemberRightIcon'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -41,7 +41,7 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat const isLoadingApprovalWorkflow = isLoadingOnyxValue(approvalWorkflowResults); const [selectedMembers, setSelectedMembers] = useState([]); - const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !isPolicyAdmin(policy) || isPendingDeletePolicy(policy); + const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !canEditWorkspaceSettings(policy) || isPendingDeletePolicy(policy); const isInitialCreationFlow = approvalWorkflow?.action === CONST.APPROVAL_WORKFLOW.ACTION.CREATE && approvalWorkflow?.isInitialFlow; const shouldShowListEmptyContent = !isLoadingApprovalWorkflow && approvalWorkflow?.availableMembers.length === 0; const firstApprover = approvalWorkflow?.originalApprovers?.[0]?.email ?? ''; diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index 8c136f2c5eba..b507c9854840 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -1243,6 +1243,43 @@ describe('actions/Policy', () => { }); }); + it('creates a Submit workspace with ADVANCED approval mode and correct feature flags', async () => { + const policyID = Policy.generatePolicyID(); + Policy.createWorkspace({ + policyOwnerEmail: ESH_EMAIL, + makeMeAdmin: true, + policyName: WORKSPACE_NAME, + policyID, + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + introSelected: {choice: CONST.ONBOARDING_CHOICES.EMPLOYER}, + currentUserAccountIDParam: ESH_ACCOUNT_ID, + currentUserEmailParam: ESH_EMAIL, + isSelfTourViewed: false, + betas: undefined, + hasActiveAdminPolicies: false, + activePolicy: undefined, + type: CONST.POLICY.TYPE.SUBMIT, + }); + await waitForBatchedUpdates(); + + await TestHelper.getOnyxData({ + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + waitForCollectionCallback: false, + callback: (policy) => { + expect(policy?.type).toBe(CONST.POLICY.TYPE.SUBMIT); + expect(policy?.role).toBe(CONST.POLICY.ROLE.EDITOR); + expect(policy?.approvalMode).toBe(CONST.POLICY.APPROVAL_MODE.ADVANCED); + expect(policy?.areWorkflowsEnabled).toBe(true); + expect(policy?.areTagsEnabled).toBe(true); + expect(policy?.areDistanceRatesEnabled).toBe(true); + expect(policy?.areCompanyCardsEnabled).toBe(false); + expect(policy?.harvesting?.enabled).toBe(false); + expect(policy?.autoReportingFrequency).toBe(CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE); + expect(policy?.employeeList?.[ESH_EMAIL]?.role).toBe(CONST.POLICY.ROLE.EDITOR); + }, + }); + }); + it('should pass areDistanceRatesEnabled as true when creating workspace with distance rates feature enabled', async () => { await Onyx.set(ONYXKEYS.SESSION, {email: ESH_EMAIL, accountID: ESH_ACCOUNT_ID}); await waitForBatchedUpdates(); diff --git a/tests/ui/OnboardingPurpose.tsx b/tests/ui/OnboardingPurpose.tsx index 1b48f6e54cce..5a487f15f185 100644 --- a/tests/ui/OnboardingPurpose.tsx +++ b/tests/ui/OnboardingPurpose.tsx @@ -13,6 +13,7 @@ import Navigation from '@libs/Navigation/Navigation'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {OnboardingModalNavigatorParamList} from '@libs/Navigation/types'; import OnboardingPurpose from '@pages/OnboardingPurpose'; +import {createWorkspace} from '@userActions/Policy/Policy'; import {completeOnboarding} from '@userActions/Report'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -24,6 +25,7 @@ import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; const mockCompleteOnboarding = jest.mocked(completeOnboarding); +const mockCreateWorkspace = jest.mocked(createWorkspace); jest.mock('@userActions/Report', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -35,6 +37,19 @@ jest.mock('@userActions/Report', () => { }; }); +jest.mock('@userActions/Policy/Policy', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@userActions/Policy/Policy'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + createWorkspace: jest.fn().mockReturnValue({ + policyID: 'test-policy-id', + adminsChatReportID: 'test-admins-report-id', + }), + }; +}); + TestHelper.setupGlobalFetchMock(); // Helper to translate onboarding purpose keys that use dynamic CONST values @@ -138,6 +153,132 @@ describe('OnboardingPurpose Page', () => { await waitForBatchedUpdatesWithAct(); }); + it('should navigate to personal details page when user selects EMPLOYER with Submit2026 beta and is from public domain', async () => { + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, { + isFromPublicDomain: true, + hasAccessibleDomainPolicies: false, + }); + await Onyx.merge(ONYXKEYS.BETAS, [CONST.BETAS.SUBMIT_2026]); + }); + + const {unmount} = renderOnboardingPurposePage(SCREENS.ONBOARDING.PURPOSE, {backTo: ''}); + + await waitForBatchedUpdatesWithAct(); + + const user = userEvent.setup(); + const employerLabel = translatePurpose(CONST.ONBOARDING_CHOICES.EMPLOYER); + const employerOption = screen.getByLabelText(employerLabel); + await user.press(employerOption); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute('')); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should navigate to workspaces page when user selects EMPLOYER with Submit2026 beta and is from private domain with name set', async () => { + const testEmail = 'test@user.com'; + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, { + isFromPublicDomain: false, + hasAccessibleDomainPolicies: true, + }); + await Onyx.merge(ONYXKEYS.LOGIN_LIST, { + [testEmail]: { + partnerName: 'expensify.com', + partnerUserID: testEmail, + validatedDate: 'fake-validatedDate', + }, + }); + await Onyx.merge(ONYXKEYS.BETAS, [CONST.BETAS.SUBMIT_2026]); + await Onyx.merge(ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM, { + firstName: 'Test', + lastName: 'User', + }); + }); + + const {unmount} = renderOnboardingPurposePage(SCREENS.ONBOARDING.PURPOSE, {backTo: ''}); + + await waitForBatchedUpdatesWithAct(); + + const user = userEvent.setup(); + const employerLabel = translatePurpose(CONST.ONBOARDING_CHOICES.EMPLOYER); + const employerOption = screen.getByLabelText(employerLabel); + await user.press(employerOption); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith(ROUTES.ONBOARDING_WORKSPACES.getRoute('')); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should create a Submit workspace from Purpose when EMPLOYER is selected and personal details already exist', async () => { + jest.spyOn(Navigation, 'dismissModal').mockImplementation(() => {}); + jest.spyOn(Navigation, 'setNavigationActionToMicrotaskQueue').mockImplementation((callback: () => void) => callback()); + + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, { + isFromPublicDomain: true, + hasAccessibleDomainPolicies: false, + }); + await Onyx.merge(ONYXKEYS.BETAS, [CONST.BETAS.SUBMIT_2026]); + await Onyx.merge(ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM, { + firstName: 'Test', + lastName: 'User', + }); + }); + + const onyxSetSpy = jest.spyOn(Onyx, 'set'); + onyxSetSpy.mockClear(); + + const {unmount} = renderOnboardingPurposePage(SCREENS.ONBOARDING.PURPOSE, {backTo: ''}); + + await waitForBatchedUpdatesWithAct(); + + const user = userEvent.setup(); + const employerLabel = translatePurpose(CONST.ONBOARDING_CHOICES.EMPLOYER); + const employerOption = screen.getByLabelText(employerLabel); + await user.press(employerOption); + + await waitFor(() => { + expect(mockCreateWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + type: CONST.POLICY.TYPE.SUBMIT, + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + }), + ); + }); + + await waitFor(() => { + expect(mockCompleteOnboarding).toHaveBeenCalledWith( + expect.objectContaining({ + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + onboardingPolicyID: 'test-policy-id', + }), + ); + }); + + await waitFor(() => { + expect(onyxSetSpy).toHaveBeenCalledWith(ONYXKEYS.NVP_ONBOARDING_RHP_VARIANT, CONST.ONBOARDING_RHP_VARIANT.RHP_ADMINS_ROOM); + expect(navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')); + }); + + onyxSetSpy.mockRestore(); + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + it('should call completeOnboarding with introSelected when user is from private domain and selects a direct-complete choice', async () => { await TestHelper.signInWithTestUser(); diff --git a/tests/ui/PersonalDetailsOnboarding.tsx b/tests/ui/PersonalDetailsOnboarding.tsx index d165bcc96aaa..d7a7dde9d632 100644 --- a/tests/ui/PersonalDetailsOnboarding.tsx +++ b/tests/ui/PersonalDetailsOnboarding.tsx @@ -14,6 +14,8 @@ import Navigation from '@libs/Navigation/Navigation'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {OnboardingModalNavigatorParamList} from '@navigation/types'; import OnboardingPersonalDetails from '@pages/OnboardingPersonalDetails'; +import {createWorkspace} from '@userActions/Policy/Policy'; +import {completeOnboarding} from '@userActions/Report'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -22,6 +24,32 @@ import SCREENS from '@src/SCREENS'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; +const mockCreateWorkspace = jest.mocked(createWorkspace); +const mockCompleteOnboarding = jest.mocked(completeOnboarding); + +jest.mock('@userActions/Report', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@userActions/Report'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + completeOnboarding: jest.fn(), + }; +}); + +jest.mock('@userActions/Policy/Policy', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@userActions/Policy/Policy'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + createWorkspace: jest.fn().mockReturnValue({ + policyID: 'test-policy-id', + adminsChatReportID: 'test-admins-report-id', + }), + }; +}); + TestHelper.setupGlobalFetchMock(); const Stack = createPlatformStackNavigator(); @@ -143,4 +171,84 @@ describe('OnboardingPersonalDetails Page', () => { unmount(); await waitForBatchedUpdatesWithAct(); }); + + it('should navigate to Onboarding workspaces page when submitting form with EMPLOYER + Submit2026 beta and validated private domain', async () => { + const testEmail = 'test@user.com'; + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, { + isFromPublicDomain: false, + hasAccessibleDomainPolicies: true, + }); + await Onyx.merge(ONYXKEYS.LOGIN_LIST, { + [testEmail]: { + partnerName: 'expensify.com', + partnerUserID: testEmail, + validatedDate: 'fake-validatedDate', + }, + }); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.EMPLOYER); + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.SUBMIT_2026]); + }); + + const {unmount} = renderOnboardingPersonalDetailsPage(SCREENS.ONBOARDING.PERSONAL_DETAILS, {backTo: ''}); + + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByText(TestHelper.translateLocal('common.continue'))); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith(ROUTES.ONBOARDING_WORKSPACES.getRoute()); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should create a Submit workspace when submitting form with EMPLOYER + Submit2026 beta and public domain', async () => { + jest.spyOn(Navigation, 'dismissModal').mockImplementation(() => {}); + jest.spyOn(Navigation, 'setNavigationActionToMicrotaskQueue').mockImplementation((callback: () => void) => callback()); + + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, { + isFromPublicDomain: true, + hasAccessibleDomainPolicies: false, + }); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.EMPLOYER); + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.SUBMIT_2026]); + }); + + const {unmount} = renderOnboardingPersonalDetailsPage(SCREENS.ONBOARDING.PERSONAL_DETAILS, {backTo: ''}); + + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByText(TestHelper.translateLocal('common.continue'))); + + await waitFor(() => { + expect(mockCreateWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + type: CONST.POLICY.TYPE.SUBMIT, + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + }), + ); + }); + + await waitFor(() => { + expect(mockCompleteOnboarding).toHaveBeenCalledWith( + expect.objectContaining({ + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + }), + ); + }); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); }); diff --git a/tests/ui/WorkspaceOnboarding.tsx b/tests/ui/WorkspaceOnboarding.tsx index 134697814764..d2307caf85cd 100644 --- a/tests/ui/WorkspaceOnboarding.tsx +++ b/tests/ui/WorkspaceOnboarding.tsx @@ -13,13 +13,54 @@ import Navigation from '@libs/Navigation/Navigation'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {OnboardingModalNavigatorParamList} from '@libs/Navigation/types'; import OnboardingWorkspaces from '@pages/OnboardingWorkspaces'; +import {joinAccessiblePolicy} from '@userActions/Policy/Member'; +import {createWorkspace} from '@userActions/Policy/Policy'; +import {completeOnboarding} from '@userActions/Report'; import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; +const mockCreateWorkspace = jest.mocked(createWorkspace); +const mockCompleteOnboarding = jest.mocked(completeOnboarding); +const mockJoinAccessiblePolicy = jest.mocked(joinAccessiblePolicy); + +jest.mock('@userActions/Report', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@userActions/Report'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + completeOnboarding: jest.fn(), + }; +}); + +jest.mock('@userActions/Policy/Policy', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@userActions/Policy/Policy'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + createWorkspace: jest.fn().mockReturnValue({ + policyID: 'test-policy-id', + adminsChatReportID: 'test-admins-report-id', + }), + }; +}); + +jest.mock('@userActions/Policy/Member', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@userActions/Policy/Member'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + joinAccessiblePolicy: jest.fn(), + }; +}); + TestHelper.setupGlobalFetchMock(); const Stack = createPlatformStackNavigator(); @@ -49,6 +90,7 @@ describe('OnboardingWorkspaces Page', () => { Onyx.init({ keys: ONYXKEYS, }); + return IntlStore.load(CONST.LOCALES.EN); }); beforeEach(() => { @@ -173,4 +215,118 @@ describe('OnboardingWorkspaces Page', () => { unmount(); await waitForBatchedUpdatesWithAct(); }); + + it('should create a Submit workspace when skip is pressed with EMPLOYER purpose and Submit2026 beta', async () => { + jest.spyOn(Navigation, 'dismissModal').mockImplementation(() => {}); + jest.spyOn(Navigation, 'setNavigationActionToMicrotaskQueue').mockImplementation((callback: () => void) => callback()); + + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: false, + }); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.EMPLOYER); + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.SUBMIT_2026]); + }); + + const {unmount} = renderOnboardingWorkspacesPage(SCREENS.ONBOARDING.WORKSPACES, {backTo: ''}); + + await waitForBatchedUpdatesWithAct(); + + const skipButton = screen.getByTestId('onboardingWorkSpaceSkipButton'); + + const mockEvent = { + nativeEvent: {}, + type: 'press', + target: skipButton, + currentTarget: skipButton, + }; + + fireEvent.press(skipButton, mockEvent); + + await waitFor(() => { + expect(mockCreateWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + type: CONST.POLICY.TYPE.SUBMIT, + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + }), + ); + }); + + await waitFor(() => { + expect(mockCompleteOnboarding).toHaveBeenCalledWith( + expect.objectContaining({ + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + }), + ); + }); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should complete onboarding with the joined Submit workspace policyID and open Categories in the admins room', async () => { + jest.spyOn(Navigation, 'dismissModal').mockImplementation(() => {}); + jest.spyOn(Navigation, 'setNavigationActionToMicrotaskQueue').mockImplementation((callback: () => void) => callback()); + + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: false, + }); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.EMPLOYER); + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.SUBMIT_2026]); + await Onyx.merge(ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM, { + firstName: 'Test', + lastName: 'User', + }); + await Onyx.set(ONYXKEYS.JOINABLE_POLICIES, { + submitPolicyID: { + policyID: 'submit-policy-id', + policyName: 'Submit Workspace', + policyOwner: 'owner@test.com', + employeeCount: 4, + hasPendingAccess: false, + automaticJoiningEnabled: true, + }, + }); + }); + + const onyxSetSpy = jest.spyOn(Onyx, 'set'); + onyxSetSpy.mockClear(); + + const {unmount} = renderOnboardingWorkspacesPage(SCREENS.ONBOARDING.WORKSPACES, {backTo: ''}); + + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByText(TestHelper.translateLocal('workspace.workspaceList.joinNow'))); + + await waitFor(() => { + expect(mockJoinAccessiblePolicy).toHaveBeenCalledWith('submit-policy-id'); + }); + + await waitFor(() => { + expect(mockCompleteOnboarding).toHaveBeenCalledWith( + expect.objectContaining({ + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + onboardingPolicyID: 'submit-policy-id', + }), + ); + }); + + await waitFor(() => { + expect(onyxSetSpy).toHaveBeenCalledWith(ONYXKEYS.NVP_ONBOARDING_RHP_VARIANT, CONST.ONBOARDING_RHP_VARIANT.RHP_ADMINS_ROOM); + expect(navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_CATEGORIES.getRoute('submit-policy-id')); + }); + + onyxSetSpy.mockRestore(); + unmount(); + await waitForBatchedUpdatesWithAct(); + }); }); diff --git a/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts b/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts new file mode 100644 index 000000000000..a01b7002d801 --- /dev/null +++ b/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts @@ -0,0 +1,330 @@ +import {renderHook} from '@testing-library/react-native'; +import useAutoCreateSubmitWorkspace from '@hooks/useAutoCreateSubmitWorkspace'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useHasActiveAdminPolicies from '@hooks/useHasActiveAdminPolicies'; +import useLocalize from '@hooks/useLocalize'; +import useOnboardingMessages from '@hooks/useOnboardingMessages'; +import useOnyx from '@hooks/useOnyx'; +import usePreferredPolicy from '@hooks/usePreferredPolicy'; +import * as navigateAfterOnboarding from '@libs/navigateAfterOnboarding'; +import * as Policy from '@userActions/Policy/Policy'; +import * as Report from '@userActions/Report'; +import * as Welcome from '@userActions/Welcome'; +import CONST from '@src/CONST'; + +jest.mock('@hooks/useOnyx', () => { + return {__esModule: true, default: jest.fn(() => [undefined])}; +}); + +jest.mock('@hooks/useCurrentUserPersonalDetails'); +jest.mock('@hooks/useHasActiveAdminPolicies'); +jest.mock('@hooks/useLocalize'); +jest.mock('@hooks/usePreferredPolicy'); +jest.mock('@hooks/useOnboardingMessages'); + +const mockTranslate = jest.fn((key: string) => key); +const mockFormatPhoneNumber = jest.fn((phone: string) => phone); + +const MOCK_SESSION = { + accountID: 12345, + email: 'test@expensify.com', +}; + +const MOCK_POLICY_ID = 'mock-policy-id'; +const MOCK_ADMINS_CHAT_REPORT_ID = 'mock-admins-chat-report-id'; +const MOCK_ONBOARDING_MESSAGE = {message: 'Welcome!', video: undefined, tasks: []}; + +function setupDefaultMocks() { + const mockUseOnyx = useOnyx as jest.Mock; + mockUseOnyx.mockImplementation((key: string) => { + if (key === 'session') { + return [MOCK_SESSION]; + } + if (key === 'betas') { + return [[]]; + } + if (key.startsWith('policy_')) { + return [false]; + } + return [undefined]; + }); + + (useCurrentUserPersonalDetails as jest.Mock).mockReturnValue({ + accountID: MOCK_SESSION.accountID, + login: MOCK_SESSION.email, + localCurrencyCode: 'USD', + }); + + (useLocalize as jest.Mock).mockReturnValue({ + translate: mockTranslate, + formatPhoneNumber: mockFormatPhoneNumber, + }); + + (usePreferredPolicy as jest.Mock).mockReturnValue({ + isRestrictedToPreferredPolicy: false, + preferredPolicyID: undefined, + isRestrictedPolicyCreation: false, + }); + + (useHasActiveAdminPolicies as jest.Mock).mockReturnValue(false); + + (useOnboardingMessages as jest.Mock).mockReturnValue({ + onboardingMessages: { + [CONST.ONBOARDING_CHOICES.EMPLOYER]: MOCK_ONBOARDING_MESSAGE, + }, + }); +} + +describe('useAutoCreateSubmitWorkspace', () => { + const createWorkspaceSpy = jest.spyOn(Policy, 'createWorkspace').mockReturnValue({ + policyID: MOCK_POLICY_ID, + adminsChatReportID: MOCK_ADMINS_CHAT_REPORT_ID, + } as ReturnType); + const completeOnboardingSpy = jest.spyOn(Report, 'completeOnboarding').mockImplementation(jest.fn()); + const setOnboardingAdminsChatReportIDSpy = jest.spyOn(Welcome, 'setOnboardingAdminsChatReportID').mockImplementation(jest.fn()); + const setOnboardingPolicyIDSpy = jest.spyOn(Welcome, 'setOnboardingPolicyID').mockImplementation(jest.fn()); + const navigateSpy = jest.spyOn(navigateAfterOnboarding, 'navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue').mockImplementation(jest.fn()); + + beforeEach(() => { + jest.clearAllMocks(); + setupDefaultMocks(); + }); + + it('creates a Submit workspace with the correct parameters for a new EMPLOYER user', () => { + // Given a new user going through onboarding with no existing workspace (onboardingPolicyID is undefined, + // hasEditableGroupPolicy is false, and policy creation is not restricted) + + // When the autoCreateSubmitWorkspace function is invoked during onboarding + const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); + result.current('John', 'Doe'); + + // Then a Submit workspace should be created because EMPLOYER users without an existing + // workspace need one auto-created to land on after onboarding + expect(createWorkspaceSpy).toHaveBeenCalledTimes(1); + expect(createWorkspaceSpy).toHaveBeenCalledWith( + expect.objectContaining({ + makeMeAdmin: true, + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + currency: 'USD', + shouldAddOnboardingTasks: false, + shouldAddGuideWelcomeMessage: false, + type: CONST.POLICY.TYPE.SUBMIT, + currentUserAccountIDParam: MOCK_SESSION.accountID, + currentUserEmailParam: MOCK_SESSION.email, + }), + ); + }); + + it('completes onboarding with the newly created workspace and admins chat IDs', () => { + // Given a new user with no pre-existing onboarding workspace + + // When the hook creates a workspace and finishes the onboarding flow + const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); + result.current('John', 'Doe'); + + // Then completeOnboarding should receive the IDs returned by createWorkspace so the backend + // can associate the guided setup data with the correct workspace and admins chat + expect(completeOnboardingSpy).toHaveBeenCalledTimes(1); + expect(completeOnboardingSpy).toHaveBeenCalledWith( + expect.objectContaining({ + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + onboardingMessage: MOCK_ONBOARDING_MESSAGE, + firstName: 'John', + lastName: 'Doe', + adminsChatReportID: MOCK_ADMINS_CHAT_REPORT_ID, + onboardingPolicyID: MOCK_POLICY_ID, + }), + ); + }); + + it('clears onboarding state after the flow completes', () => { + // Given a user completing the onboarding flow + + // When autoCreateSubmitWorkspace finishes + const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); + result.current('John', 'Doe'); + + // Then the transient onboarding Onyx keys should be cleared so the onboarding + // flow is not re-triggered on subsequent app launches + expect(setOnboardingAdminsChatReportIDSpy).toHaveBeenCalledTimes(1); + expect(setOnboardingPolicyIDSpy).toHaveBeenCalledTimes(1); + }); + + it('navigates to the submit workspace page after completing onboarding', () => { + // Given a user completing the EMPLOYER onboarding flow + + // When autoCreateSubmitWorkspace finishes setting up the workspace + const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); + result.current('John', 'Doe'); + + // Then the user should be navigated to the newly created Submit workspace + // so they land on their workspace immediately after onboarding + expect(navigateSpy).toHaveBeenCalledTimes(1); + expect(navigateSpy).toHaveBeenCalledWith(MOCK_POLICY_ID, expect.any(Boolean)); + }); + + it('reuses the existing onboarding workspace instead of creating a new one', () => { + // Given a user who already has an onboardingPolicyID set (e.g. assigned by an admin + // or from a previous partial onboarding attempt) + const existingPolicyID = 'existing-policy-id'; + const existingAdminsReportID = 'existing-admins-report-id'; + + (useOnyx as jest.Mock).mockImplementation((key: string) => { + if (key === 'onboardingPolicyID') { + return [existingPolicyID]; + } + if (key === 'onboardingAdminsChatReportID') { + return [existingAdminsReportID]; + } + if (key === 'session') { + return [MOCK_SESSION]; + } + if (key === 'betas') { + return [[]]; + } + if (key.startsWith('policy_')) { + return [false]; + } + return [undefined]; + }); + + // When the onboarding flow runs + const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); + result.current('Jane', 'Smith'); + + // Then no new workspace should be created, and completeOnboarding should use + // the pre-existing IDs to avoid creating duplicate workspaces + expect(createWorkspaceSpy).not.toHaveBeenCalled(); + expect(completeOnboardingSpy).toHaveBeenCalledWith( + expect.objectContaining({ + adminsChatReportID: existingAdminsReportID, + onboardingPolicyID: existingPolicyID, + }), + ); + }); + + it('skips workspace creation when the user is already a paid group policy admin', () => { + // Given a user who is already an admin of a paid group policy + (useOnyx as jest.Mock).mockImplementation((key: string) => { + if (key === 'session') { + return [MOCK_SESSION]; + } + if (key === 'betas') { + return [[]]; + } + if (key === 'onboardingPolicyID') { + return [undefined]; + } + if (key === 'onboardingAdminsChatReportID') { + return [undefined]; + } + if (key.startsWith('policy_')) { + return [true]; + } + return [undefined]; + }); + + // When onboarding completes + const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); + result.current('Jane', 'Smith'); + + // Then no Submit workspace should be created because the user already has + // a paid group workspace and creating another would be redundant + expect(createWorkspaceSpy).not.toHaveBeenCalled(); + }); + + it('skips workspace creation when the user domain restricts policy creation', () => { + // Given a user whose domain security group has enableRestrictedPolicyCreation set to true + (usePreferredPolicy as jest.Mock).mockReturnValue({ + isRestrictedToPreferredPolicy: false, + preferredPolicyID: undefined, + isRestrictedPolicyCreation: true, + }); + + // When the onboarding flow runs + const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); + result.current('Jane', 'Smith'); + + // Then workspace creation should be skipped because the domain admin has + // restricted users from creating their own policies + expect(createWorkspaceSpy).not.toHaveBeenCalled(); + expect(completeOnboardingSpy).toHaveBeenCalledTimes(1); + }); + + it('still completes onboarding and navigates even when workspace creation is skipped', () => { + // Given a user who cannot create a workspace due to domain restrictions + (usePreferredPolicy as jest.Mock).mockReturnValue({ + isRestrictedToPreferredPolicy: false, + preferredPolicyID: undefined, + isRestrictedPolicyCreation: true, + }); + + // When the onboarding flow runs + const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); + result.current('Jane', 'Smith'); + + // Then onboarding should still be completed and navigation should still occur + // because the user needs to finish onboarding regardless of workspace creation + expect(completeOnboardingSpy).toHaveBeenCalledTimes(1); + expect(setOnboardingAdminsChatReportIDSpy).toHaveBeenCalledTimes(1); + expect(setOnboardingPolicyIDSpy).toHaveBeenCalledTimes(1); + expect(navigateSpy).toHaveBeenCalledTimes(1); + }); + + it('uses the localCurrencyCode from personal details for workspace currency', () => { + // Given a user whose personal details have localCurrencyCode set to GBP + (useCurrentUserPersonalDetails as jest.Mock).mockReturnValue({ + accountID: MOCK_SESSION.accountID, + login: MOCK_SESSION.email, + localCurrencyCode: 'GBP', + }); + + // When a workspace is created during onboarding + const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); + result.current('John', 'Doe'); + + // Then the workspace should use GBP as its currency so it matches the user's locale + expect(createWorkspaceSpy).toHaveBeenCalledWith( + expect.objectContaining({ + currency: 'GBP', + }), + ); + }); + + it('falls back to USD when localCurrencyCode is not available', () => { + // Given a user whose personal details do not have a localCurrencyCode set + (useCurrentUserPersonalDetails as jest.Mock).mockReturnValue({ + accountID: MOCK_SESSION.accountID, + login: MOCK_SESSION.email, + localCurrencyCode: undefined, + }); + + // When a workspace is created during onboarding + const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); + result.current('John', 'Doe'); + + // Then the workspace should default to USD as a safe fallback currency + expect(createWorkspaceSpy).toHaveBeenCalledWith( + expect.objectContaining({ + currency: CONST.CURRENCY.USD, + }), + ); + }); + + it('forwards firstName and lastName to completeOnboarding for display name setup', () => { + // Given a user providing their name during the onboarding personal details step + + // When the onboarding flow completes + const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); + result.current('Alice', 'Wonderland'); + + // Then the provided name should be passed through to completeOnboarding so the + // backend can set up the user's display name as part of the guided setup + expect(completeOnboardingSpy).toHaveBeenCalledWith( + expect.objectContaining({ + firstName: 'Alice', + lastName: 'Wonderland', + }), + ); + }); +}); From 01b03abbab884ec20613a72480f78393754b50cc Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 8 May 2026 14:42:57 +0300 Subject: [PATCH 02/11] Apply #89756 onboarding fixes Cherry-picks the changes from #89756 onto this branch. --- src/libs/navigateAfterOnboarding.ts | 7 +-- .../BaseOnboardingWorkspaces.tsx | 16 ++---- src/types/onyx/JoinablePolicies.ts | 7 ++- tests/ui/WorkspaceOnboarding.tsx | 8 +-- .../unit/libs/navigateAfterOnboarding.test.ts | 49 +++++++++++++++++++ 5 files changed, 67 insertions(+), 20 deletions(-) create mode 100644 tests/unit/libs/navigateAfterOnboarding.test.ts diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index 1b78b0a4a2a8..d709be6a50fe 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -140,11 +140,8 @@ function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, isSmallScre } setOnboardingRHPVariant(CONST.ONBOARDING_RHP_VARIANT.RHP_ADMINS_ROOM); - Navigation.navigate(ROUTES.WORKSPACES_LIST.route); - Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.navigate(ROUTES.WORKSPACE_CATEGORIES.getRoute(policyID)); - SidePanelActions.openSidePanel(!isSmallScreenWidth); - }); + Navigation.navigate(ROUTES.WORKSPACE_CATEGORIES.getRoute(policyID)); + SidePanelActions.openSidePanel(!isSmallScreenWidth); } function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: string, isSmallScreenWidth = false) { diff --git a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx index 6d81539a268d..1485ab5ad76c 100644 --- a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx +++ b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx @@ -78,12 +78,8 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding const onboardingStep = useOnboardingStepCounter(SCREENS.ONBOARDING.WORKSPACES); const handleJoinWorkspace = (policy: JoinablePolicy) => { - // Only mirror the EMPLOYER ("Get paid back by my employer") + Submit-2026 onboarding flow - // when the user actually picked EMPLOYER, or when no Purpose was selected (private-domain - // users who reach this screen without going through the Purpose step). Users on the Submit - // beta who picked a different Purpose (e.g. MANAGE_TEAM) must not be re-routed through - // the Submit flow. - const shouldUseSubmitFlow = canUseSubmit2026 && (!onboardingPurposeSelected || onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.EMPLOYER); + const isJoiningSubmitPolicy = policy.policyType === CONST.POLICY.TYPE.SUBMIT; + const shouldUseSubmitFlow = canUseSubmit2026 && policy.automaticJoiningEnabled && isJoiningSubmitPolicy; if (policy.automaticJoiningEnabled) { joinAccessiblePolicy(policy.policyID); @@ -91,13 +87,11 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding askToJoinPolicy(policy.policyID); } - const engagementChoice = shouldUseSubmitFlow ? CONST.ONBOARDING_CHOICES.EMPLOYER : CONST.ONBOARDING_CHOICES.LOOKING_AROUND; completeOnboarding({ - engagementChoice, - onboardingMessage: onboardingMessages[engagementChoice], + engagementChoice: CONST.ONBOARDING_CHOICES.LOOKING_AROUND, + onboardingMessage: onboardingMessages[CONST.ONBOARDING_CHOICES.LOOKING_AROUND], firstName: onboardingPersonalDetails?.firstName ?? '', lastName: onboardingPersonalDetails?.lastName ?? '', - onboardingPolicyID: shouldUseSubmitFlow && policy.automaticJoiningEnabled ? policy.policyID : undefined, companySize: onboardingCompanySize, introSelected, isSelfTourViewed, @@ -106,7 +100,7 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding setOnboardingAdminsChatReportID(); setOnboardingPolicyID(policy.policyID); - if (shouldUseSubmitFlow && policy.automaticJoiningEnabled) { + if (shouldUseSubmitFlow) { navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policy.policyID, isSmallScreenWidth); return; } diff --git a/src/types/onyx/JoinablePolicies.ts b/src/types/onyx/JoinablePolicies.ts index 3a7d2ccd4f7c..964bfc6b6c04 100644 --- a/src/types/onyx/JoinablePolicies.ts +++ b/src/types/onyx/JoinablePolicies.ts @@ -1,3 +1,6 @@ +import type {ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; + /** Model of Joinable Policy */ type JoinablePolicy = { /** Policy id of the workspace */ @@ -10,8 +13,10 @@ type JoinablePolicy = { employeeCount: number; /** If the user has already requested access, and is currently awaiting decision */ hasPendingAccess: boolean; - /** Weather the user needs an approval to join the workspace or not */ + /** Whether the user needs an approval to join the workspace or not */ automaticJoiningEnabled: boolean; + /** Policy type returned by the backend (`team` | `corporate` | `submit2026` | ...). */ + policyType?: ValueOf; }; /** Model of Joinable Policies */ diff --git a/tests/ui/WorkspaceOnboarding.tsx b/tests/ui/WorkspaceOnboarding.tsx index d2307caf85cd..4b984bcd23b8 100644 --- a/tests/ui/WorkspaceOnboarding.tsx +++ b/tests/ui/WorkspaceOnboarding.tsx @@ -270,7 +270,7 @@ describe('OnboardingWorkspaces Page', () => { await waitForBatchedUpdatesWithAct(); }); - it('should complete onboarding with the joined Submit workspace policyID and open Categories in the admins room', async () => { + it('should complete onboarding without passing the joined workspace policyID and open Categories in the admins room', async () => { jest.spyOn(Navigation, 'dismissModal').mockImplementation(() => {}); jest.spyOn(Navigation, 'setNavigationActionToMicrotaskQueue').mockImplementation((callback: () => void) => callback()); @@ -294,6 +294,7 @@ describe('OnboardingWorkspaces Page', () => { employeeCount: 4, hasPendingAccess: false, automaticJoiningEnabled: true, + policyType: CONST.POLICY.TYPE.SUBMIT, }, }); }); @@ -314,11 +315,12 @@ describe('OnboardingWorkspaces Page', () => { await waitFor(() => { expect(mockCompleteOnboarding).toHaveBeenCalledWith( expect.objectContaining({ - engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, - onboardingPolicyID: 'submit-policy-id', + engagementChoice: CONST.ONBOARDING_CHOICES.LOOKING_AROUND, }), ); }); + const lastCompleteOnboardingArgs = mockCompleteOnboarding.mock.calls.at(-1)?.[0] as Record | undefined; + expect(lastCompleteOnboardingArgs).not.toHaveProperty('onboardingPolicyID'); await waitFor(() => { expect(onyxSetSpy).toHaveBeenCalledWith(ONYXKEYS.NVP_ONBOARDING_RHP_VARIANT, CONST.ONBOARDING_RHP_VARIANT.RHP_ADMINS_ROOM); diff --git a/tests/unit/libs/navigateAfterOnboarding.test.ts b/tests/unit/libs/navigateAfterOnboarding.test.ts new file mode 100644 index 000000000000..f3371ef65e4b --- /dev/null +++ b/tests/unit/libs/navigateAfterOnboarding.test.ts @@ -0,0 +1,49 @@ +import {navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; +import Navigation from '@libs/Navigation/Navigation'; +import ROUTES from '@src/ROUTES'; + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: { + dismissModal: jest.fn(), + navigate: jest.fn(), + setNavigationActionToMicrotaskQueue: jest.fn((callback: () => void) => callback()), + }, +})); + +jest.mock('@libs/actions/SidePanel', () => ({ + __esModule: true, + default: {openSidePanel: jest.fn()}, +})); + +jest.mock('@libs/actions/Welcome', () => ({ + setOnboardingRHPVariant: jest.fn(), +})); + +jest.mock('@libs/actions/Modal', () => ({ + setDisableDismissOnEscape: jest.fn(), +})); + +const navigationMock = Navigation as jest.Mocked; + +describe('navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('navigates to HOME when policyID is missing', () => { + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(undefined); + + expect(navigationMock.dismissModal).toHaveBeenCalledTimes(1); + expect(navigationMock.navigate).toHaveBeenCalledTimes(1); + expect(navigationMock.navigate).toHaveBeenCalledWith(ROUTES.HOME); + }); + + it('navigates to Workspace Categories when policyID is provided', () => { + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue('test-policy-id'); + + expect(navigationMock.dismissModal).toHaveBeenCalledTimes(1); + expect(navigationMock.navigate).toHaveBeenCalledTimes(1); + expect(navigationMock.navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')); + }); +}); From cfe400a727ffb7c2690ae208bba1163819224b5d Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 8 May 2026 17:24:45 +0300 Subject: [PATCH 03/11] fix(onboarding): stop re-navigating private-domain EMPLOYER users back to Workspaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a private-domain validated user selects "Get paid back by my employer" on the Purpose screen after having already skipped the Workspaces step, the EMPLOYER + SUBMIT_2026 branch was sending them back to ONBOARDING_WORKSPACES, creating an infinite loop: Personal Details → Workspaces (skip) → Purpose → EMPLOYER → Workspaces (loop) The navigate-to-Workspaces redirect was only needed if the user had never seen the Workspaces step. For private-domain users the flow always goes through Workspaces before Purpose, so by the time they reach Purpose they have already had the opportunity to join. Remove the redirect and always auto-create a Submit workspace when the name is already captured. Also remove the now-dead isValidated / loginList / session / isCurrentUserValidated references, and update the OnboardingPurpose test that was asserting the old (looping) behavior. --- .../OnboardingPurpose/BaseOnboardingPurpose.tsx | 11 ----------- tests/ui/OnboardingPurpose.tsx | 12 ++++++++++-- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx index c33ec373f627..ed904209b313 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -23,7 +23,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import OnboardingRefManager from '@libs/OnboardingRefManager'; import type {TOnboardingRef} from '@libs/OnboardingRefManager'; -import {isCurrentUserValidated} from '@libs/UserUtils'; import variables from '@styles/variables'; import {completeOnboarding} from '@userActions/Report'; import {setOnboardingErrorMessage, setOnboardingPurposeSelected} from '@userActions/Welcome'; @@ -77,11 +76,8 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const [betas] = useOnyx(ONYXKEYS.BETAS); - const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); - const [session] = useOnyx(ONYXKEYS.SESSION); const {isBetaEnabled} = usePermissions(); const canUseSubmit2026 = isBetaEnabled(CONST.BETAS.SUBMIT_2026); - const isValidated = isCurrentUserValidated(loginList, session?.email); const autoCreateSubmitWorkspace = useAutoCreateSubmitWorkspace(); const autoCreateTrackWorkspace = useAutoCreateTrackWorkspace(); const paddingHorizontal = onboardingIsMediumOrLargerScreenWidth ? styles.ph8 : styles.ph5; @@ -112,13 +108,6 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro } if (choice === CONST.ONBOARDING_CHOICES.EMPLOYER && canUseSubmit2026) { - if (isPrivateDomainAndHasAccessiblePolicies && isValidated) { - Navigation.navigate( - personalDetailsForm?.firstName ? ROUTES.ONBOARDING_WORKSPACES.getRoute(route.params?.backTo) : ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(route.params?.backTo), - ); - return; - } - if (personalDetailsForm?.firstName) { autoCreateSubmitWorkspace(personalDetailsForm.firstName, personalDetailsForm.lastName ?? ''); return; diff --git a/tests/ui/OnboardingPurpose.tsx b/tests/ui/OnboardingPurpose.tsx index 5a487f15f185..0b835228d077 100644 --- a/tests/ui/OnboardingPurpose.tsx +++ b/tests/ui/OnboardingPurpose.tsx @@ -181,7 +181,10 @@ describe('OnboardingPurpose Page', () => { await waitForBatchedUpdatesWithAct(); }); - it('should navigate to workspaces page when user selects EMPLOYER with Submit2026 beta and is from private domain with name set', async () => { + it('should create a Submit workspace when user selects EMPLOYER with Submit2026 beta and is from private domain with name set', async () => { + jest.spyOn(Navigation, 'dismissModal').mockImplementation(() => {}); + jest.spyOn(Navigation, 'setNavigationActionToMicrotaskQueue').mockImplementation((callback: () => void) => callback()); + const testEmail = 'test@user.com'; await TestHelper.signInWithTestUser(); @@ -214,7 +217,12 @@ describe('OnboardingPurpose Page', () => { await user.press(employerOption); await waitFor(() => { - expect(navigate).toHaveBeenCalledWith(ROUTES.ONBOARDING_WORKSPACES.getRoute('')); + expect(mockCreateWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + type: CONST.POLICY.TYPE.SUBMIT, + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + }), + ); }); unmount(); From c62eff340d70260ca402e52d3fb870bd743b2130 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Mon, 11 May 2026 02:13:06 +0300 Subject: [PATCH 04/11] fix(onboarding): preserve back navigation to Workspaces from joined Submit workspace Categories After joining a Submit workspace via private-domain onboarding, the user lands on Workspace > Categories. Pressing Back on that screen was falling through to the workspace's Initial page (or Home on iOS) instead of returning to the Workspaces list, because the Categories screen was navigated to directly without WorkspacesList in its back stack. Append `?backTo=/workspaces` so HeaderWithBackButton on the Categories page routes goBack to the Workspaces list, matching the expected post-onboarding flow. --- src/libs/navigateAfterOnboarding.ts | 3 ++- tests/unit/libs/navigateAfterOnboarding.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index d709be6a50fe..25b02244237b 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -4,6 +4,7 @@ import {handleRHPVariantNavigation, shouldOpenRHPVariant} from '@components/Side import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; import type {OnboardingRHPVariant} from '@src/types/onyx'; import {setDisableDismissOnEscape} from './actions/Modal'; import SidePanelActions from './actions/SidePanel'; @@ -140,7 +141,7 @@ function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, isSmallScre } setOnboardingRHPVariant(CONST.ONBOARDING_RHP_VARIANT.RHP_ADMINS_ROOM); - Navigation.navigate(ROUTES.WORKSPACE_CATEGORIES.getRoute(policyID)); + Navigation.navigate(`${ROUTES.WORKSPACE_CATEGORIES.getRoute(policyID)}?backTo=${encodeURIComponent(ROUTES.WORKSPACES_LIST.route)}` as Route); SidePanelActions.openSidePanel(!isSmallScreenWidth); } diff --git a/tests/unit/libs/navigateAfterOnboarding.test.ts b/tests/unit/libs/navigateAfterOnboarding.test.ts index f3371ef65e4b..568f5c3bda9d 100644 --- a/tests/unit/libs/navigateAfterOnboarding.test.ts +++ b/tests/unit/libs/navigateAfterOnboarding.test.ts @@ -39,11 +39,11 @@ describe('navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue', () => { expect(navigationMock.navigate).toHaveBeenCalledWith(ROUTES.HOME); }); - it('navigates to Workspace Categories when policyID is provided', () => { + it('navigates to Workspace Categories with backTo=/workspaces when policyID is provided', () => { navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue('test-policy-id'); expect(navigationMock.dismissModal).toHaveBeenCalledTimes(1); expect(navigationMock.navigate).toHaveBeenCalledTimes(1); - expect(navigationMock.navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')); + expect(navigationMock.navigate).toHaveBeenCalledWith(`${ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')}?backTo=${encodeURIComponent(ROUTES.WORKSPACES_LIST.route)}`); }); }); From bd5daef6e4646f17d79ba8b9570a3bcd77dbe52a Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Mon, 11 May 2026 02:37:07 +0300 Subject: [PATCH 05/11] fix(onboarding): pick correct backTo per layout from Submit Categories Categories is reached directly from the onboarding modal, so without an explicit backTo, goBack falls through to Home. On narrow layouts target WorkspaceInitial (overview / members / etc.); on wide layouts target the Workspaces list. --- src/libs/navigateAfterOnboarding.ts | 6 +++++- tests/unit/libs/navigateAfterOnboarding.test.ts | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index 25b02244237b..3ca2dc3ae3fb 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -141,7 +141,11 @@ function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, isSmallScre } setOnboardingRHPVariant(CONST.ONBOARDING_RHP_VARIANT.RHP_ADMINS_ROOM); - Navigation.navigate(`${ROUTES.WORKSPACE_CATEGORIES.getRoute(policyID)}?backTo=${encodeURIComponent(ROUTES.WORKSPACES_LIST.route)}` as Route); + + const categoriesRoute = ROUTES.WORKSPACE_CATEGORIES.getRoute(policyID); + const backToRoute = isSmallScreenWidth ? ROUTES.WORKSPACE_INITIAL.getRoute(policyID) : ROUTES.WORKSPACES_LIST.route; + Navigation.navigate(`${categoriesRoute}?backTo=${encodeURIComponent(backToRoute)}` as Route); + SidePanelActions.openSidePanel(!isSmallScreenWidth); } diff --git a/tests/unit/libs/navigateAfterOnboarding.test.ts b/tests/unit/libs/navigateAfterOnboarding.test.ts index 568f5c3bda9d..ef09b90e5d7f 100644 --- a/tests/unit/libs/navigateAfterOnboarding.test.ts +++ b/tests/unit/libs/navigateAfterOnboarding.test.ts @@ -39,11 +39,21 @@ describe('navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue', () => { expect(navigationMock.navigate).toHaveBeenCalledWith(ROUTES.HOME); }); - it('navigates to Workspace Categories with backTo=/workspaces when policyID is provided', () => { - navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue('test-policy-id'); + it('navigates to Workspace Categories with backTo=/workspaces on wide layouts', () => { + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue('test-policy-id', false); expect(navigationMock.dismissModal).toHaveBeenCalledTimes(1); expect(navigationMock.navigate).toHaveBeenCalledTimes(1); expect(navigationMock.navigate).toHaveBeenCalledWith(`${ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')}?backTo=${encodeURIComponent(ROUTES.WORKSPACES_LIST.route)}`); }); + + it('navigates to Workspace Categories with backTo=WorkspaceInitial on narrow layouts', () => { + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue('test-policy-id', true); + + expect(navigationMock.dismissModal).toHaveBeenCalledTimes(1); + expect(navigationMock.navigate).toHaveBeenCalledTimes(1); + expect(navigationMock.navigate).toHaveBeenCalledWith( + `${ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')}?backTo=${encodeURIComponent(ROUTES.WORKSPACE_INITIAL.getRoute('test-policy-id'))}`, + ); + }); }); From 5886694eee7a07a40486065c68b14a443fbafb05 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Mon, 11 May 2026 03:56:44 +0300 Subject: [PATCH 06/11] test: update Submit onboarding UI assertions to expect backTo query param The Submit Categories navigation now appends ?backTo=... so HeaderWithBackButton has a meaningful goBack target. Update the three onboarding UI suites to assert the new URL on the wide-layout default they run under. --- tests/ui/OnboardingPurpose.tsx | 2 +- tests/ui/PersonalDetailsOnboarding.tsx | 2 +- tests/ui/WorkspaceOnboarding.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/ui/OnboardingPurpose.tsx b/tests/ui/OnboardingPurpose.tsx index 0b835228d077..5db1948f8c66 100644 --- a/tests/ui/OnboardingPurpose.tsx +++ b/tests/ui/OnboardingPurpose.tsx @@ -279,7 +279,7 @@ describe('OnboardingPurpose Page', () => { await waitFor(() => { expect(onyxSetSpy).toHaveBeenCalledWith(ONYXKEYS.NVP_ONBOARDING_RHP_VARIANT, CONST.ONBOARDING_RHP_VARIANT.RHP_ADMINS_ROOM); - expect(navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')); + expect(navigate).toHaveBeenCalledWith(`${ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')}?backTo=${encodeURIComponent(ROUTES.WORKSPACES_LIST.route)}`); }); onyxSetSpy.mockRestore(); diff --git a/tests/ui/PersonalDetailsOnboarding.tsx b/tests/ui/PersonalDetailsOnboarding.tsx index d7a7dde9d632..fe918e3822e2 100644 --- a/tests/ui/PersonalDetailsOnboarding.tsx +++ b/tests/ui/PersonalDetailsOnboarding.tsx @@ -245,7 +245,7 @@ describe('OnboardingPersonalDetails Page', () => { }); await waitFor(() => { - expect(navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')); + expect(navigate).toHaveBeenCalledWith(`${ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')}?backTo=${encodeURIComponent(ROUTES.WORKSPACES_LIST.route)}`); }); unmount(); diff --git a/tests/ui/WorkspaceOnboarding.tsx b/tests/ui/WorkspaceOnboarding.tsx index 4b984bcd23b8..92257d51f4f1 100644 --- a/tests/ui/WorkspaceOnboarding.tsx +++ b/tests/ui/WorkspaceOnboarding.tsx @@ -263,7 +263,7 @@ describe('OnboardingWorkspaces Page', () => { }); await waitFor(() => { - expect(navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')); + expect(navigate).toHaveBeenCalledWith(`${ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')}?backTo=${encodeURIComponent(ROUTES.WORKSPACES_LIST.route)}`); }); unmount(); @@ -324,7 +324,7 @@ describe('OnboardingWorkspaces Page', () => { await waitFor(() => { expect(onyxSetSpy).toHaveBeenCalledWith(ONYXKEYS.NVP_ONBOARDING_RHP_VARIANT, CONST.ONBOARDING_RHP_VARIANT.RHP_ADMINS_ROOM); - expect(navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_CATEGORIES.getRoute('submit-policy-id')); + expect(navigate).toHaveBeenCalledWith(`${ROUTES.WORKSPACE_CATEGORIES.getRoute('submit-policy-id')}?backTo=${encodeURIComponent(ROUTES.WORKSPACES_LIST.route)}`); }); onyxSetSpy.mockRestore(); From d55a7e259b9706f219e98aae5e30c063bce23da3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Mon, 11 May 2026 04:30:12 +0300 Subject: [PATCH 07/11] refactor(onboarding): use shouldUseNarrowLayout for Submit auto-creation flow Addresses PR feedback to prefer shouldUseNarrowLayout over isSmallScreenWidth in the Submit onboarding auto-creation path. --- src/hooks/useAutoCreateSubmitWorkspace.ts | 6 +++--- src/hooks/useAutoCreateTrackWorkspace.ts | 6 +++--- src/hooks/useOnboardingWorkspaceCreationState.ts | 6 ++---- src/libs/navigateAfterOnboarding.ts | 10 +++++----- .../OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx | 4 ++-- tests/unit/libs/navigateAfterOnboarding.test.ts | 4 ++-- 6 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/hooks/useAutoCreateSubmitWorkspace.ts b/src/hooks/useAutoCreateSubmitWorkspace.ts index 7d97960bffcb..48273c938bfd 100644 --- a/src/hooks/useAutoCreateSubmitWorkspace.ts +++ b/src/hooks/useAutoCreateSubmitWorkspace.ts @@ -35,7 +35,7 @@ function useAutoCreateSubmitWorkspace() { hasActiveAdminPolicies, onboardingMessages, lastWorkspaceNumber, - isSmallScreenWidth, + shouldUseNarrowLayout, } = useOnboardingWorkspaceCreationState(); const groupPolicySelector = useMemo( @@ -86,7 +86,7 @@ function useAutoCreateSubmitWorkspace() { setOnboardingAdminsChatReportID(); setOnboardingPolicyID(); - navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(newPolicyID, isSmallScreenWidth); + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(newPolicyID, shouldUseNarrowLayout); }, [ currentUserEmail, @@ -105,7 +105,7 @@ function useAutoCreateSubmitWorkspace() { onboardingMessages, betas, hasActiveAdminPolicies, - isSmallScreenWidth, + shouldUseNarrowLayout, ], ); diff --git a/src/hooks/useAutoCreateTrackWorkspace.ts b/src/hooks/useAutoCreateTrackWorkspace.ts index 99cf11eef483..b0ecc7488e54 100644 --- a/src/hooks/useAutoCreateTrackWorkspace.ts +++ b/src/hooks/useAutoCreateTrackWorkspace.ts @@ -39,7 +39,7 @@ function useAutoCreateTrackWorkspace() { hasActiveAdminPolicies, onboardingMessages, lastWorkspaceNumber, - isSmallScreenWidth, + shouldUseNarrowLayout, } = useOnboardingWorkspaceCreationState(); const paidGroupPolicySelector = useMemo( @@ -109,7 +109,7 @@ function useAutoCreateTrackWorkspace() { setOnboardingPolicyID(); navigateAfterOnboardingWithMicrotaskQueue( - isSmallScreenWidth, + shouldUseNarrowLayout, isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), conciergeChatReportID, archivedReportsIdSet, @@ -137,7 +137,7 @@ function useAutoCreateTrackWorkspace() { onboardingMessages, betas, hasActiveAdminPolicies, - isSmallScreenWidth, + shouldUseNarrowLayout, isBetaEnabled, conciergeChatReportID, archivedReportsIdSet, diff --git a/src/hooks/useOnboardingWorkspaceCreationState.ts b/src/hooks/useOnboardingWorkspaceCreationState.ts index cb09fb0b9a29..d2c69b42d10c 100644 --- a/src/hooks/useOnboardingWorkspaceCreationState.ts +++ b/src/hooks/useOnboardingWorkspaceCreationState.ts @@ -38,9 +38,7 @@ function useOnboardingWorkspaceCreationState() { const hasActiveAdminPolicies = useHasActiveAdminPolicies(); const {onboardingMessages} = useOnboardingMessages(); const lastWorkspaceNumber = useLastWorkspaceNumber(); - - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth} = useResponsiveLayout(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); return { onboardingPolicyID, @@ -59,7 +57,7 @@ function useOnboardingWorkspaceCreationState() { hasActiveAdminPolicies, onboardingMessages, lastWorkspaceNumber, - isSmallScreenWidth, + shouldUseNarrowLayout, }; } diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index 3ca2dc3ae3fb..8a5443e93d12 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -132,7 +132,7 @@ function navigateAfterOnboardingWithMicrotaskQueue( * navigate to Workspace > Categories with the side panel open so * the #admins room is visible in Concierge Anywhere. */ -function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, isSmallScreenWidth = false) { +function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNarrowLayout = false) { setDisableDismissOnEscape(false); if (!policyID) { @@ -143,16 +143,16 @@ function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, isSmallScre setOnboardingRHPVariant(CONST.ONBOARDING_RHP_VARIANT.RHP_ADMINS_ROOM); const categoriesRoute = ROUTES.WORKSPACE_CATEGORIES.getRoute(policyID); - const backToRoute = isSmallScreenWidth ? ROUTES.WORKSPACE_INITIAL.getRoute(policyID) : ROUTES.WORKSPACES_LIST.route; + const backToRoute = shouldUseNarrowLayout ? ROUTES.WORKSPACE_INITIAL.getRoute(policyID) : ROUTES.WORKSPACES_LIST.route; Navigation.navigate(`${categoriesRoute}?backTo=${encodeURIComponent(backToRoute)}` as Route); - SidePanelActions.openSidePanel(!isSmallScreenWidth); + SidePanelActions.openSidePanel(!shouldUseNarrowLayout); } -function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: string, isSmallScreenWidth = false) { +function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: string, shouldUseNarrowLayout = false) { Navigation.dismissModal(); Navigation.setNavigationActionToMicrotaskQueue(() => { - navigateToSubmitWorkspaceAfterOnboarding(policyID, isSmallScreenWidth); + navigateToSubmitWorkspaceAfterOnboarding(policyID, shouldUseNarrowLayout); }); } diff --git a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx index 1485ab5ad76c..a51a45649e6e 100644 --- a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx +++ b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx @@ -46,7 +46,7 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding // We need to use isSmallScreenWidth, see navigateAfterOnboarding function comment // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {onboardingIsMediumOrLargerScreenWidth, isSmallScreenWidth} = useResponsiveLayout(); + const {onboardingIsMediumOrLargerScreenWidth, isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); const [joinablePolicies] = useOnyx(ONYXKEYS.JOINABLE_POLICIES); const [getAccessiblePoliciesAction] = useOnyx(ONYXKEYS.VALIDATE_USER_AND_GET_ACCESSIBLE_POLICIES); @@ -101,7 +101,7 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding setOnboardingPolicyID(policy.policyID); if (shouldUseSubmitFlow) { - navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policy.policyID, isSmallScreenWidth); + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policy.policyID, shouldUseNarrowLayout); return; } diff --git a/tests/unit/libs/navigateAfterOnboarding.test.ts b/tests/unit/libs/navigateAfterOnboarding.test.ts index ef09b90e5d7f..15fb0480da6f 100644 --- a/tests/unit/libs/navigateAfterOnboarding.test.ts +++ b/tests/unit/libs/navigateAfterOnboarding.test.ts @@ -39,7 +39,7 @@ describe('navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue', () => { expect(navigationMock.navigate).toHaveBeenCalledWith(ROUTES.HOME); }); - it('navigates to Workspace Categories with backTo=/workspaces on wide layouts', () => { + it('navigates to Workspace Categories with backTo=/workspaces when not using narrow layout', () => { navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue('test-policy-id', false); expect(navigationMock.dismissModal).toHaveBeenCalledTimes(1); @@ -47,7 +47,7 @@ describe('navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue', () => { expect(navigationMock.navigate).toHaveBeenCalledWith(`${ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')}?backTo=${encodeURIComponent(ROUTES.WORKSPACES_LIST.route)}`); }); - it('navigates to Workspace Categories with backTo=WorkspaceInitial on narrow layouts', () => { + it('navigates to Workspace Categories with backTo=WorkspaceInitial when using narrow layout', () => { navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue('test-policy-id', true); expect(navigationMock.dismissModal).toHaveBeenCalledTimes(1); From ff0f3c67a008b504c6bf2262473953a640af9416 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Mon, 11 May 2026 15:28:22 +0300 Subject: [PATCH 08/11] fix(onboarding): hide Submit workspaces from joinable list when SUBMIT_2026 beta is off Without the beta the Submit-specific onboarding flow doesn't fire, so a non-beta user joining a Submit workspace would land in a half-functional state. Filter Submit workspaces out of the list (and the routing-gate count in BaseOnboardingPrivateDomain) so they aren't offered in the first place. --- .../OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx | 5 ++++- src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx index 10bac4d5dd8a..40bf0bdeba79 100644 --- a/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx +++ b/src/pages/OnboardingPrivateDomain/BaseOnboardingPrivateDomain.tsx @@ -8,6 +8,7 @@ import ValidateCodeForm from '@components/ValidateCodeActionModal/ValidateCodeFo import useLocalize from '@hooks/useLocalize'; import useOnboardingStepCounter from '@hooks/useOnboardingStepCounter'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateOnboardingValuesAndNavigation} from '@libs/actions/Welcome'; @@ -28,10 +29,12 @@ function BaseOnboardingPrivateDomain({shouldUseNativeStyles, route}: BaseOnboard const {translate} = useLocalize(); const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); const [session] = useOnyx(ONYXKEYS.SESSION); + const {isBetaEnabled} = usePermissions(); + const canUseSubmit2026 = isBetaEnabled(CONST.BETAS.SUBMIT_2026); const [getAccessiblePoliciesAction] = useOnyx(ONYXKEYS.VALIDATE_USER_AND_GET_ACCESSIBLE_POLICIES); const [joinablePolicies] = useOnyx(ONYXKEYS.JOINABLE_POLICIES); - const joinablePoliciesLength = Object.keys(joinablePolicies ?? {}).length; + const joinablePoliciesLength = Object.values(joinablePolicies ?? {}).filter((policy) => policy.policyType !== CONST.POLICY.TYPE.SUBMIT || canUseSubmit2026).length; const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); const onboardingStep = useOnboardingStepCounter(SCREENS.ONBOARDING.PRIVATE_DOMAIN); diff --git a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx index a51a45649e6e..1e44ff822506 100644 --- a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx +++ b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx @@ -117,6 +117,7 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding }; const allPolicyIDItems = Object.values(joinablePolicies ?? {}) + .filter((policyInfo) => policyInfo.policyType !== CONST.POLICY.TYPE.SUBMIT || canUseSubmit2026) .sort((a, b) => b.employeeCount - a.employeeCount) .map((policyInfo) => ({ text: policyInfo.policyName, From 6299315722338bf2fb0f5b071510198e46a98121 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Mon, 11 May 2026 18:38:10 +0300 Subject: [PATCH 09/11] chore(onboarding): address PR review feedback from @hungvu193 - Drop the backticks in JoinablePolicy.policyType JSDoc - Source currentUserEmail / currentUserAccountID from useCurrentUserPersonalDetails instead of ONYXKEYS.SESSION --- src/hooks/useOnboardingWorkspaceCreationState.ts | 9 ++------- src/types/onyx/JoinablePolicies.ts | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/hooks/useOnboardingWorkspaceCreationState.ts b/src/hooks/useOnboardingWorkspaceCreationState.ts index d2c69b42d10c..aad14507ff65 100644 --- a/src/hooks/useOnboardingWorkspaceCreationState.ts +++ b/src/hooks/useOnboardingWorkspaceCreationState.ts @@ -14,9 +14,6 @@ import useResponsiveLayout from './useResponsiveLayout'; /** * Shared state for the onboarding workspace auto-creation hooks * (`useAutoCreateSubmitWorkspace`, `useAutoCreateTrackWorkspace`). - * - * Email and accountID come from `ONYXKEYS.SESSION` because session is hydrated - * earlier in onboarding than personal details. */ function useOnboardingWorkspaceCreationState() { const [onboardingPolicyID] = useOnyx(ONYXKEYS.ONBOARDING_POLICY_ID); @@ -24,12 +21,10 @@ function useOnboardingWorkspaceCreationState() { const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const [betas] = useOnyx(ONYXKEYS.BETAS); - const [session] = useOnyx(ONYXKEYS.SESSION); - - const currentUserEmail = session?.email ?? ''; - const currentUserAccountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const currentUserEmail = currentUserPersonalDetails.login ?? ''; + const currentUserAccountID = currentUserPersonalDetails.accountID ?? CONST.DEFAULT_NUMBER_ID; const localCurrencyCode = currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD; const activePolicy = useActivePolicy(); diff --git a/src/types/onyx/JoinablePolicies.ts b/src/types/onyx/JoinablePolicies.ts index 964bfc6b6c04..2b2f1070c21e 100644 --- a/src/types/onyx/JoinablePolicies.ts +++ b/src/types/onyx/JoinablePolicies.ts @@ -15,7 +15,7 @@ type JoinablePolicy = { hasPendingAccess: boolean; /** Whether the user needs an approval to join the workspace or not */ automaticJoiningEnabled: boolean; - /** Policy type returned by the backend (`team` | `corporate` | `submit2026` | ...). */ + /** Policy type returned by the backend (team | corporate | submit2026 | ...). */ policyType?: ValueOf; }; From d2b66e9fe141ca2a04f7238f8299b68abbc29ce3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Mon, 11 May 2026 20:04:21 +0300 Subject: [PATCH 10/11] chore: refresh eslint-seatbelt for ReimbursementAccount/USD post-merge Six pre-existing violations in ReimbursementAccount/USD files (5 useSubStep deprecations + 1 namespace-import) were exposed when CI's frozen seatbelt rejected our merge. Add them to the seatbelt to unblock CI; they belong to upstream/main code, not this PR. --- config/eslint/eslint.seatbelt.tsv | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 815e878c0f34..9f6bc3227233 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -472,7 +472,13 @@ "../../src/pages/ReimbursementAccount/EnterSignerInfo/index.tsx" "@typescript-eslint/no-deprecated/useSubStep" 1 "../../src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx" "react-hooks/refs" 3 "../../src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx" "react-hooks/set-state-in-effect" 4 +"../../src/pages/ReimbursementAccount/USD/BankInfo/BankInfo.tsx" "@typescript-eslint/no-deprecated/useSubStep" 1 +"../../src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/BeneficialOwnersStep.tsx" "@typescript-eslint/no-deprecated/useSubStep" 1 +"../../src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/BeneficialOwnerDetailsFormSubSteps/ConfirmationUBO.tsx" "no-restricted-syntax" 1 +"../../src/pages/ReimbursementAccount/USD/BusinessInfo/BusinessInfo.tsx" "@typescript-eslint/no-deprecated/useSubStep" 1 "../../src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/IndustryCode/IndustryCodeSelector.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/ReimbursementAccount/USD/CompleteVerification/CompleteVerification.tsx" "@typescript-eslint/no-deprecated/useSubStep" 1 +"../../src/pages/ReimbursementAccount/USD/Requestor/PersonalInfo/PersonalInfo.tsx" "@typescript-eslint/no-deprecated/useSubStep" 1 "../../src/pages/ReportDescriptionPage.tsx" "no-restricted-syntax" 1 "../../src/pages/ReportDetailsPage.tsx" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 "../../src/pages/ReportDetailsPage.tsx" "react-hooks/preserve-manual-memoization" 4 From 2cb3c0e439ee45ab1114a7ce5d5b219ede321435 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Mon, 11 May 2026 20:08:53 +0300 Subject: [PATCH 11/11] Revert "chore: refresh eslint-seatbelt for ReimbursementAccount/USD post-merge" This reverts commit d2b66e9fe141ca2a04f7238f8299b68abbc29ce3. --- config/eslint/eslint.seatbelt.tsv | 6 ------ 1 file changed, 6 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 9f6bc3227233..815e878c0f34 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -472,13 +472,7 @@ "../../src/pages/ReimbursementAccount/EnterSignerInfo/index.tsx" "@typescript-eslint/no-deprecated/useSubStep" 1 "../../src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx" "react-hooks/refs" 3 "../../src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx" "react-hooks/set-state-in-effect" 4 -"../../src/pages/ReimbursementAccount/USD/BankInfo/BankInfo.tsx" "@typescript-eslint/no-deprecated/useSubStep" 1 -"../../src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/BeneficialOwnersStep.tsx" "@typescript-eslint/no-deprecated/useSubStep" 1 -"../../src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/BeneficialOwnerDetailsFormSubSteps/ConfirmationUBO.tsx" "no-restricted-syntax" 1 -"../../src/pages/ReimbursementAccount/USD/BusinessInfo/BusinessInfo.tsx" "@typescript-eslint/no-deprecated/useSubStep" 1 "../../src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/IndustryCode/IndustryCodeSelector.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/ReimbursementAccount/USD/CompleteVerification/CompleteVerification.tsx" "@typescript-eslint/no-deprecated/useSubStep" 1 -"../../src/pages/ReimbursementAccount/USD/Requestor/PersonalInfo/PersonalInfo.tsx" "@typescript-eslint/no-deprecated/useSubStep" 1 "../../src/pages/ReportDescriptionPage.tsx" "no-restricted-syntax" 1 "../../src/pages/ReportDetailsPage.tsx" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 "../../src/pages/ReportDetailsPage.tsx" "react-hooks/preserve-manual-memoization" 4