From 29e2993b0bd548d67a49f9ca32b5a056127e78f5 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Mon, 30 Mar 2026 12:11:41 +0200 Subject: [PATCH 01/19] Add tests --- .../hooks/useGettingStartedItems.ts | 27 + .../home/GettingStartedSection/index.tsx | 9 + .../unit/hooks/useGettingStartedItems.test.ts | 540 ++++++++++++++++++ .../GettingStartedSectionTest.tsx | 321 +++++++++++ 4 files changed, 897 insertions(+) create mode 100644 src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts create mode 100644 src/pages/home/GettingStartedSection/index.tsx create mode 100644 tests/unit/hooks/useGettingStartedItems.test.ts create mode 100644 tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx diff --git a/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts new file mode 100644 index 000000000000..b134d4b4a672 --- /dev/null +++ b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts @@ -0,0 +1,27 @@ +type GettingStartedItem = { + key: string; + label: string; + isComplete: boolean; + route: string; +}; + +type UseGettingStartedItemsResult = { + shouldShowSection: boolean; + items: GettingStartedItem[]; +}; + +/** + * Hook that determines which "Getting Started" checklist items to show + * on the Home page for users with the manage-team onboarding intent. + * + * TODO: Implement logic — this is a stub for TDD. + */ +function useGettingStartedItems(): UseGettingStartedItemsResult { + return { + shouldShowSection: false, + 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 000000000000..da29721b4311 --- /dev/null +++ b/src/pages/home/GettingStartedSection/index.tsx @@ -0,0 +1,9 @@ +/** + * GettingStartedSection — onboarding checklist for manage-team users. + * Stub: will be implemented once the hook and completion helpers are ready. + */ +function GettingStartedSection() { + return null; +} + +export default GettingStartedSection; diff --git a/tests/unit/hooks/useGettingStartedItems.test.ts b/tests/unit/hooks/useGettingStartedItems.test.ts new file mode 100644 index 000000000000..44bed0810807 --- /dev/null +++ b/tests/unit/hooks/useGettingStartedItems.test.ts @@ -0,0 +1,540 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +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'; + +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 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_INITIAL.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 = { + 'Custom Category': { + name: 'Custom Category', + enabled: true, + unencodedName: 'Custom Category', + areCommentsRequired: false, + '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 000000000000..5db8e738e164 --- /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_INITIAL.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)); + }); + }); +}); From 86257b0e5428ec4874daa183a90dd46e83eee184 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Mon, 30 Mar 2026 17:29:53 +0200 Subject: [PATCH 02/19] Add Getting Started onboarding checklist to Home page --- src/languages/en.ts | 8 ++ src/languages/es.ts | 8 ++ src/libs/PolicyUtils.ts | 26 +++++ .../GettingStartedRow.tsx | 69 ++++++++++++ .../hooks/useGettingStartedItems.ts | 100 ++++++++++++++++-- .../home/GettingStartedSection/index.tsx | 35 +++++- .../utils/isWithinGettingStartedPeriod.ts | 18 ++++ src/pages/home/HomePage.tsx | 2 + 8 files changed, 250 insertions(+), 16 deletions(-) create mode 100644 src/pages/home/GettingStartedSection/GettingStartedRow.tsx create mode 100644 src/pages/home/GettingStartedSection/utils/isWithinGettingStartedPeriod.ts diff --git a/src/languages/en.ts b/src/languages/en.ts index 60bc4dd47a11..e41091e5668e 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1093,6 +1093,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 5d06b64cda74..f2cf8cd89a46 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -961,6 +961,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/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index e2126652abf4..1e90afcadfd9 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -687,6 +687,31 @@ 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; + } + + if (policy.customRules && policy.customRules.trim().length > 0) { + return true; + } + + const rules = policy.rules; + if (!rules) { + return false; + } + + 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 hasApprovalRules || hasExpenseRules || hasCodingRules; +} + /** * Gets a tag list of a policy by a tag index */ @@ -2088,6 +2113,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 000000000000..6d176c9156a1 --- /dev/null +++ b/src/pages/home/GettingStartedSection/GettingStartedRow.tsx @@ -0,0 +1,69 @@ +import React, {useCallback} 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 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 = useCallback(() => { + Navigation.navigate(item.route); + }, [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 index b134d4b4a672..e1dc058a1a75 100644 --- a/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts +++ b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts @@ -1,8 +1,17 @@ +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: string; + route: Route; }; type UseGettingStartedItemsResult = { @@ -10,17 +19,86 @@ type UseGettingStartedItemsResult = { items: GettingStartedItem[]; }; -/** - * Hook that determines which "Getting Started" checklist items to show - * on the Home page for users with the manage-team onboarding intent. - * - * TODO: Implement logic — this is a stub for TDD. - */ +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 { - return { - shouldShowSection: false, - items: [], - }; + 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 ?? '-1'}`); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${activePolicyID ?? '-1'}`); + + 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; diff --git a/src/pages/home/GettingStartedSection/index.tsx b/src/pages/home/GettingStartedSection/index.tsx index da29721b4311..20ff871f8903 100644 --- a/src/pages/home/GettingStartedSection/index.tsx +++ b/src/pages/home/GettingStartedSection/index.tsx @@ -1,9 +1,34 @@ -/** - * GettingStartedSection — onboarding checklist for manage-team users. - * Stub: will be implemented once the hook and completion helpers are ready. - */ +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() { - return null; + 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 000000000000..e9f6ca84b98b --- /dev/null +++ b/src/pages/home/GettingStartedSection/utils/isWithinGettingStartedPeriod.ts @@ -0,0 +1,18 @@ +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(); + return Date.now() - trialStartMs <= SIXTY_DAYS_MS; +} + +export default isWithinGettingStartedPeriod; diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index 60ce0badeba2..380e4ebaef39 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -20,6 +20,7 @@ import AnnouncementSection from './AnnouncementSection'; import AssignedCardsSection from './AssignedCardsSection'; import DiscoverSection from './DiscoverSection'; import ForYouSection from './ForYouSection'; +import GettingStartedSection from './GettingStartedSection'; import SpendOverTimeSection from './SpendOverTimeSection'; import TimeSensitiveSection from './TimeSensitiveSection'; import UpcomingTravelSection from './UpcomingTravelSection'; @@ -81,6 +82,7 @@ function HomePage() { + From 4a806bd09cf7612ed24e04a1c5e3439dec0c3809 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Mon, 30 Mar 2026 18:35:42 +0200 Subject: [PATCH 03/19] Simplify control flow in hasNonDefaultRules --- src/libs/PolicyUtils.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 1e90afcadfd9..2fc2b48ef5fd 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -692,24 +692,17 @@ function hasCustomCategories(policyCategories: OnyxEntry): boo * Defaults are: no approval/expense/coding rules and no custom rules text. */ function hasNonDefaultRules(policy: OnyxEntry): boolean { - if (!policy) { - return false; - } - - if (policy.customRules && policy.customRules.trim().length > 0) { - return true; - } - - const rules = policy.rules; - if (!rules) { + if (!policy?.rules) { return false; } + const {rules} = policy; + const hasCustomRules = !!policy.customRules && policy.customRules.trim().length > 0; 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 hasApprovalRules || hasExpenseRules || hasCodingRules; + return hasCustomRules || hasApprovalRules || hasExpenseRules || hasCodingRules; } /** From 1875f8b9616c3186db5747d5ebe70d608d7683f5 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Mon, 30 Mar 2026 18:40:27 +0200 Subject: [PATCH 04/19] Remove unnecessary useCallback from GettingStartedRow --- src/pages/home/GettingStartedSection/GettingStartedRow.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/home/GettingStartedSection/GettingStartedRow.tsx b/src/pages/home/GettingStartedSection/GettingStartedRow.tsx index 6d176c9156a1..e0e96ecf1ee4 100644 --- a/src/pages/home/GettingStartedSection/GettingStartedRow.tsx +++ b/src/pages/home/GettingStartedSection/GettingStartedRow.tsx @@ -1,4 +1,4 @@ -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; import Checkbox from '@components/Checkbox'; import Icon from '@components/Icon'; @@ -24,9 +24,9 @@ function GettingStartedRow({item}: GettingStartedRowProps) { const {shouldUseNarrowLayout} = useResponsiveLayout(); const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'Checkmark'] as const); - const navigateToItem = useCallback(() => { + const navigateToItem = () => { Navigation.navigate(item.route); - }, [item.route]); + }; return ( Date: Mon, 30 Mar 2026 18:48:27 +0200 Subject: [PATCH 05/19] Replace magic numbers with consts in GettingStartedRow --- .../home/GettingStartedSection/GettingStartedRow.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/pages/home/GettingStartedSection/GettingStartedRow.tsx b/src/pages/home/GettingStartedSection/GettingStartedRow.tsx index e0e96ecf1ee4..d19628c81c2d 100644 --- a/src/pages/home/GettingStartedSection/GettingStartedRow.tsx +++ b/src/pages/home/GettingStartedSection/GettingStartedRow.tsx @@ -36,12 +36,17 @@ function GettingStartedRow({item}: GettingStartedRowProps) { {({hovered}) => ( {item.isComplete ? ( - + ) : ( From 75486208f20ec72291c13b85fcd57093ee80cf7e Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Mon, 30 Mar 2026 18:53:49 +0200 Subject: [PATCH 06/19] Add explanatory comments to eslint-disable directives in useGettingStartedItems test --- tests/unit/hooks/useGettingStartedItems.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/hooks/useGettingStartedItems.test.ts b/tests/unit/hooks/useGettingStartedItems.test.ts index 44bed0810807..5e65e6814e6d 100644 --- a/tests/unit/hooks/useGettingStartedItems.test.ts +++ b/tests/unit/hooks/useGettingStartedItems.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/naming-convention */ import {renderHook} from '@testing-library/react-native'; import Onyx from 'react-native-onyx'; import useGettingStartedItems from '@pages/home/GettingStartedSection/hooks/useGettingStartedItems'; @@ -300,11 +299,13 @@ describe('useGettingStartedItems', () => { 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: '', From 7622ea8c1ff6c8a4f83476557ed007e9512b7c7c Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Mon, 30 Mar 2026 19:07:37 +0200 Subject: [PATCH 07/19] Fix isWithinGettingStartedPeriod to reject future trial dates --- .../utils/isWithinGettingStartedPeriod.ts | 3 ++- tests/unit/hooks/useGettingStartedItems.test.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/pages/home/GettingStartedSection/utils/isWithinGettingStartedPeriod.ts b/src/pages/home/GettingStartedSection/utils/isWithinGettingStartedPeriod.ts index e9f6ca84b98b..d0ad23cf012b 100644 --- a/src/pages/home/GettingStartedSection/utils/isWithinGettingStartedPeriod.ts +++ b/src/pages/home/GettingStartedSection/utils/isWithinGettingStartedPeriod.ts @@ -12,7 +12,8 @@ function isWithinGettingStartedPeriod(firstDayFreeTrial: string | undefined): bo } const trialStartMs = new Date(firstDayFreeTrial).getTime(); - return Date.now() - trialStartMs <= SIXTY_DAYS_MS; + const elapsed = Date.now() - trialStartMs; + return elapsed >= 0 && elapsed <= SIXTY_DAYS_MS; } export default isWithinGettingStartedPeriod; diff --git a/tests/unit/hooks/useGettingStartedItems.test.ts b/tests/unit/hooks/useGettingStartedItems.test.ts index 5e65e6814e6d..e0c43422a7ca 100644 --- a/tests/unit/hooks/useGettingStartedItems.test.ts +++ b/tests/unit/hooks/useGettingStartedItems.test.ts @@ -138,6 +138,20 @@ describe('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); From 547cc923f0f87155e45446c51c633129018fdc0f Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Tue, 31 Mar 2026 11:52:27 +0200 Subject: [PATCH 08/19] Fix create-workspace route assertions to use WORKSPACE_OVERVIEW --- tests/unit/hooks/useGettingStartedItems.test.ts | 2 +- .../home/GettingStartedSection/GettingStartedSectionTest.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/hooks/useGettingStartedItems.test.ts b/tests/unit/hooks/useGettingStartedItems.test.ts index e0c43422a7ca..c66975115d7b 100644 --- a/tests/unit/hooks/useGettingStartedItems.test.ts +++ b/tests/unit/hooks/useGettingStartedItems.test.ts @@ -199,7 +199,7 @@ describe('useGettingStartedItems', () => { const {result} = renderHook(() => useGettingStartedItems()); const createWorkspaceItem = result.current.items.find((item) => item.key === 'createWorkspace'); - expect(createWorkspaceItem?.route).toBe(ROUTES.WORKSPACE_INITIAL.getRoute(POLICY_ID)); + expect(createWorkspaceItem?.route).toBe(ROUTES.WORKSPACE_OVERVIEW.getRoute(POLICY_ID)); }); }); diff --git a/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx b/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx index 5db8e738e164..eb8f11cc9427 100644 --- a/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx +++ b/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx @@ -271,7 +271,7 @@ describe('GettingStartedSection', () => { const row = screen.getByText('homePage.gettingStartedSection.createWorkspace'); fireEvent.press(row); - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_INITIAL.getRoute(TEST_POLICY_ID)); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_OVERVIEW.getRoute(TEST_POLICY_ID)); }); it('navigates to workspace accounting when "Connect to [system]" row is pressed', async () => { From 6bab2b8860faa7a53acf135569c48c3580a1380e Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Tue, 31 Mar 2026 15:54:41 +0200 Subject: [PATCH 09/19] Add gettingStartedSection translations and format suggestionsAvailable --- src/languages/de.ts | 17 ++++++++++++++++- src/languages/fr.ts | 17 ++++++++++++++++- src/languages/it.ts | 17 ++++++++++++++++- src/languages/ja.ts | 17 ++++++++++++++++- src/languages/nl.ts | 17 ++++++++++++++++- src/languages/pl.ts | 17 ++++++++++++++++- src/languages/pt-BR.ts | 17 ++++++++++++++++- src/languages/zh-hans.ts | 17 ++++++++++++++++- 8 files changed, 128 insertions(+), 8 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index d1773b887b64..c0487851ccc0 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1056,6 +1056,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', + }, }, allSettingsScreen: { subscription: 'Abonnement', @@ -7442,7 +7450,14 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und searchIn: 'Suchen in', searchPlaceholder: 'Nach etwas suchen', suggestions: 'Vorschläge', - suggestionsAvailable: ({count}: {count: number}, query = '') => ({ + suggestionsAvailable: ( + { + count, + }: { + count: number; + }, + query = '', + ) => ({ one: `Vorschläge verfügbar${query ? ` für ${query}` : ''}. ${count} Ergebnis.`, other: (resultCount: number) => `Vorschläge verfügbar${query ? ` für ${query}` : ''}. ${resultCount} Ergebnisse.`, }), diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 0038fe534f55..44863588478c 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1059,6 +1059,14 @@ const translations: TranslationDeepObject = { inDays: () => ({one: 'Dans 1 jour', other: (count: number) => `Dans ${count} jours`}), today: 'Aujourd’hui', }, + 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', @@ -7466,7 +7474,14 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip searchIn: 'Rechercher dans', searchPlaceholder: 'Rechercher quelque chose', suggestions: 'Suggestions', - suggestionsAvailable: ({count}: {count: number}, query = '') => ({ + suggestionsAvailable: ( + { + count, + }: { + count: number; + }, + query = '', + ) => ({ one: `Suggestions disponibles${query ? ` pour ${query}` : ''}. ${count} résultat.`, other: (resultCount: number) => `Suggestions disponibles${query ? ` pour ${query}` : ''}. ${resultCount} résultats.`, }), diff --git a/src/languages/it.ts b/src/languages/it.ts index 3cdcd967b43a..66f08e95fd03 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1056,6 +1056,14 @@ const translations: TranslationDeepObject = { inDays: () => ({one: 'Tra 1 giorno', other: (count: number) => `Tra ${count} giorni`}), today: 'Oggi', }, + 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', @@ -7430,7 +7438,14 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo searchIn: 'Cerca in', searchPlaceholder: 'Cerca qualcosa', suggestions: 'Suggerimenti', - suggestionsAvailable: ({count}: {count: number}, query = '') => ({ + suggestionsAvailable: ( + { + count, + }: { + count: number; + }, + query = '', + ) => ({ one: `Suggerimenti disponibili${query ? ` per ${query}` : ''}. ${count} risultato.`, other: (resultCount: number) => `Suggerimenti disponibili${query ? ` per ${query}` : ''}. ${resultCount} risultati.`, }), diff --git a/src/languages/ja.ts b/src/languages/ja.ts index d8d6b48c73e2..1fa10b8effa8 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1039,6 +1039,14 @@ const translations: TranslationDeepObject = { inDays: () => ({one: '1日後', other: (count: number) => `${count}日後`}), today: '今日', }, + gettingStartedSection: { + title: 'はじめに', + createWorkspace: 'ワークスペースを作成', + connectAccounting: ({integrationName}: {integrationName: string}) => `${integrationName}に接続する`, + customizeCategories: '会計カテゴリをカスタマイズする', + linkCompanyCards: '会社カードを連携', + setupRules: '支出ルールを設定', + }, }, allSettingsScreen: { subscription: 'サブスクリプション', @@ -7338,7 +7346,14 @@ ${reportName} searchIn: '検索対象', searchPlaceholder: '何かを検索', suggestions: '提案', - suggestionsAvailable: ({count}: {count: number}, query = '') => ({ + suggestionsAvailable: ( + { + count, + }: { + count: number; + }, + query = '', + ) => ({ one: `候補があります${query ? `: ${query}` : ''}。${count}件の結果。`, other: (resultCount: number) => `候補があります${query ? `: ${query}` : ''}。${resultCount}件の結果。`, }), diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 6c8670a9375a..cc6170ab73a1 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1055,6 +1055,14 @@ const translations: TranslationDeepObject = { inDays: () => ({one: 'Over 1 dag', other: (count: number) => `Over ${count} dagen`}), today: 'Vandaag', }, + 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', @@ -7396,7 +7404,14 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar searchIn: 'Zoeken in', searchPlaceholder: 'Zoek iets', suggestions: 'Suggesties', - suggestionsAvailable: ({count}: {count: number}, query = '') => ({ + suggestionsAvailable: ( + { + count, + }: { + count: number; + }, + query = '', + ) => ({ one: `Suggesties beschikbaar${query ? ` voor ${query}` : ''}. ${count} resultaat.`, other: (resultCount: number) => `Suggesties beschikbaar${query ? ` voor ${query}` : ''}. ${resultCount} resultaten.`, }), diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 1a80b954af5b..d4c17d247c64 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1056,6 +1056,14 @@ const translations: TranslationDeepObject = { inDays: () => ({one: 'Za 1 dzień', other: (count: number) => `Za ${count} dni`}), today: 'Dzisiaj', }, + 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', @@ -7397,7 +7405,14 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i searchIn: 'Szukaj w', searchPlaceholder: 'Wyszukaj coś', suggestions: 'Sugestie', - suggestionsAvailable: ({count}: {count: number}, query = '') => ({ + suggestionsAvailable: ( + { + count, + }: { + count: number; + }, + query = '', + ) => ({ one: `Dostępne sugestie${query ? ` dla ${query}` : ''}. ${count} wynik.`, other: (resultCount: number) => `Dostępne sugestie${query ? ` dla ${query}` : ''}. ${resultCount} wyniki.`, }), diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 6226e66c8f91..81f0b135218b 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1054,6 +1054,14 @@ const translations: TranslationDeepObject = { inDays: () => ({one: 'Em 1 dia', other: (count: number) => `Em ${count} dias`}), today: 'Hoje', }, + 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', @@ -7384,7 +7392,14 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e searchIn: 'Pesquisar em', searchPlaceholder: 'Pesquisar algo', suggestions: 'Sugestões', - suggestionsAvailable: ({count}: {count: number}, query = '') => ({ + suggestionsAvailable: ( + { + count, + }: { + count: number; + }, + query = '', + ) => ({ one: `Sugestões disponíveis${query ? ` para ${query}` : ''}. ${count} resultado.`, other: (resultCount: number) => `Sugestões disponíveis${query ? ` para ${query}` : ''}. ${resultCount} resultados.`, }), diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 8ed909dd956a..19dc0e20c5f8 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1022,6 +1022,14 @@ const translations: TranslationDeepObject = { inDays: () => ({one: '1 天后', other: (count: number) => `在 ${count} 天后`}), today: '今天', }, + gettingStartedSection: { + title: '入门', + createWorkspace: '创建工作区', + connectAccounting: ({integrationName}: {integrationName: string}) => `连接到 ${integrationName}`, + customizeCategories: '自定义会计类别', + linkCompanyCards: '关联公司卡', + setupRules: '设置消费规则', + }, }, allSettingsScreen: { subscription: '订阅', @@ -7214,7 +7222,14 @@ ${reportName} searchIn: '搜索范围', searchPlaceholder: '搜索内容', suggestions: '建议', - suggestionsAvailable: ({count}: {count: number}, query = '') => ({ + suggestionsAvailable: ( + { + count, + }: { + count: number; + }, + query = '', + ) => ({ one: `有可用建议${query ? `:${query}` : ''}。共${count}条结果。`, other: (resultCount: number) => `有可用建议${query ? `:${query}` : ''}。共${resultCount}条结果。`, }), From 9405144c2e319570ddb1de636a2004b0d2b567f4 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Tue, 31 Mar 2026 16:00:26 +0200 Subject: [PATCH 10/19] Add Sentry label to GettingStartedRow pressable --- src/CONST/index.ts | 1 + src/pages/home/GettingStartedSection/GettingStartedRow.tsx | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 2bff68a44111..e8ea4d039a25 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8911,6 +8911,7 @@ const CONST = { }, HOME_PAGE: { WIDGET_ITEM: 'HomePage-WidgetItem', + GETTING_STARTED_ROW: 'HomePage-GettingStartedRow', }, CALENDAR_PICKER: { YEAR_PICKER: 'CalendarPicker-YearPicker', diff --git a/src/pages/home/GettingStartedSection/GettingStartedRow.tsx b/src/pages/home/GettingStartedSection/GettingStartedRow.tsx index d19628c81c2d..cd85bc335981 100644 --- a/src/pages/home/GettingStartedSection/GettingStartedRow.tsx +++ b/src/pages/home/GettingStartedSection/GettingStartedRow.tsx @@ -11,6 +11,7 @@ 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 = { @@ -32,6 +33,7 @@ function GettingStartedRow({item}: GettingStartedRowProps) { {({hovered}) => ( From dff7ec9bb70c00ae0d326ebdf5ecc92930ea83e2 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Tue, 31 Mar 2026 16:03:08 +0200 Subject: [PATCH 11/19] Remove default ID values from useOnyx collection keys --- .../GettingStartedSection/hooks/useGettingStartedItems.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts index e1dc058a1a75..6e2e1efa70c8 100644 --- a/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts +++ b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts @@ -34,8 +34,8 @@ function useGettingStartedItems(): UseGettingStartedItemsResult { 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 ?? '-1'}`); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${activePolicyID ?? '-1'}`); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${activePolicyID}`); const emptyResult: UseGettingStartedItemsResult = {shouldShowSection: false, items: []}; From d75f4a708082fd38c3a4e5cd600989ff0dc7f263 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Tue, 31 Mar 2026 16:11:03 +0200 Subject: [PATCH 12/19] Fix hasNonDefaultRules to detect customRules without rules object and add useLocalize mock to hook tests --- src/libs/PolicyUtils.ts | 11 ++++++----- tests/unit/hooks/useGettingStartedItems.test.ts | 11 +++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 2fc2b48ef5fd..308d6a9bef56 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -692,15 +692,16 @@ function hasCustomCategories(policyCategories: OnyxEntry): boo * Defaults are: no approval/expense/coding rules and no custom rules text. */ function hasNonDefaultRules(policy: OnyxEntry): boolean { - if (!policy?.rules) { + if (!policy) { return false; } - const {rules} = policy; const hasCustomRules = !!policy.customRules && policy.customRules.trim().length > 0; - 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; + + 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; } diff --git a/tests/unit/hooks/useGettingStartedItems.test.ts b/tests/unit/hooks/useGettingStartedItems.test.ts index c66975115d7b..4e6fc13229f3 100644 --- a/tests/unit/hooks/useGettingStartedItems.test.ts +++ b/tests/unit/hooks/useGettingStartedItems.test.ts @@ -8,6 +8,17 @@ 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 { From 5297e87b888b4d1367d6af3db26db2c3dafcb95a Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Wed, 1 Apr 2026 17:57:23 +0200 Subject: [PATCH 13/19] Add policy guards and company card feed detection to Getting Started section --- src/libs/CardUtils.ts | 7 +++ src/libs/actions/OnyxDerived/index.ts | 3 +- .../hooks/useGettingStartedItems.ts | 9 ++- .../unit/hooks/useGettingStartedItems.test.ts | 55 ++++++++++++++++++- 4 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index a7521d1f2287..31f02252b691 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -620,6 +620,12 @@ function getCompanyFeeds(cardFeeds: OnyxEntry, shouldFilterOu ); } +function hasCompanyCardFeeds(cardFeeds: OnyxEntry): boolean { + return Object.values(cardFeeds ?? {}).some( + (value) => value.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !value.pending && !value.feed.includes(CONST.EXPENSIFY_CARD.BANK), + ); +} + function getBankName(feedType: CardFeedWithNumber | CardFeedWithDomainID): string { const cacheKey = feedType ?? ''; const cached = getBankNameCache.get(cacheKey); @@ -1590,6 +1596,7 @@ export { isSelectedFeedExpired, isTravelCard, getCompanyFeeds, + hasCompanyCardFeeds, isPersonalCardBrokenConnection, isCustomFeed, isCSVFeedOrExpensifyCard, diff --git a/src/libs/actions/OnyxDerived/index.ts b/src/libs/actions/OnyxDerived/index.ts index ac245f116b1b..31791077f1ab 100644 --- a/src/libs/actions/OnyxDerived/index.ts +++ b/src/libs/actions/OnyxDerived/index.ts @@ -6,7 +6,6 @@ * The primary purpose is to optimize performance by reducing redundant computations. More info can be found in the README. */ import Onyx from 'react-native-onyx'; -import OnyxKeys from 'react-native-onyx/dist/OnyxKeys'; import OnyxUtils from 'react-native-onyx/dist/OnyxUtils'; import Log from '@libs/Log'; import {endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; @@ -102,7 +101,7 @@ function init() { const dependencyIndex = i; const dependencyOnyxKey = dependencies[dependencyIndex]; - if (OnyxKeys.isCollectionKey(dependencyOnyxKey)) { + if (OnyxUtils.isCollectionKey(dependencyOnyxKey)) { Onyx.connectWithoutView({ key: dependencyOnyxKey, waitForCollectionCallback: true, diff --git a/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts index 6e2e1efa70c8..e8134b7343ac 100644 --- a/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts +++ b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts @@ -1,6 +1,8 @@ +import useCardFeeds from '@hooks/useCardFeeds'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import {hasAccountingConnections, hasCustomCategories, hasNonDefaultRules} from '@libs/PolicyUtils'; +import {hasCompanyCardFeeds} from '@libs/CardUtils'; +import {hasAccountingConnections, hasCustomCategories, hasNonDefaultRules, isPaidGroupPolicy, isPendingDeletePolicy} from '@libs/PolicyUtils'; import isWithinGettingStartedPeriod from '@pages/home/GettingStartedSection/utils/isWithinGettingStartedPeriod'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -36,6 +38,7 @@ function useGettingStartedItems(): UseGettingStartedItemsResult { const [reportedIntegration] = useOnyx(ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${activePolicyID}`); + const [allCardFeeds] = useCardFeeds(activePolicyID); const emptyResult: UseGettingStartedItemsResult = {shouldShowSection: false, items: []}; @@ -44,7 +47,7 @@ function useGettingStartedItems(): UseGettingStartedItemsResult { return emptyResult; } - if (!activePolicyID || !policy) { + if (!activePolicyID || !policy || isPendingDeletePolicy(policy) || !isPaidGroupPolicy(policy)) { return emptyResult; } @@ -84,7 +87,7 @@ function useGettingStartedItems(): UseGettingStartedItemsResult { items.push({ key: 'linkCompanyCards', label: translate('homePage.gettingStartedSection.linkCompanyCards'), - isComplete: false, + isComplete: hasCompanyCardFeeds(allCardFeeds), route: ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(activePolicyID), }); } diff --git a/tests/unit/hooks/useGettingStartedItems.test.ts b/tests/unit/hooks/useGettingStartedItems.test.ts index 4e6fc13229f3..f8f64281bb60 100644 --- a/tests/unit/hooks/useGettingStartedItems.test.ts +++ b/tests/unit/hooks/useGettingStartedItems.test.ts @@ -25,6 +25,7 @@ function buildPolicy(overrides: Partial = {}): Policy { return { ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM, 'Test Workspace'), id: POLICY_ID, + pendingAction: undefined, areCompanyCardsEnabled: false, areRulesEnabled: false, connections: undefined, @@ -524,7 +525,7 @@ describe('useGettingStartedItems', () => { }); describe('edge cases', () => { - it('should handle missing active policy ID gracefully', async () => { + it('should be hidden when active policy ID is missing', 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'); @@ -533,9 +534,22 @@ describe('useGettingStartedItems', () => { const {result} = renderHook(() => useGettingStartedItems()); expect(result.current.shouldShowSection).toBe(false); + expect(result.current.items).toEqual([]); + }); + + it('should be hidden when the policy is pending deletion', async () => { + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + policy: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(false); + expect(result.current.items).toEqual([]); }); - it('should handle missing policy data gracefully', async () => { + it('should be hidden when policy data does not exist', 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'); @@ -545,6 +559,43 @@ describe('useGettingStartedItems', () => { const {result} = renderHook(() => useGettingStartedItems()); expect(result.current.shouldShowSection).toBe(false); + expect(result.current.items).toEqual([]); + }); + + it('should be hidden when active policy is a personal policy', async () => { + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + policy: {type: CONST.POLICY.TYPE.PERSONAL}, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(false); + expect(result.current.items).toEqual([]); + }); + + it('should be visible when active policy is a collect (team) policy', async () => { + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + policy: {type: CONST.POLICY.TYPE.TEAM}, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(true); + expect(result.current.items.length).toBeGreaterThan(0); + }); + + it('should be visible when active policy is a control (corporate) policy', async () => { + await setupManageTeamScenario({ + accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, + policy: {type: CONST.POLICY.TYPE.CORPORATE}, + }); + + const {result} = renderHook(() => useGettingStartedItems()); + + expect(result.current.shouldShowSection).toBe(true); + expect(result.current.items.length).toBeGreaterThan(0); }); it('should prefer NVP_INTRO_SELECTED over ONBOARDING_PURPOSE_SELECTED when both are set', async () => { From 72ec6af8f6e959b911f9efd8154f26bdd2aa290f Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 2 Apr 2026 11:44:41 +0200 Subject: [PATCH 14/19] Reuse getCompanyFeeds in hasCompanyCardFeeds to remove duplicated filtering logic --- src/libs/CardUtils.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 31f02252b691..e1d0ae28602f 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -621,9 +621,7 @@ function getCompanyFeeds(cardFeeds: OnyxEntry, shouldFilterOu } function hasCompanyCardFeeds(cardFeeds: OnyxEntry): boolean { - return Object.values(cardFeeds ?? {}).some( - (value) => value.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !value.pending && !value.feed.includes(CONST.EXPENSIFY_CARD.BANK), - ); + return Object.keys(getCompanyFeeds(cardFeeds, true, true)).length > 0; } function getBankName(feedType: CardFeedWithNumber | CardFeedWithDomainID): string { From a886a1f9ea1ad5adf881b7e6bbf76d1bc81987bb Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 2 Apr 2026 11:49:44 +0200 Subject: [PATCH 15/19] Revert accidental changes --- src/libs/actions/OnyxDerived/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/OnyxDerived/index.ts b/src/libs/actions/OnyxDerived/index.ts index 31791077f1ab..ac245f116b1b 100644 --- a/src/libs/actions/OnyxDerived/index.ts +++ b/src/libs/actions/OnyxDerived/index.ts @@ -6,6 +6,7 @@ * The primary purpose is to optimize performance by reducing redundant computations. More info can be found in the README. */ import Onyx from 'react-native-onyx'; +import OnyxKeys from 'react-native-onyx/dist/OnyxKeys'; import OnyxUtils from 'react-native-onyx/dist/OnyxUtils'; import Log from '@libs/Log'; import {endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; @@ -101,7 +102,7 @@ function init() { const dependencyIndex = i; const dependencyOnyxKey = dependencies[dependencyIndex]; - if (OnyxUtils.isCollectionKey(dependencyOnyxKey)) { + if (OnyxKeys.isCollectionKey(dependencyOnyxKey)) { Onyx.connectWithoutView({ key: dependencyOnyxKey, waitForCollectionCallback: true, From 4bb453ffa3224ccee6c9a05da90841861e764ec5 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Thu, 2 Apr 2026 13:45:26 +0200 Subject: [PATCH 16/19] Fix GettingStartedSection tests by adding policy type to mock data --- .../home/GettingStartedSection/GettingStartedSectionTest.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx b/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx index eb8f11cc9427..dcf2b41c9a6d 100644 --- a/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx +++ b/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx @@ -60,6 +60,7 @@ async function setManageTeamUserState(overrides?: { const policyData: Record = { id: TEST_POLICY_ID, name: 'Test Workspace', + type: CONST.POLICY.TYPE.TEAM, areCompanyCardsEnabled: overrides?.areCompanyCardsEnabled ?? true, areRulesEnabled: overrides?.areRulesEnabled ?? true, }; @@ -121,6 +122,7 @@ describe('GettingStartedSection', () => { { id: TEST_POLICY_ID, name: 'Test Workspace', + type: CONST.POLICY.TYPE.TEAM, areCompanyCardsEnabled: true, areRulesEnabled: true, } as never, From a7c12628300a7a04afb34c68f9732aff17f05350 Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Wed, 8 Apr 2026 16:07:45 +0200 Subject: [PATCH 17/19] Navigate to WorkspaceInitialPage on narrow layout in GettingStartedSection --- .../GettingStartedSection/hooks/useGettingStartedItems.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts index e8134b7343ac..7288c9236416 100644 --- a/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts +++ b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts @@ -1,6 +1,7 @@ import useCardFeeds from '@hooks/useCardFeeds'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import {hasCompanyCardFeeds} from '@libs/CardUtils'; import {hasAccountingConnections, hasCustomCategories, hasNonDefaultRules, isPaidGroupPolicy, isPendingDeletePolicy} from '@libs/PolicyUtils'; import isWithinGettingStartedPeriod from '@pages/home/GettingStartedSection/utils/isWithinGettingStartedPeriod'; @@ -31,6 +32,7 @@ const DIRECT_CONNECT_INTEGRATIONS = new Set([ function useGettingStartedItems(): UseGettingStartedItemsResult { const {translate} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [onboardingPurpose] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); @@ -61,7 +63,7 @@ function useGettingStartedItems(): UseGettingStartedItemsResult { key: 'createWorkspace', label: translate('homePage.gettingStartedSection.createWorkspace'), isComplete: true, - route: ROUTES.WORKSPACE_OVERVIEW.getRoute(activePolicyID), + route: shouldUseNarrowLayout ? ROUTES.WORKSPACE_INITIAL.getRoute(activePolicyID) : ROUTES.WORKSPACE_OVERVIEW.getRoute(activePolicyID), }); const isDirectConnect = !!reportedIntegration && DIRECT_CONNECT_INTEGRATIONS.has(reportedIntegration); From 11184c8bae5692b99bb199d3f69220dbafb307c9 Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Wed, 8 Apr 2026 16:46:04 +0200 Subject: [PATCH 18/19] Enable feature and navigate directly when checklist item is disabled --- src/libs/actions/Policy/Policy.ts | 4 +- .../GettingStartedRow.tsx | 5 ++- .../hooks/useGettingStartedItems.ts | 40 +++++++++++-------- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 06211b30d322..6b1824668421 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -4391,7 +4391,7 @@ function createWorkspaceFromIOUPayment( return {policyID, workspaceChatReportID: memberData.workspaceChatReportID, reportPreviewReportActionID: reportPreviewAction?.reportActionID, adminsChatReportID}; } -function enablePolicyConnections(policyID: string, enabled: boolean) { +function enablePolicyConnections(policyID: string, enabled: boolean, shouldGoBack = true) { const onyxData: OnyxData = { optimisticData: [ { @@ -4434,7 +4434,7 @@ function enablePolicyConnections(policyID: string, enabled: boolean) { API.writeWithNoDuplicatesEnableFeatureConflicts(WRITE_COMMANDS.ENABLE_POLICY_CONNECTIONS, parameters, onyxData); - if (enabled && getIsNarrowLayout()) { + if (enabled && getIsNarrowLayout() && shouldGoBack) { goBackWhenEnableFeature(); } } diff --git a/src/pages/home/GettingStartedSection/GettingStartedRow.tsx b/src/pages/home/GettingStartedSection/GettingStartedRow.tsx index cd85bc335981..ea423d828dcb 100644 --- a/src/pages/home/GettingStartedSection/GettingStartedRow.tsx +++ b/src/pages/home/GettingStartedSection/GettingStartedRow.tsx @@ -26,7 +26,10 @@ function GettingStartedRow({item}: GettingStartedRowProps) { const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'Checkmark'] as const); const navigateToItem = () => { - Navigation.navigate(item.route); + if (!item.isFeatureEnabled) { + item.enableFeature?.(); + } + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.navigate(item.route)); }; return ( diff --git a/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts index 7288c9236416..826c28ece85b 100644 --- a/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts +++ b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts @@ -5,6 +5,8 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import {hasCompanyCardFeeds} from '@libs/CardUtils'; import {hasAccountingConnections, hasCustomCategories, hasNonDefaultRules, isPaidGroupPolicy, isPendingDeletePolicy} from '@libs/PolicyUtils'; import isWithinGettingStartedPeriod from '@pages/home/GettingStartedSection/utils/isWithinGettingStartedPeriod'; +import {enablePolicyCategories} from '@userActions/Policy/Category'; +import {enableCompanyCards, enablePolicyConnections, enablePolicyRules} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -15,6 +17,8 @@ type GettingStartedItem = { label: string; isComplete: boolean; route: Route; + isFeatureEnabled?: boolean; + enableFeature?: () => void; }; type UseGettingStartedItemsResult = { @@ -75,6 +79,8 @@ function useGettingStartedItems(): UseGettingStartedItemsResult { label: translate('homePage.gettingStartedSection.connectAccounting', {integrationName}), isComplete: hasAccountingConnections(policy), route: ROUTES.WORKSPACE_ACCOUNTING.getRoute(activePolicyID), + isFeatureEnabled: policy.areConnectionsEnabled, + enableFeature: () => enablePolicyConnections(activePolicyID, true, false), }); } else { items.push({ @@ -82,26 +88,28 @@ function useGettingStartedItems(): UseGettingStartedItemsResult { label: translate('homePage.gettingStartedSection.customizeCategories'), isComplete: hasCustomCategories(policyCategories), route: ROUTES.WORKSPACE_CATEGORIES.getRoute(activePolicyID), + isFeatureEnabled: policy.areCategoriesEnabled, + enableFeature: () => enablePolicyCategories({policy, categories: policyCategories ?? {}, tags: {}, reports: [], transactionsAndViolations: {}}, true, false), }); } - if (policy.areCompanyCardsEnabled) { - items.push({ - key: 'linkCompanyCards', - label: translate('homePage.gettingStartedSection.linkCompanyCards'), - isComplete: hasCompanyCardFeeds(allCardFeeds), - route: ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(activePolicyID), - }); - } + items.push({ + key: 'linkCompanyCards', + label: translate('homePage.gettingStartedSection.linkCompanyCards'), + isComplete: hasCompanyCardFeeds(allCardFeeds), + route: ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(activePolicyID), + isFeatureEnabled: policy.areCompanyCardsEnabled, + enableFeature: () => enableCompanyCards(activePolicyID, true, false), + }); - if (policy.areRulesEnabled) { - items.push({ - key: 'setupRules', - label: translate('homePage.gettingStartedSection.setupRules'), - isComplete: hasNonDefaultRules(policy), - route: ROUTES.WORKSPACE_RULES.getRoute(activePolicyID), - }); - } + items.push({ + key: 'setupRules', + label: translate('homePage.gettingStartedSection.setupRules'), + isComplete: hasNonDefaultRules(policy), + route: ROUTES.WORKSPACE_RULES.getRoute(activePolicyID), + isFeatureEnabled: policy.areRulesEnabled, + enableFeature: () => enablePolicyRules(policy, true, false), + }); return {shouldShowSection: true, items}; } From 1d8ae1c1fb22be5ea4a88ef8363b9641b7dfc3ed Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Thu, 9 Apr 2026 09:09:54 +0200 Subject: [PATCH 19/19] Fix navigating on create workspace button and adjust jest tests --- .../hooks/useGettingStartedItems.ts | 3 +- .../unit/hooks/useGettingStartedItems.test.ts | 49 +++++++++++++++---- .../GettingStartedSectionTest.tsx | 9 ++-- 3 files changed, 46 insertions(+), 15 deletions(-) diff --git a/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts index 826c28ece85b..acdbb8472a77 100644 --- a/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts +++ b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts @@ -3,6 +3,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import {hasCompanyCardFeeds} from '@libs/CardUtils'; +import Navigation from '@libs/Navigation/Navigation'; import {hasAccountingConnections, hasCustomCategories, hasNonDefaultRules, isPaidGroupPolicy, isPendingDeletePolicy} from '@libs/PolicyUtils'; import isWithinGettingStartedPeriod from '@pages/home/GettingStartedSection/utils/isWithinGettingStartedPeriod'; import {enablePolicyCategories} from '@userActions/Policy/Category'; @@ -67,7 +68,7 @@ function useGettingStartedItems(): UseGettingStartedItemsResult { key: 'createWorkspace', label: translate('homePage.gettingStartedSection.createWorkspace'), isComplete: true, - route: shouldUseNarrowLayout ? ROUTES.WORKSPACE_INITIAL.getRoute(activePolicyID) : ROUTES.WORKSPACE_OVERVIEW.getRoute(activePolicyID), + route: shouldUseNarrowLayout ? ROUTES.WORKSPACE_INITIAL.getRoute(activePolicyID, Navigation.getActiveRoute()) : ROUTES.WORKSPACE_OVERVIEW.getRoute(activePolicyID), }); const isDirectConnect = !!reportedIntegration && DIRECT_CONNECT_INTEGRATIONS.has(reportedIntegration); diff --git a/tests/unit/hooks/useGettingStartedItems.test.ts b/tests/unit/hooks/useGettingStartedItems.test.ts index f8f64281bb60..8b34cfc86598 100644 --- a/tests/unit/hooks/useGettingStartedItems.test.ts +++ b/tests/unit/hooks/useGettingStartedItems.test.ts @@ -19,6 +19,11 @@ jest.mock('@hooks/useLocalize', () => })), ); +jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({shouldUseNarrowLayout: false}))); + +jest.mock('@userActions/Policy/Category', () => ({enablePolicyCategories: jest.fn()})); +jest.mock('@userActions/Policy/Policy', () => ({enableCompanyCards: jest.fn(), enablePolicyConnections: jest.fn(), enablePolicyRules: jest.fn()})); + const POLICY_ID = '1'; function buildPolicy(overrides: Partial = {}): Policy { @@ -349,10 +354,10 @@ describe('useGettingStartedItems', () => { }); describe('row 3 - Link company cards', () => { - it('should be shown when company cards feature is enabled on the workspace', async () => { + it('should always be shown', async () => { await setupManageTeamScenario({ accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, - policy: {areCompanyCardsEnabled: true}, + policy: {areCompanyCardsEnabled: false}, }); const {result} = renderHook(() => useGettingStartedItems()); @@ -361,7 +366,19 @@ describe('useGettingStartedItems', () => { expect(companyCardsItem).toBeDefined(); }); - it('should be hidden when company cards feature is not enabled on the workspace', async () => { + it('should have isFeatureEnabled=true when company cards feature is enabled', 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?.isFeatureEnabled).toBe(true); + }); + + it('should have isFeatureEnabled=false when company cards feature is not enabled', async () => { await setupManageTeamScenario({ accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, policy: {areCompanyCardsEnabled: false}, @@ -370,7 +387,7 @@ describe('useGettingStartedItems', () => { const {result} = renderHook(() => useGettingStartedItems()); const companyCardsItem = result.current.items.find((item) => item.key === 'linkCompanyCards'); - expect(companyCardsItem).toBeUndefined(); + expect(companyCardsItem?.isFeatureEnabled).toBe(false); }); it('should navigate to workspace company cards route', async () => { @@ -399,10 +416,10 @@ describe('useGettingStartedItems', () => { }); describe('row 4 - Set up spend rules', () => { - it('should be shown when rules feature is enabled on the workspace', async () => { + it('should always be shown', async () => { await setupManageTeamScenario({ accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, - policy: {areRulesEnabled: true}, + policy: {areRulesEnabled: false}, }); const {result} = renderHook(() => useGettingStartedItems()); @@ -411,7 +428,19 @@ describe('useGettingStartedItems', () => { expect(rulesItem).toBeDefined(); }); - it('should be hidden when rules feature is not enabled on the workspace', async () => { + it('should have isFeatureEnabled=true when rules feature is enabled', 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?.isFeatureEnabled).toBe(true); + }); + + it('should have isFeatureEnabled=false when rules feature is not enabled', async () => { await setupManageTeamScenario({ accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, policy: {areRulesEnabled: false}, @@ -420,7 +449,7 @@ describe('useGettingStartedItems', () => { const {result} = renderHook(() => useGettingStartedItems()); const rulesItem = result.current.items.find((item) => item.key === 'setupRules'); - expect(rulesItem).toBeUndefined(); + expect(rulesItem?.isFeatureEnabled).toBe(false); }); it('should navigate to workspace rules route', async () => { @@ -511,7 +540,7 @@ describe('useGettingStartedItems', () => { expect(keys).toEqual(['createWorkspace', 'customizeCategories', 'linkCompanyCards', 'setupRules']); }); - it('should only contain createWorkspace and accounting row when no optional features are enabled', async () => { + it('should always contain all four rows even when optional features are disabled', async () => { await setupManageTeamScenario({ accounting: CONST.POLICY.CONNECTIONS.NAME.QBO, policy: {areCompanyCardsEnabled: false, areRulesEnabled: false}, @@ -520,7 +549,7 @@ describe('useGettingStartedItems', () => { const {result} = renderHook(() => useGettingStartedItems()); const keys = result.current.items.map((item) => item.key); - expect(keys).toEqual(['createWorkspace', 'connectAccounting']); + expect(keys).toEqual(['createWorkspace', 'connectAccounting', 'linkCompanyCards', 'setupRules']); }); }); diff --git a/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx b/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx index dcf2b41c9a6d..a2a8a19a835d 100644 --- a/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx +++ b/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx @@ -12,6 +12,7 @@ const TEST_POLICY_ID = 'ABC123'; jest.mock('@libs/Navigation/Navigation', () => ({ navigate: jest.fn(), + setNavigationActionToMicrotaskQueue: jest.fn((callback: () => void) => callback()), })); jest.mock('@hooks/useLocalize', () => @@ -195,12 +196,12 @@ describe('GettingStartedSection', () => { expect(screen.getByText('homePage.gettingStartedSection.linkCompanyCards')).toBeTruthy(); }); - it('hides "Link company cards" row when company cards feature is disabled', async () => { + it('always shows "Link company cards" row even when company cards feature is disabled', async () => { await setManageTeamUserState({areCompanyCardsEnabled: false}); renderGettingStartedSection(); - expect(screen.queryByText('homePage.gettingStartedSection.linkCompanyCards')).toBeNull(); + expect(screen.getByText('homePage.gettingStartedSection.linkCompanyCards')).toBeTruthy(); }); it('shows "Set up spend rules" row when rules feature is enabled', async () => { @@ -211,12 +212,12 @@ describe('GettingStartedSection', () => { expect(screen.getByText('homePage.gettingStartedSection.setupRules')).toBeTruthy(); }); - it('hides "Set up spend rules" row when rules feature is disabled', async () => { + it('always shows "Set up spend rules" row even when rules feature is disabled', async () => { await setManageTeamUserState({areRulesEnabled: false}); renderGettingStartedSection(); - expect(screen.queryByText('homePage.gettingStartedSection.setupRules')).toBeNull(); + expect(screen.getByText('homePage.gettingStartedSection.setupRules')).toBeTruthy(); }); it('renders rows in the expected order: workspace, accounting, cards, rules', async () => {