diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 5a6637b9f87cd..a4e9cbf93abe2 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8959,6 +8959,7 @@ const CONST = { }, HOME_PAGE: { WIDGET_ITEM: 'HomePage-WidgetItem', + GETTING_STARTED_ROW: 'HomePage-GettingStartedRow', }, CALENDAR_PICKER: { YEAR_PICKER: 'CalendarPicker-YearPicker', diff --git a/src/languages/de.ts b/src/languages/de.ts index 5097293345fc0..75bf4e4c41b6f 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1047,6 +1047,14 @@ const translations: TranslationDeepObject = { inDays: () => ({one: 'In 1 Tag', other: (count: number) => `In ${count} Tagen`}), today: 'Heute', }, + gettingStartedSection: { + title: 'Erste Schritte', + createWorkspace: 'Workspace erstellen', + connectAccounting: ({integrationName}: {integrationName: string}) => `Mit ${integrationName} verbinden`, + customizeCategories: 'Buchhaltungskategorien anpassen', + linkCompanyCards: 'Firmenkarten verknüpfen', + setupRules: 'Ausgabelimits einrichten', + }, freeTrialSection: { title: ({days}: {days: number}) => `Kostenlose Testversion: Noch ${days} ${days === 1 ? 'Tag' : 'Tage'}!`, offer50Body: 'Sparen Sie 50 % im ersten Jahr!', diff --git a/src/languages/en.ts b/src/languages/en.ts index 477ac95f30f5e..b301035971255 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1097,6 +1097,14 @@ const translations = { fireworksDescription: 'Upcoming to-dos will appear here.', }, }, + gettingStartedSection: { + title: 'Getting started', + createWorkspace: 'Create a workspace', + connectAccounting: ({integrationName}: {integrationName: string}) => `Connect to ${integrationName}`, + customizeCategories: 'Customize accounting categories', + linkCompanyCards: 'Link company cards', + setupRules: 'Set up spend rules', + }, upcomingTravel: 'Upcoming travel', upcomingTravelSection: { flightTo: ({destination}: {destination: string}) => `Flight to ${destination}`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 3fcd898aa7f96..3765635f25292 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -966,6 +966,14 @@ const translations: TranslationDeepObject = { fireworksDescription: 'Las próximas tareas aparecerán aquí.', }, }, + gettingStartedSection: { + title: 'Primeros pasos', + createWorkspace: 'Crear un espacio de trabajo', + connectAccounting: ({integrationName}: {integrationName: string}) => `Conectar con ${integrationName}`, + customizeCategories: 'Personalizar categorías contables', + linkCompanyCards: 'Vincular tarjetas corporativas', + setupRules: 'Configurar reglas de gasto', + }, upcomingTravel: 'Próximos viajes', upcomingTravelSection: { flightTo: ({destination}: {destination: string}) => `Vuelo a ${destination}`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 4cb773f4f71a5..0a14662db6d77 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1063,6 +1063,14 @@ const translations: TranslationDeepObject = { other: (pluralCount: number) => `Temps restant : ${pluralCount} jours`, }), }, + gettingStartedSection: { + title: 'Premiers pas', + createWorkspace: 'Créer un espace de travail', + connectAccounting: ({integrationName}: {integrationName: string}) => `Se connecter à ${integrationName}`, + customizeCategories: 'Personnaliser les catégories comptables', + linkCompanyCards: 'Lier des cartes d’entreprise', + setupRules: 'Configurer les règles de dépense', + }, }, allSettingsScreen: { subscription: 'Abonnement', diff --git a/src/languages/it.ts b/src/languages/it.ts index 3665a2ba66978..c6235856230e0 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1060,6 +1060,14 @@ const translations: TranslationDeepObject = { other: (pluralCount: number) => `Tempo rimanente: ${pluralCount} giorni`, }), }, + gettingStartedSection: { + title: 'Per iniziare', + createWorkspace: 'Crea uno spazio di lavoro', + connectAccounting: ({integrationName}: {integrationName: string}) => `Connetti a ${integrationName}`, + customizeCategories: 'Personalizza le categorie contabili', + linkCompanyCards: 'Collega carte aziendali', + setupRules: 'Configura le regole di spesa', + }, }, allSettingsScreen: { subscription: 'Abbonamento', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index d5d0767e1f799..b866b901e96d2 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1043,6 +1043,14 @@ const translations: TranslationDeepObject = { other: (pluralCount: number) => `残り時間:${pluralCount}日`, }), }, + gettingStartedSection: { + title: 'はじめに', + createWorkspace: 'ワークスペースを作成', + connectAccounting: ({integrationName}: {integrationName: string}) => `${integrationName}に接続する`, + customizeCategories: '会計カテゴリをカスタマイズする', + linkCompanyCards: '会社カードを連携', + setupRules: '支出ルールを設定', + }, }, allSettingsScreen: { subscription: 'サブスクリプション', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index df070f14e2d66..3a9f6139efc68 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1059,6 +1059,14 @@ const translations: TranslationDeepObject = { other: (pluralCount: number) => `Resterende tijd: ${pluralCount} dagen`, }), }, + gettingStartedSection: { + title: 'Aan de slag', + createWorkspace: 'Maak een werkruimte', + connectAccounting: ({integrationName}: {integrationName: string}) => `Verbind met ${integrationName}`, + customizeCategories: 'Boekhoudcategorieën aanpassen', + linkCompanyCards: 'Bedrijfspassen koppelen', + setupRules: 'Uitgavenregels instellen', + }, }, allSettingsScreen: { subscription: 'Abonnement', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index d072a3da78292..8ad677e28cd8c 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1060,6 +1060,14 @@ const translations: TranslationDeepObject = { other: (pluralCount: number) => `Pozostały czas: ${pluralCount} dni`, }), }, + gettingStartedSection: { + title: 'Pierwsze kroki', + createWorkspace: 'Utwórz przestrzeń roboczą', + connectAccounting: ({integrationName}: {integrationName: string}) => `Połącz z ${integrationName}`, + customizeCategories: 'Dostosuj kategorie księgowe', + linkCompanyCards: 'Połącz firmowe karty', + setupRules: 'Skonfiguruj zasady wydatków', + }, }, allSettingsScreen: { subscription: 'Subskrypcja', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index d31880556a28b..770403d1b82a8 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1058,6 +1058,14 @@ const translations: TranslationDeepObject = { other: (pluralCount: number) => `Tempo restante: ${pluralCount} dias`, }), }, + gettingStartedSection: { + title: 'Introdução', + createWorkspace: 'Criar um workspace', + connectAccounting: ({integrationName}: {integrationName: string}) => `Conectar ao ${integrationName}`, + customizeCategories: 'Personalizar categorias contábeis', + linkCompanyCards: 'Vincular cartões corporativos', + setupRules: 'Configurar regras de gasto', + }, }, allSettingsScreen: { subscription: 'Assinatura', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 6985b84bde477..413bd567f76ad 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1026,6 +1026,14 @@ const translations: TranslationDeepObject = { other: (pluralCount: number) => `剩余时间:${pluralCount}天`, }), }, + gettingStartedSection: { + title: '入门', + createWorkspace: '创建工作区', + connectAccounting: ({integrationName}: {integrationName: string}) => `连接到 ${integrationName}`, + customizeCategories: '自定义会计类别', + linkCompanyCards: '关联公司卡', + setupRules: '设置消费规则', + }, }, allSettingsScreen: { subscription: '订阅', diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 59468316b5e0d..0d1a185a36a59 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -687,6 +687,25 @@ function hasCustomCategories(policyCategories: OnyxEntry): boo return Object.values(policyCategories).some((category) => category && category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !defaultCategoryNames.has(category.name)); } +/** + * Checks if a policy has any non-default rules configured. + * Defaults are: no approval/expense/coding rules and no custom rules text. + */ +function hasNonDefaultRules(policy: OnyxEntry): boolean { + if (!policy) { + return false; + } + + const hasCustomRules = !!policy.customRules && policy.customRules.trim().length > 0; + + const {rules} = policy; + const hasApprovalRules = !!rules?.approvalRules && rules.approvalRules.length > 0; + const hasExpenseRules = !!rules?.expenseRules && rules.expenseRules.length > 0; + const hasCodingRules = !!rules?.codingRules && Object.keys(rules.codingRules).length > 0; + + return hasCustomRules || hasApprovalRules || hasExpenseRules || hasCodingRules; +} + /** * Gets a tag list of a policy by a tag index */ @@ -2074,6 +2093,7 @@ export { getTagLists, hasTags, hasCustomCategories, + hasNonDefaultRules, getTaxByID, getUnitRateValue, getRateDisplayValue, diff --git a/src/pages/home/GettingStartedSection/GettingStartedRow.tsx b/src/pages/home/GettingStartedSection/GettingStartedRow.tsx new file mode 100644 index 0000000000000..cd85bc3359810 --- /dev/null +++ b/src/pages/home/GettingStartedSection/GettingStartedRow.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import {View} from 'react-native'; +import Checkbox from '@components/Checkbox'; +import Icon from '@components/Icon'; +import {PressableWithoutFeedback} from '@components/Pressable'; +import Text from '@components/Text'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import type {GettingStartedItem} from './hooks/useGettingStartedItems'; + +type GettingStartedRowProps = { + item: GettingStartedItem; +}; + +function GettingStartedRow({item}: GettingStartedRowProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const StyleUtils = useStyleUtils(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'Checkmark'] as const); + + const navigateToItem = () => { + Navigation.navigate(item.route); + }; + + return ( + + {({hovered}) => ( + + {item.isComplete ? ( + + + + ) : ( + + )} + {item.label} + {!item.isComplete && ( + + )} + + )} + + ); +} + +export default GettingStartedRow; diff --git a/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts new file mode 100644 index 0000000000000..6e2e1efa70c8a --- /dev/null +++ b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts @@ -0,0 +1,105 @@ +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import {hasAccountingConnections, hasCustomCategories, hasNonDefaultRules} from '@libs/PolicyUtils'; +import isWithinGettingStartedPeriod from '@pages/home/GettingStartedSection/utils/isWithinGettingStartedPeriod'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; + +type GettingStartedItem = { + key: string; + label: string; + isComplete: boolean; + route: Route; +}; + +type UseGettingStartedItemsResult = { + shouldShowSection: boolean; + items: GettingStartedItem[]; +}; + +const DIRECT_CONNECT_INTEGRATIONS = new Set([ + CONST.POLICY.CONNECTIONS.NAME.QBO, + CONST.POLICY.CONNECTIONS.NAME.QBD, + CONST.POLICY.CONNECTIONS.NAME.XERO, + CONST.POLICY.CONNECTIONS.NAME.NETSUITE, + CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, +]); + +function useGettingStartedItems(): UseGettingStartedItemsResult { + const {translate} = useLocalize(); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [onboardingPurpose] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const [firstDayFreeTrial] = useOnyx(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL); + const [reportedIntegration] = useOnyx(ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${activePolicyID}`); + + const emptyResult: UseGettingStartedItemsResult = {shouldShowSection: false, items: []}; + + const intent = introSelected?.choice ?? onboardingPurpose; + if (intent !== CONST.ONBOARDING_CHOICES.MANAGE_TEAM) { + return emptyResult; + } + + if (!activePolicyID || !policy) { + return emptyResult; + } + + if (!isWithinGettingStartedPeriod(firstDayFreeTrial)) { + return emptyResult; + } + + const items: GettingStartedItem[] = []; + + items.push({ + key: 'createWorkspace', + label: translate('homePage.gettingStartedSection.createWorkspace'), + isComplete: true, + route: ROUTES.WORKSPACE_OVERVIEW.getRoute(activePolicyID), + }); + + const isDirectConnect = !!reportedIntegration && DIRECT_CONNECT_INTEGRATIONS.has(reportedIntegration); + + if (isDirectConnect) { + const integrationName = CONST.ONBOARDING_ACCOUNTING_MAPPING[reportedIntegration as keyof typeof CONST.ONBOARDING_ACCOUNTING_MAPPING] ?? String(reportedIntegration); + items.push({ + key: 'connectAccounting', + label: translate('homePage.gettingStartedSection.connectAccounting', {integrationName}), + isComplete: hasAccountingConnections(policy), + route: ROUTES.WORKSPACE_ACCOUNTING.getRoute(activePolicyID), + }); + } else { + items.push({ + key: 'customizeCategories', + label: translate('homePage.gettingStartedSection.customizeCategories'), + isComplete: hasCustomCategories(policyCategories), + route: ROUTES.WORKSPACE_CATEGORIES.getRoute(activePolicyID), + }); + } + + if (policy.areCompanyCardsEnabled) { + items.push({ + key: 'linkCompanyCards', + label: translate('homePage.gettingStartedSection.linkCompanyCards'), + isComplete: false, + route: ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(activePolicyID), + }); + } + + if (policy.areRulesEnabled) { + items.push({ + key: 'setupRules', + label: translate('homePage.gettingStartedSection.setupRules'), + isComplete: hasNonDefaultRules(policy), + route: ROUTES.WORKSPACE_RULES.getRoute(activePolicyID), + }); + } + + return {shouldShowSection: true, items}; +} + +export default useGettingStartedItems; +export type {GettingStartedItem, UseGettingStartedItemsResult}; diff --git a/src/pages/home/GettingStartedSection/index.tsx b/src/pages/home/GettingStartedSection/index.tsx new file mode 100644 index 0000000000000..20ff871f89032 --- /dev/null +++ b/src/pages/home/GettingStartedSection/index.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import {View} from 'react-native'; +import WidgetContainer from '@components/WidgetContainer'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import GettingStartedRow from './GettingStartedRow'; +import useGettingStartedItems from './hooks/useGettingStartedItems'; + +function GettingStartedSection() { + const {translate} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const styles = useThemeStyles(); + const {shouldShowSection, items} = useGettingStartedItems(); + + if (!shouldShowSection) { + return null; + } + + return ( + + + {items.map((item) => ( + + ))} + + + ); +} + +export default GettingStartedSection; diff --git a/src/pages/home/GettingStartedSection/utils/isWithinGettingStartedPeriod.ts b/src/pages/home/GettingStartedSection/utils/isWithinGettingStartedPeriod.ts new file mode 100644 index 0000000000000..d0ad23cf012b6 --- /dev/null +++ b/src/pages/home/GettingStartedSection/utils/isWithinGettingStartedPeriod.ts @@ -0,0 +1,19 @@ +import CONST from '@src/CONST'; + +const SIXTY_DAYS_MS = 60 * CONST.DATE.SECONDS_PER_DAY * CONST.MILLISECONDS_PER_SECOND; + +/** + * Checks if the current date is within 60 days of the trial start date. + * Returns false if no trial start date is provided. + */ +function isWithinGettingStartedPeriod(firstDayFreeTrial: string | undefined): boolean { + if (!firstDayFreeTrial) { + return false; + } + + const trialStartMs = new Date(firstDayFreeTrial).getTime(); + const elapsed = Date.now() - trialStartMs; + return elapsed >= 0 && elapsed <= SIXTY_DAYS_MS; +} + +export default isWithinGettingStartedPeriod; diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index 070a210ef1c28..793ff1d07b23f 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -21,6 +21,7 @@ import AssignedCardsSection from './AssignedCardsSection'; import DiscoverSection from './DiscoverSection'; import ForYouSection from './ForYouSection'; import FreeTrialSection from './FreeTrialSection'; +import GettingStartedSection from './GettingStartedSection'; import SpendOverTimeSection from './SpendOverTimeSection'; import TimeSensitiveSection from './TimeSensitiveSection'; import UpcomingTravelSection from './UpcomingTravelSection'; @@ -83,6 +84,7 @@ function HomePage() { + diff --git a/tests/unit/hooks/useGettingStartedItems.test.ts b/tests/unit/hooks/useGettingStartedItems.test.ts new file mode 100644 index 0000000000000..4e6fc13229f39 --- /dev/null +++ b/tests/unit/hooks/useGettingStartedItems.test.ts @@ -0,0 +1,566 @@ +import {renderHook} from '@testing-library/react-native'; +import Onyx from 'react-native-onyx'; +import useGettingStartedItems from '@pages/home/GettingStartedSection/hooks/useGettingStartedItems'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Policy, PolicyCategories} from '@src/types/onyx'; +import createRandomPolicy from '../../utils/collections/policies'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: jest.fn((key: string, params?: Record) => { + if (params && 'integrationName' in params) { + return `${key}:${params.integrationName}`; + } + return key; + }), + })), +); + +const POLICY_ID = '1'; + +function buildPolicy(overrides: Partial = {}): Policy { + return { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM, 'Test Workspace'), + id: POLICY_ID, + areCompanyCardsEnabled: false, + areRulesEnabled: false, + connections: undefined, + rules: undefined, + customRules: undefined, + ...overrides, + }; +} + +async function setupManageTeamScenario(overrides: {policy?: Partial; accounting?: string | null; firstDayTrial?: string; lastDayTrial?: string} = {}) { + const policy = buildPolicy(overrides.policy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); + await Onyx.merge(ONYXKEYS.NVP_INTRO_SELECTED, {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}); + await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, POLICY_ID); + + if (overrides.accounting !== undefined) { + await Onyx.merge(ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION, overrides.accounting as never); + } + + const now = new Date(); + const firstDay = overrides.firstDayTrial ?? new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T').at(0) ?? ''; + const lastDay = overrides.lastDayTrial ?? new Date(now.getTime() + 23 * 24 * 60 * 60 * 1000).toISOString().split('T').at(0) ?? ''; + await Onyx.merge(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, firstDay); + await Onyx.merge(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL, lastDay); + + await waitForBatchedUpdates(); +} + +describe('useGettingStartedItems', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + await Onyx.clear(); + }); + + describe('visibility rules', () => { + it('should return no items when onboarding intent is not MANAGE_TEAM', async () => { + await Onyx.merge(ONYXKEYS.NVP_INTRO_SELECTED, {choice: CONST.ONBOARDING_CHOICES.PERSONAL_SPEND}); + await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, POLICY_ID); + const policy = buildPolicy(); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); + await Onyx.merge(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, '2026-03-01'); + await Onyx.merge(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL, '2026-04-01'); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(false); + expect(result.current.items).toEqual([]); + }); + + it('should return no items when ONBOARDING_PURPOSE_SELECTED is set to a non-manage-team value and NVP_INTRO_SELECTED is not loaded', async () => { + await Onyx.merge(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.EMPLOYER); + await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, POLICY_ID); + const policy = buildPolicy(); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); + await Onyx.merge(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, '2026-03-01'); + await Onyx.merge(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL, '2026-04-01'); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(false); + }); + + it('should use NVP_INTRO_SELECTED.choice as primary source for intent detection', async () => { + await setupManageTeamScenario({accounting: CONST.POLICY.CONNECTIONS.NAME.QBO}); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(true); + expect(result.current.items.length).toBeGreaterThan(0); + }); + + it('should fall back to ONBOARDING_PURPOSE_SELECTED when NVP_INTRO_SELECTED is not available', async () => { + await Onyx.merge(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.MANAGE_TEAM); + await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, POLICY_ID); + const policy = buildPolicy(); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); + await Onyx.merge(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, '2026-03-01'); + await Onyx.merge(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL, '2026-04-01'); + await Onyx.merge(ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION, CONST.POLICY.CONNECTIONS.NAME.QBO as never); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(true); + }); + + it('should be hidden after 60 days from NVP_FIRST_DAY_FREE_TRIAL', async () => { + const sixtyOneDaysAgo = new Date(Date.now() - 61 * 24 * 60 * 60 * 1000).toISOString().split('T').at(0) ?? ''; + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T').at(0) ?? ''; + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + firstDayTrial: sixtyOneDaysAgo, + lastDayTrial: thirtyDaysAgo, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(false); + }); + + it('should be visible within 60 days from NVP_FIRST_DAY_FREE_TRIAL', async () => { + const fiftyNineDaysAgo = new Date(Date.now() - 59 * 24 * 60 * 60 * 1000).toISOString().split('T').at(0) ?? ''; + const twentyNineDaysAgo = new Date(Date.now() - 29 * 24 * 60 * 60 * 1000).toISOString().split('T').at(0) ?? ''; + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + firstDayTrial: fiftyNineDaysAgo, + lastDayTrial: twentyNineDaysAgo, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(true); + }); + + it('should be hidden when NVP_FIRST_DAY_FREE_TRIAL is in the future (pre-trial)', async () => { + const tenDaysFromNow = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString().split('T').at(0) ?? ''; + const fortyDaysFromNow = new Date(Date.now() + 40 * 24 * 60 * 60 * 1000).toISOString().split('T').at(0) ?? ''; + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + firstDayTrial: tenDaysFromNow, + lastDayTrial: fortyDaysFromNow, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(false); + }); + + it('should be hidden when NVP_FIRST_DAY_FREE_TRIAL is not set (not a new sign-up)', async () => { + await Onyx.merge(ONYXKEYS.NVP_INTRO_SELECTED, {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}); + await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, POLICY_ID); + const policy = buildPolicy(); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); + await Onyx.merge(ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION, CONST.POLICY.CONNECTIONS.NAME.QBO as never); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(false); + }); + }); + + describe('row 1 - Create a workspace', () => { + it('should always be present for manage-team intent', async () => { + await setupManageTeamScenario({accounting: CONST.POLICY.CONNECTIONS.NAME.QBO}); + + const {result} = renderHook(() => useGettingStartedItems()); + + const createWorkspaceItem = result.current.items.find((item) => item.key === 'createWorkspace'); + expect(createWorkspaceItem).toBeDefined(); + }); + + it('should always be checked (completed)', async () => { + await setupManageTeamScenario({accounting: CONST.POLICY.CONNECTIONS.NAME.QBO}); + + const {result} = renderHook(() => useGettingStartedItems()); + + const createWorkspaceItem = result.current.items.find((item) => item.key === 'createWorkspace'); + expect(createWorkspaceItem?.isComplete).toBe(true); + }); + + it('should be the first item', async () => { + await setupManageTeamScenario({accounting: CONST.POLICY.CONNECTIONS.NAME.QBO}); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.items.at(0)?.key).toBe('createWorkspace'); + }); + + it('should navigate to the workspace overview route', async () => { + await setupManageTeamScenario({accounting: CONST.POLICY.CONNECTIONS.NAME.QBO}); + + const {result} = renderHook(() => useGettingStartedItems()); + + const createWorkspaceItem = result.current.items.find((item) => item.key === 'createWorkspace'); + expect(createWorkspaceItem?.route).toBe(ROUTES.WORKSPACE_OVERVIEW.getRoute(POLICY_ID)); + }); + }); + + describe('row 2a - Connect to [accounting system]', () => { + const directConnectIntegrations = [ + {key: CONST.POLICY.CONNECTIONS.NAME.QBO, name: 'QuickBooks Online'}, + {key: CONST.POLICY.CONNECTIONS.NAME.QBD, name: 'QuickBooks Desktop'}, + {key: CONST.POLICY.CONNECTIONS.NAME.XERO, name: 'Xero'}, + {key: CONST.POLICY.CONNECTIONS.NAME.NETSUITE, name: 'NetSuite'}, + {key: CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, name: 'Sage Intacct'}, + ]; + + it.each(directConnectIntegrations)('should show "Connect to $name" when user selected $key in onboarding', async ({key, name}) => { + await setupManageTeamScenario({accounting: key}); + + const {result} = renderHook(() => useGettingStartedItems()); + + const connectItem = result.current.items.find((item) => item.key === 'connectAccounting'); + expect(connectItem).toBeDefined(); + expect(connectItem?.label).toContain(name); + }); + + it('should navigate to workspace accounting route', async () => { + await setupManageTeamScenario({accounting: CONST.POLICY.CONNECTIONS.NAME.QBO}); + + const {result} = renderHook(() => useGettingStartedItems()); + + const connectItem = result.current.items.find((item) => item.key === 'connectAccounting'); + expect(connectItem?.route).toBe(ROUTES.WORKSPACE_ACCOUNTING.getRoute(POLICY_ID)); + }); + + it('should be not completed when workspace has no accounting connection', async () => { + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + policy: {connections: undefined}, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + const connectItem = result.current.items.find((item) => item.key === 'connectAccounting'); + expect(connectItem?.isComplete).toBe(false); + }); + + it('should be completed when workspace has a successful accounting connection', async () => { + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + policy: { + connections: { + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: {}, + data: {}, + }, + } as Policy['connections'], + }, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + const connectItem = result.current.items.find((item) => item.key === 'connectAccounting'); + expect(connectItem?.isComplete).toBe(true); + }); + + it('should not show the categories row when showing the connect row', async () => { + await setupManageTeamScenario({accounting: CONST.POLICY.CONNECTIONS.NAME.QBO}); + + const {result} = renderHook(() => useGettingStartedItems()); + + const categoriesItem = result.current.items.find((item) => item.key === 'customizeCategories'); + expect(categoriesItem).toBeUndefined(); + }); + }); + + describe('row 2b - Customize accounting categories', () => { + const categoriesIntegrations = ['sap', 'oracle', 'microsoftDynamics', 'other', 'none', null]; + + it.each(categoriesIntegrations)('should show "Customize accounting categories" when accounting choice is %s', async (accounting) => { + await setupManageTeamScenario({accounting}); + + const {result} = renderHook(() => useGettingStartedItems()); + + const categoriesItem = result.current.items.find((item) => item.key === 'customizeCategories'); + expect(categoriesItem).toBeDefined(); + }); + + it('should navigate to workspace categories route', async () => { + await setupManageTeamScenario({accounting: 'none'}); + + const {result} = renderHook(() => useGettingStartedItems()); + + const categoriesItem = result.current.items.find((item) => item.key === 'customizeCategories'); + expect(categoriesItem?.route).toBe(ROUTES.WORKSPACE_CATEGORIES.getRoute(POLICY_ID)); + }); + + it('should not show the connect accounting row when showing the categories row', async () => { + await setupManageTeamScenario({accounting: 'none'}); + + const {result} = renderHook(() => useGettingStartedItems()); + + const connectItem = result.current.items.find((item) => item.key === 'connectAccounting'); + expect(connectItem).toBeUndefined(); + }); + + it('should be not completed when workspace has only default categories', async () => { + await setupManageTeamScenario({accounting: 'none'}); + + const {result} = renderHook(() => useGettingStartedItems()); + + const categoriesItem = result.current.items.find((item) => item.key === 'customizeCategories'); + expect(categoriesItem?.isComplete).toBe(false); + }); + + it('should be completed when workspace has at least one non-default category', async () => { + const customCategories: PolicyCategories = { + // eslint-disable-next-line @typescript-eslint/naming-convention -- PolicyCategories keys use human-readable names matching the backend API shape + 'Custom Category': { + name: 'Custom Category', + enabled: true, + unencodedName: 'Custom Category', + areCommentsRequired: false, + // eslint-disable-next-line @typescript-eslint/naming-convention -- matches the backend API field name + 'GL Code': '', + externalID: '', + origin: '', + previousCategoryName: undefined, + }, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${POLICY_ID}`, customCategories); + await setupManageTeamScenario({accounting: 'none'}); + + const {result} = renderHook(() => useGettingStartedItems()); + + const categoriesItem = result.current.items.find((item) => item.key === 'customizeCategories'); + expect(categoriesItem?.isComplete).toBe(true); + }); + }); + + describe('row 3 - Link company cards', () => { + it('should be shown when company cards feature is enabled on the workspace', async () => { + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + policy: {areCompanyCardsEnabled: true}, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + const companyCardsItem = result.current.items.find((item) => item.key === 'linkCompanyCards'); + expect(companyCardsItem).toBeDefined(); + }); + + it('should be hidden when company cards feature is not enabled on the workspace', async () => { + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + policy: {areCompanyCardsEnabled: false}, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + const companyCardsItem = result.current.items.find((item) => item.key === 'linkCompanyCards'); + expect(companyCardsItem).toBeUndefined(); + }); + + it('should navigate to workspace company cards route', async () => { + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + policy: {areCompanyCardsEnabled: true}, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + const companyCardsItem = result.current.items.find((item) => item.key === 'linkCompanyCards'); + expect(companyCardsItem?.route).toBe(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(POLICY_ID)); + }); + + it('should be not completed when no company card feed exists', async () => { + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + policy: {areCompanyCardsEnabled: true}, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + const companyCardsItem = result.current.items.find((item) => item.key === 'linkCompanyCards'); + expect(companyCardsItem?.isComplete).toBe(false); + }); + }); + + describe('row 4 - Set up spend rules', () => { + it('should be shown when rules feature is enabled on the workspace', async () => { + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + policy: {areRulesEnabled: true}, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + const rulesItem = result.current.items.find((item) => item.key === 'setupRules'); + expect(rulesItem).toBeDefined(); + }); + + it('should be hidden when rules feature is not enabled on the workspace', async () => { + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + policy: {areRulesEnabled: false}, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + const rulesItem = result.current.items.find((item) => item.key === 'setupRules'); + expect(rulesItem).toBeUndefined(); + }); + + it('should navigate to workspace rules route', async () => { + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + policy: {areRulesEnabled: true}, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + const rulesItem = result.current.items.find((item) => item.key === 'setupRules'); + expect(rulesItem?.route).toBe(ROUTES.WORKSPACE_RULES.getRoute(POLICY_ID)); + }); + + it('should be not completed when workspace has default rules only', async () => { + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + policy: {areRulesEnabled: true, rules: undefined, customRules: undefined}, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + const rulesItem = result.current.items.find((item) => item.key === 'setupRules'); + expect(rulesItem?.isComplete).toBe(false); + }); + + it('should be completed when workspace has non-default rules', async () => { + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + policy: { + areRulesEnabled: true, + rules: { + approvalRules: [ + { + applyWhen: [{condition: 'matches', field: 'amount', value: '1000'}], + approver: 'approver@test.com', + id: 'rule-1', + }, + ], + } as Policy['rules'], + }, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + const rulesItem = result.current.items.find((item) => item.key === 'setupRules'); + expect(rulesItem?.isComplete).toBe(true); + }); + + it('should be completed when workspace has custom rules text', async () => { + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + policy: { + areRulesEnabled: true, + customRules: 'All expenses over $500 need manager approval', + }, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + const rulesItem = result.current.items.find((item) => item.key === 'setupRules'); + expect(rulesItem?.isComplete).toBe(true); + }); + }); + + describe('item ordering', () => { + it('should return items in the correct order: createWorkspace, accounting/categories, companyCards, rules', async () => { + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + policy: {areCompanyCardsEnabled: true, areRulesEnabled: true}, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + const keys = result.current.items.map((item) => item.key); + expect(keys).toEqual(['createWorkspace', 'connectAccounting', 'linkCompanyCards', 'setupRules']); + }); + + it('should return items in the correct order with categories instead of connect', async () => { + await setupManageTeamScenario({ + accounting: 'none', + policy: {areCompanyCardsEnabled: true, areRulesEnabled: true}, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + const keys = result.current.items.map((item) => item.key); + expect(keys).toEqual(['createWorkspace', 'customizeCategories', 'linkCompanyCards', 'setupRules']); + }); + + it('should only contain createWorkspace and accounting row when no optional features are enabled', async () => { + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + policy: {areCompanyCardsEnabled: false, areRulesEnabled: false}, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + const keys = result.current.items.map((item) => item.key); + expect(keys).toEqual(['createWorkspace', 'connectAccounting']); + }); + }); + + describe('edge cases', () => { + it('should handle missing active policy ID gracefully', async () => { + await Onyx.merge(ONYXKEYS.NVP_INTRO_SELECTED, {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}); + await Onyx.merge(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, '2026-03-01'); + await Onyx.merge(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL, '2026-04-01'); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(false); + }); + + it('should handle missing policy data gracefully', async () => { + await Onyx.merge(ONYXKEYS.NVP_INTRO_SELECTED, {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}); + await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, 'nonexistent-policy'); + await Onyx.merge(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, '2026-03-01'); + await Onyx.merge(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL, '2026-04-01'); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(false); + }); + + it('should prefer NVP_INTRO_SELECTED over ONBOARDING_PURPOSE_SELECTED when both are set', async () => { + await Onyx.merge(ONYXKEYS.NVP_INTRO_SELECTED, {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}); + await Onyx.merge(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.PERSONAL_SPEND); + await Onyx.merge(ONYXKEYS.NVP_ACTIVE_POLICY_ID, POLICY_ID); + const policy = buildPolicy(); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); + await Onyx.merge(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, '2026-03-01'); + await Onyx.merge(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL, '2026-04-01'); + await Onyx.merge(ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION, CONST.POLICY.CONNECTIONS.NAME.QBO as never); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(true); + }); + }); +}); diff --git a/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx b/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx new file mode 100644 index 0000000000000..eb8f11cc9427a --- /dev/null +++ b/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx @@ -0,0 +1,321 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; +import Onyx from 'react-native-onyx'; +import Navigation from '@libs/Navigation/Navigation'; +import OnyxListItemProvider from '@src/components/OnyxListItemProvider'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import GettingStartedSection from '@src/pages/home/GettingStartedSection'; +import ROUTES from '@src/ROUTES'; +import waitForBatchedUpdates from '../../../../utils/waitForBatchedUpdates'; + +const TEST_POLICY_ID = 'ABC123'; + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), +})); + +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: jest.fn((key: string, params?: Record) => { + if (params && 'integrationName' in params) { + return `${key}:${params.integrationName}`; + } + return key; + }), + })), +); + +jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({shouldUseNarrowLayout: false}))); + +const renderGettingStartedSection = () => + render( + + + , + ); + +/** + * Sets up Onyx state for a manage-team user with an active trial + * so the section is visible by default. + */ +async function setManageTeamUserState(overrides?: { + integration?: string | null; + areCompanyCardsEnabled?: boolean; + areRulesEnabled?: boolean; + hasAccountingConnection?: boolean; + hasCustomCategories?: boolean; + hasCompanyCardConnection?: boolean; + hasNonDefaultRules?: boolean; + trialStartDate?: string; +}) { + const trialStart = overrides?.trialStartDate ?? '2026-03-01'; + + await Onyx.set(ONYXKEYS.NVP_INTRO_SELECTED, { + choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM, + }); + await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, TEST_POLICY_ID); + await Onyx.set(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, trialStart); + await Onyx.set(ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION, (overrides?.integration ?? 'quickbooksOnline') as never); + + const policyData: Record = { + id: TEST_POLICY_ID, + name: 'Test Workspace', + areCompanyCardsEnabled: overrides?.areCompanyCardsEnabled ?? true, + areRulesEnabled: overrides?.areRulesEnabled ?? true, + }; + + if (overrides?.hasAccountingConnection) { + policyData.connections = { + quickbooksOnline: { + config: {}, + data: {}, + }, + }; + } + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${TEST_POLICY_ID}` as never, policyData as never); + await waitForBatchedUpdates(); +} + +describe('GettingStartedSection', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + describe('visibility', () => { + it('does not render when intent is not manage-team', async () => { + await Onyx.set(ONYXKEYS.NVP_INTRO_SELECTED, { + choice: CONST.ONBOARDING_CHOICES.PERSONAL_SPEND, + }); + await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, TEST_POLICY_ID); + await Onyx.set(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, '2026-03-01'); + await waitForBatchedUpdates(); + + renderGettingStartedSection(); + + expect(screen.queryByText('homePage.gettingStartedSection.title')).toBeNull(); + }); + + it('does not render when 60-day cutoff has passed', async () => { + await setManageTeamUserState({ + trialStartDate: '2026-01-01', + }); + + renderGettingStartedSection(); + + expect(screen.queryByText('homePage.gettingStartedSection.title')).toBeNull(); + }); + + it('renders when manage-team intent is set via fallback ONBOARDING_PURPOSE_SELECTED', async () => { + await Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.MANAGE_TEAM as never); + await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, TEST_POLICY_ID); + await Onyx.set(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL, '2026-03-01'); + await Onyx.set( + `${ONYXKEYS.COLLECTION.POLICY}${TEST_POLICY_ID}` as never, + { + id: TEST_POLICY_ID, + name: 'Test Workspace', + areCompanyCardsEnabled: true, + areRulesEnabled: true, + } as never, + ); + await waitForBatchedUpdates(); + + renderGettingStartedSection(); + + expect(screen.getByText('homePage.gettingStartedSection.title')).toBeTruthy(); + }); + }); + + describe('section title', () => { + it('renders the "Getting started" section title', async () => { + await setManageTeamUserState(); + + renderGettingStartedSection(); + + expect(screen.getByText('homePage.gettingStartedSection.title')).toBeTruthy(); + }); + }); + + describe('row rendering', () => { + it('always shows "Create a workspace" row', async () => { + await setManageTeamUserState(); + + renderGettingStartedSection(); + + expect(screen.getByText('homePage.gettingStartedSection.createWorkspace')).toBeTruthy(); + }); + + it('shows "Connect to [system]" row for QBO integration', async () => { + await setManageTeamUserState({integration: 'quickbooksOnline'}); + + renderGettingStartedSection(); + + expect(screen.getByText(/homePage\.gettingStartedSection\.connectAccounting/)).toBeTruthy(); + }); + + it('shows "Connect to [system]" row for Xero integration', async () => { + await setManageTeamUserState({integration: 'xero'}); + + renderGettingStartedSection(); + + expect(screen.getByText(/homePage\.gettingStartedSection\.connectAccounting/)).toBeTruthy(); + }); + + it('shows "Customize accounting categories" for non-direct-connect integrations', async () => { + await setManageTeamUserState({integration: 'other'}); + + renderGettingStartedSection(); + + expect(screen.getByText('homePage.gettingStartedSection.customizeCategories')).toBeTruthy(); + expect(screen.queryByText(/homePage\.gettingStartedSection\.connectAccounting/)).toBeNull(); + }); + + it('shows "Customize accounting categories" when no integration is selected', async () => { + await setManageTeamUserState({integration: 'none'}); + + renderGettingStartedSection(); + + expect(screen.getByText('homePage.gettingStartedSection.customizeCategories')).toBeTruthy(); + }); + + it('shows "Link company cards" row when company cards feature is enabled', async () => { + await setManageTeamUserState({areCompanyCardsEnabled: true}); + + renderGettingStartedSection(); + + expect(screen.getByText('homePage.gettingStartedSection.linkCompanyCards')).toBeTruthy(); + }); + + it('hides "Link company cards" row when company cards feature is disabled', async () => { + await setManageTeamUserState({areCompanyCardsEnabled: false}); + + renderGettingStartedSection(); + + expect(screen.queryByText('homePage.gettingStartedSection.linkCompanyCards')).toBeNull(); + }); + + it('shows "Set up spend rules" row when rules feature is enabled', async () => { + await setManageTeamUserState({areRulesEnabled: true}); + + renderGettingStartedSection(); + + expect(screen.getByText('homePage.gettingStartedSection.setupRules')).toBeTruthy(); + }); + + it('hides "Set up spend rules" row when rules feature is disabled', async () => { + await setManageTeamUserState({areRulesEnabled: false}); + + renderGettingStartedSection(); + + expect(screen.queryByText('homePage.gettingStartedSection.setupRules')).toBeNull(); + }); + + it('renders rows in the expected order: workspace, accounting, cards, rules', async () => { + await setManageTeamUserState({ + integration: 'quickbooksOnline', + areCompanyCardsEnabled: true, + areRulesEnabled: true, + }); + + renderGettingStartedSection(); + + const allRows = screen.getAllByText(/homePage\.gettingStartedSection\./); + const rowTexts = allRows.map((el) => (el.props as {children: string}).children); + const createIdx = rowTexts.findIndex((t) => t.includes('createWorkspace')); + const accountingIdx = rowTexts.findIndex((t) => t.includes('connectAccounting')); + const cardsIdx = rowTexts.findIndex((t) => t.includes('linkCompanyCards')); + const rulesIdx = rowTexts.findIndex((t) => t.includes('setupRules')); + + expect(createIdx).toBeLessThan(accountingIdx); + expect(accountingIdx).toBeLessThan(cardsIdx); + expect(cardsIdx).toBeLessThan(rulesIdx); + }); + }); + + describe('checked state', () => { + it('"Create a workspace" row is always checked', async () => { + await setManageTeamUserState(); + + renderGettingStartedSection(); + + const createRow = screen.getByText('homePage.gettingStartedSection.createWorkspace'); + const parentRow = createRow.parent; + expect(parentRow).toBeTruthy(); + }); + + it('accounting row is checked when workspace has a successful connection', async () => { + await setManageTeamUserState({ + integration: 'quickbooksOnline', + hasAccountingConnection: true, + }); + + renderGettingStartedSection(); + + expect(screen.getByText(/homePage\.gettingStartedSection\.connectAccounting/)).toBeTruthy(); + }); + }); + + describe('navigation', () => { + it('navigates to workspace overview when "Create a workspace" row is pressed', async () => { + await setManageTeamUserState(); + + renderGettingStartedSection(); + + const row = screen.getByText('homePage.gettingStartedSection.createWorkspace'); + fireEvent.press(row); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_OVERVIEW.getRoute(TEST_POLICY_ID)); + }); + + it('navigates to workspace accounting when "Connect to [system]" row is pressed', async () => { + await setManageTeamUserState({integration: 'quickbooksOnline'}); + + renderGettingStartedSection(); + + const row = screen.getByText(/homePage\.gettingStartedSection\.connectAccounting/); + fireEvent.press(row); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_ACCOUNTING.getRoute(TEST_POLICY_ID)); + }); + + it('navigates to workspace categories when "Customize categories" row is pressed', async () => { + await setManageTeamUserState({integration: 'other'}); + + renderGettingStartedSection(); + + const row = screen.getByText('homePage.gettingStartedSection.customizeCategories'); + fireEvent.press(row); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_CATEGORIES.getRoute(TEST_POLICY_ID)); + }); + + it('navigates to workspace company cards when "Link company cards" row is pressed', async () => { + await setManageTeamUserState({areCompanyCardsEnabled: true}); + + renderGettingStartedSection(); + + const row = screen.getByText('homePage.gettingStartedSection.linkCompanyCards'); + fireEvent.press(row); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(TEST_POLICY_ID)); + }); + + it('navigates to workspace rules when "Set up spend rules" row is pressed', async () => { + await setManageTeamUserState({areRulesEnabled: true}); + + renderGettingStartedSection(); + + const row = screen.getByText('homePage.gettingStartedSection.setupRules'); + fireEvent.press(row); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_RULES.getRoute(TEST_POLICY_ID)); + }); + }); +});