diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 4e05bed5714c..c3833805240c 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -887,6 +887,7 @@ const CONST = { BULK_DUPLICATE_REPORT: 'bulkDuplicateReport', BULK_EDIT: 'bulkEdit', NEW_MANUAL_EXPENSE_FLOW: 'newManualExpenseFlow', + SUBMIT_2026: 'submit2026', BULK_SUBMIT_APPROVE_PAY: 'bulkSubmitApprovePay', }, BUTTON_STATES: { @@ -3515,6 +3516,8 @@ const CONST = { // Often referred to as "collect" workspaces TEAM: 'team', + + SUBMIT: 'submit2026', }, RULE_CONDITIONS: { MATCHES: 'matches', @@ -3533,6 +3536,7 @@ const CONST = { ADMIN: 'admin', AUDITOR: 'auditor', USER: 'user', + EDITOR: 'editor', }, AUTO_REIMBURSEMENT_MAX_LIMIT_CENTS: 2000000, diff --git a/src/components/SidePanel/SidePanelContextProvider.tsx b/src/components/SidePanel/SidePanelContextProvider.tsx index 87fb63d098a4..c1dee29c6d69 100644 --- a/src/components/SidePanel/SidePanelContextProvider.tsx +++ b/src/components/SidePanel/SidePanelContextProvider.tsx @@ -10,7 +10,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import SidePanelActions from '@libs/actions/SidePanel'; import DateUtils from '@libs/DateUtils'; import focusComposerWithDelay from '@libs/focusComposerWithDelay'; -import {isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, shouldShowPolicy} from '@libs/PolicyUtils'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -82,7 +82,7 @@ function SidePanelContextProvider({children}: PropsWithChildren) { const isRHPAdminsRoom = onboardingRHPVariant === CONST.ONBOARDING_RHP_VARIANT.RHP_ADMINS_ROOM; const isRHPHomePage = onboardingRHPVariant === CONST.ONBOARDING_RHP_VARIANT.RHP_HOME_PAGE; - const isUserAdmin = isPolicyAdmin(activePolicy, sessionEmail); + const isUserAdmin = canEditWorkspaceSettings(activePolicy); const isPolicyActive = shouldShowPolicy(activePolicy, false, sessionEmail ?? ''); const adminsChatReportID = activePolicy?.chatReportIDAdmins?.toString(); diff --git a/src/components/WorkspaceMemberRoleList.tsx b/src/components/WorkspaceMemberRoleList.tsx index 1263e0d38d8c..e6f4b8097854 100644 --- a/src/components/WorkspaceMemberRoleList.tsx +++ b/src/components/WorkspaceMemberRoleList.tsx @@ -1,12 +1,15 @@ +import {emailSelector} from '@selectors/Session'; import React from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; -import {isControlPolicy} from '@libs/PolicyUtils'; +import {isControlPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import type {Policy} from '@src/types/onyx'; import HeaderWithBackButton from './HeaderWithBackButton'; @@ -32,6 +35,7 @@ type WorkspaceMemberRoleListProps = { function WorkspaceMemberRoleList({role, policy, navigateBackTo = undefined, isLoading = false, onSelectRole = () => {}}: WorkspaceMemberRoleListProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + const [currentUserEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); const workspaceRoles: ListItemType[] = [ { @@ -58,7 +62,18 @@ function WorkspaceMemberRoleList({role, policy, navigateBackTo = undefined, isLo ]; const isPolicyControl = isControlPolicy(policy); - const availableRoleItems: ListItemType[] = workspaceRoles.filter((item) => isPolicyControl || item.value !== CONST.POLICY.ROLE.AUDITOR); + // Only strict admins can assign the ADMIN role. Editors (e.g. Submit workspace owners) can + // invite/manage members but must not be able to escalate anyone to admin. + const canAssignAdminRole = isPolicyAdmin(policy, currentUserEmail); + const availableRoleItems: ListItemType[] = workspaceRoles.filter((item) => { + if (item.value === CONST.POLICY.ROLE.AUDITOR && !isPolicyControl) { + return false; + } + if (item.value === CONST.POLICY.ROLE.ADMIN && !canAssignAdminRole) { + return false; + } + return true; + }); return ( <> diff --git a/src/hooks/useAutoCreateSubmitWorkspace.ts b/src/hooks/useAutoCreateSubmitWorkspace.ts new file mode 100644 index 000000000000..7d97960bffcb --- /dev/null +++ b/src/hooks/useAutoCreateSubmitWorkspace.ts @@ -0,0 +1,115 @@ +import {useCallback, useMemo} from 'react'; +import type {OnyxCollection} from 'react-native-onyx'; +import {navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; +import {createDisplayName} from '@libs/PersonalDetailsUtils'; +import {canEditWorkspaceSettings, isGroupPolicy} from '@libs/PolicyUtils'; +import {createWorkspace, generateDefaultWorkspaceName, generatePolicyID} from '@userActions/Policy/Policy'; +import {completeOnboarding} from '@userActions/Report'; +import {setOnboardingAdminsChatReportID, setOnboardingPolicyID} from '@userActions/Welcome'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; +import useOnboardingWorkspaceCreationState from './useOnboardingWorkspaceCreationState'; +import useOnyx from './useOnyx'; + +/** + * Hook that provides a function to auto-create a Submit workspace for EMPLOYER + * users during onboarding and complete the onboarding flow. + * + * Shared by BaseOnboardingPersonalDetails, BaseOnboardingPurpose, and BaseOnboardingWorkspaces. + */ +function useAutoCreateSubmitWorkspace() { + const { + onboardingPolicyID, + onboardingAdminsChatReportID, + introSelected, + isSelfTourViewed, + betas, + currentUserEmail, + currentUserAccountID, + localCurrencyCode, + activePolicy, + translate, + formatPhoneNumber, + isRestrictedPolicyCreation, + hasActiveAdminPolicies, + onboardingMessages, + lastWorkspaceNumber, + isSmallScreenWidth, + } = useOnboardingWorkspaceCreationState(); + + const groupPolicySelector = useMemo( + () => (policies: OnyxCollection) => Object.values(policies ?? {}).some((policy) => isGroupPolicy(policy) && canEditWorkspaceSettings(policy)), + [], + ); + const [hasEditableGroupPolicy] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: groupPolicySelector}); + + const autoCreateSubmitWorkspace = useCallback( + (firstName: string, lastName: string) => { + const shouldCreateWorkspace = !isRestrictedPolicyCreation && !onboardingPolicyID && !hasEditableGroupPolicy; + const displayName = createDisplayName(currentUserEmail, {firstName, lastName}, formatPhoneNumber); + + const {adminsChatReportID: newAdminsChatReportID, policyID: newPolicyID} = shouldCreateWorkspace + ? createWorkspace({ + policyOwnerEmail: undefined, + makeMeAdmin: true, + policyName: generateDefaultWorkspaceName(currentUserEmail, lastWorkspaceNumber, translate, displayName), + policyID: generatePolicyID(), + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + currency: localCurrencyCode, + file: undefined, + shouldAddOnboardingTasks: false, + introSelected, + activePolicy, + currentUserAccountIDParam: currentUserAccountID, + currentUserEmailParam: currentUserEmail, + shouldAddGuideWelcomeMessage: false, + type: CONST.POLICY.TYPE.SUBMIT, + betas, + isSelfTourViewed, + hasActiveAdminPolicies, + }) + : {adminsChatReportID: onboardingAdminsChatReportID, policyID: onboardingPolicyID}; + + completeOnboarding({ + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + onboardingMessage: onboardingMessages[CONST.ONBOARDING_CHOICES.EMPLOYER], + firstName, + lastName, + adminsChatReportID: newAdminsChatReportID, + onboardingPolicyID: newPolicyID, + introSelected, + isSelfTourViewed, + betas, + }); + + setOnboardingAdminsChatReportID(); + setOnboardingPolicyID(); + + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(newPolicyID, isSmallScreenWidth); + }, + [ + currentUserEmail, + currentUserAccountID, + lastWorkspaceNumber, + translate, + formatPhoneNumber, + isRestrictedPolicyCreation, + onboardingPolicyID, + hasEditableGroupPolicy, + onboardingAdminsChatReportID, + localCurrencyCode, + introSelected, + activePolicy, + isSelfTourViewed, + onboardingMessages, + betas, + hasActiveAdminPolicies, + isSmallScreenWidth, + ], + ); + + return autoCreateSubmitWorkspace; +} + +export default useAutoCreateSubmitWorkspace; diff --git a/src/hooks/useAutoCreateTrackWorkspace.ts b/src/hooks/useAutoCreateTrackWorkspace.ts index 78dc14f3edb9..99cf11eef483 100644 --- a/src/hooks/useAutoCreateTrackWorkspace.ts +++ b/src/hooks/useAutoCreateTrackWorkspace.ts @@ -1,4 +1,3 @@ -import {hasSeenTourSelector} from '@selectors/Onboarding'; import {useCallback, useMemo} from 'react'; import type {OnyxCollection} from 'react-native-onyx'; import isSidePanelReportSupported from '@components/SidePanel/isSidePanelReportSupported'; @@ -12,17 +11,10 @@ import {setOnboardingAdminsChatReportID, setOnboardingPolicyID} from '@userActio import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {OnboardingPurpose, OnboardingRHPVariant, Policy} from '@src/types/onyx'; -import useActivePolicy from './useActivePolicy'; import useArchivedReportsIdSet from './useArchivedReportsIdSet'; -import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; -import useHasActiveAdminPolicies from './useHasActiveAdminPolicies'; -import useLastWorkspaceNumber from './useLastWorkspaceNumber'; -import useLocalize from './useLocalize'; -import useOnboardingMessages from './useOnboardingMessages'; +import useOnboardingWorkspaceCreationState from './useOnboardingWorkspaceCreationState'; import useOnyx from './useOnyx'; import usePermissions from './usePermissions'; -import usePreferredPolicy from './usePreferredPolicy'; -import useResponsiveLayout from './useResponsiveLayout'; /** * Hook that provides a function to auto-create a workspace for Track (PERSONAL_SPEND) @@ -31,57 +23,57 @@ import useResponsiveLayout from './useResponsiveLayout'; * Shared by BaseOnboardingPersonalDetails and BaseOnboardingPurpose. */ function useAutoCreateTrackWorkspace() { - const [onboardingPolicyID] = useOnyx(ONYXKEYS.ONBOARDING_POLICY_ID); - const [onboardingAdminsChatReportID] = useOnyx(ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID); - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); - const [betas] = useOnyx(ONYXKEYS.BETAS); - const [session] = useOnyx(ONYXKEYS.SESSION); + const { + onboardingPolicyID, + onboardingAdminsChatReportID, + introSelected, + isSelfTourViewed, + betas, + currentUserEmail, + currentUserAccountID, + localCurrencyCode, + activePolicy, + translate, + formatPhoneNumber, + isRestrictedPolicyCreation, + hasActiveAdminPolicies, + onboardingMessages, + lastWorkspaceNumber, + isSmallScreenWidth, + } = useOnboardingWorkspaceCreationState(); + const paidGroupPolicySelector = useMemo( - () => (policies: OnyxCollection) => Object.values(policies ?? {}).some((policy) => isPaidGroupPolicy(policy) && isPolicyAdmin(policy, session?.email)), - [session?.email], + () => (policies: OnyxCollection) => Object.values(policies ?? {}).some((policy) => isPaidGroupPolicy(policy) && isPolicyAdmin(policy, currentUserEmail)), + [currentUserEmail], ); const [hasPaidGroupAdminPolicy] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: paidGroupPolicySelector}); + const [conciergeChatReportID = ''] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [onboardingValues] = useOnyx(ONYXKEYS.NVP_ONBOARDING); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const archivedReportsIdSet = useArchivedReportsIdSet(); const {isBetaEnabled} = usePermissions(); - const {translate, formatPhoneNumber} = useLocalize(); - const activePolicy = useActivePolicy(); - const {isRestrictedPolicyCreation} = usePreferredPolicy(); - const hasActiveAdminPolicies = useHasActiveAdminPolicies(); - const lastWorkspaceNumber = useLastWorkspaceNumber(); - const {onboardingMessages} = useOnboardingMessages(); - - // We use isSmallScreenWidth instead of shouldUseNarrowLayout because navigateAfterOnboarding - // relies on actual device screen width to handle navigation stack differences: on small screens, - // removing OnboardingModalNavigator redirects to HOME, requiring explicit navigation to the last - // accessed report. This behavior is tied to screen size, not responsive layout mode. - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth} = useResponsiveLayout(); const mergedAccountConciergeReportID = !onboardingValues?.shouldRedirectToClassicAfterMerge && onboardingValues?.shouldValidate ? conciergeChatReportID : undefined; const autoCreateTrackWorkspace = useCallback( async (firstName: string, lastName: string, onboardingPurposeSelected: OnboardingPurpose) => { const shouldCreateWorkspace = !isRestrictedPolicyCreation && !onboardingPolicyID && !hasPaidGroupAdminPolicy; - const displayName = createDisplayName(session?.email ?? '', {firstName, lastName}, formatPhoneNumber); + const displayName = createDisplayName(currentUserEmail, {firstName, lastName}, formatPhoneNumber); const {adminsChatReportID: newAdminsChatReportID, policyID: newPolicyID} = shouldCreateWorkspace ? createWorkspace({ policyOwnerEmail: undefined, makeMeAdmin: true, - policyName: generateDefaultWorkspaceName(session?.email ?? '', lastWorkspaceNumber, translate, displayName), + policyName: generateDefaultWorkspaceName(currentUserEmail, lastWorkspaceNumber, translate, displayName), policyID: generatePolicyID(), engagementChoice: CONST.ONBOARDING_CHOICES.TRACK_WORKSPACE, - currency: currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD, + currency: localCurrencyCode, file: undefined, shouldAddOnboardingTasks: false, introSelected, activePolicy, - currentUserAccountIDParam: session?.accountID ?? CONST.DEFAULT_NUMBER_ID, - currentUserEmailParam: session?.email ?? '', + currentUserAccountIDParam: currentUserAccountID, + currentUserEmailParam: currentUserEmail, shouldAddGuideWelcomeMessage: false, onboardingPurposeSelected, betas, @@ -129,8 +121,8 @@ function useAutoCreateTrackWorkspace() { } }, [ - session?.email, - session?.accountID, + currentUserEmail, + currentUserAccountID, lastWorkspaceNumber, translate, formatPhoneNumber, @@ -138,7 +130,7 @@ function useAutoCreateTrackWorkspace() { onboardingPolicyID, hasPaidGroupAdminPolicy, onboardingAdminsChatReportID, - currentUserPersonalDetails.localCurrencyCode, + localCurrencyCode, introSelected, activePolicy, isSelfTourViewed, diff --git a/src/hooks/useOnboardingWorkspaceCreationState.ts b/src/hooks/useOnboardingWorkspaceCreationState.ts new file mode 100644 index 000000000000..cb09fb0b9a29 --- /dev/null +++ b/src/hooks/useOnboardingWorkspaceCreationState.ts @@ -0,0 +1,66 @@ +import {hasSeenTourSelector} from '@selectors/Onboarding'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useActivePolicy from './useActivePolicy'; +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useHasActiveAdminPolicies from './useHasActiveAdminPolicies'; +import useLastWorkspaceNumber from './useLastWorkspaceNumber'; +import useLocalize from './useLocalize'; +import useOnboardingMessages from './useOnboardingMessages'; +import useOnyx from './useOnyx'; +import usePreferredPolicy from './usePreferredPolicy'; +import useResponsiveLayout from './useResponsiveLayout'; + +/** + * Shared state for the onboarding workspace auto-creation hooks + * (`useAutoCreateSubmitWorkspace`, `useAutoCreateTrackWorkspace`). + * + * Email and accountID come from `ONYXKEYS.SESSION` because session is hydrated + * earlier in onboarding than personal details. + */ +function useOnboardingWorkspaceCreationState() { + const [onboardingPolicyID] = useOnyx(ONYXKEYS.ONBOARDING_POLICY_ID); + const [onboardingAdminsChatReportID] = useOnyx(ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [session] = useOnyx(ONYXKEYS.SESSION); + + const currentUserEmail = session?.email ?? ''; + const currentUserAccountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; + + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const localCurrencyCode = currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD; + + const activePolicy = useActivePolicy(); + const {translate, formatPhoneNumber} = useLocalize(); + const {isRestrictedPolicyCreation} = usePreferredPolicy(); + const hasActiveAdminPolicies = useHasActiveAdminPolicies(); + const {onboardingMessages} = useOnboardingMessages(); + const lastWorkspaceNumber = useLastWorkspaceNumber(); + + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + + return { + onboardingPolicyID, + onboardingAdminsChatReportID, + introSelected, + isSelfTourViewed, + betas, + currentUserPersonalDetails, + currentUserEmail, + currentUserAccountID, + localCurrencyCode, + activePolicy, + translate, + formatPhoneNumber, + isRestrictedPolicyCreation, + hasActiveAdminPolicies, + onboardingMessages, + lastWorkspaceNumber, + isSmallScreenWidth, + }; +} + +export default useOnboardingWorkspaceCreationState; diff --git a/src/languages/de.ts b/src/languages/de.ts index c10716e76f13..bf229f1ccc67 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6989,6 +6989,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 9f2ab127479b..53ec223f1d75 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7001,6 +7001,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 0a86328905db..2805891bcb07 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6417,6 +6417,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 f792e7e5c11b..120dd1b75b2b 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7011,6 +7011,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 85d165d597be..0cccc4491cdd 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6974,6 +6974,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 d3f4b3a2163f..2bf9418fd86b 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6897,6 +6897,10 @@ ${reportName} label: 'コントロール', description: '高度な要件を持つ組織向け。', }, + submit2026: { + label: '提出', + description: '雇用主に経費を提出したい従業員向け。', + }, }, description: '自分に合ったプランをお選びください。機能と料金の詳細な一覧は、こちらのページをご覧ください', subscriptionLink: 'プランの種類と料金のヘルプページ', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 6a5e6b167ffb..b56b6c3866e3 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6953,6 +6953,10 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, label: 'Beheer', description: 'Voor organisaties met geavanceerde vereisten.', }, + submit2026: { + label: 'Indienen', + description: 'Voor werknemers die onkosten bij hun werkgever willen indienen.', + }, }, description: 'Kies een pakket dat bij je past. Voor een gedetailleerd overzicht van functies en prijzen, bekijk onze', subscriptionLink: 'hulppagina voor abonnementstypen en prijzen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index d93360d7bbe1..954f0fdc1ffd 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6946,6 +6946,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 83878b0ec8b9..ffbfa26b6e8f 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6952,6 +6952,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 beb5b27b0ad3..9fcc25c15598 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6779,6 +6779,10 @@ ${reportName} label: '控制', description: '适用于具有高级需求的组织。', }, + submit2026: { + label: '提交', + description: '适用于希望向雇主提交费用的员工。', + }, }, description: '选择适合您的方案。要查看详细的功能和价格列表,请访问我们的', subscriptionLink: '方案类型和价格帮助页面', diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 8e3866794b98..1291d1131c6d 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -879,6 +879,32 @@ function isPaidGroupPolicy(policy: OnyxInputOrEntry): boolean { return policy?.type === CONST.POLICY.TYPE.TEAM || policy?.type === CONST.POLICY.TYPE.CORPORATE; } +function isSubmitPolicy(policy: OnyxInputOrEntry): boolean { + return policy?.type === CONST.POLICY.TYPE.SUBMIT; +} + +function isPolicyEditor(policy: OnyxEntry): boolean { + return policy?.role === CONST.POLICY.ROLE.EDITOR; +} + +/** + * Returns true if the user can edit workspace settings — admins on any workspace, or editors on Submit workspaces. + */ +function canEditWorkspaceSettings(policy: OnyxEntry): boolean { + return isPolicyAdmin(policy) || isPolicyEditor(policy); +} + +/** + * Returns true for any group workspace: paid (Team/Corporate) or Submit. + * + * Note: not to be confused with `ReportUtils.isGroupPolicy(policyType: string)`, + * which excludes Submit. Use this helper when Submit workspaces should be treated + * like paid workspaces (e.g. access gating for shared workspace pages). + */ +function isGroupPolicy(policy: OnyxInputOrEntry): boolean { + return isPaidGroupPolicy(policy) || isSubmitPolicy(policy); +} + function getOwnedPaidPolicies(policies: OnyxCollection | null, currentUserAccountID: number | undefined): Policy[] { return Object.values(policies ?? {}).filter((policy): policy is Policy => isPolicyOwner(policy, currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID) && isPaidGroupPolicy(policy)); } @@ -2177,6 +2203,10 @@ export { isDelayedSubmissionEnabled, getCorrectedAutoReportingFrequency, isPaidGroupPolicy, + isSubmitPolicy, + isPolicyEditor, + canEditWorkspaceSettings, + isGroupPolicy, isPendingDeletePolicy, isPolicyAdmin, isPolicyUser, diff --git a/src/libs/SubscriptionUtils.ts b/src/libs/SubscriptionUtils.ts index 7193ad676243..3c26d554354d 100644 --- a/src/libs/SubscriptionUtils.ts +++ b/src/libs/SubscriptionUtils.ts @@ -539,7 +539,7 @@ function getSubscriptionPrice( privateSubscriptionType: SubscriptionType | undefined, hasTeam2025Pricing: boolean, ): number { - if (!privateSubscriptionType || !plan) { + if (!privateSubscriptionType || !plan || plan === CONST.POLICY.TYPE.SUBMIT) { return 0; } diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 4c8704f37d83..a59721afb0ca 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -1083,28 +1083,55 @@ function inviteMemberToWorkspace(policyID: string, inviterEmail?: string) { } /** - * Add member to the selected private domain workspace based on policyID + * Add member to the selected private domain workspace based on policyID. + * + * The optimistic merge is intentionally limited to `isLoading: true`. We can't + * tell the policy's real type/role from the joinable-policy payload (it's not + * exposed on `JoinablePolicy`), so writing speculative `type: SUBMIT` / + * `role: EDITOR` would corrupt Team/Corporate policies joined via the same + * private-domain flow. The `isLoading` flag is enough to suppress the brief + * NotFoundPage flash in `WorkspaceInitialPage` / `AccessOrNotFoundWrapper` + * until the backend response hydrates the policy with its actual shape. */ function joinAccessiblePolicy(policyID: string) { const memberJoinKey = `${ONYXKEYS.COLLECTION.POLICY_JOIN_MEMBER}${policyID}` as const; + const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; - const optimisticData: Array> = [ + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: memberJoinKey, value: {policyID}, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: {isLoading: true}, + }, ]; - const failureData: Array> = [ + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: {isLoading: false}, + }, + ]; + + const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: memberJoinKey, value: {policyID, errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.people.error.genericAdd')}, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: {isLoading: false}, + }, ]; - API.write(WRITE_COMMANDS.JOIN_ACCESSIBLE_POLICY, {policyID}, {optimisticData, failureData}); + API.write(WRITE_COMMANDS.JOIN_ACCESSIBLE_POLICY, {policyID}, {optimisticData, successData, failureData}); } /** diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 28223c560537..49e3c7eb9cca 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -214,7 +214,7 @@ type BuildPolicyDataOptions = { onboardingPurposeSelected?: OnboardingPurpose; shouldAddGuideWelcomeMessage?: boolean; shouldCreateControlPolicy?: boolean; - type?: typeof CONST.POLICY.TYPE.TEAM | typeof CONST.POLICY.TYPE.CORPORATE; + type?: typeof CONST.POLICY.TYPE.TEAM | typeof CONST.POLICY.TYPE.CORPORATE | typeof CONST.POLICY.TYPE.SUBMIT; // TODO: Make it required once we complete refactoring the buildPolicyData function to use isSelfTourViewed. Refactor issue: https://github.com/Expensify/App/issues/66424 isSelfTourViewed?: boolean; betas: OnyxEntry; @@ -2290,7 +2290,7 @@ function createDraftInitialWorkspace( makeMeAdmin = false, currency = '', file?: File, - type: typeof CONST.POLICY.TYPE.TEAM | typeof CONST.POLICY.TYPE.CORPORATE = CONST.POLICY.TYPE.TEAM, + type: typeof CONST.POLICY.TYPE.TEAM | typeof CONST.POLICY.TYPE.CORPORATE | typeof CONST.POLICY.TYPE.SUBMIT = CONST.POLICY.TYPE.TEAM, isAnnualSubscription = false, ) { const {customUnits, outputCurrency} = buildOptimisticDistanceRateCustomUnits(currency); @@ -2367,6 +2367,27 @@ 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, + 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 * @@ -2429,7 +2450,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< @@ -2469,7 +2492,7 @@ function buildPolicyData(options: BuildPolicyDataOptions): OnyxData Categories with the side panel open so + * the #admins room is visible in Concierge Anywhere. + */ +function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, isSmallScreenWidth = false) { + setDisableDismissOnEscape(false); + + if (!policyID) { + Navigation.navigate(ROUTES.HOME); + return; + } + + setOnboardingRHPVariant(CONST.ONBOARDING_RHP_VARIANT.RHP_ADMINS_ROOM); + Navigation.navigate(ROUTES.WORKSPACES_LIST.route); + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate(ROUTES.WORKSPACE_CATEGORIES.getRoute(policyID)); + SidePanelActions.openSidePanel(!isSmallScreenWidth); + }); +} + +function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: string, isSmallScreenWidth = false) { + Navigation.dismissModal(); + Navigation.setNavigationActionToMicrotaskQueue(() => { + navigateToSubmitWorkspaceAfterOnboarding(policyID, isSmallScreenWidth); + }); +} + +export {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue}; diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx index 85b3e67b4bc8..956f91be9169 100644 --- a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx +++ b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx @@ -10,6 +10,7 @@ import Text from '@components/Text'; import TextInput from '@components/TextInput'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import useArchivedReportsIdSet from '@hooks/useArchivedReportsIdSet'; +import useAutoCreateSubmitWorkspace from '@hooks/useAutoCreateSubmitWorkspace'; import useAutoCreateTrackWorkspace from '@hooks/useAutoCreateTrackWorkspace'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; @@ -54,6 +55,7 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat const [onboardingPersonalDetailsForm] = useOnyx(ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const autoCreateTrackWorkspace = useAutoCreateTrackWorkspace(); + const autoCreateSubmitWorkspace = useAutoCreateSubmitWorkspace(); // When we merge public email with work email, we now want to navigate to the // concierge chat report of the new work email and not the last accessed report. @@ -64,6 +66,7 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat const {inputCallbackRef} = useAutoFocusInput(); const [shouldValidateOnChange, setShouldValidateOnChange] = useState(false); const {isBetaEnabled} = usePermissions(); + const canUseSubmit2026 = isBetaEnabled(CONST.BETAS.SUBMIT_2026); const onboardingStep = useOnboardingStepCounter(SCREENS.ONBOARDING.PERSONAL_DETAILS); const isPrivateDomainAndHasAccessiblePolicies = !account?.isFromPublicDomain && !!account?.hasAccessibleDomainPolicies; @@ -131,6 +134,16 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat clearPersonalDetailsDraft(); setPersonalDetails(firstName, lastName); + if (onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.EMPLOYER && canUseSubmit2026) { + if (isPrivateDomainAndHasAccessiblePolicies && isValidated) { + Navigation.navigate(ROUTES.ONBOARDING_WORKSPACES.getRoute(route.params?.backTo)); + return; + } + updateDisplayName(firstName, lastName, formatPhoneNumber, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, session?.email ?? ''); + autoCreateSubmitWorkspace(firstName, lastName); + return; + } + if (isPrivateDomainAndHasAccessiblePolicies && (!onboardingPurposeSelected || isVsb || isSmb)) { const nextRoute = isValidated ? ROUTES.ONBOARDING_WORKSPACES : ROUTES.ONBOARDING_PRIVATE_DOMAIN; Navigation.navigate(nextRoute.getRoute(route.params?.backTo)); @@ -157,12 +170,14 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat session?.email, isPrivateDomainAndHasAccessiblePolicies, onboardingPurposeSelected, + canUseSubmit2026, isVsb, isSmb, completeOnboarding, isValidated, route.params?.backTo, autoCreateTrackWorkspace, + autoCreateSubmitWorkspace, ], ); diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx index 2643a641699a..c33ec373f627 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -9,18 +9,21 @@ import type {MenuItemProps} from '@components/MenuItem'; import MenuItemList from '@components/MenuItemList'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; +import useAutoCreateSubmitWorkspace from '@hooks/useAutoCreateSubmitWorkspace'; import useAutoCreateTrackWorkspace from '@hooks/useAutoCreateTrackWorkspace'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnboardingMessages from '@hooks/useOnboardingMessages'; import useOnboardingStepCounter from '@hooks/useOnboardingStepCounter'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import OnboardingRefManager from '@libs/OnboardingRefManager'; import type {TOnboardingRef} from '@libs/OnboardingRefManager'; +import {isCurrentUserValidated} from '@libs/UserUtils'; import variables from '@styles/variables'; import {completeOnboarding} from '@userActions/Report'; import {setOnboardingErrorMessage, setOnboardingPurposeSelected} from '@userActions/Welcome'; @@ -74,6 +77,12 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const [betas] = useOnyx(ONYXKEYS.BETAS); + const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); + const [session] = useOnyx(ONYXKEYS.SESSION); + const {isBetaEnabled} = usePermissions(); + const canUseSubmit2026 = isBetaEnabled(CONST.BETAS.SUBMIT_2026); + const isValidated = isCurrentUserValidated(loginList, session?.email); + const autoCreateSubmitWorkspace = useAutoCreateSubmitWorkspace(); const autoCreateTrackWorkspace = useAutoCreateTrackWorkspace(); const paddingHorizontal = onboardingIsMediumOrLargerScreenWidth ? styles.ph8 : styles.ph5; @@ -102,6 +111,23 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro return; } + if (choice === CONST.ONBOARDING_CHOICES.EMPLOYER && canUseSubmit2026) { + if (isPrivateDomainAndHasAccessiblePolicies && isValidated) { + Navigation.navigate( + personalDetailsForm?.firstName ? ROUTES.ONBOARDING_WORKSPACES.getRoute(route.params?.backTo) : ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(route.params?.backTo), + ); + return; + } + + if (personalDetailsForm?.firstName) { + autoCreateSubmitWorkspace(personalDetailsForm.firstName, personalDetailsForm.lastName ?? ''); + return; + } + + Navigation.navigate(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(route.params?.backTo)); + return; + } + if (isPrivateDomainAndHasAccessiblePolicies && personalDetailsForm?.firstName) { if (choice === CONST.ONBOARDING_CHOICES.PERSONAL_SPEND) { autoCreateTrackWorkspace(personalDetailsForm.firstName, personalDetailsForm.lastName ?? '', choice); diff --git a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx index b91ad2e1a154..e11b1db42c91 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'; @@ -19,7 +20,7 @@ import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {navigateAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboardingWithMicrotaskQueue, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import {isCurrentUserValidated} from '@libs/UserUtils'; @@ -69,20 +70,34 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding const [onboardingValues] = useOnyx(ONYXKEYS.NVP_ONBOARDING); const isVsb = onboardingValues?.signupQualifier === CONST.ONBOARDING_SIGNUP_QUALIFIERS.VSB; const isSmb = onboardingValues?.signupQualifier === CONST.ONBOARDING_SIGNUP_QUALIFIERS.SMB; + const [onboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED); + const canUseSubmit2026 = isBetaEnabled(CONST.BETAS.SUBMIT_2026); + const isEmployerWithSubmit = onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.EMPLOYER && canUseSubmit2026; + const autoCreateSubmitWorkspace = useAutoCreateSubmitWorkspace(); const shouldHideBackButton = onboardingValues?.shouldValidate === false && route.params?.backTo === ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute(); const onboardingStep = useOnboardingStepCounter(SCREENS.ONBOARDING.WORKSPACES); const handleJoinWorkspace = (policy: JoinablePolicy) => { + // Only mirror the EMPLOYER ("Get paid back by my employer") + Submit-2026 onboarding flow + // when the user actually picked EMPLOYER, or when no Purpose was selected (private-domain + // users who reach this screen without going through the Purpose step). Users on the Submit + // beta who picked a different Purpose (e.g. MANAGE_TEAM) must not be re-routed through + // the Submit flow. + const shouldUseSubmitFlow = canUseSubmit2026 && (!onboardingPurposeSelected || onboardingPurposeSelected === CONST.ONBOARDING_CHOICES.EMPLOYER); + if (policy.automaticJoiningEnabled) { joinAccessiblePolicy(policy.policyID); } else { askToJoinPolicy(policy.policyID); } + + const engagementChoice = shouldUseSubmitFlow ? CONST.ONBOARDING_CHOICES.EMPLOYER : CONST.ONBOARDING_CHOICES.LOOKING_AROUND; completeOnboarding({ - engagementChoice: CONST.ONBOARDING_CHOICES.LOOKING_AROUND, - onboardingMessage: onboardingMessages[CONST.ONBOARDING_CHOICES.LOOKING_AROUND], + engagementChoice, + onboardingMessage: onboardingMessages[engagementChoice], firstName: onboardingPersonalDetails?.firstName ?? '', lastName: onboardingPersonalDetails?.lastName ?? '', + onboardingPolicyID: shouldUseSubmitFlow && policy.automaticJoiningEnabled ? policy.policyID : undefined, companySize: onboardingCompanySize, introSelected, isSelfTourViewed, @@ -91,6 +106,11 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding setOnboardingAdminsChatReportID(); setOnboardingPolicyID(policy.policyID); + if (shouldUseSubmitFlow && policy.automaticJoiningEnabled) { + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policy.policyID, isSmallScreenWidth); + return; + } + navigateAfterOnboardingWithMicrotaskQueue( isSmallScreenWidth, isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), @@ -146,6 +166,11 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding }); const skipJoiningWorkspaces = () => { + if (isEmployerWithSubmit) { + autoCreateSubmitWorkspace(onboardingPersonalDetails?.firstName ?? '', onboardingPersonalDetails?.lastName ?? ''); + return; + } + if (isVsb) { Navigation.navigate(ROUTES.ONBOARDING_ACCOUNTING.getRoute(route.params?.backTo)); return; diff --git a/src/pages/workspace/AccessOrNotFoundWrapper.tsx b/src/pages/workspace/AccessOrNotFoundWrapper.tsx index 87fab021f830..636662d1062d 100644 --- a/src/pages/workspace/AccessOrNotFoundWrapper.tsx +++ b/src/pages/workspace/AccessOrNotFoundWrapper.tsx @@ -14,7 +14,7 @@ import {openWorkspace} from '@libs/actions/Policy/Policy'; import {isValidMoneyRequestType} from '@libs/IOUUtils'; import goBackFromWorkspaceSettingPages from '@libs/Navigation/helpers/goBackFromWorkspaceSettingPages'; import Navigation from '@libs/Navigation/Navigation'; -import {canSendInvoice, isControlPolicy, isPaidGroupPolicy, isPolicyAccessible, isPolicyAdmin, isPolicyFeatureEnabled as isPolicyFeatureEnabledUtil} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, canSendInvoice, isControlPolicy, isGroupPolicy, isPolicyAccessible, isPolicyFeatureEnabled as isPolicyFeatureEnabledUtil} from '@libs/PolicyUtils'; import {canCreateRequest} from '@libs/ReportUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; @@ -30,9 +30,9 @@ import callOrReturn from '@src/types/utils/callOrReturn'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; const ACCESS_VARIANTS = { - [CONST.POLICY.ACCESS_VARIANTS.PAID]: (policy: OnyxEntry) => isPaidGroupPolicy(policy), + [CONST.POLICY.ACCESS_VARIANTS.PAID]: (policy: OnyxEntry) => isGroupPolicy(policy), [CONST.POLICY.ACCESS_VARIANTS.CONTROL]: (policy: OnyxEntry) => isControlPolicy(policy), - [CONST.POLICY.ACCESS_VARIANTS.ADMIN]: (policy: OnyxEntry, login: string) => isPolicyAdmin(policy, login), + [CONST.POLICY.ACCESS_VARIANTS.ADMIN]: (policy: OnyxEntry) => canEditWorkspaceSettings(policy), [CONST.IOU.ACCESS_VARIANTS.CREATE]: ( policy: OnyxEntry, login: string, @@ -164,7 +164,8 @@ function AccessOrNotFoundWrapper({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isPolicyIDInRoute, policyID]); - const shouldShowFullScreenLoadingIndicator = !isMoneyRequest && isLoadingReportData !== false && (!Object.entries(policy ?? {}).length || !policy?.id); + const isPolicyEmpty = !Object.entries(policy ?? {}).length || !policy?.id; + const shouldShowFullScreenLoadingIndicator = !isMoneyRequest && (isLoadingReportData !== false || !!policy?.isLoading) && isPolicyEmpty; const isFeatureEnabled = featureName ? isPolicyFeatureEnabledUtil(policy, featureName) : true; diff --git a/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx b/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx index 8ad8ac82a280..4d40137e051d 100644 --- a/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx +++ b/src/pages/workspace/DynamicWorkspaceOverviewPlanTypePage.tsx @@ -55,7 +55,17 @@ function DynamicWorkspaceOverviewPlanTypePage({policy}: WithPolicyProps) { }, [policy?.type]); const workspacePlanTypes = Object.values(CONST.POLICY.TYPE) - .filter((type) => type !== CONST.POLICY.TYPE.PERSONAL) + .filter((type) => { + if (type === CONST.POLICY.TYPE.PERSONAL) { + return false; + } + // Guard: don't leak the SUBMIT plan type into the plan-type list for paid workspaces. + // Submit-specific plan-type UX (exposing SUBMIT for Submit policies) ships in #87263. + if (type === CONST.POLICY.TYPE.SUBMIT) { + return false; + } + return true; + }) .map((policyType) => ({ value: policyType, text: translate(`workspace.planTypePage.planTypes.${policyType as PersonalPolicyTypeExcludedProps}.label`), @@ -81,6 +91,14 @@ function DynamicWorkspaceOverviewPlanTypePage({policy}: WithPolicyProps) { ) : null; const handleUpdatePlan = () => { + // Submit policies don't expose SUBMIT in the option list, but the editor can + // still pick Team/Corporate. Route any selection from a Submit policy to the + // upgrade screen — the polished Submit-specific upgrade UX ships in #87263. + if (policyID && policy?.type === CONST.POLICY.TYPE.SUBMIT && (currentPlan === CONST.POLICY.TYPE.TEAM || currentPlan === CONST.POLICY.TYPE.CORPORATE)) { + Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policyID)); + return; + } + if (policyID && policy?.type === CONST.POLICY.TYPE.TEAM && currentPlan === CONST.POLICY.TYPE.CORPORATE) { Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policyID)); return; diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index ad9bc4901466..2a1d8c17ff04 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -14,7 +14,6 @@ import ScrollView from '@components/ScrollView'; import useCardFeedErrors from '@hooks/useCardFeedErrors'; import useConfirmReadyToOpenApp from '@hooks/useConfirmReadyToOpenApp'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -33,14 +32,14 @@ import {clearErrors, openPolicyInitialPage, removeWorkspace} from '@libs/actions import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import { + canEditWorkspaceSettings, canPolicyAccessFeature, shouldShowPolicy as checkIfShouldShowPolicy, goBackFromInvalidPolicy, hasAccountingFeatureConnection, hasPolicyCategoriesError, - isPaidGroupPolicy, + isGroupPolicy, isPendingDeletePolicy, - isPolicyAdmin, isTimeTrackingEnabled, shouldShowEmployeeListError, shouldShowSyncError, @@ -95,7 +94,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const {translate} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); const {isBetaEnabled} = usePermissions(); - const {login} = useCurrentUserPersonalDetails(); + const isFocused = useIsFocused(); const activeRoute = useNavigationState((state) => findFocusedRoute(state)?.name); const waitForNavigate = useWaitForNavigation(); @@ -134,7 +133,8 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const policyName = policy?.name ?? ''; const hasPolicyCreationError = policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && !isEmptyObject(policy.errors); - const shouldShowProtectedItems = isPolicyAdmin(policy, login); + const shouldShowProtectedItems = canEditWorkspaceSettings(policy); + const accountingConnectionNames = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES; const hasSyncError = shouldShowSyncError(policy, isConnectionInProgress(connectionSyncProgress, policy), accountingConnectionNames); const hasMembersError = shouldShowEmployeeListError(policy); @@ -184,8 +184,9 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const shouldShowPolicy = checkIfShouldShowPolicy(policy, true, currentUserLogin); const isPendingDelete = isPendingDeletePolicy(policy); const prevIsPendingDelete = isPendingDeletePolicy(prevPolicy); - - const shouldShowNotFoundPage = !shouldShowPolicy && (!isPendingDelete || prevIsPendingDelete); + // While the policy is being fetched (e.g., right after joinAccessiblePolicy), the role is not yet populated, + // so checkIfShouldShowPolicy returns false. Suppress NotFound during this loading window. + const shouldShowNotFoundPage = !shouldShowPolicy && !policy?.isLoading && (!isPendingDelete || prevIsPendingDelete); const fetchPolicyData = () => { if (policyDraft?.id || !isFocused) { return; @@ -227,7 +228,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac }, ]; - if (isPaidGroupPolicy(policy) && shouldShowProtectedItems) { + if (isGroupPolicy(policy) && shouldShowProtectedItems) { workspaceMenuItems.push({ translationKey: 'common.reports', icon: expensifyIcons.Document, @@ -451,7 +452,10 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac > Navigation.goBack(route.params?.backTo ?? ROUTES.WORKSPACES_LIST.route)} + onBackButtonPress={() => + // On narrow, goBack() pops the workspace split; goBack with a route can't resolve inside TAB_NAVIGATOR. + shouldUseNarrowLayout && !route.params?.backTo ? Navigation.goBack() : Navigation.goBack(route.params?.backTo ?? ROUTES.WORKSPACES_LIST.route) + } policyAvatar={policyAvatar} shouldDisplayHelpButton={shouldUseNarrowLayout} /> diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 33473c1f8c3d..c4db802bf574 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -62,13 +62,13 @@ import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import {isPersonalDetailsReady, sortAlphabetically} from '@libs/OptionsListUtils'; import {getDisplayNameOrDefault, getPersonalDetailsByIDs} from '@libs/PersonalDetailsUtils'; import { + canEditWorkspaceSettings, getConnectionExporters, getMemberAccountIDsForWorkspace, isControlPolicy, isDeletedPolicyEmployee, isExpensifyTeam, isPaidGroupPolicy, - isPolicyAdmin as isPolicyAdminUtils, isPolicyApprover, } from '@libs/PolicyUtils'; import {getDisplayNameForParticipant} from '@libs/ReportUtils'; @@ -155,7 +155,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type for the decision modal // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); - const isPolicyAdmin = isPolicyAdminUtils(policy); + const isPolicyAdmin = canEditWorkspaceSettings(policy); const isLoading = useMemo( () => !isOfflineAndNoMemberDataAvailable && (!isPersonalDetailsReady(personalDetails) || isEmptyObject(policy?.employeeList)), [isOfflineAndNoMemberDataAvailable, personalDetails, policy?.employeeList], diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index 445882b87b8b..4f8d0cec2cc7 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -53,6 +53,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import { + canEditWorkspaceSettings, getConnectionExporters, getUserFriendlyWorkspaceType, goBackFromInvalidPolicy, @@ -109,7 +110,7 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const isBankAccountVerified = !!settings?.paymentBankAccountID; const shouldBlockCurrencyChange = useShouldBlockCurrencyChange(policyID); - const isPolicyAdmin = isPolicyAdminPolicyUtils(policy); + const isPolicyAdmin = canEditWorkspaceSettings(policy); const outputCurrency = policy?.outputCurrency ?? ''; const currencySymbol = getCurrencySymbol(outputCurrency) ?? ''; const formattedCurrency = !isEmptyObject(policy) ? `${outputCurrency} - ${currencySymbol}` : ''; @@ -177,7 +178,7 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const policyName = policy?.name ?? ''; const policyDescription = policy?.description ?? translate('workspace.common.defaultDescription'); const policyCurrency = policy?.outputCurrency ?? ''; - const readOnly = !isPolicyAdminPolicyUtils(policy); + const readOnly = !canEditWorkspaceSettings(policy); const currencyReadOnly = readOnly || isBankAccountVerified; const isOwner = isPolicyOwner(policy, currentUserPersonalDetails.accountID); const shouldShowAddress = !readOnly || !!formattedAddress; diff --git a/src/pages/workspace/WorkspacePageWithSections.tsx b/src/pages/workspace/WorkspacePageWithSections.tsx index a2e1c8846792..1d90f6414c89 100644 --- a/src/pages/workspace/WorkspacePageWithSections.tsx +++ b/src/pages/workspace/WorkspacePageWithSections.tsx @@ -19,7 +19,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {openWorkspaceView} from '@libs/actions/BankAccounts'; import goBackFromWorkspaceSettingPages from '@libs/Navigation/helpers/goBackFromWorkspaceSettingPages'; import Navigation from '@libs/Navigation/Navigation'; -import {isPendingDeletePolicy, isPolicyAdmin, shouldShowPolicy as shouldShowPolicyUtil} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, isPendingDeletePolicy, shouldShowPolicy as shouldShowPolicyUtil} from '@libs/PolicyUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -172,7 +172,7 @@ function WorkspacePageWithSections({ } // We check isPendingDelete and prevIsPendingDelete to prevent the NotFound view from showing right after we delete the workspace - return (!isEmptyObject(policy) && !isPolicyAdmin(policy) && !shouldShowNonAdmin) || (!shouldShowPolicy && !(isPendingDelete && !prevIsPendingDelete)); + return (!isEmptyObject(policy) && !canEditWorkspaceSettings(policy) && !shouldShowNonAdmin) || (!shouldShowPolicy && !(isPendingDelete && !prevIsPendingDelete)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [policy, shouldShowNonAdmin, shouldShowPolicy]); diff --git a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx index 8661e2e0a8b4..576f4af16821 100644 --- a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx +++ b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx @@ -15,7 +15,7 @@ import {updateXeroMappings} from '@libs/actions/connections/Xero'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {canModifyPlan, getDefaultApprover, getPerDiemCustomUnit, isControlPolicy} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, canModifyPlan, getDefaultApprover, getPerDiemCustomUnit, isControlPolicy} from '@libs/PolicyUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import {enablePerDiem} from '@userActions/Policy/PerDiem'; import CONST from '@src/CONST'; @@ -231,7 +231,10 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { }, [isUpgraded, canPerformUpgrade, confirmUpgrade]), ); - if (!canPerformUpgrade) { + // Gate the page to users who can edit workspace settings (admins on any policy, + // or editors on Submit policies). `canPerformUpgrade` (strict admin) still controls + // whether the upgrade button is active, so editors see the intro but can't upgrade. + if (!canEditWorkspaceSettings(policy)) { return ; } @@ -264,7 +267,7 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { policyID={policyID} feature={feature} onUpgrade={onUpgradeToCorporate} - buttonDisabled={isOffline} + buttonDisabled={isOffline || !canPerformUpgrade} loading={policy?.isPendingUpgrade} backTo={route.params.backTo} /> diff --git a/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx b/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx index 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 56618ac811b5..40fb435a3dc8 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -49,10 +49,11 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import {getPaymentMethodDescription} from '@libs/PaymentUtils'; import {getDisplayNameOrDefault, getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import { + canEditWorkspaceSettings, getCorrectedAutoReportingFrequency, hasDynamicExternalWorkflow, isControlPolicy, - isPaidGroupPolicy as isPaidGroupPolicyUtil, + isGroupPolicy as isGroupPolicyUtil, isPolicyAdmin as isPolicyAdminUtil, } from '@libs/PolicyUtils'; import {hasInProgressVBBA} from '@libs/ReimbursementAccountUtils'; @@ -630,7 +631,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { ); - const isPaidGroupPolicy = isPaidGroupPolicyUtil(policy); + const isGroupPolicy = isGroupPolicyUtil(policy); const isLoading = !!(policy?.isLoading && policy?.reimbursementChoice === undefined); return ( @@ -643,7 +644,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { icon={illustrations.Workflows} route={route} shouldShowOfflineIndicatorInWideScreen - shouldShowNotFoundPage={!isPaidGroupPolicy || !isPolicyAdmin} + shouldShowNotFoundPage={!isGroupPolicy || !canEditWorkspaceSettings(policy)} isLoading={isLoading} shouldShowLoading={isLoading} shouldUseScrollView diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApprovalLimitPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApprovalLimitPage.tsx index 5e14701cac06..f7822beb595f 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApprovalLimitPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApprovalLimitPage.tsx @@ -23,7 +23,7 @@ import {convertToBackendAmount, convertToFrontendAmountAsString} from '@libs/Cur import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import {goBackFromInvalidPolicy, isPendingDeletePolicy, isPolicyAdmin} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, goBackFromInvalidPolicy, isPendingDeletePolicy} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -65,7 +65,7 @@ function WorkspaceWorkflowsApprovalsApprovalLimitPage({policy, isLoadingReportDa const selectedApproverPersonalDetails = selectedApproverEmail ? personalDetailsByEmail?.[selectedApproverEmail] : undefined; const selectedApproverDisplayName = selectedApproverEmail ? Str.removeSMSDomain(selectedApproverPersonalDetails?.displayName ?? selectedApproverEmail) : ''; - const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !isPolicyAdmin(policy) || isPendingDeletePolicy(policy); + const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !canEditWorkspaceSettings(policy) || isPendingDeletePolicy(policy); const approverDisplayName = currentApprover ? Str.removeSMSDomain(currentApprover.displayName) : ''; const isApproverSelected = isEditFlow ? approverDisplayName.length > 0 : true; diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx index 15da8a38321d..2c81ebc003c4 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx @@ -13,7 +13,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import {goBackFromInvalidPolicy, isPendingDeletePolicy, isPolicyAdmin} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, goBackFromInvalidPolicy, isPendingDeletePolicy} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -36,7 +36,7 @@ function WorkspaceWorkflowsApprovalsCreatePage({policy, isLoadingReportData = tr const [addExpenseApprovalsTaskReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${addExpenseApprovalsTaskReportID}`); const formRef = useRef(null); - const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !isPolicyAdmin(policy) || isPendingDeletePolicy(policy); + const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !canEditWorkspaceSettings(policy) || isPendingDeletePolicy(policy); const createApprovalWorkflow = useCallback(() => { if (!approvalWorkflow) { diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx index 6ada3231860a..d43a130d3aa8 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx @@ -18,7 +18,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import {goBackFromInvalidPolicy, isPendingDeletePolicy, isPolicyAdmin} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, goBackFromInvalidPolicy, isPendingDeletePolicy} from '@libs/PolicyUtils'; import {convertPolicyEmployeesToApprovalWorkflows, mergeWorkflowMembersWithAvailableMembers} from '@libs/WorkflowUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -102,7 +102,7 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true const {currentApprovalWorkflow, defaultWorkflowMembers, usedApproverEmails} = getApprovalWorkflowData(); - const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !isPolicyAdmin(policy) || isPendingDeletePolicy(policy) || !currentApprovalWorkflow; + const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !canEditWorkspaceSettings(policy) || isPendingDeletePolicy(policy) || !currentApprovalWorkflow; // Set the initial approval workflow when the page is loaded useEffect(() => { diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx index cc9f680177a7..dcb9a4639f43 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx @@ -15,7 +15,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; -import {getDefaultApprover, getMemberAccountIDsForWorkspace, isPendingDeletePolicy, isPolicyAdmin} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, getDefaultApprover, getMemberAccountIDsForWorkspace, isPendingDeletePolicy} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import MemberRightIcon from '@pages/workspace/MemberRightIcon'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -41,7 +41,7 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat const isLoadingApprovalWorkflow = isLoadingOnyxValue(approvalWorkflowResults); const [selectedMembers, setSelectedMembers] = useState([]); - const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !isPolicyAdmin(policy) || isPendingDeletePolicy(policy); + const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !canEditWorkspaceSettings(policy) || isPendingDeletePolicy(policy); const isInitialCreationFlow = approvalWorkflow?.action === CONST.APPROVAL_WORKFLOW.ACTION.CREATE && approvalWorkflow?.isInitialFlow; const shouldShowListEmptyContent = !isLoadingApprovalWorkflow && approvalWorkflow?.availableMembers.length === 0; const firstApprover = approvalWorkflow?.originalApprovers?.[0]?.email ?? ''; diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index 0aa0eacbd0fa..504e274532fc 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -1112,6 +1112,43 @@ describe('actions/Policy', () => { }); }); + it('creates a Submit workspace with ADVANCED approval mode and correct feature flags', async () => { + const policyID = Policy.generatePolicyID(); + Policy.createWorkspace({ + policyOwnerEmail: ESH_EMAIL, + makeMeAdmin: true, + policyName: WORKSPACE_NAME, + policyID, + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + introSelected: {choice: CONST.ONBOARDING_CHOICES.EMPLOYER}, + currentUserAccountIDParam: ESH_ACCOUNT_ID, + currentUserEmailParam: ESH_EMAIL, + isSelfTourViewed: false, + betas: undefined, + hasActiveAdminPolicies: false, + activePolicy: undefined, + type: CONST.POLICY.TYPE.SUBMIT, + }); + await waitForBatchedUpdates(); + + await TestHelper.getOnyxData({ + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + waitForCollectionCallback: false, + callback: (policy) => { + expect(policy?.type).toBe(CONST.POLICY.TYPE.SUBMIT); + expect(policy?.role).toBe(CONST.POLICY.ROLE.EDITOR); + expect(policy?.approvalMode).toBe(CONST.POLICY.APPROVAL_MODE.ADVANCED); + expect(policy?.areWorkflowsEnabled).toBe(true); + expect(policy?.areTagsEnabled).toBe(true); + expect(policy?.areDistanceRatesEnabled).toBe(true); + expect(policy?.areCompanyCardsEnabled).toBe(false); + expect(policy?.harvesting?.enabled).toBe(false); + expect(policy?.autoReportingFrequency).toBe(CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE); + expect(policy?.employeeList?.[ESH_EMAIL]?.role).toBe(CONST.POLICY.ROLE.EDITOR); + }, + }); + }); + it('should pass areDistanceRatesEnabled as true when creating workspace with distance rates feature enabled', async () => { await Onyx.set(ONYXKEYS.SESSION, {email: ESH_EMAIL, accountID: ESH_ACCOUNT_ID}); await waitForBatchedUpdates(); diff --git a/tests/ui/OnboardingPurpose.tsx b/tests/ui/OnboardingPurpose.tsx index 1b48f6e54cce..5a487f15f185 100644 --- a/tests/ui/OnboardingPurpose.tsx +++ b/tests/ui/OnboardingPurpose.tsx @@ -13,6 +13,7 @@ import Navigation from '@libs/Navigation/Navigation'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {OnboardingModalNavigatorParamList} from '@libs/Navigation/types'; import OnboardingPurpose from '@pages/OnboardingPurpose'; +import {createWorkspace} from '@userActions/Policy/Policy'; import {completeOnboarding} from '@userActions/Report'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -24,6 +25,7 @@ import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; const mockCompleteOnboarding = jest.mocked(completeOnboarding); +const mockCreateWorkspace = jest.mocked(createWorkspace); jest.mock('@userActions/Report', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -35,6 +37,19 @@ jest.mock('@userActions/Report', () => { }; }); +jest.mock('@userActions/Policy/Policy', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@userActions/Policy/Policy'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + createWorkspace: jest.fn().mockReturnValue({ + policyID: 'test-policy-id', + adminsChatReportID: 'test-admins-report-id', + }), + }; +}); + TestHelper.setupGlobalFetchMock(); // Helper to translate onboarding purpose keys that use dynamic CONST values @@ -138,6 +153,132 @@ describe('OnboardingPurpose Page', () => { await waitForBatchedUpdatesWithAct(); }); + it('should navigate to personal details page when user selects EMPLOYER with Submit2026 beta and is from public domain', async () => { + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, { + isFromPublicDomain: true, + hasAccessibleDomainPolicies: false, + }); + await Onyx.merge(ONYXKEYS.BETAS, [CONST.BETAS.SUBMIT_2026]); + }); + + const {unmount} = renderOnboardingPurposePage(SCREENS.ONBOARDING.PURPOSE, {backTo: ''}); + + await waitForBatchedUpdatesWithAct(); + + const user = userEvent.setup(); + const employerLabel = translatePurpose(CONST.ONBOARDING_CHOICES.EMPLOYER); + const employerOption = screen.getByLabelText(employerLabel); + await user.press(employerOption); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute('')); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should navigate to workspaces page when user selects EMPLOYER with Submit2026 beta and is from private domain with name set', async () => { + const testEmail = 'test@user.com'; + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, { + isFromPublicDomain: false, + hasAccessibleDomainPolicies: true, + }); + await Onyx.merge(ONYXKEYS.LOGIN_LIST, { + [testEmail]: { + partnerName: 'expensify.com', + partnerUserID: testEmail, + validatedDate: 'fake-validatedDate', + }, + }); + await Onyx.merge(ONYXKEYS.BETAS, [CONST.BETAS.SUBMIT_2026]); + await Onyx.merge(ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM, { + firstName: 'Test', + lastName: 'User', + }); + }); + + const {unmount} = renderOnboardingPurposePage(SCREENS.ONBOARDING.PURPOSE, {backTo: ''}); + + await waitForBatchedUpdatesWithAct(); + + const user = userEvent.setup(); + const employerLabel = translatePurpose(CONST.ONBOARDING_CHOICES.EMPLOYER); + const employerOption = screen.getByLabelText(employerLabel); + await user.press(employerOption); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith(ROUTES.ONBOARDING_WORKSPACES.getRoute('')); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should create a Submit workspace from Purpose when EMPLOYER is selected and personal details already exist', async () => { + jest.spyOn(Navigation, 'dismissModal').mockImplementation(() => {}); + jest.spyOn(Navigation, 'setNavigationActionToMicrotaskQueue').mockImplementation((callback: () => void) => callback()); + + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, { + isFromPublicDomain: true, + hasAccessibleDomainPolicies: false, + }); + await Onyx.merge(ONYXKEYS.BETAS, [CONST.BETAS.SUBMIT_2026]); + await Onyx.merge(ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM, { + firstName: 'Test', + lastName: 'User', + }); + }); + + const onyxSetSpy = jest.spyOn(Onyx, 'set'); + onyxSetSpy.mockClear(); + + const {unmount} = renderOnboardingPurposePage(SCREENS.ONBOARDING.PURPOSE, {backTo: ''}); + + await waitForBatchedUpdatesWithAct(); + + const user = userEvent.setup(); + const employerLabel = translatePurpose(CONST.ONBOARDING_CHOICES.EMPLOYER); + const employerOption = screen.getByLabelText(employerLabel); + await user.press(employerOption); + + await waitFor(() => { + expect(mockCreateWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + type: CONST.POLICY.TYPE.SUBMIT, + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + }), + ); + }); + + await waitFor(() => { + expect(mockCompleteOnboarding).toHaveBeenCalledWith( + expect.objectContaining({ + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + onboardingPolicyID: 'test-policy-id', + }), + ); + }); + + await waitFor(() => { + expect(onyxSetSpy).toHaveBeenCalledWith(ONYXKEYS.NVP_ONBOARDING_RHP_VARIANT, CONST.ONBOARDING_RHP_VARIANT.RHP_ADMINS_ROOM); + expect(navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')); + }); + + onyxSetSpy.mockRestore(); + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + it('should call completeOnboarding with introSelected when user is from private domain and selects a direct-complete choice', async () => { await TestHelper.signInWithTestUser(); diff --git a/tests/ui/PersonalDetailsOnboarding.tsx b/tests/ui/PersonalDetailsOnboarding.tsx index d165bcc96aaa..d7a7dde9d632 100644 --- a/tests/ui/PersonalDetailsOnboarding.tsx +++ b/tests/ui/PersonalDetailsOnboarding.tsx @@ -14,6 +14,8 @@ import Navigation from '@libs/Navigation/Navigation'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {OnboardingModalNavigatorParamList} from '@navigation/types'; import OnboardingPersonalDetails from '@pages/OnboardingPersonalDetails'; +import {createWorkspace} from '@userActions/Policy/Policy'; +import {completeOnboarding} from '@userActions/Report'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -22,6 +24,32 @@ import SCREENS from '@src/SCREENS'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; +const mockCreateWorkspace = jest.mocked(createWorkspace); +const mockCompleteOnboarding = jest.mocked(completeOnboarding); + +jest.mock('@userActions/Report', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@userActions/Report'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + completeOnboarding: jest.fn(), + }; +}); + +jest.mock('@userActions/Policy/Policy', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@userActions/Policy/Policy'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + createWorkspace: jest.fn().mockReturnValue({ + policyID: 'test-policy-id', + adminsChatReportID: 'test-admins-report-id', + }), + }; +}); + TestHelper.setupGlobalFetchMock(); const Stack = createPlatformStackNavigator(); @@ -143,4 +171,84 @@ describe('OnboardingPersonalDetails Page', () => { unmount(); await waitForBatchedUpdatesWithAct(); }); + + it('should navigate to Onboarding workspaces page when submitting form with EMPLOYER + Submit2026 beta and validated private domain', async () => { + const testEmail = 'test@user.com'; + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, { + isFromPublicDomain: false, + hasAccessibleDomainPolicies: true, + }); + await Onyx.merge(ONYXKEYS.LOGIN_LIST, { + [testEmail]: { + partnerName: 'expensify.com', + partnerUserID: testEmail, + validatedDate: 'fake-validatedDate', + }, + }); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.EMPLOYER); + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.SUBMIT_2026]); + }); + + const {unmount} = renderOnboardingPersonalDetailsPage(SCREENS.ONBOARDING.PERSONAL_DETAILS, {backTo: ''}); + + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByText(TestHelper.translateLocal('common.continue'))); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith(ROUTES.ONBOARDING_WORKSPACES.getRoute()); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should create a Submit workspace when submitting form with EMPLOYER + Submit2026 beta and public domain', async () => { + jest.spyOn(Navigation, 'dismissModal').mockImplementation(() => {}); + jest.spyOn(Navigation, 'setNavigationActionToMicrotaskQueue').mockImplementation((callback: () => void) => callback()); + + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, { + isFromPublicDomain: true, + hasAccessibleDomainPolicies: false, + }); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.EMPLOYER); + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.SUBMIT_2026]); + }); + + const {unmount} = renderOnboardingPersonalDetailsPage(SCREENS.ONBOARDING.PERSONAL_DETAILS, {backTo: ''}); + + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByText(TestHelper.translateLocal('common.continue'))); + + await waitFor(() => { + expect(mockCreateWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + type: CONST.POLICY.TYPE.SUBMIT, + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + }), + ); + }); + + await waitFor(() => { + expect(mockCompleteOnboarding).toHaveBeenCalledWith( + expect.objectContaining({ + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + }), + ); + }); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); }); diff --git a/tests/ui/WorkspaceOnboarding.tsx b/tests/ui/WorkspaceOnboarding.tsx index 134697814764..d2307caf85cd 100644 --- a/tests/ui/WorkspaceOnboarding.tsx +++ b/tests/ui/WorkspaceOnboarding.tsx @@ -13,13 +13,54 @@ import Navigation from '@libs/Navigation/Navigation'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {OnboardingModalNavigatorParamList} from '@libs/Navigation/types'; import OnboardingWorkspaces from '@pages/OnboardingWorkspaces'; +import {joinAccessiblePolicy} from '@userActions/Policy/Member'; +import {createWorkspace} from '@userActions/Policy/Policy'; +import {completeOnboarding} from '@userActions/Report'; import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; +const mockCreateWorkspace = jest.mocked(createWorkspace); +const mockCompleteOnboarding = jest.mocked(completeOnboarding); +const mockJoinAccessiblePolicy = jest.mocked(joinAccessiblePolicy); + +jest.mock('@userActions/Report', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@userActions/Report'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + completeOnboarding: jest.fn(), + }; +}); + +jest.mock('@userActions/Policy/Policy', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@userActions/Policy/Policy'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + createWorkspace: jest.fn().mockReturnValue({ + policyID: 'test-policy-id', + adminsChatReportID: 'test-admins-report-id', + }), + }; +}); + +jest.mock('@userActions/Policy/Member', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@userActions/Policy/Member'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + joinAccessiblePolicy: jest.fn(), + }; +}); + TestHelper.setupGlobalFetchMock(); const Stack = createPlatformStackNavigator(); @@ -49,6 +90,7 @@ describe('OnboardingWorkspaces Page', () => { Onyx.init({ keys: ONYXKEYS, }); + return IntlStore.load(CONST.LOCALES.EN); }); beforeEach(() => { @@ -173,4 +215,118 @@ describe('OnboardingWorkspaces Page', () => { unmount(); await waitForBatchedUpdatesWithAct(); }); + + it('should create a Submit workspace when skip is pressed with EMPLOYER purpose and Submit2026 beta', async () => { + jest.spyOn(Navigation, 'dismissModal').mockImplementation(() => {}); + jest.spyOn(Navigation, 'setNavigationActionToMicrotaskQueue').mockImplementation((callback: () => void) => callback()); + + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: false, + }); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.EMPLOYER); + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.SUBMIT_2026]); + }); + + const {unmount} = renderOnboardingWorkspacesPage(SCREENS.ONBOARDING.WORKSPACES, {backTo: ''}); + + await waitForBatchedUpdatesWithAct(); + + const skipButton = screen.getByTestId('onboardingWorkSpaceSkipButton'); + + const mockEvent = { + nativeEvent: {}, + type: 'press', + target: skipButton, + currentTarget: skipButton, + }; + + fireEvent.press(skipButton, mockEvent); + + await waitFor(() => { + expect(mockCreateWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + type: CONST.POLICY.TYPE.SUBMIT, + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + }), + ); + }); + + await waitFor(() => { + expect(mockCompleteOnboarding).toHaveBeenCalledWith( + expect.objectContaining({ + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + }), + ); + }); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should complete onboarding with the joined Submit workspace policyID and open Categories in the admins room', async () => { + jest.spyOn(Navigation, 'dismissModal').mockImplementation(() => {}); + jest.spyOn(Navigation, 'setNavigationActionToMicrotaskQueue').mockImplementation((callback: () => void) => callback()); + + await TestHelper.signInWithTestUser(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: false, + }); + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.EMPLOYER); + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.SUBMIT_2026]); + await Onyx.merge(ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM, { + firstName: 'Test', + lastName: 'User', + }); + await Onyx.set(ONYXKEYS.JOINABLE_POLICIES, { + submitPolicyID: { + policyID: 'submit-policy-id', + policyName: 'Submit Workspace', + policyOwner: 'owner@test.com', + employeeCount: 4, + hasPendingAccess: false, + automaticJoiningEnabled: true, + }, + }); + }); + + const onyxSetSpy = jest.spyOn(Onyx, 'set'); + onyxSetSpy.mockClear(); + + const {unmount} = renderOnboardingWorkspacesPage(SCREENS.ONBOARDING.WORKSPACES, {backTo: ''}); + + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByText(TestHelper.translateLocal('workspace.workspaceList.joinNow'))); + + await waitFor(() => { + expect(mockJoinAccessiblePolicy).toHaveBeenCalledWith('submit-policy-id'); + }); + + await waitFor(() => { + expect(mockCompleteOnboarding).toHaveBeenCalledWith( + expect.objectContaining({ + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + onboardingPolicyID: 'submit-policy-id', + }), + ); + }); + + await waitFor(() => { + expect(onyxSetSpy).toHaveBeenCalledWith(ONYXKEYS.NVP_ONBOARDING_RHP_VARIANT, CONST.ONBOARDING_RHP_VARIANT.RHP_ADMINS_ROOM); + expect(navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_CATEGORIES.getRoute('submit-policy-id')); + }); + + onyxSetSpy.mockRestore(); + unmount(); + await waitForBatchedUpdatesWithAct(); + }); }); diff --git a/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts b/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts new file mode 100644 index 000000000000..a01b7002d801 --- /dev/null +++ b/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts @@ -0,0 +1,330 @@ +import {renderHook} from '@testing-library/react-native'; +import useAutoCreateSubmitWorkspace from '@hooks/useAutoCreateSubmitWorkspace'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useHasActiveAdminPolicies from '@hooks/useHasActiveAdminPolicies'; +import useLocalize from '@hooks/useLocalize'; +import useOnboardingMessages from '@hooks/useOnboardingMessages'; +import useOnyx from '@hooks/useOnyx'; +import usePreferredPolicy from '@hooks/usePreferredPolicy'; +import * as navigateAfterOnboarding from '@libs/navigateAfterOnboarding'; +import * as Policy from '@userActions/Policy/Policy'; +import * as Report from '@userActions/Report'; +import * as Welcome from '@userActions/Welcome'; +import CONST from '@src/CONST'; + +jest.mock('@hooks/useOnyx', () => { + return {__esModule: true, default: jest.fn(() => [undefined])}; +}); + +jest.mock('@hooks/useCurrentUserPersonalDetails'); +jest.mock('@hooks/useHasActiveAdminPolicies'); +jest.mock('@hooks/useLocalize'); +jest.mock('@hooks/usePreferredPolicy'); +jest.mock('@hooks/useOnboardingMessages'); + +const mockTranslate = jest.fn((key: string) => key); +const mockFormatPhoneNumber = jest.fn((phone: string) => phone); + +const MOCK_SESSION = { + accountID: 12345, + email: 'test@expensify.com', +}; + +const MOCK_POLICY_ID = 'mock-policy-id'; +const MOCK_ADMINS_CHAT_REPORT_ID = 'mock-admins-chat-report-id'; +const MOCK_ONBOARDING_MESSAGE = {message: 'Welcome!', video: undefined, tasks: []}; + +function setupDefaultMocks() { + const mockUseOnyx = useOnyx as jest.Mock; + mockUseOnyx.mockImplementation((key: string) => { + if (key === 'session') { + return [MOCK_SESSION]; + } + if (key === 'betas') { + return [[]]; + } + if (key.startsWith('policy_')) { + return [false]; + } + return [undefined]; + }); + + (useCurrentUserPersonalDetails as jest.Mock).mockReturnValue({ + accountID: MOCK_SESSION.accountID, + login: MOCK_SESSION.email, + localCurrencyCode: 'USD', + }); + + (useLocalize as jest.Mock).mockReturnValue({ + translate: mockTranslate, + formatPhoneNumber: mockFormatPhoneNumber, + }); + + (usePreferredPolicy as jest.Mock).mockReturnValue({ + isRestrictedToPreferredPolicy: false, + preferredPolicyID: undefined, + isRestrictedPolicyCreation: false, + }); + + (useHasActiveAdminPolicies as jest.Mock).mockReturnValue(false); + + (useOnboardingMessages as jest.Mock).mockReturnValue({ + onboardingMessages: { + [CONST.ONBOARDING_CHOICES.EMPLOYER]: MOCK_ONBOARDING_MESSAGE, + }, + }); +} + +describe('useAutoCreateSubmitWorkspace', () => { + const createWorkspaceSpy = jest.spyOn(Policy, 'createWorkspace').mockReturnValue({ + policyID: MOCK_POLICY_ID, + adminsChatReportID: MOCK_ADMINS_CHAT_REPORT_ID, + } as ReturnType); + const completeOnboardingSpy = jest.spyOn(Report, 'completeOnboarding').mockImplementation(jest.fn()); + const setOnboardingAdminsChatReportIDSpy = jest.spyOn(Welcome, 'setOnboardingAdminsChatReportID').mockImplementation(jest.fn()); + const setOnboardingPolicyIDSpy = jest.spyOn(Welcome, 'setOnboardingPolicyID').mockImplementation(jest.fn()); + const navigateSpy = jest.spyOn(navigateAfterOnboarding, 'navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue').mockImplementation(jest.fn()); + + beforeEach(() => { + jest.clearAllMocks(); + setupDefaultMocks(); + }); + + it('creates a Submit workspace with the correct parameters for a new EMPLOYER user', () => { + // Given a new user going through onboarding with no existing workspace (onboardingPolicyID is undefined, + // hasEditableGroupPolicy is false, and policy creation is not restricted) + + // When the autoCreateSubmitWorkspace function is invoked during onboarding + const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); + result.current('John', 'Doe'); + + // Then a Submit workspace should be created because EMPLOYER users without an existing + // workspace need one auto-created to land on after onboarding + expect(createWorkspaceSpy).toHaveBeenCalledTimes(1); + expect(createWorkspaceSpy).toHaveBeenCalledWith( + expect.objectContaining({ + makeMeAdmin: true, + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + currency: 'USD', + shouldAddOnboardingTasks: false, + shouldAddGuideWelcomeMessage: false, + type: CONST.POLICY.TYPE.SUBMIT, + currentUserAccountIDParam: MOCK_SESSION.accountID, + currentUserEmailParam: MOCK_SESSION.email, + }), + ); + }); + + it('completes onboarding with the newly created workspace and admins chat IDs', () => { + // Given a new user with no pre-existing onboarding workspace + + // When the hook creates a workspace and finishes the onboarding flow + const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); + result.current('John', 'Doe'); + + // Then completeOnboarding should receive the IDs returned by createWorkspace so the backend + // can associate the guided setup data with the correct workspace and admins chat + expect(completeOnboardingSpy).toHaveBeenCalledTimes(1); + expect(completeOnboardingSpy).toHaveBeenCalledWith( + expect.objectContaining({ + engagementChoice: CONST.ONBOARDING_CHOICES.EMPLOYER, + onboardingMessage: MOCK_ONBOARDING_MESSAGE, + firstName: 'John', + lastName: 'Doe', + adminsChatReportID: MOCK_ADMINS_CHAT_REPORT_ID, + onboardingPolicyID: MOCK_POLICY_ID, + }), + ); + }); + + it('clears onboarding state after the flow completes', () => { + // Given a user completing the onboarding flow + + // When autoCreateSubmitWorkspace finishes + const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); + result.current('John', 'Doe'); + + // Then the transient onboarding Onyx keys should be cleared so the onboarding + // flow is not re-triggered on subsequent app launches + expect(setOnboardingAdminsChatReportIDSpy).toHaveBeenCalledTimes(1); + expect(setOnboardingPolicyIDSpy).toHaveBeenCalledTimes(1); + }); + + it('navigates to the submit workspace page after completing onboarding', () => { + // Given a user completing the EMPLOYER onboarding flow + + // When autoCreateSubmitWorkspace finishes setting up the workspace + const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); + result.current('John', 'Doe'); + + // Then the user should be navigated to the newly created Submit workspace + // so they land on their workspace immediately after onboarding + expect(navigateSpy).toHaveBeenCalledTimes(1); + expect(navigateSpy).toHaveBeenCalledWith(MOCK_POLICY_ID, expect.any(Boolean)); + }); + + it('reuses the existing onboarding workspace instead of creating a new one', () => { + // Given a user who already has an onboardingPolicyID set (e.g. assigned by an admin + // or from a previous partial onboarding attempt) + const existingPolicyID = 'existing-policy-id'; + const existingAdminsReportID = 'existing-admins-report-id'; + + (useOnyx as jest.Mock).mockImplementation((key: string) => { + if (key === 'onboardingPolicyID') { + return [existingPolicyID]; + } + if (key === 'onboardingAdminsChatReportID') { + return [existingAdminsReportID]; + } + if (key === 'session') { + return [MOCK_SESSION]; + } + if (key === 'betas') { + return [[]]; + } + if (key.startsWith('policy_')) { + return [false]; + } + return [undefined]; + }); + + // When the onboarding flow runs + const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); + result.current('Jane', 'Smith'); + + // Then no new workspace should be created, and completeOnboarding should use + // the pre-existing IDs to avoid creating duplicate workspaces + expect(createWorkspaceSpy).not.toHaveBeenCalled(); + expect(completeOnboardingSpy).toHaveBeenCalledWith( + expect.objectContaining({ + adminsChatReportID: existingAdminsReportID, + onboardingPolicyID: existingPolicyID, + }), + ); + }); + + it('skips workspace creation when the user is already a paid group policy admin', () => { + // Given a user who is already an admin of a paid group policy + (useOnyx as jest.Mock).mockImplementation((key: string) => { + if (key === 'session') { + return [MOCK_SESSION]; + } + if (key === 'betas') { + return [[]]; + } + if (key === 'onboardingPolicyID') { + return [undefined]; + } + if (key === 'onboardingAdminsChatReportID') { + return [undefined]; + } + if (key.startsWith('policy_')) { + return [true]; + } + return [undefined]; + }); + + // When onboarding completes + const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); + result.current('Jane', 'Smith'); + + // Then no Submit workspace should be created because the user already has + // a paid group workspace and creating another would be redundant + expect(createWorkspaceSpy).not.toHaveBeenCalled(); + }); + + it('skips workspace creation when the user domain restricts policy creation', () => { + // Given a user whose domain security group has enableRestrictedPolicyCreation set to true + (usePreferredPolicy as jest.Mock).mockReturnValue({ + isRestrictedToPreferredPolicy: false, + preferredPolicyID: undefined, + isRestrictedPolicyCreation: true, + }); + + // When the onboarding flow runs + const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); + result.current('Jane', 'Smith'); + + // Then workspace creation should be skipped because the domain admin has + // restricted users from creating their own policies + expect(createWorkspaceSpy).not.toHaveBeenCalled(); + expect(completeOnboardingSpy).toHaveBeenCalledTimes(1); + }); + + it('still completes onboarding and navigates even when workspace creation is skipped', () => { + // Given a user who cannot create a workspace due to domain restrictions + (usePreferredPolicy as jest.Mock).mockReturnValue({ + isRestrictedToPreferredPolicy: false, + preferredPolicyID: undefined, + isRestrictedPolicyCreation: true, + }); + + // When the onboarding flow runs + const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); + result.current('Jane', 'Smith'); + + // Then onboarding should still be completed and navigation should still occur + // because the user needs to finish onboarding regardless of workspace creation + expect(completeOnboardingSpy).toHaveBeenCalledTimes(1); + expect(setOnboardingAdminsChatReportIDSpy).toHaveBeenCalledTimes(1); + expect(setOnboardingPolicyIDSpy).toHaveBeenCalledTimes(1); + expect(navigateSpy).toHaveBeenCalledTimes(1); + }); + + it('uses the localCurrencyCode from personal details for workspace currency', () => { + // Given a user whose personal details have localCurrencyCode set to GBP + (useCurrentUserPersonalDetails as jest.Mock).mockReturnValue({ + accountID: MOCK_SESSION.accountID, + login: MOCK_SESSION.email, + localCurrencyCode: 'GBP', + }); + + // When a workspace is created during onboarding + const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); + result.current('John', 'Doe'); + + // Then the workspace should use GBP as its currency so it matches the user's locale + expect(createWorkspaceSpy).toHaveBeenCalledWith( + expect.objectContaining({ + currency: 'GBP', + }), + ); + }); + + it('falls back to USD when localCurrencyCode is not available', () => { + // Given a user whose personal details do not have a localCurrencyCode set + (useCurrentUserPersonalDetails as jest.Mock).mockReturnValue({ + accountID: MOCK_SESSION.accountID, + login: MOCK_SESSION.email, + localCurrencyCode: undefined, + }); + + // When a workspace is created during onboarding + const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); + result.current('John', 'Doe'); + + // Then the workspace should default to USD as a safe fallback currency + expect(createWorkspaceSpy).toHaveBeenCalledWith( + expect.objectContaining({ + currency: CONST.CURRENCY.USD, + }), + ); + }); + + it('forwards firstName and lastName to completeOnboarding for display name setup', () => { + // Given a user providing their name during the onboarding personal details step + + // When the onboarding flow completes + const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); + result.current('Alice', 'Wonderland'); + + // Then the provided name should be passed through to completeOnboarding so the + // backend can set up the user's display name as part of the guided setup + expect(completeOnboardingSpy).toHaveBeenCalledWith( + expect.objectContaining({ + firstName: 'Alice', + lastName: 'Wonderland', + }), + ); + }); +});