diff --git a/src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx b/src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx index e854ce63acf1..2c06fdfdddd0 100644 --- a/src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx @@ -4,6 +4,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import useReviewWorkspaceSettingsTaskCompletion from '@hooks/useReviewWorkspaceSettingsTaskCompletion'; @@ -11,6 +12,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import {canEditWorkspaceSettings, goBackFromInvalidPolicy, isGroupPolicy, isPendingDeletePolicy} from '@libs/PolicyUtils'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicy from '@pages/workspace/withPolicy'; @@ -37,6 +39,7 @@ type AutoReportingOffsetKeys = ValueOf(); const selectedOffset = userSelectedOffset ?? offset; + // Freeze the day selected when the page opened so it stays pinned to the top for the whole open/focus cycle, even as the live selection changes. + const initialOffset = useInitialSelection(selectedOffset, {resetOnFocus: true}); const [searchText, setSearchText] = useState(''); const trimmedText = searchText.trim().toLowerCase(); @@ -57,25 +62,30 @@ function WorkspaceAutoReportingMonthlyOffsetPage({policy, route}: WorkspaceAutoR return { text: toLocaleOrdinal(day), keyForList: day.toString(), // we have to cast it as string for to work + value: day.toString(), isSelected: day === selectedOffset, isNumber: true, }; }).concat([ { keyForList: 'lastDayOfMonth', + value: 'lastDayOfMonth', text: translate('workflowsPage.frequencies.lastDayOfMonth'), isSelected: selectedOffset === CONST.POLICY.AUTO_REPORTING_OFFSET.LAST_DAY_OF_MONTH, isNumber: false, }, { keyForList: 'lastBusinessDayOfMonth', + value: 'lastBusinessDayOfMonth', text: translate('workflowsPage.frequencies.lastBusinessDayOfMonth'), isSelected: selectedOffset === CONST.POLICY.AUTO_REPORTING_OFFSET.LAST_BUSINESS_DAY_OF_MONTH, isNumber: false, }, ]); - const filteredDaysOfMonth = daysOfMonth.filter((dayItem) => dayItem.text.toLowerCase().includes(trimmedText)); + // Pin the frozen initial day to the top of the full list before search filtering, so it stays pinned while searching. + const orderedDaysOfMonth = moveInitialSelectionToTop(daysOfMonth, [String(initialOffset)]); + const filteredDaysOfMonth = orderedDaysOfMonth.filter((dayItem) => dayItem.text.toLowerCase().includes(trimmedText)); const onSelectDayOfMonth = (item: WorkspaceAutoReportingMonthlyOffsetPageItem) => { setUserSelectedOffset(item.isNumber ? parseInt(item.keyForList, 10) : (item.keyForList as AutoReportingOffsetKeys)); @@ -135,8 +145,11 @@ function WorkspaceAutoReportingMonthlyOffsetPage({policy, route}: WorkspaceAutoR onSelectRow={onSelectDayOfMonth} textInputOptions={textInputOptions} confirmButtonOptions={confirmButtonOptions} - initiallyFocusedItemKey={offset.toString()} + initiallyFocusedItemKey={String(initialOffset)} shouldSingleExecuteRowSelect + shouldScrollToFocusedIndexOnMount={false} + shouldUpdateFocusedIndex + disableMaintainingScrollPosition addBottomSafeAreaPadding showScrollIndicator /> diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx index c79ce65e7021..3aa5bcb8cd2f 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx @@ -13,6 +13,7 @@ import type {ListItem} from '@components/SelectionList/types'; import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useInitialSelection from '@hooks/useInitialSelection'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -90,7 +91,9 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); const [searchTerm, setSearchTerm] = useState(''); const [sharedBankAccountData] = useOnyx(ONYXKEYS.SHARE_BANK_ACCOUNT); - const [selectedPayer, setSelectedPayer] = useState(policy?.achAccount?.reimburser ?? policy?.owner); + const savedPayer = policy?.achAccount?.reimburser ?? policy?.owner; + const [selectedPayer, setSelectedPayer] = useState(savedPayer); + const initialPayer = useInitialSelection(savedPayer, {resetOnFocus: true}); const shouldShowSuccess = sharedBankAccountData?.shouldShowSuccess ?? false; const styles = useThemeStyles(); const {showConfirmModal, closeModal} = useConfirmModal(); @@ -125,6 +128,8 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR } const roleBadge = ; const isAuthorizedPayer = selectedPayer === details?.login; + // Section placement is frozen to the initially selected payer so switching payers only moves the checkmark instead of reordering the list. + const isInitialPayer = initialPayer === details?.login; const formattedMember = { keyForList: String(adminAccountID), accountID: adminAccountID, @@ -144,7 +149,7 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR errors: policyEmployee.errors, pendingAction: (policyEmployee.pendingAction ?? isAuthorizedPayer) ? policy?.pendingFields?.reimburser : null, }; - if (isAuthorizedPayer) { + if (isInitialPayer) { authorizedPayerDetails.push(formattedMember); } else { policyAdminDetails.push(formattedMember); @@ -159,7 +164,8 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR const sectionsArray: MembersSection[] = []; if (searchTerm !== '') { const searchValue = getSearchValueForPhoneOrEmail(searchTerm, countryCode); - const filteredOptions = tokenizedSearch([...formattedPolicyAdmins, ...formattedAuthorizedPayer], searchValue, (option) => [option.text ?? '', option.login ?? '']); + // Keep the frozen initial payer first so it stays pinned to the top of the search results (tokenizedSearch preserves input order). + const filteredOptions = tokenizedSearch([...formattedAuthorizedPayer, ...formattedPolicyAdmins], searchValue, (option) => [option.text ?? '', option.login ?? '']); return [ { title: undefined, @@ -386,6 +392,7 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR }} initiallyFocusedItemKey={formattedAuthorizedPayer.at(0)?.keyForList} shouldSingleExecuteRowSelect + shouldUpdateFocusedIndex addBottomSafeAreaPadding footerContent={ { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualNavigation, + // No-op focus effect: useInitialSelection still freezes via its useState seed, which is what we assert on. + useFocusEffect: jest.fn(), + }; +}); + +// withPolicy injects the policy from Onyx; the test injects it directly instead. +jest.mock('@pages/workspace/withPolicy', () => (component: unknown) => component); +jest.mock( + '@pages/workspace/AccessOrNotFoundWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock( + '@components/BlockingViews/FullPageNotFoundView', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock( + '@components/ScreenWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); +jest.mock('@hooks/useReviewWorkspaceSettingsTaskCompletion', () => jest.fn(() => () => undefined)); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + toLocaleOrdinal: (day: number) => String(day), + })), +); + +type MockListItem = {value: string; keyForList: string; isSelected?: boolean; text?: string; isNumber?: boolean}; + +type MockSelectionListProps = { + data: MockListItem[]; + initiallyFocusedItemKey?: string; + shouldScrollToFocusedIndexOnMount?: boolean; + shouldUpdateFocusedIndex?: boolean; + disableMaintainingScrollPosition?: boolean; + onSelectRow: (item: MockListItem) => void; +}; + +type PageProps = {policy: Policy; route: {params: {policyID: string}}}; + +// The identity-mocked withPolicy renders the inner component, so re-type the export to the props it actually accepts. +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- re-typing the identity-mocked HOC export to its inner component props +const Page = WorkspaceAutoReportingMonthlyOffsetPage as unknown as React.ComponentType; + +function pageElement(offset: number) { + return ( + + ); +} + +describe('WorkspaceAutoReportingMonthlyOffsetPage', () => { + const mockedSelectionList = jest.mocked(SelectionList); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- narrows the props captured from the mocked SelectionList in this test + const getSelectionListProps = () => mockedSelectionList.mock.lastCall?.[0] as MockSelectionListProps | undefined; + + beforeEach(() => { + mockedSelectionList.mockClear(); + }); + + it('pins the initially selected day to the top on open', () => { + // The 15th sorts to the middle, so seeing it first proves pinning (not the natural day order) put it there. + render(pageElement(15)); + + const props = getSelectionListProps(); + expect(props?.data.at(0)?.value).toBe('15'); + expect(props?.data.at(0)?.isSelected).toBe(true); + // The 1st would be first if nothing were pinned. + expect(props?.data.at(0)?.value).not.toBe('1'); + expect(props?.initiallyFocusedItemKey).toBe('15'); + expect(props?.shouldScrollToFocusedIndexOnMount).toBe(false); + expect(props?.shouldUpdateFocusedIndex).toBe(true); + expect(props?.disableMaintainingScrollPosition).toBe(true); + }); + + it('keeps the originally pinned day at the top while the live selection changes', () => { + render(pageElement(15)); + + // Simulate the user picking a different day. The frozen pin must not jump to it. + act(() => { + getSelectionListProps()?.onSelectRow({value: '3', keyForList: '3', isNumber: true}); + }); + + const props = getSelectionListProps(); + const order = props?.data.map((item) => item.value) ?? []; + expect(order.at(0)).toBe('15'); + expect(order.indexOf('15')).toBeLessThan(order.indexOf('3')); + // The checkmark still follows the live value. + expect(props?.data.find((item) => item.value === '3')?.isSelected).toBe(true); + expect(props?.data.find((item) => item.value === '15')?.isSelected).toBe(false); + }); +}); diff --git a/tests/ui/WorkspaceWorkflowsPayerPageTest.tsx b/tests/ui/WorkspaceWorkflowsPayerPageTest.tsx new file mode 100644 index 000000000000..dde4202384f3 --- /dev/null +++ b/tests/ui/WorkspaceWorkflowsPayerPageTest.tsx @@ -0,0 +1,208 @@ +import {act, render} from '@testing-library/react-native'; + +import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; + +// eslint-disable-next-line no-restricted-imports -- type-only wildcard import used solely to type jest.requireActual below; isPaidGroupPolicy is not referenced +import type * as PolicyUtilsModule from '@libs/PolicyUtils'; + +import WorkspaceWorkflowsPayerPage from '@pages/workspace/workflows/WorkspaceWorkflowsPayerPage'; + +import type {PersonalDetailsList, Policy} from '@src/types/onyx'; + +import type * as ReactNavigation from '@react-navigation/native'; + +import React from 'react'; + +// Capture the latest focus-effect callback so a test can simulate the page regaining focus (e.g. returning after toggling a setting). +const mockFocus: {callback?: () => void} = {}; +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualNavigation, + useFocusEffect: jest.fn((callback: () => void) => { + mockFocus.callback = callback; + }), + }; +}); + +// The HOC injects policy/personalDetails from Onyx; the test injects them directly instead. +jest.mock('@pages/workspace/withPolicyAndFullscreenLoading', () => (component: unknown) => component); +jest.mock( + '@pages/workspace/AccessOrNotFoundWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock( + '@components/BlockingViews/FullPageNotFoundView', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock( + '@components/ScreenWrapper', + () => + ({children}: {children: React.ReactNode}) => + children, +); +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@pages/workspace/workflows/WorkspaceWorkflowsPayerSuccessPage', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/SelectionListWithSections', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/UserListItem', () => jest.fn(() => null)); + +jest.mock('@hooks/useOnyx', () => jest.fn(() => [undefined])); +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); +jest.mock('@hooks/useConfirmModal', () => jest.fn(() => ({showConfirmModal: jest.fn(), closeModal: jest.fn()}))); +jest.mock('@hooks/usePressLoading', () => jest.fn(() => ({isLoading: false, startWithLoading: jest.fn()}))); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({accountID: 1, login: 'owner@test.com'}))); +jest.mock('@hooks/usePersonalDetailByLogin', () => ({ + __esModule: true, + default: jest.fn(() => 'Display Name'), + // employeePersonalDetails is only forwarded to the mocked getMemberAccountIDsForWorkspace, so the value is irrelevant. + usePersonalDetailsByLogins: jest.fn(() => ({})), +})); +jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: jest.fn(() => ({FallbackAvatar: 'FallbackAvatar'}))})); +jest.mock('@libs/BankAccountUtils', () => ({isBankAccountPartiallySetup: jest.fn(() => false)})); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + formatPhoneNumber: (value: string) => value, + })), +); +jest.mock('@libs/PolicyUtils', () => { + const actual = jest.requireActual('@libs/PolicyUtils'); + return { + ...actual, + canMemberWrite: jest.fn(() => true), + isExpensifyTeam: jest.fn(() => false), + isPendingDeletePolicy: jest.fn(() => false), + goBackFromInvalidPolicy: jest.fn(), + getMemberAccountIDsForWorkspace: jest.fn(() => + Object.fromEntries([ + ['payer@test.com', 2], + ['admin@test.com', 3], + ]), + ), + }; +}); + +type MockMember = {keyForList: string; accountID: number; isSelected?: boolean}; +type MockSection = {data: MockMember[]}; + +type MockSectionsListProps = { + sections: MockSection[]; + onSelectRow: (item: MockMember) => void; + shouldUpdateFocusedIndex?: boolean; + initiallyFocusedItemKey?: string; + textInputOptions?: {onChangeText?: (text: string) => void}; +}; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- only the login field of each entry is read in this page +const PERSONAL_DETAILS = Object.fromEntries([ + // Display names share the "Test" prefix so a single search term matches both members. + [2, {accountID: 2, login: 'payer@test.com', displayName: 'Test Payer'}], + [3, {accountID: 3, login: 'admin@test.com', displayName: 'Test Admin'}], +]) as unknown as PersonalDetailsList; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- only a handful of policy fields are read in this page +const POLICY = { + id: 'policy1', + owner: 'owner@test.com', + ownerAccountID: 1, + reimbursementChoice: 'reimburseManual', + achAccount: {reimburser: 'payer@test.com', bankAccountID: 55, state: 'OPEN'}, + employeeList: Object.fromEntries([ + ['payer@test.com', {role: 'admin'}], + ['admin@test.com', {role: 'admin'}], + ]), +} as unknown as Policy; + +type PageProps = {policy: Policy; personalDetails: PersonalDetailsList; isLoadingReportData: boolean; route: {params: {policyID: string}}}; + +// The identity-mocked HOC renders the inner component, so re-type the export to the props it actually accepts. +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- re-typing the identity-mocked HOC export to its inner component props +const Page = WorkspaceWorkflowsPayerPage as unknown as React.ComponentType; + +function pageElement() { + return ( + + ); +} + +describe('WorkspaceWorkflowsPayerPage', () => { + const mockedList = jest.mocked(SelectionListWithSections); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- narrows the props captured from the mocked list in this test + const getListProps = () => mockedList.mock.lastCall?.[0] as MockSectionsListProps | undefined; + + beforeEach(() => { + mockedList.mockClear(); + }); + + it('pins the initially selected payer to the top section on open', () => { + render(pageElement()); + + const props = getListProps(); + // First (untitled) section holds the frozen initial payer. + expect(props?.sections.at(0)?.data.at(0)?.keyForList).toBe('2'); + expect(props?.sections.at(0)?.data.at(0)?.isSelected).toBe(true); + expect(props?.initiallyFocusedItemKey).toBe('2'); + expect(props?.shouldUpdateFocusedIndex).toBe(true); + }); + + it('keeps the initial payer pinned in the top section while the live selection changes', () => { + render(pageElement()); + + // Simulate the user picking a different admin. The frozen section placement must not jump. + act(() => { + getListProps()?.onSelectRow({keyForList: '3', accountID: 3}); + }); + + const props = getListProps(); + // The initial payer is still alone at the top section... + expect(props?.sections.at(0)?.data.at(0)?.keyForList).toBe('2'); + expect(props?.sections.at(0)?.data.at(0)?.isSelected).toBe(false); + // ...and the newly picked admin only got the checkmark, staying in the admins section. + const admin = props?.sections.at(1)?.data.find((member) => member.keyForList === '3'); + expect(admin?.isSelected).toBe(true); + }); + + it('pins the saved payer to the top of the search results', () => { + render(pageElement()); + + // Searching collapses the two sections into one filtered list. + act(() => { + getListProps()?.textInputOptions?.onChangeText?.('Test'); + }); + + const props = getListProps(); + // The saved payer leads the filtered results instead of appearing after the admins. + expect(props?.sections.at(0)?.data.at(0)?.keyForList).toBe('2'); + }); + + it('keeps the saved payer in the top section (not the unsaved pick) when the page regains focus', () => { + render(pageElement()); + + // Pick a different admin without saving. + act(() => { + getListProps()?.onSelectRow({keyForList: '3', accountID: 3}); + }); + + // Simulate returning to the page (e.g. after toggling a setting), which fires the focus effect. + act(() => { + mockFocus.callback?.(); + }); + + const props = getListProps(); + // The saved payer stays alone at the top section; the unsaved pick must not jump there. + expect(props?.sections.at(0)?.data.at(0)?.keyForList).toBe('2'); + expect(props?.sections.at(0)?.data.at(0)?.isSelected).toBe(false); + // The unsaved pick keeps only the checkmark, still in the admins section. + const admin = props?.sections.at(1)?.data.find((member) => member.keyForList === '3'); + expect(admin?.isSelected).toBe(true); + }); +});