Skip to content

Commit 761a3f5

Browse files
authored
Merge pull request Expensify#87972 from software-mansion-labs/impr/intersecting-date-filters-search
Handle queries that use both a date preset and a custom range in Search filters UI
2 parents e64e63e + 75cba7d commit 761a3f5

5 files changed

Lines changed: 193 additions & 49 deletions

File tree

src/libs/SearchQueryUtils.ts

Lines changed: 92 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {addDays, format, parse} from 'date-fns';
1+
import {addDays, endOfMonth, format, parse, startOfMonth, startOfYear, subMonths} from 'date-fns';
22
import cloneDeep from 'lodash/cloneDeep';
33
import Onyx from 'react-native-onyx';
44
import type {OnyxCollection, OnyxUpdate} from 'react-native-onyx';
@@ -983,6 +983,56 @@ function getAllPolicyValues<T extends OnyxCollectionKey>(
983983
return policyID.map((id) => policyData?.[`${key}${id}`]).filter((data) => !!data) as Array<OnyxCollectionValuesMapping[T]>;
984984
}
985985

986+
function getEarlierDate(someDate: string | undefined, otherDate: string | undefined) {
987+
if (someDate && otherDate) {
988+
return someDate < otherDate ? someDate : otherDate;
989+
}
990+
return someDate ?? otherDate;
991+
}
992+
993+
function getLaterDate(someDate: string | undefined, otherDate: string | undefined) {
994+
if (someDate && otherDate) {
995+
return someDate > otherDate ? someDate : otherDate;
996+
}
997+
return someDate ?? otherDate;
998+
}
999+
1000+
/**
1001+
* Returns the start and end date range for a date preset.
1002+
*/
1003+
function getDateRangeForPreset(preset: SearchDatePreset): {start: string; end: string} {
1004+
const now = new Date();
1005+
let start: Date;
1006+
let end: Date;
1007+
const lastMonth = subMonths(now, 1);
1008+
1009+
switch (preset) {
1010+
case CONST.SEARCH.DATE_PRESETS.THIS_MONTH:
1011+
start = startOfMonth(now);
1012+
end = endOfMonth(now);
1013+
break;
1014+
case CONST.SEARCH.DATE_PRESETS.LAST_MONTH:
1015+
start = startOfMonth(lastMonth);
1016+
end = endOfMonth(lastMonth);
1017+
break;
1018+
case CONST.SEARCH.DATE_PRESETS.YEAR_TO_DATE:
1019+
start = startOfYear(now);
1020+
end = now;
1021+
break;
1022+
case CONST.SEARCH.DATE_PRESETS.LAST_12_MONTHS:
1023+
start = startOfMonth(subMonths(now, 11));
1024+
end = endOfMonth(now);
1025+
break;
1026+
default:
1027+
return {start: '', end: ''};
1028+
}
1029+
1030+
return {
1031+
start: format(start, 'yyyy-MM-dd'),
1032+
end: format(end, 'yyyy-MM-dd'),
1033+
};
1034+
}
1035+
9861036
/**
9871037
* Generates object with search filter values, in a format that can be consumed by SearchAdvancedFiltersForm.
9881038
* Main usage of this is to generate the initial values for AdvancedFilters from existing query.
@@ -1135,10 +1185,13 @@ function buildFilterFormValuesFromQuery(
11351185
return filter.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO && (isValidDate(filter.value.toString()) || isSearchDatePreset(filter.value.toString()));
11361186
});
11371187
const existingRangeBoundaries = parseRangeQueryValue(filtersForm[rangeKey]);
1138-
const rangeValue = getRangeQueryValue(rangeStartFilter?.value.toString() ?? existingRangeBoundaries.from, rangeEndFilter?.value.toString() ?? existingRangeBoundaries.to);
1188+
const rangeValue = getRangeQueryValue(
1189+
getLaterDate(rangeStartFilter?.value.toString(), existingRangeBoundaries.from),
1190+
getEarlierDate(rangeEndFilter?.value.toString(), existingRangeBoundaries.to),
1191+
);
11391192

1140-
filtersForm[beforeKey] = beforeFilter?.value.toString() ?? filtersForm[beforeKey];
1141-
filtersForm[afterKey] = afterFilter?.value.toString() ?? filtersForm[afterKey];
1193+
filtersForm[beforeKey] = getEarlierDate(beforeFilter?.value.toString(), filtersForm[beforeKey]);
1194+
filtersForm[afterKey] = getLaterDate(afterFilter?.value.toString(), filtersForm[afterKey]);
11421195
filtersForm[onKey] = onFilter?.value.toString() ?? filtersForm[onKey];
11431196
filtersForm[negatedKey] = negatedFilter?.value.toString() ?? filtersForm[negatedKey];
11441197

@@ -1216,13 +1269,16 @@ function buildFilterFormValuesFromQuery(
12161269
}
12171270

12181271
const existingRangeBoundaries = parseRangeQueryValue(filtersForm[dateRangeKey]);
1219-
const rangeValue = getRangeQueryValue(dateRangeStartFilter?.value.toString() ?? existingRangeBoundaries.from, dateRangeEndFilter?.value.toString() ?? existingRangeBoundaries.to);
1272+
const rangeValue = getRangeQueryValue(
1273+
getLaterDate(dateRangeStartFilter?.value.toString(), existingRangeBoundaries.from),
1274+
getEarlierDate(dateRangeEndFilter?.value.toString(), existingRangeBoundaries.to),
1275+
);
12201276

12211277
filtersForm[textKey] = textFilter?.value.toString() ?? filtersForm[textKey];
12221278
filtersForm[negatedKey] = negatedFilter?.value.toString() ?? filtersForm[negatedKey];
12231279
filtersForm[dateOnKey] = dateOnFilter?.value.toString() ?? filtersForm[dateOnKey];
1224-
filtersForm[dateBeforeKey] = dateBeforeFilter?.value.toString() ?? filtersForm[dateBeforeKey];
1225-
filtersForm[dateAfterKey] = dateAfterFilter?.value.toString() ?? filtersForm[dateAfterKey];
1280+
filtersForm[dateBeforeKey] = getEarlierDate(dateBeforeFilter?.value.toString(), filtersForm[dateBeforeKey]);
1281+
filtersForm[dateAfterKey] = getLaterDate(dateAfterFilter?.value.toString(), filtersForm[dateAfterKey]);
12261282

12271283
if (rangeValue) {
12281284
filtersForm[dateRangeKey] = rangeValue;
@@ -1232,6 +1288,34 @@ function buildFilterFormValuesFromQuery(
12321288
}
12331289
}
12341290

1291+
// The UI doesn't combine date presets with other date modifiers, but a raw query string can.
1292+
// When that happens, we resolve the preset to a date range, merge with the explicit constraints,
1293+
// and store as one range.
1294+
for (const dateKey of DATE_FILTER_KEYS) {
1295+
const onKey = `${dateKey}${CONST.SEARCH.DATE_MODIFIERS.ON}` as const;
1296+
const rangeKey = `${dateKey}${CONST.SEARCH.DATE_MODIFIERS.RANGE}` as const;
1297+
const beforeKey = `${dateKey}${CONST.SEARCH.DATE_MODIFIERS.BEFORE}` as const;
1298+
const afterKey = `${dateKey}${CONST.SEARCH.DATE_MODIFIERS.AFTER}` as const;
1299+
1300+
if (!isSearchDatePreset(filtersForm[onKey]) || !(filtersForm[rangeKey] ?? filtersForm[beforeKey] ?? filtersForm[afterKey])) {
1301+
continue;
1302+
}
1303+
1304+
const presetRange = getDateRangeForPreset(filtersForm[onKey]);
1305+
if (!presetRange.start || !presetRange.end) {
1306+
continue;
1307+
}
1308+
1309+
const existingRange = parseRangeQueryValue(filtersForm[rangeKey]);
1310+
const newRangeStart = getLaterDate(getLaterDate(presetRange.start, existingRange.from), getInclusiveRangeBoundary(filtersForm[afterKey], 1));
1311+
const newRangeEnd = getEarlierDate(getEarlierDate(presetRange.end, existingRange.to), getInclusiveRangeBoundary(filtersForm[beforeKey], -1));
1312+
1313+
filtersForm[rangeKey] = getRangeQueryValue(newRangeStart, newRangeEnd);
1314+
filtersForm[onKey] = undefined;
1315+
filtersForm[beforeKey] = undefined;
1316+
filtersForm[afterKey] = undefined;
1317+
}
1318+
12351319
const [typeKey, typeValue] = Object.entries(CONST.SEARCH.DATA_TYPES).find(([, value]) => value === queryJSON.type) ?? [];
12361320
filtersForm[FILTER_KEYS.TYPE] = typeValue ? queryJSON.type : CONST.SEARCH.DATA_TYPES.EXPENSE;
12371321

@@ -2043,6 +2127,7 @@ export {
20432127
getRangeQueryValue,
20442128
parseRangeQueryValue,
20452129
isSearchDatePreset,
2130+
getDateRangeForPreset,
20462131
isFilterSupported,
20472132
buildSearchQueryJSON,
20482133
buildSearchQueryString,

src/libs/SearchUIUtils.ts

Lines changed: 2 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable max-lines */
22
// TODO: Remove this disable once SearchUIUtils is refactored (see dedicated refactor issue)
3-
import {addDays, endOfMonth, format, parse, startOfMonth, startOfYear, subDays, subMonths} from 'date-fns';
3+
import {addDays, format, parse, subDays} from 'date-fns';
44
import type {TextStyle, ViewStyle} from 'react-native';
55
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
66
import type {ValueOf} from 'type-fest';
@@ -167,6 +167,7 @@ import {
167167
buildSearchQueryString,
168168
getCurrentSearchQueryJSON,
169169
getDateRangeDisplayValueFromFormValue,
170+
getDateRangeForPreset,
170171
isAmountFilterKey,
171172
isFilterSupported,
172173
isSearchDatePreset,
@@ -4566,42 +4567,6 @@ function getDatePresets(filterKey: SearchDateFilterKeys, hasFeed: boolean): Sear
45664567
}
45674568
}
45684569

