Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ 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';

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';
Expand All @@ -37,6 +39,7 @@ type AutoReportingOffsetKeys = ValueOf<typeof CONST.POLICY.AUTO_REPORTING_OFFSET
type WorkspaceAutoReportingMonthlyOffsetPageItem = {
text: string;
keyForList: string;
value: string;
isSelected: boolean;
isNumber?: boolean;
};
Expand All @@ -48,6 +51,8 @@ function WorkspaceAutoReportingMonthlyOffsetPage({policy, route}: WorkspaceAutoR
const offset = policy?.autoReportingOffset ?? 1;
const [userSelectedOffset, setUserSelectedOffset] = useState<number | AutoReportingOffsetKeys | undefined>();
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();

Expand All @@ -57,25 +62,30 @@ function WorkspaceAutoReportingMonthlyOffsetPage({policy, route}: WorkspaceAutoR
return {
text: toLocaleOrdinal(day),
keyForList: day.toString(), // we have to cast it as string for <ListItem> 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));
Expand Down Expand Up @@ -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
/>
Expand Down
13 changes: 10 additions & 3 deletions src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string | undefined>(policy?.achAccount?.reimburser ?? policy?.owner);
const savedPayer = policy?.achAccount?.reimburser ?? policy?.owner;
const [selectedPayer, setSelectedPayer] = useState<string | undefined>(savedPayer);
const initialPayer = useInitialSelection(savedPayer, {resetOnFocus: true});
const shouldShowSuccess = sharedBankAccountData?.shouldShowSuccess ?? false;
const styles = useThemeStyles();
const {showConfirmModal, closeModal} = useConfirmModal();
Expand Down Expand Up @@ -125,6 +128,8 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR
}
const roleBadge = <Badge text={isOwner ? translate('common.owner') : translate('workspace.common.roleName', policyEmployee.role)} />;
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,
Expand All @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -386,6 +392,7 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR
}}
initiallyFocusedItemKey={formattedAuthorizedPayer.at(0)?.keyForList}
shouldSingleExecuteRowSelect
shouldUpdateFocusedIndex
addBottomSafeAreaPadding
footerContent={
<FormAlertWithSubmitButton
Expand Down
120 changes: 120 additions & 0 deletions tests/ui/WorkspaceAutoReportingMonthlyOffsetPageTest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {act, render} from '@testing-library/react-native';

import SelectionList from '@components/SelectionList';

import WorkspaceAutoReportingMonthlyOffsetPage from '@pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage';

import type {Policy} from '@src/types/onyx';

import type * as ReactNavigation from '@react-navigation/native';

import React from 'react';

jest.mock('@react-navigation/native', () => {
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<PageProps>;

function pageElement(offset: number) {
return (
<Page
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- only the offset field is read in this page
policy={{id: 'policy1', autoReportingOffset: offset} as Policy}
route={{params: {policyID: 'policy1'}}}
/>
);
}

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);
});
});
Loading
Loading