From 7a5368cfe5274e700dda44f210b31e27ef00bbed Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 7 Apr 2026 22:25:08 +0200 Subject: [PATCH 01/29] feat: Submit workspace creation + onboarding flow (Wave 2) When a user selects "Get paid back by my employer" with the SUBMIT_2026 beta enabled, create a Submit workspace and complete onboarding, then navigate to Workspace > Categories with the side panel open. --- src/CONST/index.ts | 5 + src/hooks/useAutoCreateSubmitWorkspace.ts | 123 ++++++++++++++++++ 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/SubscriptionUtils.ts | 2 +- src/libs/actions/Policy/Policy.ts | 29 ++++- src/libs/navigateAfterOnboarding.ts | 26 +++- .../BaseOnboardingPersonalDetails.tsx | 16 +++ .../BaseOnboardingPurpose.tsx | 14 ++ .../BaseOnboardingWorkspaces.tsx | 23 +++- .../DynamicWorkspaceOverviewPlanTypePage.tsx | 2 +- 19 files changed, 267 insertions(+), 13 deletions(-) create mode 100644 src/hooks/useAutoCreateSubmitWorkspace.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 54d570a31911..212643c5c256 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -869,6 +869,7 @@ const CONST = { FREEZE_CARD: 'freezeCard', BULK_EDIT: 'bulkEdit', NEW_MANUAL_EXPENSE_FLOW: 'newManualExpenseFlow', + SUBMIT_2026: 'submit2026', }, BUTTON_STATES: { DEFAULT: 'default', @@ -3440,6 +3441,9 @@ const CONST = { // Often referred to as "collect" workspaces TEAM: 'team', + + // New free Submit workspace (distinct from Classic "submit") + SUBMIT: 'submit2026', }, RULE_CONDITIONS: { MATCHES: 'matches', @@ -3458,6 +3462,7 @@ const CONST = { ADMIN: 'admin', AUDITOR: 'auditor', USER: 'user', + EDITOR: 'editor', }, AUTO_REIMBURSEMENT_MAX_LIMIT_CENTS: 2000000, diff --git a/src/hooks/useAutoCreateSubmitWorkspace.ts b/src/hooks/useAutoCreateSubmitWorkspace.ts new file mode 100644 index 000000000000..e02de3cce7f4 --- /dev/null +++ b/src/hooks/useAutoCreateSubmitWorkspace.ts @@ -0,0 +1,123 @@ +import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {useCallback, useMemo} from 'react'; +import type {OnyxCollection} from 'react-native-onyx'; +import {navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; +import {createDisplayName} from '@libs/PersonalDetailsUtils'; +import {isPaidGroupPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; +import {createWorkspace, generatePolicyID, newGenerateDefaultWorkspaceName} 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 {lastWorkspaceNumberSelector} from '@src/selectors/Policy'; +import type {Policy} from '@src/types/onyx'; +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useHasActiveAdminPolicies from './useHasActiveAdminPolicies'; +import useLocalize from './useLocalize'; +import useOnboardingMessages from './useOnboardingMessages'; +import useOnyx from './useOnyx'; +import usePreferredPolicy from './usePreferredPolicy'; + +/** + * Hook that provides a function to auto-create a Submit workspace for EMPLOYER + * users during onboarding and complete the onboarding flow. + * + * After creating the workspace, navigates to Workspace > Categories with the + * side panel open so #admins is visible in Concierge Anywhere. + * + * Shared by BaseOnboardingPersonalDetails, BaseOnboardingPurpose, and BaseOnboardingWorkspaces. + */ +function useAutoCreateSubmitWorkspace() { + 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 paidGroupPolicySelector = useMemo( + () => (policies: OnyxCollection) => Object.values(policies ?? {}).some((policy) => isPaidGroupPolicy(policy) && isPolicyAdmin(policy, session?.email)), + [session?.email], + ); + const lastWorkspaceNumberWithEmailSelector = useCallback( + (policies: OnyxCollection) => { + return lastWorkspaceNumberSelector(policies, session?.email ?? ''); + }, + [session?.email], + ); + const [hasPaidGroupAdminPolicy] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: paidGroupPolicySelector}); + const [lastWorkspaceNumber] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: lastWorkspaceNumberWithEmailSelector}); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const {translate, formatPhoneNumber} = useLocalize(); + const {isRestrictedPolicyCreation} = usePreferredPolicy(); + const hasActiveAdminPolicies = useHasActiveAdminPolicies(); + const {onboardingMessages} = useOnboardingMessages(); + + const autoCreateSubmitWorkspace = useCallback( + (firstName: string, lastName: string) => { + const shouldCreateWorkspace = !isRestrictedPolicyCreation && !onboardingPolicyID && !hasPaidGroupAdminPolicy; + const displayName = createDisplayName(session?.email ?? '', {firstName, lastName}, formatPhoneNumber); + + const {adminsChatReportID: newAdminsChatReportID, policyID: newPolicyID} = shouldCreateWorkspace + ? createWorkspace({ + policyOwnerEmail: undefined, + makeMeAdmin: true, + policyName: newGenerateDefaultWorkspaceName(session?.email ?? '', lastWorkspaceNumber, translate, displayName), + policyID: generatePolicyID(), + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + currency: currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD, + file: undefined, + shouldAddOnboardingTasks: false, + introSelected, + activePolicyID, + currentUserAccountIDParam: session?.accountID ?? CONST.DEFAULT_NUMBER_ID, + currentUserEmailParam: session?.email ?? '', + 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); + }, + [ + session?.email, + session?.accountID, + lastWorkspaceNumber, + translate, + formatPhoneNumber, + isRestrictedPolicyCreation, + onboardingPolicyID, + hasPaidGroupAdminPolicy, + onboardingAdminsChatReportID, + currentUserPersonalDetails.localCurrencyCode, + introSelected, + activePolicyID, + isSelfTourViewed, + onboardingMessages, + betas, + hasActiveAdminPolicies, + ], + ); + + return autoCreateSubmitWorkspace; +} + +export default useAutoCreateSubmitWorkspace; diff --git a/src/languages/de.ts b/src/languages/de.ts index 8f411c70e681..73e2d7d052e5 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6825,6 +6825,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 a5867646e848..c675478968da 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6780,6 +6780,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 ae51e8001082..de2c8a76e56b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6278,6 +6278,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 ae6196763821..073e5a53db8f 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6848,6 +6848,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 21b0d587e97e..bf8447e6c2d6 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6814,6 +6814,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 eccda8fc9a4f..3d733c84bd63 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6739,6 +6739,10 @@ ${reportName} label: 'コントロール', description: '高度な要件を持つ組織向け。', }, + submit2026: { + label: '提出', + description: '雇用主に経費を提出したい従業員向け。', + }, }, description: '自分に合ったプランをお選びください。機能と料金の詳細な一覧は、こちらのページをご覧ください', subscriptionLink: 'プランの種類と料金のヘルプページ', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 7336d6d4a873..1b4ca3bd4890 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6795,6 +6795,10 @@ Voeg meer 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 c9b6fed1f72a..0e7296cbb09c 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6786,6 +6786,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 f43fe751cfe5..cc18441aefb6 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6793,6 +6793,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 91244efc29fe..e11427c691a8 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6615,6 +6615,10 @@ ${reportName} label: '控制', description: '适用于具有高级需求的组织。', }, + submit2026: { + label: '提交', + description: '适用于希望向雇主提交费用的员工。', + }, }, description: '选择适合您的方案。要查看详细的功能和价格列表,请访问我们的', subscriptionLink: '方案类型和价格帮助页面', diff --git a/src/libs/SubscriptionUtils.ts b/src/libs/SubscriptionUtils.ts index afca7d213481..b87748418df8 100644 --- a/src/libs/SubscriptionUtils.ts +++ b/src/libs/SubscriptionUtils.ts @@ -505,7 +505,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/Policy.ts b/src/libs/actions/Policy/Policy.ts index 8790905b6c6c..9d68faf8f368 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -206,7 +206,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; @@ -2326,7 +2326,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, ) { const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail); const {customUnits, outputCurrency} = buildOptimisticDistanceRateCustomUnits(currency); @@ -2402,6 +2402,20 @@ type BuildPolicyDataKeys = | typeof ONYXKEYS.NVP_LAST_PAYMENT_METHOD | typeof ONYXKEYS.PERSONAL_DETAILS_LIST; +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 * @@ -2463,7 +2477,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< @@ -2512,8 +2528,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) { + setDisableDismissOnEscape(false); + + if (policyID) { + Navigation.navigate(ROUTES.WORKSPACE_CATEGORIES.getRoute(policyID)); + SidePanelActions.openSidePanel(true); + } else { + Navigation.navigate(ROUTES.HOME); + } +} + +function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: string) { + Navigation.dismissModal(); + Navigation.setNavigationActionToMicrotaskQueue(() => { + navigateToSubmitWorkspaceAfterOnboarding(policyID); + }); +} + +export {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue}; diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx index 85b3e67b4bc8..d1a1819ead76 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,17 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat clearPersonalDetailsDraft(); setPersonalDetails(firstName, lastName); + if (onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.EMPLOYER && canUseSubmit2026) { + if (isPrivateDomainAndHasAccessiblePolicies) { + const nextRoute = isValidated ? ROUTES.ONBOARDING_WORKSPACES : ROUTES.ONBOARDING_PRIVATE_DOMAIN; + Navigation.navigate(nextRoute.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 +171,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..32c678080fba 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -15,6 +15,7 @@ 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'; @@ -74,6 +75,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 {isBetaEnabled} = usePermissions(); + const canUseSubmit2026 = isBetaEnabled(CONST.BETAS.SUBMIT_2026); const autoCreateTrackWorkspace = useAutoCreateTrackWorkspace(); const paddingHorizontal = onboardingIsMediumOrLargerScreenWidth ? styles.ph8 : styles.ph5; @@ -102,6 +105,17 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro return; } + if (choice === CONST.ONBOARDING_CHOICES.EMPLOYER && canUseSubmit2026) { + if (isPrivateDomainAndHasAccessiblePolicies) { + Navigation.navigate( + personalDetailsForm?.firstName ? ROUTES.ONBOARDING_WORKSPACES.getRoute(route.params?.backTo) : ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(route.params?.backTo), + ); + 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 92d30621f053..005c8cbe7304 100644 --- a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx +++ b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx @@ -9,6 +9,7 @@ import SelectionList from '@components/SelectionList'; import UserListItem from '@components/SelectionList/ListItem/UserListItem'; 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'; @@ -18,7 +19,7 @@ import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; 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'; @@ -66,6 +67,10 @@ 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); @@ -75,9 +80,11 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding } else { askToJoinPolicy(policy.policyID); } + + const engagementChoice = isEmployerWithSubmit ? 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 ?? '', companySize: onboardingCompanySize, @@ -88,6 +95,11 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding setOnboardingAdminsChatReportID(); setOnboardingPolicyID(policy.policyID); + if (isEmployerWithSubmit && policy.automaticJoiningEnabled) { + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policy.policyID); + return; + } + navigateAfterOnboardingWithMicrotaskQueue( isSmallScreenWidth, isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), @@ -138,6 +150,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/DynamicWorkspaceOverviewPlanTypePage.tsx b/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx index 8ad8ac82a280..72c4ab983f0b 100644 --- a/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx +++ b/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx @@ -55,7 +55,7 @@ function DynamicWorkspaceOverviewPlanTypePage({policy}: WithPolicyProps) { }, [policy?.type]); const workspacePlanTypes = Object.values(CONST.POLICY.TYPE) - .filter((type) => type !== CONST.POLICY.TYPE.PERSONAL) + .filter((type) => type !== CONST.POLICY.TYPE.PERSONAL && type !== CONST.POLICY.TYPE.SUBMIT) .map((policyType) => ({ value: policyType, text: translate(`workspace.planTypePage.planTypes.${policyType as PersonalPolicyTypeExcludedProps}.label`), From 0916a57dd86ff446dd82b425377c36745d7f3b29 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 7 Apr 2026 22:42:48 +0200 Subject: [PATCH 02/29] fix: load translations in WorkspaceOnboarding test The useAutoCreateSubmitWorkspace hook runs a selector that calls translate() for workspace name generation. The test needs IntlStore loaded, matching the pattern in PersonalDetailsOnboarding test. --- tests/ui/WorkspaceOnboarding.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/ui/WorkspaceOnboarding.tsx b/tests/ui/WorkspaceOnboarding.tsx index 134697814764..e006bc8d490b 100644 --- a/tests/ui/WorkspaceOnboarding.tsx +++ b/tests/ui/WorkspaceOnboarding.tsx @@ -14,6 +14,7 @@ import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigati import type {OnboardingModalNavigatorParamList} from '@libs/Navigation/types'; import OnboardingWorkspaces from '@pages/OnboardingWorkspaces'; 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'; @@ -49,6 +50,7 @@ describe('OnboardingWorkspaces Page', () => { Onyx.init({ keys: ONYXKEYS, }); + return IntlStore.load(CONST.LOCALES.EN); }); beforeEach(() => { From 5fcd6386184e6c24d302d8a3ecb1e88e3dc94ba3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 7 Apr 2026 22:49:47 +0200 Subject: [PATCH 03/29] test: add tests for Submit workspace onboarding flow - PolicyTest: verify createWorkspace with SUBMIT type produces correct defaults (ADVANCED approval, workflows/tags/distance rates enabled, company cards disabled, harvesting disabled) - OnboardingPurpose: verify EMPLOYER + Submit2026 beta navigates to personal details (public domain) or workspaces (private domain) - OnboardingWorkspaces: verify skip with EMPLOYER + Submit2026 beta calls createWorkspace with SUBMIT type and completeOnboarding with EMPLOYER engagement choice --- tests/actions/PolicyTest.ts | 34 ++++++++++++ tests/ui/OnboardingPurpose.tsx | 60 +++++++++++++++++++++ tests/ui/WorkspaceOnboarding.tsx | 91 ++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+) diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index c99fda026b59..c978422a2b75 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -846,6 +846,40 @@ 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, + 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?.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); + }, + }); + }); + 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..68db7b7b9e68 100644 --- a/tests/ui/OnboardingPurpose.tsx +++ b/tests/ui/OnboardingPurpose.tsx @@ -138,6 +138,66 @@ 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 () => { + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, { + isFromPublicDomain: false, + hasAccessibleDomainPolicies: true, + }); + 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 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/WorkspaceOnboarding.tsx b/tests/ui/WorkspaceOnboarding.tsx index e006bc8d490b..6ff801e507bf 100644 --- a/tests/ui/WorkspaceOnboarding.tsx +++ b/tests/ui/WorkspaceOnboarding.tsx @@ -9,10 +9,13 @@ import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; import * as useResponsiveLayoutModule from '@hooks/useResponsiveLayout'; import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; +import {navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; 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 {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'; @@ -21,6 +24,43 @@ 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 mockNavigateToSubmitWorkspace = jest.mocked(navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue); + +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('@libs/navigateAfterOnboarding', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@libs/navigateAfterOnboarding'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue: jest.fn(), + }; +}); + TestHelper.setupGlobalFetchMock(); const Stack = createPlatformStackNavigator(); @@ -175,4 +215,55 @@ describe('OnboardingWorkspaces Page', () => { unmount(); await waitForBatchedUpdatesWithAct(); }); + + it('should create a Submit workspace when skip is pressed with EMPLOYER purpose and Submit2026 beta', async () => { + 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(mockNavigateToSubmitWorkspace).toHaveBeenCalledWith('test-policy-id'); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); }); From edfd3622dc8a392257e0f6600efa1c4a122519fe Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 7 Apr 2026 22:59:41 +0200 Subject: [PATCH 04/29] chore: remove narration comment from CONST and trim hook JSDoc --- src/CONST/index.ts | 1 - src/hooks/useAutoCreateSubmitWorkspace.ts | 3 --- 2 files changed, 4 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 212643c5c256..1d5f053481c6 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3442,7 +3442,6 @@ const CONST = { // Often referred to as "collect" workspaces TEAM: 'team', - // New free Submit workspace (distinct from Classic "submit") SUBMIT: 'submit2026', }, RULE_CONDITIONS: { diff --git a/src/hooks/useAutoCreateSubmitWorkspace.ts b/src/hooks/useAutoCreateSubmitWorkspace.ts index e02de3cce7f4..ae3f04b30a64 100644 --- a/src/hooks/useAutoCreateSubmitWorkspace.ts +++ b/src/hooks/useAutoCreateSubmitWorkspace.ts @@ -22,9 +22,6 @@ import usePreferredPolicy from './usePreferredPolicy'; * Hook that provides a function to auto-create a Submit workspace for EMPLOYER * users during onboarding and complete the onboarding flow. * - * After creating the workspace, navigates to Workspace > Categories with the - * side panel open so #admins is visible in Concierge Anywhere. - * * Shared by BaseOnboardingPersonalDetails, BaseOnboardingPurpose, and BaseOnboardingWorkspaces. */ function useAutoCreateSubmitWorkspace() { From fc5eb7c4d658ff15baded395240876393d15a4dd Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 7 Apr 2026 23:52:32 +0200 Subject: [PATCH 05/29] test: improve coverage for Submit onboarding flow - WorkspaceOnboarding: stop mocking navigateAfterOnboarding, spy on Navigation primitives instead so the real navigation functions execute (covers navigateAfterOnboarding.ts) - PersonalDetailsOnboarding: add EMPLOYER + Submit2026 tests for both private domain (navigate to workspaces) and public domain (creates Submit workspace) branches --- tests/ui/PersonalDetailsOnboarding.tsx | 101 +++++++++++++++++++++++++ tests/ui/WorkspaceOnboarding.tsx | 17 +---- 2 files changed, 105 insertions(+), 13 deletions(-) diff --git a/tests/ui/PersonalDetailsOnboarding.tsx b/tests/ui/PersonalDetailsOnboarding.tsx index 66565567b699..3e8d2518c35d 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(); @@ -141,4 +169,77 @@ 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 () => { + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, { + isFromPublicDomain: false, + hasAccessibleDomainPolicies: true, + }); + await Onyx.merge(ONYXKEYS.LOGIN_LIST, mockLoginList); + 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 6ff801e507bf..00b8953ab220 100644 --- a/tests/ui/WorkspaceOnboarding.tsx +++ b/tests/ui/WorkspaceOnboarding.tsx @@ -9,7 +9,6 @@ import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; import * as useResponsiveLayoutModule from '@hooks/useResponsiveLayout'; import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; -import {navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {OnboardingModalNavigatorParamList} from '@libs/Navigation/types'; @@ -26,7 +25,6 @@ import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct' const mockCreateWorkspace = jest.mocked(createWorkspace); const mockCompleteOnboarding = jest.mocked(completeOnboarding); -const mockNavigateToSubmitWorkspace = jest.mocked(navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue); jest.mock('@userActions/Report', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -51,16 +49,6 @@ jest.mock('@userActions/Policy/Policy', () => { }; }); -jest.mock('@libs/navigateAfterOnboarding', () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const actual = jest.requireActual('@libs/navigateAfterOnboarding'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return { - ...actual, - navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue: jest.fn(), - }; -}); - TestHelper.setupGlobalFetchMock(); const Stack = createPlatformStackNavigator(); @@ -217,6 +205,9 @@ describe('OnboardingWorkspaces Page', () => { }); 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 () => { @@ -260,7 +251,7 @@ describe('OnboardingWorkspaces Page', () => { }); await waitFor(() => { - expect(mockNavigateToSubmitWorkspace).toHaveBeenCalledWith('test-policy-id'); + expect(navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')); }); unmount(); From 6aa9d5dacd37dc44b3873a503aff32e4e9f0fd93 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 8 Apr 2026 00:02:53 +0200 Subject: [PATCH 06/29] fix: use test user email in login list for validated private domain test The test user signs in as test@user.com but the login list had fake@gmail.com, so isCurrentUserValidated returned false and routed to PRIVATE_DOMAIN instead of WORKSPACES. --- tests/ui/PersonalDetailsOnboarding.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/ui/PersonalDetailsOnboarding.tsx b/tests/ui/PersonalDetailsOnboarding.tsx index 3e8d2518c35d..ce0cc5171130 100644 --- a/tests/ui/PersonalDetailsOnboarding.tsx +++ b/tests/ui/PersonalDetailsOnboarding.tsx @@ -171,6 +171,7 @@ describe('OnboardingPersonalDetails Page', () => { }); 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 () => { @@ -178,7 +179,13 @@ describe('OnboardingPersonalDetails Page', () => { isFromPublicDomain: false, hasAccessibleDomainPolicies: true, }); - await Onyx.merge(ONYXKEYS.LOGIN_LIST, mockLoginList); + 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]); }); From 55728a8ef2114b4a526c9407959b02622e5646bb Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 14 Apr 2026 02:36:28 +0200 Subject: [PATCH 07/29] fix: add SUBMIT to isPaidGroupPolicy and gate Purpose shortcut on isValidated - Include SUBMIT in isPaidGroupPolicy so WorkspaceCategoriesPage (and other PAID-gated workspace pages) are accessible for Submit workspaces - Add isValidated check in BaseOnboardingPurpose before routing EMPLOYER+Submit users with a saved firstName directly to Workspaces, preventing unvalidated users from bypassing domain verification - Update OnboardingPurpose test to set validated login for the private domain shortcut test --- src/libs/PolicyUtils.ts | 2 +- src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx | 8 +++++++- tests/ui/OnboardingPurpose.tsx | 8 ++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index ce8f511615b1..af2cb09cada0 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -825,7 +825,7 @@ function isPendingDeletePolicy(policy: OnyxEntry): boolean { } function isPaidGroupPolicy(policy: OnyxInputOrEntry): boolean { - return policy?.type === CONST.POLICY.TYPE.TEAM || policy?.type === CONST.POLICY.TYPE.CORPORATE; + return policy?.type === CONST.POLICY.TYPE.TEAM || policy?.type === CONST.POLICY.TYPE.CORPORATE || policy?.type === CONST.POLICY.TYPE.SUBMIT; } function getOwnedPaidPolicies(policies: OnyxCollection | null, currentUserAccountID: number | undefined): Policy[] { diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx index 32c678080fba..b39f8e031f26 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -22,6 +22,7 @@ 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'; @@ -75,8 +76,11 @@ 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 autoCreateTrackWorkspace = useAutoCreateTrackWorkspace(); const paddingHorizontal = onboardingIsMediumOrLargerScreenWidth ? styles.ph8 : styles.ph5; @@ -108,7 +112,9 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro if (choice === CONST.ONBOARDING_CHOICES.EMPLOYER && canUseSubmit2026) { if (isPrivateDomainAndHasAccessiblePolicies) { Navigation.navigate( - personalDetailsForm?.firstName ? ROUTES.ONBOARDING_WORKSPACES.getRoute(route.params?.backTo) : ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(route.params?.backTo), + personalDetailsForm?.firstName && isValidated + ? ROUTES.ONBOARDING_WORKSPACES.getRoute(route.params?.backTo) + : ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(route.params?.backTo), ); return; } diff --git a/tests/ui/OnboardingPurpose.tsx b/tests/ui/OnboardingPurpose.tsx index 68db7b7b9e68..fa380c43638b 100644 --- a/tests/ui/OnboardingPurpose.tsx +++ b/tests/ui/OnboardingPurpose.tsx @@ -167,6 +167,7 @@ describe('OnboardingPurpose Page', () => { }); 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 () => { @@ -174,6 +175,13 @@ describe('OnboardingPurpose Page', () => { 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', From 08e703503e2aa5fb13137704de8aa5effd5ccda4 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 14 Apr 2026 02:47:05 +0200 Subject: [PATCH 08/29] fix: revert isPaidGroupPolicy to exclude Submit, add isSubmitPolicy/isGroupPolicy helpers Submit workspaces are free and must not be treated as paid policies in billing, subscription, and access-gating logic. Reverts isPaidGroupPolicy to TEAM|CORPORATE only and adds purpose-specific helpers. Also fixes: - Optimistic role set to EDITOR for Submit workspace creators - Plan Type page conditionally shows Submit only for Submit workspaces - Plan Type page handles Submit -> Collect/Control upgrade navigation - Test asserts EDITOR role on Submit workspace creation --- src/libs/PolicyUtils.ts | 12 +++++++++++- src/libs/actions/Policy/Policy.ts | 8 ++++---- .../DynamicWorkspaceOverviewPlanTypePage.tsx | 16 +++++++++++++++- tests/actions/PolicyTest.ts | 2 ++ 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index af2cb09cada0..71dd13fb6846 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -825,7 +825,15 @@ function isPendingDeletePolicy(policy: OnyxEntry): boolean { } function isPaidGroupPolicy(policy: OnyxInputOrEntry): boolean { - return policy?.type === CONST.POLICY.TYPE.TEAM || policy?.type === CONST.POLICY.TYPE.CORPORATE || policy?.type === CONST.POLICY.TYPE.SUBMIT; + 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 isGroupPolicy(policy: OnyxInputOrEntry): boolean { + return isPaidGroupPolicy(policy) || isSubmitPolicy(policy); } function getOwnedPaidPolicies(policies: OnyxCollection | null, currentUserAccountID: number | undefined): Policy[] { @@ -2154,6 +2162,8 @@ export { isDelayedSubmissionEnabled, getCorrectedAutoReportingFrequency, isPaidGroupPolicy, + isSubmitPolicy, + isGroupPolicy, isPendingDeletePolicy, isPolicyAdmin, isPolicyUser, diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 377f16ce20ba..e6c63774769f 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -2512,7 +2512,7 @@ function buildPolicyData(options: BuildPolicyDataOptions): OnyxData type !== CONST.POLICY.TYPE.PERSONAL && type !== CONST.POLICY.TYPE.SUBMIT) + .filter((type) => { + if (type === CONST.POLICY.TYPE.PERSONAL) { + return false; + } + if (type === CONST.POLICY.TYPE.SUBMIT && !isSubmitPolicy(policy)) { + return false; + } + return true; + }) .map((policyType) => ({ value: policyType, text: translate(`workspace.planTypePage.planTypes.${policyType as PersonalPolicyTypeExcludedProps}.label`), @@ -81,6 +90,11 @@ function DynamicWorkspaceOverviewPlanTypePage({policy}: WithPolicyProps) { ) : null; const handleUpdatePlan = () => { + if (policyID && isSubmitPolicy(policy) && currentPlan !== CONST.POLICY.TYPE.SUBMIT) { + 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/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index 595849852e9d..5b65860b8c5b 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -869,6 +869,7 @@ describe('actions/Policy', () => { 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); @@ -876,6 +877,7 @@ describe('actions/Policy', () => { 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); }, }); }); From 00e659c5d0a68a9a04eb61d9f5f99f1085b1ef89 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 14 Apr 2026 03:09:11 +0200 Subject: [PATCH 09/29] fix: resolve onboarding loop for unvalidated private-domain EMPLOYER users Unvalidated private-domain users selecting "Get paid back by my employer" were stuck in an infinite loop between the Purpose page and the private domain verification page. Now only route to the join-workspace flow when the user is validated; otherwise create the Submit workspace directly. Also adds autoCreateSubmitWorkspace to BaseOnboardingPurpose so it can create the workspace when personal details already exist, passes onboardingPolicyID when joining a Submit workspace, sets the RHP variant to open #admins after navigation, and adds tests for the new paths. --- src/libs/actions/Welcome/index.ts | 6 ++ src/libs/navigateAfterOnboarding.ts | 3 + .../BaseOnboardingPersonalDetails.tsx | 5 +- .../BaseOnboardingPurpose.tsx | 12 ++- .../BaseOnboardingWorkspaces.tsx | 1 + tests/ui/OnboardingPurpose.tsx | 73 +++++++++++++++++++ tests/ui/WorkspaceOnboarding.tsx | 72 ++++++++++++++++++ 7 files changed, 167 insertions(+), 5 deletions(-) diff --git a/src/libs/actions/Welcome/index.ts b/src/libs/actions/Welcome/index.ts index a10f96e62a8e..78c91087ef4c 100644 --- a/src/libs/actions/Welcome/index.ts +++ b/src/libs/actions/Welcome/index.ts @@ -13,6 +13,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {OnboardingPurpose} from '@src/types/onyx'; import type Onboarding from '@src/types/onyx/Onboarding'; +import type OnboardingRHPVariant from '@src/types/onyx/OnboardingRHPVariant'; import type {OnboardingCompanySize} from './OnboardingFlow'; let isLoadingReportData = true; @@ -61,6 +62,10 @@ function setOnboardingPolicyID(policyID?: string) { Onyx.set(ONYXKEYS.ONBOARDING_POLICY_ID, policyID ?? null); } +function setOnboardingRHPVariant(value?: OnboardingRHPVariant) { + Onyx.set(ONYXKEYS.NVP_ONBOARDING_RHP_VARIANT, value ?? null); +} + function updateOnboardingLastVisitedPath(path: string) { Onyx.merge(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH, path); } @@ -175,6 +180,7 @@ export { resetAllChecks, setOnboardingAdminsChatReportID, setOnboardingPolicyID, + setOnboardingRHPVariant, completeHybridAppOnboarding, setOnboardingErrorMessage, setOnboardingCompanySize, diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index 1ec5c55f11ea..299aba027d74 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -1,7 +1,9 @@ import {handleRHPVariantNavigation, shouldOpenRHPVariant} from '@components/SidePanel/RHPVariantTest'; +import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import {setDisableDismissOnEscape} from './actions/Modal'; import SidePanelActions from './actions/SidePanel'; +import {setOnboardingRHPVariant} from './actions/Welcome'; import shouldOpenOnAdminRoom from './Navigation/helpers/shouldOpenOnAdminRoom'; import Navigation from './Navigation/Navigation'; import {findLastAccessedReport, isConciergeChatReport, isSelfDM} from './ReportUtils'; @@ -108,6 +110,7 @@ function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string) { setDisableDismissOnEscape(false); if (policyID) { + setOnboardingRHPVariant(CONST.ONBOARDING_RHP_VARIANT.RHP_ADMINS_ROOM); Navigation.navigate(ROUTES.WORKSPACE_CATEGORIES.getRoute(policyID)); SidePanelActions.openSidePanel(true); } else { diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx index d1a1819ead76..956f91be9169 100644 --- a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx +++ b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx @@ -135,9 +135,8 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat setPersonalDetails(firstName, lastName); if (onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.EMPLOYER && canUseSubmit2026) { - if (isPrivateDomainAndHasAccessiblePolicies) { - const nextRoute = isValidated ? ROUTES.ONBOARDING_WORKSPACES : ROUTES.ONBOARDING_PRIVATE_DOMAIN; - Navigation.navigate(nextRoute.getRoute(route.params?.backTo)); + 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 ?? ''); diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx index b39f8e031f26..627dc647ef6d 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -9,6 +9,7 @@ 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'; @@ -81,6 +82,7 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro 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; @@ -110,14 +112,20 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro } if (choice === CONST.ONBOARDING_CHOICES.EMPLOYER && canUseSubmit2026) { - if (isPrivateDomainAndHasAccessiblePolicies) { + if (isPrivateDomainAndHasAccessiblePolicies && isValidated) { Navigation.navigate( - personalDetailsForm?.firstName && isValidated + 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; } diff --git a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx index 1c3935558000..76fdbc9c8329 100644 --- a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx +++ b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx @@ -90,6 +90,7 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding onboardingMessage: onboardingMessages[engagementChoice], firstName: onboardingPersonalDetails?.firstName ?? '', lastName: onboardingPersonalDetails?.lastName ?? '', + onboardingPolicyID: isEmployerWithSubmit && policy.automaticJoiningEnabled ? policy.policyID : undefined, companySize: onboardingCompanySize, introSelected, isSelfTourViewed, diff --git a/tests/ui/OnboardingPurpose.tsx b/tests/ui/OnboardingPurpose.tsx index fa380c43638b..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 @@ -206,6 +221,64 @@ describe('OnboardingPurpose Page', () => { 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/WorkspaceOnboarding.tsx b/tests/ui/WorkspaceOnboarding.tsx index 00b8953ab220..d2307caf85cd 100644 --- a/tests/ui/WorkspaceOnboarding.tsx +++ b/tests/ui/WorkspaceOnboarding.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 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'; @@ -25,6 +26,7 @@ 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 @@ -49,6 +51,16 @@ jest.mock('@userActions/Policy/Policy', () => { }; }); +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(); @@ -257,4 +269,64 @@ describe('OnboardingWorkspaces Page', () => { 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(); + }); }); From ad24a0c9dcf9358096ea4ae10705f5c8cefc3226 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 14 Apr 2026 16:53:04 +0200 Subject: [PATCH 10/29] fix: allow Submit workspace editors to access workspace feature pages Add isPolicyEditor and canEditWorkspaceSettings helpers to PolicyUtils as defined in the design doc. Update AccessOrNotFoundWrapper PAID/ADMIN variants, WorkspaceInitialPage sidebar gating, and Workflows pages to use these helpers so Submit workspaces with Editor role can access Categories, Workflows, and other enabled features after onboarding. Without this, the onboarding flow creates a Submit workspace and navigates to Categories, but the page shows "not found" because existing access checks only recognized paid policies and admin roles. --- src/libs/PolicyUtils.ts | 13 +++++++++++++ src/pages/workspace/AccessOrNotFoundWrapper.tsx | 6 +++--- src/pages/workspace/WorkspaceInitialPage.tsx | 7 ++++--- .../WorkspaceAutoReportingFrequencyPage.tsx | 4 ++-- .../WorkspaceAutoReportingMonthlyOffsetPage.tsx | 4 ++-- .../workspace/workflows/WorkspaceWorkflowsPage.tsx | 5 ++++- 6 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 71dd13fb6846..9079dbbca680 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -832,6 +832,17 @@ 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); +} + function isGroupPolicy(policy: OnyxInputOrEntry): boolean { return isPaidGroupPolicy(policy) || isSubmitPolicy(policy); } @@ -2163,6 +2174,8 @@ export { getCorrectedAutoReportingFrequency, isPaidGroupPolicy, isSubmitPolicy, + isPolicyEditor, + canEditWorkspaceSettings, isGroupPolicy, isPendingDeletePolicy, isPolicyAdmin, diff --git a/src/pages/workspace/AccessOrNotFoundWrapper.tsx b/src/pages/workspace/AccessOrNotFoundWrapper.tsx index 2a38f0a58292..d1af34c2535b 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, _login: string) => canEditWorkspaceSettings(policy), [CONST.IOU.ACCESS_VARIANTS.CREATE]: ( policy: OnyxEntry, login: string, diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index a973381712f7..fe1d87bd3231 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -38,9 +38,10 @@ import { goBackFromInvalidPolicy, hasAccountingFeatureConnection, hasPolicyCategoriesError, + canEditWorkspaceSettings, + isGroupPolicy, isPaidGroupPolicy, isPendingDeletePolicy, - isPolicyAdmin, isTimeTrackingEnabled, shouldShowEmployeeListError, shouldShowSyncError, @@ -133,7 +134,7 @@ 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 shouldDisplayLHB = !shouldUseNarrowLayout; const accountingConnectionNames = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES; @@ -228,7 +229,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/workflows/WorkspaceAutoReportingFrequencyPage.tsx b/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx index 0b94b5a3e8a7..b05076c6819b 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 167c6cfeb448..5debd69369f1 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 2f033b0cdbb5..d5730583dee2 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -50,6 +50,8 @@ import { getCorrectedAutoReportingFrequency, hasDynamicExternalWorkflow, isControlPolicy, + canEditWorkspaceSettings, + isGroupPolicy as isGroupPolicyUtil, isPaidGroupPolicy as isPaidGroupPolicyUtil, isPolicyAdmin as isPolicyAdminUtil, } from '@libs/PolicyUtils'; @@ -626,6 +628,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { ); const isPaidGroupPolicy = isPaidGroupPolicyUtil(policy); + const isGroupPolicy = isGroupPolicyUtil(policy); const isLoading = !!(policy?.isLoading && policy?.reimbursementChoice === undefined); return ( @@ -638,7 +641,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { icon={illustrations.Workflows} route={route} shouldShowOfflineIndicatorInWideScreen - shouldShowNotFoundPage={!isPaidGroupPolicy || !isPolicyAdmin} + shouldShowNotFoundPage={!isGroupPolicy || !canEditWorkspaceSettings(policy)} isLoading={isLoading} shouldShowLoading={isLoading} shouldUseScrollView From c0b38abf4d9858ffc4dde783bf9de3d52ccf814a Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 14 Apr 2026 16:57:22 +0200 Subject: [PATCH 11/29] fix: prettier formatting --- src/pages/workspace/WorkspaceInitialPage.tsx | 2 +- src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index fe1d87bd3231..697ae29bf8bf 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -33,12 +33,12 @@ import {convertToDisplayString} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import { + canEditWorkspaceSettings, canPolicyAccessFeature, shouldShowPolicy as checkIfShouldShowPolicy, goBackFromInvalidPolicy, hasAccountingFeatureConnection, hasPolicyCategoriesError, - canEditWorkspaceSettings, isGroupPolicy, isPaidGroupPolicy, isPendingDeletePolicy, diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index d5730583dee2..b20aaa30af1c 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -47,10 +47,10 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import {getPaymentMethodDescription} from '@libs/PaymentUtils'; import {getDisplayNameOrDefault, getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import { + canEditWorkspaceSettings, getCorrectedAutoReportingFrequency, hasDynamicExternalWorkflow, isControlPolicy, - canEditWorkspaceSettings, isGroupPolicy as isGroupPolicyUtil, isPaidGroupPolicy as isPaidGroupPolicyUtil, isPolicyAdmin as isPolicyAdminUtil, From fefc8fff5d8d0d54fd4e2b2a307a1cd9e2d9686a Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 14 Apr 2026 17:12:25 +0200 Subject: [PATCH 12/29] fixing prettier --- src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx index 627dc647ef6d..c33ec373f627 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -114,9 +114,7 @@ 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), + personalDetailsForm?.firstName ? ROUTES.ONBOARDING_WORKSPACES.getRoute(route.params?.backTo) : ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(route.params?.backTo), ); return; } From 94aa3436a5843c096b3ddad21d033275d5072881 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 14 Apr 2026 17:25:55 +0200 Subject: [PATCH 13/29] fixing prettier --- src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index b20aaa30af1c..c30db954602a 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -52,7 +52,6 @@ import { hasDynamicExternalWorkflow, isControlPolicy, isGroupPolicy as isGroupPolicyUtil, - isPaidGroupPolicy as isPaidGroupPolicyUtil, isPolicyAdmin as isPolicyAdminUtil, } from '@libs/PolicyUtils'; import {hasInProgressVBBA} from '@libs/ReimbursementAccountUtils'; @@ -627,7 +626,6 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { ); - const isPaidGroupPolicy = isPaidGroupPolicyUtil(policy); const isGroupPolicy = isGroupPolicyUtil(policy); const isLoading = !!(policy?.isLoading && policy?.reimbursementChoice === undefined); From 81fbfa29a22b525a310c022ba1500982e8c3cf39 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 14 Apr 2026 17:53:37 +0200 Subject: [PATCH 14/29] fix: resolve ESLint errors in CI - Extract getRoleForNewWorkspaceMember to avoid nested ternary in Policy.ts - Remove unused _login param from AccessOrNotFoundWrapper ADMIN variant - Remove unused isPaidGroupPolicy import and login variable from WorkspaceInitialPage - Remove dead isPaidGroupPolicy from WorkspaceWorkflowsPage --- src/libs/actions/Policy/Policy.ts | 9 ++++++++- src/pages/workspace/AccessOrNotFoundWrapper.tsx | 2 +- src/pages/workspace/WorkspaceInitialPage.tsx | 4 +--- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index e6c63774769f..cf73a3b4e021 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -2395,6 +2395,13 @@ 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 getApprovalModeForNewWorkspace( isSubmitWorkspace: boolean, shouldEnableWorkflowsByDefault: boolean, @@ -2565,7 +2572,7 @@ function buildPolicyData(options: BuildPolicyDataOptions): OnyxData) => isGroupPolicy(policy), [CONST.POLICY.ACCESS_VARIANTS.CONTROL]: (policy: OnyxEntry) => isControlPolicy(policy), - [CONST.POLICY.ACCESS_VARIANTS.ADMIN]: (policy: OnyxEntry, _login: string) => canEditWorkspaceSettings(policy), + [CONST.POLICY.ACCESS_VARIANTS.ADMIN]: (policy: OnyxEntry) => canEditWorkspaceSettings(policy), [CONST.IOU.ACCESS_VARIANTS.CREATE]: ( policy: OnyxEntry, login: string, diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 697ae29bf8bf..e7b7533bc566 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -13,7 +13,6 @@ import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import useCardFeedErrors from '@hooks/useCardFeedErrors'; import useConfirmReadyToOpenApp from '@hooks/useConfirmReadyToOpenApp'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -40,7 +39,6 @@ import { hasAccountingFeatureConnection, hasPolicyCategoriesError, isGroupPolicy, - isPaidGroupPolicy, isPendingDeletePolicy, isTimeTrackingEnabled, shouldShowEmployeeListError, @@ -95,7 +93,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const {shouldUseNarrowLayout} = useResponsiveLayout(); const {translate} = useLocalize(); const {isBetaEnabled} = usePermissions(); - const {login} = useCurrentUserPersonalDetails(); + const isFocused = useIsFocused(); const activeRoute = useNavigationState((state) => findFocusedRoute(state)?.name); const waitForNavigate = useWaitForNavigation(); From ef02c3f1d464f54d5aca9c90fdfc7c0abadf03cf Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 14 Apr 2026 18:56:37 +0200 Subject: [PATCH 15/29] fix: use canEditWorkspaceSettings in WorkspacePageWithSections fallback check The fallback admin check used isPolicyAdmin which blocked editors on Submit workspaces from seeing any page rendered through WorkspacePageWithSections. --- src/pages/workspace/WorkspacePageWithSections.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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]); From 8d71e48edcd28add0747ba27314812e1a0bf4fbd Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 16 Apr 2026 23:53:32 +0200 Subject: [PATCH 16/29] fix: show loading instead of not-found for joining workspaces, don't open side panel on mobile - Set isLoading on policy stub before navigating to Categories after joining a workspace, so AccessOrNotFoundWrapper shows a loading indicator instead of briefly flashing "not found" while policy data loads - Only open the side panel on large screens after Submit onboarding to avoid Concierge covering the Categories page on mobile --- src/libs/navigateAfterOnboarding.ts | 2 +- src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx | 2 ++ src/pages/workspace/AccessOrNotFoundWrapper.tsx | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index f33920174369..3898d3ec5c68 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -137,7 +137,7 @@ function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string) { if (policyID) { setOnboardingRHPVariant(CONST.ONBOARDING_RHP_VARIANT.RHP_ADMINS_ROOM); Navigation.navigate(ROUTES.WORKSPACE_CATEGORIES.getRoute(policyID)); - SidePanelActions.openSidePanel(true); + SidePanelActions.openSidePanel(false); } else { Navigation.navigate(ROUTES.HOME); } diff --git a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx index 76fdbc9c8329..08f3de8f9a5c 100644 --- a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx +++ b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx @@ -2,6 +2,7 @@ import {useFocusEffect} from '@react-navigation/native'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import React, {useState} from 'react'; import {View} from 'react-native'; +import Onyx from 'react-native-onyx'; import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -100,6 +101,7 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding setOnboardingPolicyID(policy.policyID); if (isEmployerWithSubmit && policy.automaticJoiningEnabled) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.policyID}`, {isLoading: true}); navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policy.policyID); return; } diff --git a/src/pages/workspace/AccessOrNotFoundWrapper.tsx b/src/pages/workspace/AccessOrNotFoundWrapper.tsx index 4c62a0bceca1..7d5036f6a897 100644 --- a/src/pages/workspace/AccessOrNotFoundWrapper.tsx +++ b/src/pages/workspace/AccessOrNotFoundWrapper.tsx @@ -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; From 762dcfce3d54054a124da285185732bab1d07541 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 17 Apr 2026 01:34:56 +0200 Subject: [PATCH 17/29] test: add unit tests for useAutoCreateSubmitWorkspace hook Also extract setPolicyLoading helper in Policy actions to avoid direct Onyx.merge calls in BaseOnboardingWorkspaces. --- src/libs/actions/Policy/Policy.ts | 5 + .../BaseOnboardingWorkspaces.tsx | 5 +- .../useAutoCreateSubmitWorkspace.test.ts | 334 ++++++++++++++++++ 3 files changed, 341 insertions(+), 3 deletions(-) create mode 100644 tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index e1a90a68af1e..535a8bfc677d 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -7065,6 +7065,10 @@ function setWorkspaceConfirmationCurrency(currency: string) { Onyx.merge(ONYXKEYS.FORMS.WORKSPACE_CONFIRMATION_FORM_DRAFT, {currency}); } +function setPolicyLoading(policyID: string, isLoading: boolean) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {isLoading}); +} + export { leaveWorkspace, addBillingCardAndRequestPolicyOwnerChange, @@ -7190,5 +7194,6 @@ export { setWorkspaceConfirmationCurrency, setPolicyRequireCompanyCardsEnabled, setPolicyTimeTrackingDefaultRate, + setPolicyLoading, }; export type {BuildPolicyDataKeys, WorkspaceMembersChats}; diff --git a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx index 08f3de8f9a5c..24919df4cd66 100644 --- a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx +++ b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx @@ -2,7 +2,6 @@ import {useFocusEffect} from '@react-navigation/native'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import React, {useState} from 'react'; import {View} from 'react-native'; -import Onyx from 'react-native-onyx'; import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -26,7 +25,7 @@ import Navigation from '@libs/Navigation/Navigation'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import {isCurrentUserValidated} from '@libs/UserUtils'; import {askToJoinPolicy, joinAccessiblePolicy} from '@userActions/Policy/Member'; -import {getAccessiblePolicies} from '@userActions/Policy/Policy'; +import {getAccessiblePolicies, setPolicyLoading} from '@userActions/Policy/Policy'; import {completeOnboarding} from '@userActions/Report'; import {setOnboardingAdminsChatReportID, setOnboardingPolicyID} from '@userActions/Welcome'; import CONST from '@src/CONST'; @@ -101,7 +100,7 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding setOnboardingPolicyID(policy.policyID); if (isEmployerWithSubmit && policy.automaticJoiningEnabled) { - Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.policyID}`, {isLoading: true}); + setPolicyLoading(policy.policyID, true); navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policy.policyID); return; } diff --git a/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts b/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts new file mode 100644 index 000000000000..6ff95602db9c --- /dev/null +++ b/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts @@ -0,0 +1,334 @@ +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'; +// eslint-disable-next-line no-restricted-syntax +import * as Policy from '@userActions/Policy/Policy'; +// eslint-disable-next-line no-restricted-syntax +import * as Report from '@userActions/Report'; +// eslint-disable-next-line no-restricted-syntax +import * as Welcome from '@userActions/Welcome'; +import CONST from '@src/CONST'; + +jest.mock('@hooks/useOnyx', () => { + // eslint-disable-next-line @typescript-eslint/naming-convention + 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, + // hasPaidGroupAdminPolicy 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); + }); + + 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 9e50b0339738af7d77f321ca4ea46ddb2b7422d9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Sat, 18 Apr 2026 22:06:26 +0200 Subject: [PATCH 18/29] Fix Submit workspace access gaps and onboarding NotFound flash - joinAccessiblePolicy now sets optimistic {isLoading: true} on the policy key (cleared in success/failure), so the Categories page has loading state before the backend response. - WorkspaceInitialPage suppresses NotFound while policy.isLoading is true, preventing the full-screen NotFound flash during optimistic join. - canEditWorkspaceSettings used in WorkspaceMembersPage and WorkspaceOverviewPage so Submit editor-owners can invite members and edit name/currency/plan type. - canModifyPlan uses canEditWorkspaceSettings so Submit editor-owners can reach the upgrade flow (Rules, Plan type, other paywalled features). - canEditWorkspaceSettings tightened to only grant editor access on Submit policies. - SidePanelContextProvider routes Submit editor-owners to the #admins room. - useAutoCreateSubmitWorkspace guard now checks any editable group policy to prevent duplicate Submit workspace creation. --- .../SidePanel/SidePanelContextProvider.tsx | 4 +-- src/hooks/useAutoCreateSubmitWorkspace.ts | 14 +++++------ src/libs/PolicyUtils.ts | 11 ++++++-- src/libs/actions/Policy/Member.ts | 25 ++++++++++++++++--- src/libs/actions/Policy/Policy.ts | 5 ---- .../BaseOnboardingWorkspaces.tsx | 3 +-- src/pages/workspace/WorkspaceInitialPage.tsx | 4 ++- src/pages/workspace/WorkspaceMembersPage.tsx | 4 +-- src/pages/workspace/WorkspaceOverviewPage.tsx | 5 ++-- .../useAutoCreateSubmitWorkspace.test.ts | 2 +- 10 files changed, 50 insertions(+), 27 deletions(-) diff --git a/src/components/SidePanel/SidePanelContextProvider.tsx b/src/components/SidePanel/SidePanelContextProvider.tsx index 0d76f918f3f7..181373937f24 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/hooks/useAutoCreateSubmitWorkspace.ts b/src/hooks/useAutoCreateSubmitWorkspace.ts index ae3f04b30a64..783b0e42136a 100644 --- a/src/hooks/useAutoCreateSubmitWorkspace.ts +++ b/src/hooks/useAutoCreateSubmitWorkspace.ts @@ -3,7 +3,7 @@ import {useCallback, useMemo} from 'react'; import type {OnyxCollection} from 'react-native-onyx'; import {navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; import {createDisplayName} from '@libs/PersonalDetailsUtils'; -import {isPaidGroupPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, isGroupPolicy} from '@libs/PolicyUtils'; import {createWorkspace, generatePolicyID, newGenerateDefaultWorkspaceName} from '@userActions/Policy/Policy'; import {completeOnboarding} from '@userActions/Report'; import {setOnboardingAdminsChatReportID, setOnboardingPolicyID} from '@userActions/Welcome'; @@ -31,9 +31,9 @@ function useAutoCreateSubmitWorkspace() { const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const [betas] = useOnyx(ONYXKEYS.BETAS); const [session] = useOnyx(ONYXKEYS.SESSION); - const paidGroupPolicySelector = useMemo( - () => (policies: OnyxCollection) => Object.values(policies ?? {}).some((policy) => isPaidGroupPolicy(policy) && isPolicyAdmin(policy, session?.email)), - [session?.email], + const groupPolicySelector = useMemo( + () => (policies: OnyxCollection) => Object.values(policies ?? {}).some((policy) => isGroupPolicy(policy) && canEditWorkspaceSettings(policy)), + [], ); const lastWorkspaceNumberWithEmailSelector = useCallback( (policies: OnyxCollection) => { @@ -41,7 +41,7 @@ function useAutoCreateSubmitWorkspace() { }, [session?.email], ); - const [hasPaidGroupAdminPolicy] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: paidGroupPolicySelector}); + const [hasEditableGroupPolicy] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: groupPolicySelector}); const [lastWorkspaceNumber] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: lastWorkspaceNumberWithEmailSelector}); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); @@ -52,7 +52,7 @@ function useAutoCreateSubmitWorkspace() { const autoCreateSubmitWorkspace = useCallback( (firstName: string, lastName: string) => { - const shouldCreateWorkspace = !isRestrictedPolicyCreation && !onboardingPolicyID && !hasPaidGroupAdminPolicy; + const shouldCreateWorkspace = !isRestrictedPolicyCreation && !onboardingPolicyID && !hasEditableGroupPolicy; const displayName = createDisplayName(session?.email ?? '', {firstName, lastName}, formatPhoneNumber); const {adminsChatReportID: newAdminsChatReportID, policyID: newPolicyID} = shouldCreateWorkspace @@ -102,7 +102,7 @@ function useAutoCreateSubmitWorkspace() { formatPhoneNumber, isRestrictedPolicyCreation, onboardingPolicyID, - hasPaidGroupAdminPolicy, + hasEditableGroupPolicy, onboardingAdminsChatReportID, currentUserPersonalDetails.localCurrencyCode, introSelected, diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 44aac7c797f7..bc7a1fd0af05 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -893,9 +893,16 @@ function isPolicyEditor(policy: OnyxEntry): boolean { * 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); + return isPolicyAdmin(policy) || (isSubmitPolicy(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); } @@ -2009,7 +2016,7 @@ function canModifyPlan(ownerPolicies: Policy[] | undefined, policy: OnyxEntry 1; } - return !!policy && isPolicyAdmin(policy); + return !!policy && canEditWorkspaceSettings(policy); } function getAdminsPrivateEmailDomains(policy?: Policy) { diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 46bee82ca352..265f69305170 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -1071,24 +1071,43 @@ function inviteMemberToWorkspace(policyID: string, inviterEmail?: string) { */ 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 535a8bfc677d..e1a90a68af1e 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -7065,10 +7065,6 @@ function setWorkspaceConfirmationCurrency(currency: string) { Onyx.merge(ONYXKEYS.FORMS.WORKSPACE_CONFIRMATION_FORM_DRAFT, {currency}); } -function setPolicyLoading(policyID: string, isLoading: boolean) { - Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {isLoading}); -} - export { leaveWorkspace, addBillingCardAndRequestPolicyOwnerChange, @@ -7194,6 +7190,5 @@ export { setWorkspaceConfirmationCurrency, setPolicyRequireCompanyCardsEnabled, setPolicyTimeTrackingDefaultRate, - setPolicyLoading, }; export type {BuildPolicyDataKeys, WorkspaceMembersChats}; diff --git a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx index 24919df4cd66..76fdbc9c8329 100644 --- a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx +++ b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx @@ -25,7 +25,7 @@ import Navigation from '@libs/Navigation/Navigation'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import {isCurrentUserValidated} from '@libs/UserUtils'; import {askToJoinPolicy, joinAccessiblePolicy} from '@userActions/Policy/Member'; -import {getAccessiblePolicies, setPolicyLoading} from '@userActions/Policy/Policy'; +import {getAccessiblePolicies} from '@userActions/Policy/Policy'; import {completeOnboarding} from '@userActions/Report'; import {setOnboardingAdminsChatReportID, setOnboardingPolicyID} from '@userActions/Welcome'; import CONST from '@src/CONST'; @@ -100,7 +100,6 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding setOnboardingPolicyID(policy.policyID); if (isEmployerWithSubmit && policy.automaticJoiningEnabled) { - setPolicyLoading(policy.policyID, true); navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policy.policyID); return; } diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index e7b7533bc566..f8281913cdc6 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -182,8 +182,10 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const shouldShowPolicy = checkIfShouldShowPolicy(policy, true, currentUserLogin); const isPendingDelete = isPendingDeletePolicy(policy); const prevIsPendingDelete = isPendingDeletePolicy(prevPolicy); + // 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. // eslint-disable-next-line rulesdir/no-negated-variables - const shouldShowNotFoundPage = !shouldShowPolicy && (!isPendingDelete || prevIsPendingDelete); + const shouldShowNotFoundPage = !shouldShowPolicy && !policy?.isLoading && (!isPendingDelete || prevIsPendingDelete); const shouldShowNavigationTabBar = !shouldShowNotFoundPage; const fetchPolicyData = () => { diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index bb9b7ed17dd8..32280bf79fcb 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -53,13 +53,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'; @@ -135,7 +135,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 f104e84f9421..414ff7aaecf6 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}` : ''; @@ -175,7 +176,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/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts b/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts index 6ff95602db9c..ea277a045d70 100644 --- a/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts +++ b/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts @@ -96,7 +96,7 @@ describe('useAutoCreateSubmitWorkspace', () => { 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, - // hasPaidGroupAdminPolicy is false, and policy creation is not restricted) + // hasEditableGroupPolicy is false, and policy creation is not restricted) // When the autoCreateSubmitWorkspace function is invoked during onboarding const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); From 5f1fd5b0f428178b16c96d3463f6f14a1345f859 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Sat, 18 Apr 2026 22:27:42 +0200 Subject: [PATCH 19/29] Label Submit workspaces as "Submit" instead of "Free" getUserFriendlyWorkspaceType only switched on CORPORATE/TEAM, so submit2026 policies fell through to the default branch and rendered "Free" on the Overview plan-type row and workspaces list. Added an explicit SUBMIT case and a localized workspace.type.submit string across all 10 language files. --- src/languages/de.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/libs/PolicyUtils.ts | 2 ++ 11 files changed, 12 insertions(+) diff --git a/src/languages/de.ts b/src/languages/de.ts index 19e082d021b4..e942f521e0f2 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5154,6 +5154,7 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU free: 'Kostenlos', control: 'Steuerung', collect: 'Einziehen', + submit: 'Einreichen', }, companyCards: { addCards: 'Karten hinzufügen', diff --git a/src/languages/en.ts b/src/languages/en.ts index d0cc37cf2215..33c3af595cb0 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5178,6 +5178,7 @@ const translations = { free: 'Free', control: 'Control', collect: 'Collect', + submit: 'Submit', }, companyCards: { addCards: 'Add cards', diff --git a/src/languages/es.ts b/src/languages/es.ts index 12df8d0f8e60..4025557339f5 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5017,6 +5017,7 @@ ${amount} para ${merchant} - ${date}`, free: 'Gratis', control: 'Controlar', collect: 'Recopilar', + submit: 'Enviar', }, companyCards: { addCards: 'Añadir tarjetas', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index a4c6b12096ae..aaa0558920d4 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5164,6 +5164,7 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. free: 'Gratuit', control: 'Contrôle', collect: 'Encaisser', + submit: 'Soumettre', }, companyCards: { addCards: 'Ajouter des cartes', diff --git a/src/languages/it.ts b/src/languages/it.ts index ed4978a983e5..1c536e883810 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -5138,6 +5138,7 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. free: 'Gratis', control: 'Controllo', collect: 'Riscuoti', + submit: 'Invia', }, companyCards: { addCards: 'Aggiungi carte', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 6b0a4e6fa5bc..bea8893dbffa 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5092,6 +5092,7 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO free: '無料', control: 'コントロール', collect: '回収', + submit: '提出', }, companyCards: { addCards: 'カードを追加', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 28c06a450862..840bfcecac64 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -5131,6 +5131,7 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ free: 'Gratis', control: 'Beheer', collect: 'Incasseren', + submit: 'Indienen', }, companyCards: { addCards: 'Kaarten toevoegen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 355de46e29b8..de72a67aa3b7 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5122,6 +5122,7 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy free: 'Darmowy', control: 'Sterowanie', collect: 'Zbierz', + submit: 'Prześlij', }, companyCards: { addCards: 'Dodaj karty', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index b2ac0cdb0b6b..70b0c82e1b31 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -5124,6 +5124,7 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS free: 'Grátis', control: 'Controle', collect: 'Cobrar', + submit: 'Enviar', }, companyCards: { addCards: 'Adicionar cartões', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 0b1bfc6dfd09..ec97cff9fd63 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -5008,6 +5008,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM free: '免费', control: '控制', collect: '收款', + submit: '提交', }, companyCards: { addCards: '添加卡片', diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index bc7a1fd0af05..c581402ceab7 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -1884,6 +1884,8 @@ function getUserFriendlyWorkspaceType(workspaceType: ValueOf Date: Sat, 18 Apr 2026 22:29:45 +0200 Subject: [PATCH 20/29] Fix NotFound on approval workflow pages for Submit editors Approval workflow sub-pages (Create/Edit/ExpensesFrom/ApprovalLimit) still used !isPolicyAdmin(policy), so Submit editors who could reach the Workflows page via canEditWorkspaceSettings were rejected the moment they tapped "Add approval" or an existing workflow. Switched all four to canEditWorkspaceSettings to match the broader editor gating used across the onboarding flow. --- .../WorkspaceWorkflowsApprovalsApprovalLimitPage.tsx | 4 ++-- .../approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx | 4 ++-- .../approvals/WorkspaceWorkflowsApprovalsEditPage.tsx | 4 ++-- .../approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApprovalLimitPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApprovalLimitPage.tsx index 1d8dcc34661d..b7c39c633bf5 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApprovalLimitPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApprovalLimitPage.tsx @@ -22,7 +22,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 selectedApproverDisplayName = selectedApproverEmail ? Str.removeSMSDomain(selectedApproverPersonalDetails?.displayName ?? selectedApproverEmail) : ''; // eslint-disable-next-line rulesdir/no-negated-variables - 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 e28c5325d259..94a5093c6814 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'; @@ -37,7 +37,7 @@ function WorkspaceWorkflowsApprovalsCreatePage({policy, isLoadingReportData = tr const formRef = useRef(null); // eslint-disable-next-line rulesdir/no-negated-variables - 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 df85168781ec..0f39cfbd34f5 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx @@ -17,7 +17,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'; @@ -102,7 +102,7 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true const {currentApprovalWorkflow, defaultWorkflowMembers, usedApproverEmails} = getApprovalWorkflowData(); // eslint-disable-next-line rulesdir/no-negated-variables - 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 b7a777b5fe0d..167e60a29c72 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx @@ -12,7 +12,7 @@ import {setApprovalWorkflowMembers} from '@libs/actions/Workflow'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import {getMemberAccountIDsForWorkspace, isPendingDeletePolicy, isPolicyAdmin} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, getMemberAccountIDsForWorkspace, isPendingDeletePolicy} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import MemberRightIcon from '@pages/workspace/MemberRightIcon'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -38,7 +38,7 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat const [selectedMembers, setSelectedMembers] = useState([]); // eslint-disable-next-line rulesdir/no-negated-variables - 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 && approvalWorkflow.availableMembers.length === 0; const firstApprover = approvalWorkflow?.originalApprovers?.[0]?.email ?? ''; From ca6b67304640b9b6fc274c2f1954fcf973b7f575 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Sat, 18 Apr 2026 22:47:39 +0200 Subject: [PATCH 21/29] Prevent Submit editors from escalating members to admin WorkspaceMemberRoleList unconditionally exposed the ADMIN role in both the invite flow (DynamicWorkspaceInviteMessageRolePage) and the role editor (WorkspaceMemberDetailsRolePage). After widening ACCESS_VARIANTS for Submit editor onboarding, an editor could open either page and select ADMIN for a new or existing member, escalating them to real workspace admin -- contradicting the design doc's "editors cannot change roles to admin" requirement. Hide the ADMIN option unless the current user is themselves a strict policy admin. Backend authorization remains the source of truth; this is defense in depth at the UI layer. --- src/components/WorkspaceMemberRoleList.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/components/WorkspaceMemberRoleList.tsx b/src/components/WorkspaceMemberRoleList.tsx index 1263e0d38d8c..c774f071a48d 100644 --- a/src/components/WorkspaceMemberRoleList.tsx +++ b/src/components/WorkspaceMemberRoleList.tsx @@ -3,10 +3,12 @@ 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 +34,7 @@ type WorkspaceMemberRoleListProps = { function WorkspaceMemberRoleList({role, policy, navigateBackTo = undefined, isLoading = false, onSelectRole = () => {}}: WorkspaceMemberRoleListProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + const [session] = useOnyx(ONYXKEYS.SESSION); const workspaceRoles: ListItemType[] = [ { @@ -58,7 +61,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, session?.email); + 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 ( <> From 608edba6dc26e7b941ead062ba211af0ae927072 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Sat, 18 Apr 2026 23:05:40 +0200 Subject: [PATCH 22/29] Move Plan Type / Submit label hunks out of #87261 to #87263 The Plan Type page UX and the "Submit" workspace label belong to issue #87263 (Update Workspaces list + Plan Type page), not the workspace-creation + onboarding scope of #87261. Split them out so each PR stays reviewable and paywall/plan-type concerns land as a cohesive change. - PolicyUtils: revert canModifyPlan to isPolicyAdmin; drop the SUBMIT case from getUserFriendlyWorkspaceType. - DynamicWorkspaceOverviewPlanTypePage: drop the Submit-specific upgrade-redirect in handleUpdatePlan. Keep an unconditional SUBMIT exclusion in the plan-type filter as defensive infra so introducing CONST.POLICY.TYPE.SUBMIT here does not leak SUBMIT into the plan selector for paid admins. Guard is commented with a pointer to #87263 which will loosen it. - Languages: drop workspace.type.submit in all 10 locales. Retain planTypes.submit2026.{label,description} because PersonalPolicyTypeExcludedProps includes submit2026 and removing them fails typecheck. --- src/languages/de.ts | 1 - src/languages/en.ts | 1 - src/languages/es.ts | 1 - src/languages/fr.ts | 1 - src/languages/it.ts | 1 - src/languages/ja.ts | 1 - src/languages/nl.ts | 1 - src/languages/pl.ts | 1 - src/languages/pt-BR.ts | 1 - src/languages/zh-hans.ts | 1 - src/libs/PolicyUtils.ts | 4 +--- .../workspace/DynamicWorkspaceOverviewPlanTypePage.tsx | 10 +++------- 12 files changed, 4 insertions(+), 20 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index fb8042dc5904..12af4b806a78 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5159,7 +5159,6 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU free: 'Kostenlos', control: 'Steuerung', collect: 'Einziehen', - submit: 'Einreichen', }, companyCards: { addCards: 'Karten hinzufügen', diff --git a/src/languages/en.ts b/src/languages/en.ts index 6c1d3b535862..4b6da357b43e 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5183,7 +5183,6 @@ const translations = { free: 'Free', control: 'Control', collect: 'Collect', - submit: 'Submit', }, companyCards: { addCards: 'Add cards', diff --git a/src/languages/es.ts b/src/languages/es.ts index 907220cee718..4f4ddd94eaf7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5022,7 +5022,6 @@ ${amount} para ${merchant} - ${date}`, free: 'Gratis', control: 'Controlar', collect: 'Recopilar', - submit: 'Enviar', }, companyCards: { addCards: 'Añadir tarjetas', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 80252928f6a8..b5ce6c1092bc 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5169,7 +5169,6 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. free: 'Gratuit', control: 'Contrôle', collect: 'Encaisser', - submit: 'Soumettre', }, companyCards: { addCards: 'Ajouter des cartes', diff --git a/src/languages/it.ts b/src/languages/it.ts index b6bb97536e9c..ef37aed5ee97 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -5143,7 +5143,6 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. free: 'Gratis', control: 'Controllo', collect: 'Riscuoti', - submit: 'Invia', }, companyCards: { addCards: 'Aggiungi carte', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 25ec92e34aac..d940b7f6f581 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5097,7 +5097,6 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO free: '無料', control: 'コントロール', collect: '回収', - submit: '提出', }, companyCards: { addCards: 'カードを追加', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 6fffb2acd00d..2d413ff9d705 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -5136,7 +5136,6 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ free: 'Gratis', control: 'Beheer', collect: 'Incasseren', - submit: 'Indienen', }, companyCards: { addCards: 'Kaarten toevoegen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 9ff5f0f59693..cd810f59118f 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5127,7 +5127,6 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy free: 'Darmowy', control: 'Sterowanie', collect: 'Zbierz', - submit: 'Prześlij', }, companyCards: { addCards: 'Dodaj karty', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index aa4990978a71..0f9120fdfe02 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -5129,7 +5129,6 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS free: 'Grátis', control: 'Controle', collect: 'Cobrar', - submit: 'Enviar', }, companyCards: { addCards: 'Adicionar cartões', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index de8692c8beee..e039972625f4 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -5013,7 +5013,6 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM free: '免费', control: '控制', collect: '收款', - submit: '提交', }, companyCards: { addCards: '添加卡片', diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index c581402ceab7..96c5e93ca32b 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -1884,8 +1884,6 @@ function getUserFriendlyWorkspaceType(workspaceType: ValueOf 1; } - return !!policy && canEditWorkspaceSettings(policy); + return !!policy && isPolicyAdmin(policy); } function getAdminsPrivateEmailDomains(policy?: Policy) { diff --git a/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx b/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx index 8753982c078a..cac2bc6fe51d 100644 --- a/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx +++ b/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx @@ -17,7 +17,6 @@ import usePrivateSubscription from '@hooks/usePrivateSubscription'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import OpenWorkspacePlanPage from '@libs/actions/Policy/Plan'; -import {isSubmitPolicy} from '@libs/PolicyUtils'; import {isSubscriptionTypeOfInvoicing} from '@libs/SubscriptionUtils'; import Navigation from '@navigation/Navigation'; import CardSectionUtils from '@pages/settings/Subscription/CardSection/utils'; @@ -60,7 +59,9 @@ function DynamicWorkspaceOverviewPlanTypePage({policy}: WithPolicyProps) { if (type === CONST.POLICY.TYPE.PERSONAL) { return false; } - if (type === CONST.POLICY.TYPE.SUBMIT && !isSubmitPolicy(policy)) { + // 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; @@ -90,11 +91,6 @@ function DynamicWorkspaceOverviewPlanTypePage({policy}: WithPolicyProps) { ) : null; const handleUpdatePlan = () => { - if (policyID && isSubmitPolicy(policy) && currentPlan !== CONST.POLICY.TYPE.SUBMIT) { - 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; From 1884f9fa33b6fccb0d72b3f2914ba9ed563a1e8f Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Sun, 19 Apr 2026 00:58:01 +0200 Subject: [PATCH 23/29] fix(test): disable no-restricted-syntax for navigateAfterOnboarding spy jest.spyOn requires a module namespace object; use the same inline eslint-disable used for the @userActions spies above. --- tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts b/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts index ea277a045d70..4ab8cf6b0eb0 100644 --- a/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts +++ b/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts @@ -6,6 +6,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnboardingMessages from '@hooks/useOnboardingMessages'; import useOnyx from '@hooks/useOnyx'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; +// eslint-disable-next-line no-restricted-syntax import * as navigateAfterOnboarding from '@libs/navigateAfterOnboarding'; // eslint-disable-next-line no-restricted-syntax import * as Policy from '@userActions/Policy/Policy'; From 98d28245f2314e2632262af75888342d04f8a67d Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Mon, 20 Apr 2026 12:18:37 +0200 Subject: [PATCH 24/29] Let Submit editors land on the upgrade page instead of NotFound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WorkspaceUpgradePage gated on canModifyPlan (strict admin), so any non-admin who was redirected to /upgrade hit NotFound. Submit editors hit this any time they tap a Control-only toggle in "More features" (e.g. Rules). #87262 will add isSubmitPolicy redirects from Roles, Approvals, Payments, Accounting, Company Cards, Expensify Card, Travel, and Invoicing to this same page — all of those would dead-end on NotFound for editors without this fix. - Gate the page with canEditWorkspaceSettings (admins on any policy, editors on Submit policies) so editors render the upgrade intro. - Keep canPerformUpgrade (strict admin) controlling the upgrade button via buttonDisabled, so editors can't trigger an upgrade. - onUpgradeToCorporate and confirmUpgrade already guard on canPerformUpgrade, so no new write paths are opened for editors. --- src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx index 1caa84b99cc0..3555f32ee520 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'; @@ -226,7 +226,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 ; } @@ -259,7 +262,7 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { policyID={policyID} feature={feature} onUpgrade={onUpgradeToCorporate} - buttonDisabled={isOffline} + buttonDisabled={isOffline || !canPerformUpgrade} loading={policy?.isPendingUpgrade} backTo={route.params.backTo} /> From 20adf33b8a64c38215f500f6217b5738e1a4939a Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Mon, 20 Apr 2026 12:36:12 +0200 Subject: [PATCH 25/29] Fix typecheck: use generateDefaultWorkspaceName after upstream rename Upstream main renamed newGenerateDefaultWorkspaceName back to generateDefaultWorkspaceName. The Track hook picked up the new name during the merge, but useAutoCreateSubmitWorkspace.ts was missed, breaking CI's tsc typecheck. --- src/hooks/useAutoCreateSubmitWorkspace.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/useAutoCreateSubmitWorkspace.ts b/src/hooks/useAutoCreateSubmitWorkspace.ts index 783b0e42136a..bed5697ab304 100644 --- a/src/hooks/useAutoCreateSubmitWorkspace.ts +++ b/src/hooks/useAutoCreateSubmitWorkspace.ts @@ -4,7 +4,7 @@ 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, generatePolicyID, newGenerateDefaultWorkspaceName} from '@userActions/Policy/Policy'; +import {createWorkspace, generateDefaultWorkspaceName, generatePolicyID} from '@userActions/Policy/Policy'; import {completeOnboarding} from '@userActions/Report'; import {setOnboardingAdminsChatReportID, setOnboardingPolicyID} from '@userActions/Welcome'; import CONST from '@src/CONST'; @@ -59,7 +59,7 @@ function useAutoCreateSubmitWorkspace() { ? createWorkspace({ policyOwnerEmail: undefined, makeMeAdmin: true, - policyName: newGenerateDefaultWorkspaceName(session?.email ?? '', lastWorkspaceNumber, translate, displayName), + policyName: generateDefaultWorkspaceName(session?.email ?? '', lastWorkspaceNumber, translate, displayName), policyID: generatePolicyID(), engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, currency: currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD, From 42ee71e9770690b7a77738b34c9b5da9b63fbf47 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 22 Apr 2026 10:27:56 +0200 Subject: [PATCH 26/29] Route Submit-beta onboarding joins through the Submit flow Previously only users who picked "Get paid back by my employer" landed on Workspace > Categories with #admins open after auto-joining a workspace during onboarding. Private-domain signups that reach the Join Workspace screen without selecting a Purpose would fall through to a HOME redirect, which appears as "nothing happens" after clicking Join now (report by @hungvu193 on #87283). Use the SUBMIT_2026 beta as the gate instead so any auto-join during onboarding by a Submit-era user follows the same Submit navigation as the EMPLOYER flow. Non-beta users are unchanged. --- .../BaseOnboardingWorkspaces.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx index 76fdbc9c8329..0ab91b4fe2c9 100644 --- a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx +++ b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx @@ -84,13 +84,19 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding askToJoinPolicy(policy.policyID); } - const engagementChoice = isEmployerWithSubmit ? CONST.ONBOARDING_CHOICES.EMPLOYER : CONST.ONBOARDING_CHOICES.LOOKING_AROUND; + // Mirror the EMPLOYER ("Get paid back by my employer") + Submit-2026 onboarding flow + // for any user auto-joining a workspace during onboarding while the Submit-2026 beta + // is enabled, including private-domain signups that reach this screen without + // selecting a Purpose. JoinablePolicy doesn't carry policy.type from the backend + // today, so the beta is our best local signal for Submit-era onboarding. + const shouldUseSubmitFlow = canUseSubmit2026; + const engagementChoice = shouldUseSubmitFlow ? CONST.ONBOARDING_CHOICES.EMPLOYER : CONST.ONBOARDING_CHOICES.LOOKING_AROUND; completeOnboarding({ engagementChoice, onboardingMessage: onboardingMessages[engagementChoice], firstName: onboardingPersonalDetails?.firstName ?? '', lastName: onboardingPersonalDetails?.lastName ?? '', - onboardingPolicyID: isEmployerWithSubmit && policy.automaticJoiningEnabled ? policy.policyID : undefined, + onboardingPolicyID: shouldUseSubmitFlow && policy.automaticJoiningEnabled ? policy.policyID : undefined, companySize: onboardingCompanySize, introSelected, isSelfTourViewed, @@ -99,7 +105,7 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding setOnboardingAdminsChatReportID(); setOnboardingPolicyID(policy.policyID); - if (isEmployerWithSubmit && policy.automaticJoiningEnabled) { + if (shouldUseSubmitFlow && policy.automaticJoiningEnabled) { navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policy.policyID); return; } From 1efec60df5fc5b392e2993518359b3e98f2754dd Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 22 Apr 2026 16:49:46 +0200 Subject: [PATCH 27/29] Hide Reports sidebar entry on Submit workspaces --- src/pages/workspace/WorkspaceInitialPage.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index b03704505658..16407fc47aad 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -40,6 +40,7 @@ import { hasPolicyCategoriesError, isGroupPolicy, isPendingDeletePolicy, + isSubmitPolicy, isTimeTrackingEnabled, shouldShowEmployeeListError, shouldShowSyncError, @@ -233,13 +234,15 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac ]; if (isGroupPolicy(policy) && shouldShowProtectedItems) { - workspaceMenuItems.push({ - translationKey: 'common.reports', - icon: expensifyIcons.Document, - action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_REPORTS.getRoute(policyID)))), - screenName: SCREENS.WORKSPACE.REPORTS, - sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.REPORTS, - }); + if (!isSubmitPolicy(policy)) { + workspaceMenuItems.push({ + translationKey: 'common.reports', + icon: expensifyIcons.Document, + action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_REPORTS.getRoute(policyID)))), + screenName: SCREENS.WORKSPACE.REPORTS, + sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.REPORTS, + }); + } if (policyFeatureStates?.[CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED]) { workspaceMenuItems.push({ From 9abb34cd32a6c15a87f2e423d60f920c48a00d21 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 23 Apr 2026 15:58:31 +0200 Subject: [PATCH 28/29] Stamp Submit optimistic defaults when joining a workspace --- src/libs/actions/Policy/Member.ts | 38 +++++++++++++++---- .../BaseOnboardingWorkspaces.tsx | 13 ++++--- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 60c451421198..55d8108bede8 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -1079,10 +1079,28 @@ function inviteMemberToWorkspace(policyID: string, inviterEmail?: string) { /** * Add member to the selected private domain workspace based on policyID */ -function joinAccessiblePolicy(policyID: string) { +function joinAccessiblePolicy(policyID: string, isSubmitWorkspace = false) { const memberJoinKey = `${ONYXKEYS.COLLECTION.POLICY_JOIN_MEMBER}${policyID}` as const; const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; + const optimisticPolicyValue: Partial = isSubmitWorkspace + ? { + isLoading: true, + type: CONST.POLICY.TYPE.SUBMIT, + role: CONST.POLICY.ROLE.EDITOR, + areCategoriesEnabled: true, + areTagsEnabled: true, + areWorkflowsEnabled: true, + areDistanceRatesEnabled: true, + areReportFieldsEnabled: false, + areCompanyCardsEnabled: false, + areConnectionsEnabled: false, + areExpensifyCardsEnabled: false, + areInvoicesEnabled: false, + areRulesEnabled: false, + } + : {isLoading: true}; + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1092,7 +1110,7 @@ function joinAccessiblePolicy(policyID: string) { { onyxMethod: Onyx.METHOD.MERGE, key: policyKey, - value: {isLoading: true}, + value: optimisticPolicyValue, }, ]; @@ -1110,11 +1128,17 @@ function joinAccessiblePolicy(policyID: string) { key: memberJoinKey, value: {policyID, errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.people.error.genericAdd')}, }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: policyKey, - value: {isLoading: false}, - }, + isSubmitWorkspace + ? { + onyxMethod: Onyx.METHOD.SET, + key: policyKey, + value: null, + } + : { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: {isLoading: false}, + }, ]; API.write(WRITE_COMMANDS.JOIN_ACCESSIBLE_POLICY, {policyID}, {optimisticData, successData, failureData}); diff --git a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx index 0ab91b4fe2c9..335a984c4315 100644 --- a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx +++ b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx @@ -78,18 +78,19 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding const onboardingStep = useOnboardingStepCounter(SCREENS.ONBOARDING.WORKSPACES); const handleJoinWorkspace = (policy: JoinablePolicy) => { - if (policy.automaticJoiningEnabled) { - joinAccessiblePolicy(policy.policyID); - } else { - askToJoinPolicy(policy.policyID); - } - // Mirror the EMPLOYER ("Get paid back by my employer") + Submit-2026 onboarding flow // for any user auto-joining a workspace during onboarding while the Submit-2026 beta // is enabled, including private-domain signups that reach this screen without // selecting a Purpose. JoinablePolicy doesn't carry policy.type from the backend // today, so the beta is our best local signal for Submit-era onboarding. const shouldUseSubmitFlow = canUseSubmit2026; + + if (policy.automaticJoiningEnabled) { + joinAccessiblePolicy(policy.policyID, shouldUseSubmitFlow); + } else { + askToJoinPolicy(policy.policyID); + } + const engagementChoice = shouldUseSubmitFlow ? CONST.ONBOARDING_CHOICES.EMPLOYER : CONST.ONBOARDING_CHOICES.LOOKING_AROUND; completeOnboarding({ engagementChoice, From 5aaf16e97a7e761fb1555ab29559917b543fa4af Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 23 Apr 2026 16:33:32 +0200 Subject: [PATCH 29/29] Fix CI: spellcheck + joinAccessiblePolicy test expected args --- src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx | 6 +++--- tests/ui/WorkspaceOnboarding.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx index 335a984c4315..68fbd55b30cb 100644 --- a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx +++ b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx @@ -80,9 +80,9 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding const handleJoinWorkspace = (policy: JoinablePolicy) => { // Mirror the EMPLOYER ("Get paid back by my employer") + Submit-2026 onboarding flow // for any user auto-joining a workspace during onboarding while the Submit-2026 beta - // is enabled, including private-domain signups that reach this screen without - // selecting a Purpose. JoinablePolicy doesn't carry policy.type from the backend - // today, so the beta is our best local signal for Submit-era onboarding. + // is enabled, including private-domain users who reach this screen without selecting + // a Purpose. JoinablePolicy doesn't carry policy.type from the backend today, so the + // beta is our best local signal for Submit-era onboarding. const shouldUseSubmitFlow = canUseSubmit2026; if (policy.automaticJoiningEnabled) { diff --git a/tests/ui/WorkspaceOnboarding.tsx b/tests/ui/WorkspaceOnboarding.tsx index d2307caf85cd..e9924135b7f0 100644 --- a/tests/ui/WorkspaceOnboarding.tsx +++ b/tests/ui/WorkspaceOnboarding.tsx @@ -308,7 +308,7 @@ describe('OnboardingWorkspaces Page', () => { fireEvent.press(screen.getByText(TestHelper.translateLocal('workspace.workspaceList.joinNow'))); await waitFor(() => { - expect(mockJoinAccessiblePolicy).toHaveBeenCalledWith('submit-policy-id'); + expect(mockJoinAccessiblePolicy).toHaveBeenCalledWith('submit-policy-id', true); }); await waitFor(() => {