4569-
/**
4570-
* Returns the start and end date range for a date preset.
4571-
*/
4572-
function getDateRangeForPreset(preset: SearchDatePreset): {start: string; end: string} {
4573-
const now = new Date();
4574-
let start: Date;
4575-
let end: Date;
4576-
const lastMonth = subMonths(now, 1);
4577-
4578-
switch (preset) {
4579-
case CONST.SEARCH.DATE_PRESETS.THIS_MONTH:
4580-
start = startOfMonth(now);
4581-
end = endOfMonth(now);
4582-
break;
4583-
case CONST.SEARCH.DATE_PRESETS.LAST_MONTH:
4584-
start = startOfMonth(lastMonth);
4585-
end = endOfMonth(lastMonth);
4586-
break;
4587-
case CONST.SEARCH.DATE_PRESETS.YEAR_TO_DATE:
4588-
start = startOfYear(now);
4589-
end = now;
4590-
break;
4591-
case CONST.SEARCH.DATE_PRESETS.LAST_12_MONTHS:
4592-
start = startOfMonth(subMonths(now, 11));
4593-
end = endOfMonth(now);
4594-
break;
4595-
default:
4596-
return {start: '', end: ''};
4597-
}
4598-
4599-
return {
4600-
start: format(start, 'yyyy-MM-dd'),
4601-
end: format(end, 'yyyy-MM-dd'),
4602-
};
4603-
}
4604-
46054570
/**
46064571
* Checks if a string value is a date preset
46074572
*/
@@ -5827,7 +5792,6 @@ export {
58275792
isTransactionAmountTooLong,
58285793
isTransactionTaxAmountTooLong,
58295794
getDatePresets,
5830-
getDateRangeForPreset,
58315795
createAndOpenSearchTransactionThread,
58325796
getWithdrawalTypeOptions,
58335797
getActionOptions,

src/pages/workspace/travel/WorkspaceTravelInvoicingExportPage.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@ import {exportTravelInvoiceStatementCSV, getTravelInvoiceStatementPDF} from '@li
1818
import Navigation from '@libs/Navigation/Navigation';
1919
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
2020
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
21-
import {getRangeBoundariesFromFormValue, isSearchDatePreset} from '@libs/SearchQueryUtils';
22-
import {getDateRangeForPreset} from '@libs/SearchUIUtils';
21+
import {getDateRangeForPreset, getRangeBoundariesFromFormValue, isSearchDatePreset} from '@libs/SearchQueryUtils';
2322
import {downloadTravelInvoiceStatementPDF} from '@libs/TravelInvoicingUtils';
2423
import CONFIG from '@src/CONFIG';
2524
import CONST from '@src/CONST';

tests/unit/Search/SearchQueryUtilsTest.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -993,6 +993,102 @@ describe('SearchQueryUtils', () => {
993993
expect(result.dateRange).toBeUndefined();
994994
});
995995

996+
describe('date preset and range merging', () => {
997+
beforeEach(() => {
998+
jest.useFakeTimers();
999+
jest.setSystemTime(new Date('2026-04-15T12:00:00Z'));
1000+
});
1001+
1002+
afterEach(() => {
1003+
jest.useRealTimers();
1004+
});
1005+
1006+
test('merges date preset with inclusive range into a single range', () => {
1007+
const queryString = 'type:expense date:year-to-date date>=2026-04-01 date<=2026-04-30';
1008+
const queryJSON = buildSearchQueryJSON(queryString);
1009+
1010+
if (!queryJSON) {
1011+
throw new Error('Failed to parse query string');
1012+
}
1013+
1014+
const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}, {});
1015+
expect(result.dateOn).toBeUndefined();
1016+
expect(result.dateAfter).toBeUndefined();
1017+
expect(result.dateBefore).toBeUndefined();
1018+
expect(result.dateRange).toBe('2026-04-01,2026-04-15');
1019+
});
1020+
1021+
test('removes redundant date preset when date is fully included in the preset range', () => {
1022+
const queryString = 'type:expense date:last-12-months date>=2026-04-01 date<=2026-04-30';
1023+
const queryJSON = buildSearchQueryJSON(queryString);
1024+
1025+
if (!queryJSON) {
1026+
throw new Error('Failed to parse query string');
1027+
}
1028+
1029+
const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}, {});
1030+
expect(result.dateOn).toBeUndefined();
1031+
expect(result.dateAfter).toBeUndefined();
1032+
expect(result.dateBefore).toBeUndefined();
1033+
expect(result.dateRange).toBe('2026-04-01,2026-04-30');
1034+
});
1035+
1036+
test('merges all date-based filters, not only "date"', () => {
1037+
const queryString = 'type:expense approved:year-to-date approved>=2026-04-01 approved<=2026-04-30';
1038+
const queryJSON = buildSearchQueryJSON(queryString);
1039+
1040+
if (!queryJSON) {
1041+
throw new Error('Failed to parse query string');
1042+
}
1043+
1044+
const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}, {});
1045+
expect(result.approvedOn).toBeUndefined();
1046+
expect(result.approvedAfter).toBeUndefined();
1047+
expect(result.approvedBefore).toBeUndefined();
1048+
expect(result.approvedRange).toBe('2026-04-01,2026-04-15');
1049+
});
1050+
1051+
test('merges date preset and strict inequalities into a single range', () => {
1052+
const queryString = 'type:expense date:year-to-date date>2026-04-01 date<2026-04-30';
1053+
const queryJSON = buildSearchQueryJSON(queryString);
1054+
1055+
if (!queryJSON) {
1056+
throw new Error('Failed to parse query string');
1057+
}
1058+
1059+
const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}, {});
1060+
expect(result.dateOn).toBeUndefined();
1061+
expect(result.dateAfter).toBeUndefined();
1062+
expect(result.dateBefore).toBeUndefined();
1063+
expect(result.dateRange).toBe('2026-04-02,2026-04-15');
1064+
});
1065+
1066+
test('does not modify a standalone preset without other date constraints', () => {
1067+
const queryString = 'type:expense date:last-12-months';
1068+
const queryJSON = buildSearchQueryJSON(queryString);
1069+
1070+
if (!queryJSON) {
1071+
throw new Error('Failed to parse query string');
1072+
}
1073+
1074+
const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}, {});
1075+
expect(result.dateOn).toBe('last-12-months');
1076+
expect(result.dateRange).toBeUndefined();
1077+
});
1078+
1079+
test('merges multiple ranges into one', () => {
1080+
const queryString = 'type:expense date>=2026-04-01 date<=2026-04-30 date>=2026-03-01 date<=2026-04-15';
1081+
const queryJSON = buildSearchQueryJSON(queryString);
1082+
1083+
if (!queryJSON) {
1084+
throw new Error('Failed to parse query string');
1085+
}
1086+
1087+
const result = buildFilterFormValuesFromQuery(queryJSON, {}, {}, {}, {}, {}, {}, {});
1088+
expect(result.dateRange).toBe('2026-04-01,2026-04-15');
1089+
});
1090+
});
1091+
9961092
test('hydrates explicit report field range flag from inclusive range boundaries', () => {
9971093
const policyCategories = {};
9981094
const policyTags = {};

tests/unit/Search/SearchUIUtilsTest.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import IntlStore from '@src/languages/IntlStore';
3636
import type {CardFeedForDisplay} from '@src/libs/CardFeedUtils';
3737
import {getCardDescriptionForSearchTable} from '@src/libs/CardUtils';
3838
import DateUtils from '@src/libs/DateUtils';
39-
import {getUserFriendlyValue} from '@src/libs/SearchQueryUtils';
39+
import {getDateRangeForPreset, getUserFriendlyValue} from '@src/libs/SearchQueryUtils';
4040
import * as SearchUIUtils from '@src/libs/SearchUIUtils';
4141
import ONYXKEYS from '@src/ONYXKEYS';
4242
import ROUTES from '@src/ROUTES';
@@ -3549,7 +3549,7 @@ describe('SearchUIUtils', () => {
35493549
});
35503550

35513551
it('should resolve LAST_12_MONTHS preset to span the current month and 11 preceding months', () => {
3552-
const range = SearchUIUtils.getDateRangeForPreset(CONST.SEARCH.DATE_PRESETS.LAST_12_MONTHS);
3552+
const range = getDateRangeForPreset(CONST.SEARCH.DATE_PRESETS.LAST_12_MONTHS);
35533553

35543554
// Clock is frozen at 2026-02-15, so last 12 months = March 2025 through February 2026
35553555
expect(range.start).toBe('2025-03-01');

0 commit comments

Comments
 (0)