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
15 changes: 13 additions & 2 deletions src/components/DatePicker/CalendarPicker/YearPickerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import useInitialSelection from '@hooks/useInitialSelection';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';

import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils';
import moveInitialSelectionToTop, {shouldMoveInitialSelectionToTop} from '@libs/SelectionListOrderUtils';

import CONST from '@src/CONST';

Expand Down Expand Up @@ -39,7 +39,18 @@ function YearPickerModal({isVisible, years, currentYear, onYearChange, onClose,
const initialYear = useInitialSelection(resolvedCurrentYear, {isVisible});
// Pin the frozen initial year to the top of the full sorted list before search filtering, so it stays pinned while searching.
// Copy before sorting so we don't mutate the caller's `years` prop during render.
const sortedYears = [...years].sort((a, b) => b.value - a.value);
// Long lists (where the pin applies) show upcoming years ascending, then past years nearest first, so the row after the pinned year is the next year.
// Short lists aren't pinned, so they keep the newest-first order.
const compareNewestFirst = (a: CalendarPickerListItem, b: CalendarPickerListItem) => b.value - a.value;
const compareAroundInitialYear = (a: CalendarPickerListItem, b: CalendarPickerListItem) => {
const isAUpcoming = a.value > initialYear;
const isBUpcoming = b.value > initialYear;
if (isAUpcoming !== isBUpcoming) {
return isAUpcoming ? -1 : 1;
}
return isAUpcoming ? a.value - b.value : b.value - a.value;
};
const sortedYears = [...years].sort(shouldMoveInitialSelectionToTop(years.length) ? compareAroundInitialYear : compareNewestFirst);
const orderedYears = moveInitialSelectionToTop(sortedYears, [String(initialYear)]);
const data = searchText === '' ? orderedYears : orderedYears.filter((year) => year.text?.includes(searchText));
const headerMessage = !data.length ? translate('common.noResultsFound') : '';
Expand Down
8 changes: 7 additions & 1 deletion src/libs/SelectionListOrderUtils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import CONST from '@src/CONST';

/** Whether a list of `itemCount` items is long enough for the initial selection to be pinned to the top. */
function shouldMoveInitialSelectionToTop(itemCount: number): boolean {
return itemCount >= CONST.STANDARD_LIST_ITEM_LIMIT;
}

function moveInitialSelectionToTop<T extends {value?: number | string}>(items: T[], initialSelectedValues: string[]): T[] {
if (initialSelectedValues.length === 0 || items.length < CONST.STANDARD_LIST_ITEM_LIMIT) {
if (initialSelectedValues.length === 0 || !shouldMoveInitialSelectionToTop(items.length)) {
return items;
}

Expand All @@ -22,3 +27,4 @@ function moveInitialSelectionToTop<T extends {value?: number | string}>(items: T
}

export default moveInitialSelectionToTop;
export {shouldMoveInitialSelectionToTop};
63 changes: 56 additions & 7 deletions tests/ui/YearPickerModalTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ describe('YearPickerModal', () => {
});

it('pins the current year to the top on open', () => {
// Given a year list long enough for the pin to apply
render(
<YearPickerModal
isVisible
Expand All @@ -59,16 +60,18 @@ describe('YearPickerModal', () => {
/>,
);

// When the picker opens
const props = getSelectionListProps();

// Then the current year leads the list and is the focused item
expect(props?.data.at(0)?.value).toBe(2020);
// The list sorts newest-first, so 2027 would lead if nothing were pinned.
expect(props?.data.at(0)?.value).not.toBe(2027);
expect(props?.initiallyFocusedItemKey).toBe('2020');
expect(props?.shouldScrollToFocusedIndexOnMount).toBe(false);
expect(props?.shouldUpdateFocusedIndex).toBe(true);
});

it('keeps the current year pinned at the top of the search results', () => {
it('lists upcoming years ascending, then past years nearest first, after the pinned year', () => {
// Given a year list long enough for the pin to apply, with 2020 selected
render(
<YearPickerModal
isVisible
Expand All @@ -78,15 +81,58 @@ describe('YearPickerModal', () => {
/>,
);

// "202" matches 2020-2027. 2027 sorts first, so 2020 leading proves the pin held.
// When the picker opens
const values = getSelectionListProps()?.data.map((year) => year.value);

// Then the row after the pinned year is the next year, not the latest year in the list
expect(values).toEqual([2020, 2021, 2022, 2023, 2024, 2025, 2026, 2027, 2019, 2018, 2017, 2016, 2015, 2014]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tests/ui/YearPickerModalTest.tsx:88

🟢 Can we add a case for each end of the list ? When the selected year is the newest year there are no upcoming years, and when it is the oldest there are no past years.

Both hit the isAUpcoming !== isBUpcoming branch differently and are easy to break later:

it.each([
    [2027, [2027, 2026, 2025, 2024, 2023, 2022, 2021, 2020, 2019, 2018, 2017, 2016, 2015, 2014]],
    [2014, [2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026, 2027]],
])('orders correctly when %i is at the edge of the list', (currentYear, expected) => { ... });

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 6b6f78c. I added the it.each case for 2027 (newest, only past years follow) and 2014 (oldest, only upcoming years follow). YearPickerModalTest, SelectionListOrderUtilsTest, and CalendarPickerTest pass (49 tests).


view run

});

it.each([
// The newest year is selected, so there are no upcoming years and only past years follow, nearest first
[2027, [2027, 2026, 2025, 2024, 2023, 2022, 2021, 2020, 2019, 2018, 2017, 2016, 2015, 2014]],
// The oldest year is selected, so there are no past years and only upcoming years follow, ascending
[2014, [2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026, 2027]],
])('orders correctly when %i is at the edge of the list', (currentYear, expected) => {
// Given a year list long enough for the pin to apply, with a year at one end of the list selected
render(
<YearPickerModal
isVisible
years={buildYears(2014, 14)}
currentYear={currentYear}
onClose={jest.fn()}
/>,
);

// When the picker opens
const values = getSelectionListProps()?.data.map((year) => year.value);

// Then the selected year leads and the rest of the list is ordered from the only side that has years
expect(values).toEqual(expected);
});

it('keeps the current year pinned at the top of the search results', () => {
// Given a year list long enough for the pin to apply, with 2024 selected so "202" matches years on both sides of it
render(
<YearPickerModal
isVisible
years={buildYears(2014, 14)}
currentYear={2024}
onClose={jest.fn()}
/>,
);

// When the user searches "202", which matches 2020-2027
act(() => {
getSelectionListProps()?.textInputOptions?.onChangeText?.('202');
});

expect(getSelectionListProps()?.data.at(0)?.value).toBe(2020);
// Then 2024 stays first, the upcoming years follow in ascending order, and the past years come last, nearest first
expect(getSelectionListProps()?.data.map((year) => year.value)).toEqual([2024, 2025, 2026, 2027, 2023, 2022, 2021, 2020]);
});

it('does not reorder when the year list is under the item-limit threshold', () => {
// Given a year list shorter than the item-limit threshold
render(
<YearPickerModal
isVisible
Expand All @@ -96,7 +142,10 @@ describe('YearPickerModal', () => {
/>,
);

// Below the threshold moveInitialSelectionToTop is a no-op, so newest-first order is kept.
expect(getSelectionListProps()?.data.at(0)?.value).toBe(2024);
// When the picker opens
const values = getSelectionListProps()?.data.map((year) => year.value);

// Then nothing is pinned and the newest-first order is kept
expect(values).toEqual([2024, 2023, 2022, 2021, 2020]);
});
});
13 changes: 12 additions & 1 deletion tests/unit/SelectionListOrderUtilsTest.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils';
import moveInitialSelectionToTop, {shouldMoveInitialSelectionToTop} from '@libs/SelectionListOrderUtils';

import CONST from '@src/CONST';

Expand Down Expand Up @@ -86,4 +86,15 @@ describe('SelectionListOrderUtils', () => {
expect(valuesOf(moveInitialSelectionToTop(items, valuesOf(items)))).toEqual(valuesOf(items));
});
});

describe('shouldMoveInitialSelectionToTop', () => {
it('applies the pin only once the list reaches the global threshold (boundary is inclusive)', () => {
// Given item counts just below, at, and above the threshold
// When checking whether the pin applies to each
// Then only the counts at or above the threshold pin, matching moveInitialSelectionToTop's early return
expect(shouldMoveInitialSelectionToTop(CONST.STANDARD_LIST_ITEM_LIMIT - 1)).toBe(false);
expect(shouldMoveInitialSelectionToTop(CONST.STANDARD_LIST_ITEM_LIMIT)).toBe(true);
expect(shouldMoveInitialSelectionToTop(CONST.STANDARD_LIST_ITEM_LIMIT + 1)).toBe(true);
});
});
});
Loading