From 29e2993b0bd548d67a49f9ca32b5a056127e78f5 Mon Sep 17 00:00:00 2001 From: Adam Grzybowski Date: Mon, 30 Mar 2026 12:11:41 +0200 Subject: [PATCH 01/12] 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 0000000000000..b134d4b4a672c --- /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 0000000000000..da29721b43114 --- /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 0000000000000..44bed0810807c --- /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 0000000000000..5db8e738e1647 --- /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/12] 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 60bc4dd47a112..e41091e5668e2 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 5d06b64cda74f..f2cf8cd89a462 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 e2126652abf44..1e90afcadfd98 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 0000000000000..6d176c9156a12 --- /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 b134d4b4a672c..e1dc058a1a750 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 da29721b43114..20ff871f89032 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 0000000000000..e9f6ca84b98be --- /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 60ce0badeba2b..380e4ebaef39f 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/12] 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 1e90afcadfd98..2fc2b48ef5fd1 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/12] 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 6d176c9156a12..e0e96ecf1ee4e 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/12] 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 e0e96ecf1ee4e..d19628c81c2d5 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/12] 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 44bed0810807c..5e65e6814e6d9 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/12] 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 e9f6ca84b98be..d0ad23cf012b6 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 5e65e6814e6d9..e0c43422a7ca2 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/12] 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 e0c43422a7ca2..c66975115d7bf 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 5db8e738e1647..eb8f11cc9427a 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/12] 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 d1773b887b64e..c0487851ccc09 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 0038fe534f559..44863588478c8 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 3cdcd967b43a1..66f08e95fd03f 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 d8d6b48c73e25..1fa10b8effa80 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 6c8670a9375a5..cc6170ab73a10 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 1a80b954af5bc..d4c17d247c647 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 6226e66c8f919..81f0b135218b8 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 8ed909dd956ae..19dc0e20c5f89 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/12] 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 2bff68a44111a..e8ea4d039a255 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 d19628c81c2d5..cd85bc3359810 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/12] 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 e1dc058a1a750..6e2e1efa70c8a 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/12] 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 2fc2b48ef5fd1..308d6a9bef569 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 c66975115d7bf..4e6fc13229f39 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 {