diff --git a/packages/jsapi-components/src/usePickerWithSelectedValues.test.ts b/packages/jsapi-components/src/usePickerWithSelectedValues.test.ts index 97166b414..e4400b6a4 100644 --- a/packages/jsapi-components/src/usePickerWithSelectedValues.test.ts +++ b/packages/jsapi-components/src/usePickerWithSelectedValues.test.ts @@ -67,6 +67,7 @@ const mock = { searchTextFilter: jest.fn() as FilterConditionFactory, selectedKey: 'mock.selectedKey', excludeSelectedValuesFilter: jest.fn() as FilterConditionFactory, + timeZone: 'mock.timeZone', value: 'mock.value', viewportData: createMockProxy>>(), }; @@ -101,6 +102,7 @@ async function renderOnceAndWait( columnName: mock.columnName, mapItemToValue: mock.mapItemToValue, filterConditionFactories: mock.filterConditionFactories, + timeZone: 'mock.timeZone', ...overrides, }) ); @@ -170,7 +172,8 @@ it.each([undefined, false, true])( expect(createSearchTextFilter).toHaveBeenCalledWith( tableUtils, mock.columnName, - '' + '', + mock.timeZone ); expect(createSelectedValuesFilter).toHaveBeenCalledWith( @@ -246,7 +249,8 @@ it.each([undefined, false, true])( expect(createSearchTextFilter).toHaveBeenCalledWith( tableUtils, mock.columnName, - trimSearchText === true ? mock.searchTextTrimmed : mock.searchText + trimSearchText === true ? mock.searchTextTrimmed : mock.searchText, + mock.timeZone ); expect(createSelectedValuesFilter).not.toHaveBeenCalled(); diff --git a/packages/jsapi-components/src/usePickerWithSelectedValues.ts b/packages/jsapi-components/src/usePickerWithSelectedValues.ts index d63b2ef78..6c3537bb2 100644 --- a/packages/jsapi-components/src/usePickerWithSelectedValues.ts +++ b/packages/jsapi-components/src/usePickerWithSelectedValues.ts @@ -48,6 +48,7 @@ export interface UsePickerWithSelectedValuesResult { * @param mapItemToValue A function to map an item to a value * @param filterConditionFactories Optional filter condition factories to apply to the list * @param trimSearchText Whether to trim the search text before filtering. Defaults to false + * @param timeZone The timezone to use for date parsing */ export function usePickerWithSelectedValues({ maybeTable, @@ -55,12 +56,14 @@ export function usePickerWithSelectedValues({ mapItemToValue, filterConditionFactories = [], trimSearchText = false, + timeZone, }: { maybeTable: dh.Table | null; columnName: string; mapItemToValue: (item: KeyedItem) => TValue; filterConditionFactories?: FilterConditionFactory[]; trimSearchText?: boolean; + timeZone: string; }): UsePickerWithSelectedValuesResult { const { itemHeight } = usePickerItemScale(); @@ -117,8 +120,14 @@ export function usePickerWithSelectedValues({ isApplyingFilter || valueExistsIsLoading ? null : valueExists; const searchTextFilter = useMemo( - () => createSearchTextFilter(tableUtils, columnName, appliedSearchText), - [appliedSearchText, columnName, tableUtils] + () => + createSearchTextFilter( + tableUtils, + columnName, + appliedSearchText, + timeZone + ), + [appliedSearchText, columnName, tableUtils, timeZone] ); // Filter out selected values from the picker diff --git a/packages/jsapi-components/src/useSearchableViewportData.test.ts b/packages/jsapi-components/src/useSearchableViewportData.test.ts index 74d2332c3..661f94872 100644 --- a/packages/jsapi-components/src/useSearchableViewportData.test.ts +++ b/packages/jsapi-components/src/useSearchableViewportData.test.ts @@ -1,6 +1,6 @@ import { act, renderHook } from '@testing-library/react-hooks'; import type { DebouncedFunc } from 'lodash'; -import type { FilterCondition, Table } from '@deephaven/jsapi-types'; +import type { dh as DhType } from '@deephaven/jsapi-types'; import { createSearchTextFilter, FilterConditionFactory, @@ -43,19 +43,23 @@ beforeEach(() => { describe('useSearchableViewportData: %s', () => { type SearchTextChangeHandler = DebouncedFunc<(value: string) => void>; - const table = createMockProxy(); - const columnNames = ['Aaa', 'Bbb', 'Ccc']; + const table = createMockProxy(); + const searchColumnNames = ['Aaa', 'Bbb', 'Ccc']; const additionalFilterConditionFactories = [ jest.fn(), jest.fn(), ] as FilterConditionFactory[]; + const mockTimeZone = 'mock.timeZone'; + const mockResult = { createSearchTextFilter: jest.fn() as FilterConditionFactory, useDebouncedCallback: jest.fn() as unknown as SearchTextChangeHandler, - useFilterConditionFactories: [] as FilterCondition[], + useFilterConditionFactories: [] as DhType.FilterCondition[], useTableUtils: createMockProxy(), - useViewportData: createMockProxy>({ + useViewportData: createMockProxy< + UseViewportDataResult + >({ table, }), }; @@ -82,7 +86,10 @@ describe('useSearchableViewportData: %s', () => { asMock(useViewportData) .mockName('useViewportData') .mockReturnValue( - mockResult.useViewportData as UseViewportDataResult + mockResult.useViewportData as UseViewportDataResult< + unknown, + DhType.Table + > ); asMock(createSearchTextFilter) @@ -96,11 +103,12 @@ describe('useSearchableViewportData: %s', () => { it('should create windowed viewport for list data', () => { const { result } = renderHook(() => - useSearchableViewportData( + useSearchableViewportData({ table, - columnNames, - ...additionalFilterConditionFactories - ) + searchColumnNames, + additionalFilterConditionFactories, + timeZone: mockTimeZone, + }) ); expect(useViewportData).toHaveBeenCalledWith({ @@ -116,25 +124,27 @@ describe('useSearchableViewportData: %s', () => { ); expect(result.current).toEqual({ - list: mockResult.useViewportData, + ...mockResult.useViewportData, onSearchTextChange: mockResult.useDebouncedCallback, }); }); it('should filter data based on search text', () => { const { result } = renderHook(() => - useSearchableViewportData( + useSearchableViewportData({ table, - columnNames, - ...additionalFilterConditionFactories - ) + searchColumnNames, + additionalFilterConditionFactories, + timeZone: mockTimeZone, + }) ); const testCommon = (expectedSearchText: string) => { expect(createSearchTextFilter).toHaveBeenCalledWith( mockResult.useTableUtils, - columnNames, - expectedSearchText + searchColumnNames, + expectedSearchText, + mockTimeZone ); expect(useFilterConditionFactories).toHaveBeenCalledWith( diff --git a/packages/jsapi-components/src/useSearchableViewportData.ts b/packages/jsapi-components/src/useSearchableViewportData.ts index 114f32c05..dfa3902e5 100644 --- a/packages/jsapi-components/src/useSearchableViewportData.ts +++ b/packages/jsapi-components/src/useSearchableViewportData.ts @@ -12,33 +12,56 @@ import { } from '@deephaven/utils'; import { useDebouncedCallback } from '@deephaven/react-hooks'; import { useTableUtils } from './useTableUtils'; -import useViewportData, { UseViewportDataResult } from './useViewportData'; +import useViewportData, { + UseViewportDataProps, + UseViewportDataResult, +} from './useViewportData'; import useFilterConditionFactories from './useFilterConditionFactories'; import useViewportFilter from './useViewportFilter'; -export interface SearchableViewportData { - list: UseViewportDataResult; +export interface UseSearchableViewportDataProps + extends UseViewportDataProps { + additionalFilterConditionFactories?: FilterConditionFactory[]; + searchColumnNames: string | string[]; + timeZone: string; +} + +export interface SearchableViewportData + extends UseViewportDataResult { onSearchTextChange: (searchText: string) => void; } /** * Use a viewport data list with a search text filter. Supports additional filters. - * @param maybeTable The table to use + * @param table The table to use + * @param itemHeight The height of each item + * @param scrollDebounce The debounce time for scroll events + * @param viewportSize The size of the viewport + * @param viewportPadding The padding around the viewport + * @param deserializeRow The row deserializer * @param searchColumnNames The column names to search + * @param timeZone Timezone to use for date parsing * @param additionalFilterConditionFactories Additional filter condition factories */ -export function useSearchableViewportData( - maybeTable: dh.Table | null, - searchColumnNames: string | string[], - ...additionalFilterConditionFactories: FilterConditionFactory[] -): SearchableViewportData { +export function useSearchableViewportData({ + additionalFilterConditionFactories = [], + searchColumnNames, + timeZone, + ...props +}: UseSearchableViewportDataProps): SearchableViewportData { const tableUtils = useTableUtils(); const [searchText, setSearchText] = useState(''); const searchTextFilter = useMemo( - () => createSearchTextFilter(tableUtils, searchColumnNames, searchText), - [searchColumnNames, searchText, tableUtils] + () => + createSearchTextFilter( + tableUtils, + searchColumnNames, + searchText, + timeZone + ), + [searchColumnNames, searchText, tableUtils, timeZone] ); const onSearchTextChange = useDebouncedCallback( @@ -47,10 +70,10 @@ export function useSearchableViewportData( ); const list = useViewportData({ - table: maybeTable, itemHeight: TABLE_ROW_HEIGHT, viewportSize: VIEWPORT_SIZE, viewportPadding: VIEWPORT_PADDING, + ...props, }); const filter = useFilterConditionFactories( @@ -62,7 +85,7 @@ export function useSearchableViewportData( useViewportFilter(list, filter); return { - list, + ...list, onSearchTextChange, }; } diff --git a/packages/jsapi-utils/src/DateUtils.test.ts b/packages/jsapi-utils/src/DateUtils.test.ts index b2f20a90a..7349844bb 100644 --- a/packages/jsapi-utils/src/DateUtils.test.ts +++ b/packages/jsapi-utils/src/DateUtils.test.ts @@ -43,6 +43,7 @@ describe('dateTimeString parsing tests', () => { minutes, seconds, nanos, + overflow, }: { year?: string; month?: string; @@ -51,9 +52,11 @@ describe('dateTimeString parsing tests', () => { minutes?: string; seconds?: string; nanos?: string; - } + overflow?: string; + }, + allowOverflow = false ) { - expect(DateUtils.parseDateTimeString(text)).toMatchObject({ + const expected = { year, month, date, @@ -61,7 +64,11 @@ describe('dateTimeString parsing tests', () => { minutes, seconds, nanos, - }); + }; + + expect(DateUtils.parseDateTimeString(text, allowOverflow)).toMatchObject( + allowOverflow ? { ...expected, overflow } : expected + ); } function testDateTimeStringThrows(text) { @@ -74,6 +81,15 @@ describe('dateTimeString parsing tests', () => { testDateTimeString('2012', { year: '2012', }); + + testDateTimeString( + '2012 overflow', + { + year: '2012', + overflow: ' overflow', + }, + true + ); }); it('handles YYYY-mm', () => { @@ -81,6 +97,47 @@ describe('dateTimeString parsing tests', () => { year: '2012', month: '04', }); + + testDateTimeString( + '2012-04 overflow', + { + year: '2012', + month: '04', + overflow: ' overflow', + }, + true + ); + }); + + it('handles YYYY-xxx', () => { + testDateTimeString('2012-mar', { + year: '2012', + month: 'mar', + }); + + testDateTimeString('2012-march', { + year: '2012', + month: 'march', + }); + }); + + it('handles YYYY-m-d', () => { + testDateTimeString('2012-4-6', { + year: '2012', + month: '4', + date: '6', + }); + + testDateTimeString( + '2012-04-20 overflow', + { + year: '2012', + month: '04', + date: '20', + overflow: ' overflow', + }, + true + ); }); it('handles YYYY-mm-dd', () => { @@ -89,6 +146,17 @@ describe('dateTimeString parsing tests', () => { month: '04', date: '20', }); + + testDateTimeString( + '2012-04-20 overflow', + { + year: '2012', + month: '04', + date: '20', + overflow: ' overflow', + }, + true + ); }); it('handles YYYY-mm-ddTHH', () => { @@ -98,6 +166,18 @@ describe('dateTimeString parsing tests', () => { date: '20', hours: '12', }); + + testDateTimeString( + '2012-04-20T12 overflow', + { + year: '2012', + month: '04', + date: '20', + hours: '12', + overflow: ' overflow', + }, + true + ); }); it('handles YYYY-mm-ddTHH:mm', () => { @@ -108,6 +188,19 @@ describe('dateTimeString parsing tests', () => { hours: '12', minutes: '13', }); + + testDateTimeString( + '2012-04-20T12:13 overflow', + { + year: '2012', + month: '04', + date: '20', + hours: '12', + minutes: '13', + overflow: ' overflow', + }, + true + ); }); it('handles YYYY-mm-ddTHH:mm:ss', () => { @@ -119,6 +212,20 @@ describe('dateTimeString parsing tests', () => { minutes: '13', seconds: '14', }); + + testDateTimeString( + '2012-04-20T12:13:14 overflow', + { + year: '2012', + month: '04', + date: '20', + hours: '12', + minutes: '13', + seconds: '14', + overflow: ' overflow', + }, + true + ); }); it('handles YYYY-mm-ddTHH:mm:ss.SSS', () => { @@ -131,6 +238,21 @@ describe('dateTimeString parsing tests', () => { seconds: '14', nanos: '321', }); + + testDateTimeString( + '2012-04-20T12:13:14.321 overflow', + { + year: '2012', + month: '04', + date: '20', + hours: '12', + minutes: '13', + seconds: '14', + nanos: '321', + overflow: ' overflow', + }, + true + ); }); it('handles YYYY-mm-dd HH:mm:ss.SSSSSS', () => { testDateTimeString('2012-04-20 12:13:14.654321', { @@ -142,6 +264,21 @@ describe('dateTimeString parsing tests', () => { seconds: '14', nanos: '654321', }); + + testDateTimeString( + '2012-04-20 12:13:14.654321 overflow', + { + year: '2012', + month: '04', + date: '20', + hours: '12', + minutes: '13', + seconds: '14', + nanos: '654321', + overflow: ' overflow', + }, + true + ); }); it('handles YYYY-mm-dd HH:mm:ss.SSSSSS', () => { testDateTimeString('2012-04-20 12:13:14.654321', { @@ -153,6 +290,21 @@ describe('dateTimeString parsing tests', () => { seconds: '14', nanos: '654321', }); + + testDateTimeString( + '2012-04-20 12:13:14.654321 overflow', + { + year: '2012', + month: '04', + date: '20', + hours: '12', + minutes: '13', + seconds: '14', + nanos: '654321', + overflow: ' overflow', + }, + true + ); }); it('handles YYYY-mm-dd HH:mm:ss.SSSSSSSSS', () => { testDateTimeString('2012-04-20 12:13:14.987654321', { @@ -164,10 +316,26 @@ describe('dateTimeString parsing tests', () => { seconds: '14', nanos: '987654321', }); + + testDateTimeString( + '2012-04-20 12:13:14.987654321 overflow', + { + year: '2012', + month: '04', + date: '20', + hours: '12', + minutes: '13', + seconds: '14', + nanos: '987654321', + overflow: ' overflow', + }, + true + ); }); it('throws an error for invalid dates', () => { testDateTimeStringThrows('not a date'); + testDateTimeStringThrows('2013-231-04'); testDateTimeStringThrows('20133-23-04'); testDateTimeStringThrows('2013-23-043'); testDateTimeStringThrows('2013-23-34zzz'); @@ -312,3 +480,53 @@ describe('getJsDate', () => { expect(DateUtils.getJsDate(dateWrapper)).toEqual(dateWrapper.asDate()); }); }); + +describe('trimDateTimeStringTimeZone', () => { + const dateTimeTexts = [ + '2024', + '2012-04', + '2012-04-20', + '2012-04-20T12', + '2012-04-20T12:13', + '2012-04-20T12:13:14', + '2012-04-20T12:13:14.321', + ]; + + it.each(dateTimeTexts)( + 'should return given string if no overflow: %s', + given => { + const actual = DateUtils.trimDateTimeStringTimeZone(given); + expect(actual).toEqual(given); + } + ); + + it.each(dateTimeTexts)( + 'should trim date time string overflow: %s', + expected => { + const given = `${expected} overflow`; + const actual = DateUtils.trimDateTimeStringTimeZone(given); + expect(actual).toEqual(expected); + } + ); + + it.each([ + '2024overflow', + '2012-04overflow', + '2012-04-20overflow', + '2012-04-20T12overflow', + '2012-04-20T12:13overflow', + '2012-04-20T12:13:14overflow', + '2012-04-20T12:13:14.321overflow', + '2024overflow', + '2012-04 overflow', + '2012-04-20 overflow', + '2012-04-20T12 overflow', + '2012-04-20T12:13 overflow', + '2012-04-20T12:13:14 overflow', + '2012-04-20T12:13:14.321 overflow', + ])('should throw for invalid timezone overflow: %s', invalidOverflow => { + expect(() => DateUtils.trimDateTimeStringTimeZone(invalidOverflow)).toThrow( + `Unexpected timezone format in overflow: '${invalidOverflow}'` + ); + }); +}); diff --git a/packages/jsapi-utils/src/DateUtils.ts b/packages/jsapi-utils/src/DateUtils.ts index cd2f78880..59090ba31 100644 --- a/packages/jsapi-utils/src/DateUtils.ts +++ b/packages/jsapi-utils/src/DateUtils.ts @@ -1,5 +1,8 @@ import type { dh as DhType } from '@deephaven/jsapi-types'; +const DATE_TIME_REGEX = + /\s*(\d{4})([-./](\d{1,2}|[a-z]+))?([-./](\d{1,2}))?([tT\s](\d{2})([:](\d{2}))?([:](\d{2}))?([.](\d{1,9}))?)?(.*)/; + interface DateParts { year: T; month: T; @@ -214,12 +217,15 @@ export class DateUtils { * Anything that is not captured in the string will be undefined. * * @param dateTimeString The date time string to parse + * @param allowOverflow If true, will allow overflow characters after the date + * string * @returns Containing the date time components */ - static parseDateTimeString(dateTimeString: string): DateParts { - const regex = - /\s*(\d{4})([-./]([\da-z]+))?([-./](\d{1,2}))?([tT\s](\d{2})([:](\d{2}))?([:](\d{2}))?([.](\d{1,9}))?)?(.*)/; - const result = regex.exec(dateTimeString); + static parseDateTimeString( + dateTimeString: string, + allowOverflow = false + ): DateParts & { overflow?: string } { + const result = DATE_TIME_REGEX.exec(dateTimeString); if (result == null) { throw new Error(`Unexpected date string: ${dateTimeString}`); } @@ -241,13 +247,16 @@ export class DateUtils { nanos, overflow, ] = result; - if (overflow != null && overflow.length > 0) { + + if (!allowOverflow && overflow != null && overflow.length > 0) { throw new Error( `Unexpected characters after date string '${dateTimeString}': ${overflow}` ); } - return { year, month, date, hours, minutes, seconds, nanos }; + const dateParts = { year, month, date, hours, minutes, seconds, nanos }; + + return allowOverflow ? { ...dateParts, overflow } : dateParts; } /** @@ -374,6 +383,32 @@ export class DateUtils { } return dateWrapper.asDate(); } + + /** + * Trim overflow (usually timezone) from a date time string. + * @param dateTimeString The date time string to trim + * @returns The date time string without overflow + */ + static trimDateTimeStringTimeZone(dateTimeString: string): string { + const { overflow = '' } = DateUtils.parseDateTimeString( + dateTimeString, + true + ); + + if (overflow === '') { + return dateTimeString; + } + + // Expecting timezone overflow to be a single space followed by some + // combination of letters + if (!/^\s[A-Za-z]+/.test(overflow)) { + throw new Error( + `Unexpected timezone format in overflow: '${dateTimeString}'` + ); + } + + return dateTimeString.slice(0, -overflow.length); + } } export default DateUtils; diff --git a/packages/jsapi-utils/src/FilterUtils.test.ts b/packages/jsapi-utils/src/FilterUtils.test.ts index 0b0b93db4..c997a765c 100644 --- a/packages/jsapi-utils/src/FilterUtils.test.ts +++ b/packages/jsapi-utils/src/FilterUtils.test.ts @@ -1,11 +1,6 @@ import { Key } from 'react'; import dh from '@deephaven/jsapi-shim'; -import type { - Column, - FilterCondition, - FilterValue, - Table, -} from '@deephaven/jsapi-types'; +import type { dh as DhType } from '@deephaven/jsapi-types'; import { KeyedItem, TestUtils } from '@deephaven/utils'; import { createComboboxFilterArgs, @@ -20,36 +15,39 @@ import TableUtils from './TableUtils'; const { asMock, createMockProxy } = TestUtils; -const table = createMockProxy
({}); +const table = createMockProxy({}); const tableUtils = new TableUtils(dh); const makeFilterValue = jest.spyOn(tableUtils, 'makeFilterValue'); +const makeSearchTextFilter = jest.spyOn(tableUtils, 'makeSearchTextFilter'); const mockColumn = { - A: createMockProxy({ + A: createMockProxy({ type: 'columnA.type', name: 'A', }), - B: createMockProxy({ + B: createMockProxy({ type: 'columnB.type', name: 'B', }), } as const; +const mockTimeZone = 'mock.timeZone'; + type MockColumnName = keyof typeof mockColumn; const findColumn = (columnName: string) => mockColumn[columnName as MockColumnName]; const makeSelectValueFilter = jest.spyOn(tableUtils, 'makeSelectValueFilter'); -const makeSelectValueFilterResult = createMockProxy({}); -const makeFilterValueResultCache = new Map(); +const makeSelectValueFilterResult = createMockProxy({}); +const makeFilterValueResultCache = new Map(); type MonkeyName = `monkey-${string}`; const getMonkeyDataItem = jest.fn, [Key]>(); const mapItem = jest.fn]>(); function createMockFilterCondition(depth: number) { - return createMockProxy( + return createMockProxy( ['and', 'or', 'not'].reduce( (config, key) => { // eslint-disable-next-line no-param-reassign @@ -59,7 +57,7 @@ function createMockFilterCondition(depth: number) { .mockReturnValue( depth > 0 ? createMockFilterCondition(depth - 1) - : createMockProxy() + : createMockProxy() ); return config; }, @@ -69,7 +67,7 @@ function createMockFilterCondition(depth: number) { } function createMockFilterValue() { - return createMockProxy( + return createMockProxy( [ 'contains', 'containsIgnoreCase', @@ -96,7 +94,7 @@ function createMockFilterValue() { * Get `makeFilterValue` mock result from the cache. Adds new instance to the * cache if it doesn't exist */ -function getMakeFilterValueResult(value: string): FilterValue { +function getMakeFilterValueResult(value: string): DhType.FilterValue { if (!makeFilterValueResultCache.has(value)) { makeFilterValueResultCache.set(value, createMockFilterValue()); } @@ -165,7 +163,7 @@ describe('createNotNullOrEmptyFilterCondition', () => { ); expect(column.filter().isNull().not().and).toHaveBeenCalledWith( - column.filter().notEq({} as FilterValue) + column.filter().notEq({} as DhType.FilterValue) ); expect(actual).toBe( @@ -173,7 +171,7 @@ describe('createNotNullOrEmptyFilterCondition', () => { .filter() .isNull() .not() - .and({} as FilterCondition) + .and({} as DhType.FilterCondition) ); }); }); @@ -183,11 +181,17 @@ describe('createSearchTextFilter', () => { const searchText = ' blah '; const trimmedSearchText = 'blah'; - createSearchTextFilter(tableUtils, mockColumn.A.name, searchText)(table); - - expect(makeFilterValue).toHaveBeenCalledWith( - mockColumn.A.type, - trimmedSearchText + createSearchTextFilter( + tableUtils, + mockColumn.A.name, + searchText, + mockTimeZone + )(table); + + expect(makeSearchTextFilter).toHaveBeenCalledWith( + mockColumn.A, + trimmedSearchText, + mockTimeZone ); }); @@ -198,7 +202,8 @@ describe('createSearchTextFilter', () => { createSearchTextFilter( tableUtils, mockColumn.A.name, - 'mock.searchText' + 'mock.searchText', + mockTimeZone )(tableArg) ).toBeNull(); }); @@ -210,7 +215,8 @@ describe('createSearchTextFilter', () => { createSearchTextFilter( tableUtils, mockColumn.A.name, - 'mock.searchText' + 'mock.searchText', + mockTimeZone )(table) ).toBeNull(); }); @@ -222,7 +228,8 @@ describe('createSearchTextFilter', () => { createSearchTextFilter( tableUtils, mockColumn.A.name, - searchTextArg + searchTextArg, + mockTimeZone )(table) ).toBeNull(); } @@ -239,7 +246,8 @@ describe('createSearchTextFilter', () => { const actual = createSearchTextFilter( tableUtils, columnOrColumnNames, - searchText + searchText, + mockTimeZone )(table); // Normalize to an array @@ -285,19 +293,19 @@ describe('createShowOnlyEmptyFilterCondition', () => { if (isOn) { expect(column.filter().isNull().or).toHaveBeenCalledWith( - column.filter().eq({} as FilterValue) + column.filter().eq({} as DhType.FilterValue) ); expect(actual).toBe( column .filter() .isNull() - .or({} as FilterCondition) + .or({} as DhType.FilterCondition) ); } else { expect(column.filter().notEq).toHaveBeenCalledWith(emptyStringValue); - expect(column.filter().eq({} as FilterValue).or).toHaveBeenCalledWith( - column.filter().notEq({} as FilterValue) - ); + expect( + column.filter().eq({} as DhType.FilterValue).or + ).toHaveBeenCalledWith(column.filter().notEq({} as DhType.FilterValue)); } } ); @@ -306,9 +314,15 @@ describe('createShowOnlyEmptyFilterCondition', () => { describe.each([undefined, 'and', 'or'] as const)( 'createFilterConditionFactory: %s', operator => { - const createColumnCondition = jest.fn(); - - const createMockFilterConditionResult: Record = { + const createColumnCondition = jest.fn< + DhType.FilterCondition, + [DhType.Column] + >(); + + const createMockFilterConditionResult: Record< + string, + DhType.FilterCondition + > = { [mockColumn.A.name]: createMockFilterCondition(1), [mockColumn.B.name]: createMockFilterCondition(1), }; diff --git a/packages/jsapi-utils/src/FilterUtils.ts b/packages/jsapi-utils/src/FilterUtils.ts index a92e51a88..9022130f5 100644 --- a/packages/jsapi-utils/src/FilterUtils.ts +++ b/packages/jsapi-utils/src/FilterUtils.ts @@ -30,11 +30,13 @@ export function createComboboxFilterArgs( * @param tableUtils TableUtils instance to create filter from * @param columnNames Names of the columns to filter * @param searchText Text to search (will be trimmed of leading / trailing whitespace) + * @param timeZone Timezone to use for date parsing */ export function createSearchTextFilter( tableUtils: TableUtils, columnNames: string | string[], - searchText: string + searchText: string, + timeZone: string ): FilterConditionFactory { /** * Creates a filter condition that matches based on search text. @@ -51,12 +53,7 @@ export function createSearchTextFilter( const factory = createFilterConditionFactory( columnNames, - col => - col - .filter() - .containsIgnoreCase( - tableUtils.makeFilterValue(col.type, searchTextTrimmed) - ), + col => tableUtils.makeSearchTextFilter(col, searchTextTrimmed, timeZone), 'or' ); diff --git a/packages/jsapi-utils/src/Formatter.ts b/packages/jsapi-utils/src/Formatter.ts index eb5d19a38..ece2104b5 100644 --- a/packages/jsapi-utils/src/Formatter.ts +++ b/packages/jsapi-utils/src/Formatter.ts @@ -1,4 +1,5 @@ import type { dh as DhType } from '@deephaven/jsapi-types'; +import { assertInstanceOf } from '@deephaven/utils'; import TableUtils, { DataType } from './TableUtils'; import { BooleanColumnFormatter, @@ -123,7 +124,7 @@ export class Formatter { defaultColumnFormatter: TableColumnFormatter; - typeFormatterMap: Map; + typeFormatterMap: ReadonlyMap; columnFormatMap: Map>; @@ -207,10 +208,11 @@ export class Formatter { * @returns The time zone name E.g. America/New_York */ get timeZone(): string { - const formatter = this.typeFormatterMap.get( - TableUtils.dataType.DATETIME - ) as DateTimeColumnFormatter; - return formatter?.dhTimeZone?.id; + const formatter = this.typeFormatterMap.get(TableUtils.dataType.DATETIME); + + assertInstanceOf(formatter, DateTimeColumnFormatter); + + return formatter.dhTimeZone.id; } } diff --git a/packages/jsapi-utils/src/TableUtils.test.ts b/packages/jsapi-utils/src/TableUtils.test.ts index b891b676d..ed2fb19e7 100644 --- a/packages/jsapi-utils/src/TableUtils.test.ts +++ b/packages/jsapi-utils/src/TableUtils.test.ts @@ -618,6 +618,224 @@ describe('getValueType', () => { ); }); +describe('makeFilterValue', () => { + const mockDh = { + FilterValue: { + ofString: jest.fn(), + ofNumber: jest.fn(), + }, + LongWrapper: { + ofString: jest.fn(), + }, + } as unknown as typeof dh; + + const mockTableUtils = new TableUtils(mockDh); + + it.each(['char', 'java.lang.Character', 'java.lang.String'])( + 'should handle text type: %s', + columnType => { + const value = 'test'; + mockTableUtils.makeFilterValue(columnType, value); + expect(mockDh.FilterValue.ofString).toHaveBeenCalledWith(value); + } + ); + + it.each(['long', 'java.lang.Long'])( + 'should handle long type: %s', + columnType => { + const value = '1,000'; + mockTableUtils.makeFilterValue(columnType, value); + expect(mockDh.LongWrapper.ofString).toHaveBeenCalledWith('1000'); + } + ); + + it.each([ + 'int', + 'java.lang.Integer', + 'java.math.BigInteger', + 'short', + 'java.lang.Short', + 'byte', + 'java.lang.Byte', + 'double', + 'java.lang.Double', + 'java.math.BigDecimal', + 'float', + 'java.lang.Float', + ])('should handle number type: %s', columnType => { + const value = '1,234'; + mockTableUtils.makeFilterValue(columnType, value); + expect(mockDh.FilterValue.ofNumber).toHaveBeenCalledWith('1234'); + }); +}); + +describe('makeSearchTextFilter', () => { + const mockTableUtils = new TableUtils(dh); + const mockSearchText = 'mock.searchText'; + const mockTimeZone = 'mock.timeZone'; + + const mockReturnedFilterCondition = createMockProxy( + {}, + { label: 'mockReturnedFilterCondition' } + ); + const mockNeverFilterCondition = createMockProxy( + {}, + { label: 'mockNeverFilterCondition' } + ); + const mockMakeFilterValue = createMockProxy( + {}, + { label: 'mockMakeFilterValue' } + ); + const mockColumnFilterResult = createMockProxy( + {}, + { label: 'mockColumnFilterResult' } + ); + const mockOperatorResult = createMockProxy( + {}, + { label: 'mockOperatorResult' } + ); + + beforeEach(() => { + jest.spyOn(mockTableUtils, 'makeQuickBooleanFilter'); + jest.spyOn(mockTableUtils, 'makeQuickDateFilterWithOperation'); + jest.spyOn(mockTableUtils, 'makeQuickNumberFilter'); + jest.spyOn(mockTableUtils, 'makeFilterValue'); + jest + .spyOn(mockTableUtils, 'makeNeverFilter') + .mockReturnValue(mockNeverFilterCondition); + }); + + it.each([mockReturnedFilterCondition, null])( + 'should make boolean filters: %s', + returnedFilterCondition => { + asMock(mockTableUtils.makeQuickBooleanFilter).mockReturnValue( + returnedFilterCondition + ); + + const booleanColumn = createMockProxy({ + type: TableUtils.dataType.BOOLEAN, + }); + + const actual = mockTableUtils.makeSearchTextFilter( + booleanColumn, + mockSearchText, + mockTimeZone + ); + + expect(mockTableUtils.makeQuickBooleanFilter).toHaveBeenCalledWith( + booleanColumn, + mockSearchText + ); + + if (returnedFilterCondition == null) { + expect(actual).toBe(mockNeverFilterCondition); + } else { + expect(actual).toBe(returnedFilterCondition); + } + } + ); + + it.each(['2021-07-04T08:00:00 ET', 'invalid'])( + 'should make datetime filters: %s', + searchText => { + asMock(mockTableUtils.makeQuickDateFilterWithOperation).mockReturnValue( + mockReturnedFilterCondition + ); + + const dateColumn = createMockProxy({ + type: TableUtils.dataType.DATETIME, + }); + + const actual = mockTableUtils.makeSearchTextFilter( + dateColumn, + searchText, + mockTimeZone + ); + + if (searchText === 'invalid') { + expect(actual).toBe(mockNeverFilterCondition); + } else { + expect( + mockTableUtils.makeQuickDateFilterWithOperation + ).toHaveBeenCalledWith( + dateColumn, + DateUtils.trimDateTimeStringTimeZone(searchText), + 'eq', + mockTimeZone + ); + expect(actual).toBe(mockReturnedFilterCondition); + } + } + ); + + it.each(['java.math.BigDecimal', 'java.math.BigInteger'])( + 'should handle big number types as string eq filters: %s', + bigNumberType => { + jest + .spyOn(mockTableUtils, 'makeFilterValue') + .mockReturnValue(mockMakeFilterValue); + + asMock(mockColumnFilterResult.eq).mockImplementation( + () => mockOperatorResult + ); + + const bigNumberColumn = createMockProxy({ + type: bigNumberType, + filter: () => mockColumnFilterResult, + }); + + const actual = mockTableUtils.makeSearchTextFilter( + bigNumberColumn, + mockSearchText, + mockTimeZone + ); + + expect(mockTableUtils.makeFilterValue).toHaveBeenCalledWith( + bigNumberColumn.type, + mockSearchText + ); + expect(bigNumberColumn.filter().eq).toHaveBeenCalledWith( + mockMakeFilterValue + ); + expect(actual).toBe(mockOperatorResult); + } + ); + + it.each([ + TableUtils.dataType.CHAR, + TableUtils.dataType.STRING, + TableUtils.dataType.UNKNOWN, + ])('should default to string contains filter: %s', columnType => { + jest + .spyOn(mockTableUtils, 'makeFilterValue') + .mockReturnValue(mockMakeFilterValue); + + asMock(mockColumnFilterResult.containsIgnoreCase).mockImplementation( + () => mockOperatorResult + ); + + const column = createMockProxy({ + type: columnType, + filter: () => mockColumnFilterResult, + }); + + const actual = mockTableUtils.makeSearchTextFilter( + column, + mockSearchText, + mockTimeZone + ); + + expect(mockTableUtils.makeFilterValue).toHaveBeenCalledWith( + column.type, + mockSearchText + ); + expect(column.filter().containsIgnoreCase).toHaveBeenCalledWith( + mockMakeFilterValue + ); + expect(actual).toBe(mockOperatorResult); + }); +}); + describe('toggleSortForColumn', () => { it('toggles sort properly', () => { const columns = makeColumns(); diff --git a/packages/jsapi-utils/src/TableUtils.ts b/packages/jsapi-utils/src/TableUtils.ts index 7d7fde3d2..3903959dd 100644 --- a/packages/jsapi-utils/src/TableUtils.ts +++ b/packages/jsapi-utils/src/TableUtils.ts @@ -7,6 +7,7 @@ import { import Log from '@deephaven/log'; import type { dh as DhType } from '@deephaven/jsapi-types'; import { + assertNotNull, bindAllMethods, CancelablePromise, PromiseUtils, @@ -1690,6 +1691,67 @@ export class TableUtils { } } + /** + * Create a filter condition that can search a column by a given `searchText` + * value. + * @param column The column to search + * @param searchText The text to search for + * @param timeZone The time zone to make this filter in if it is a date type. E.g. America/New_York + * @returns The filter condition that can be applied to the column + */ + makeSearchTextFilter( + column: DhType.Column, + searchText: string, + timeZone: string + ): DhType.FilterCondition { + const valueType = this.getValueType(column.type); + + try { + if (valueType === this.dh.ValueType.BOOLEAN) { + const maybeFilterCondition = this.makeQuickBooleanFilter( + column, + searchText + ); + assertNotNull(maybeFilterCondition); + return maybeFilterCondition; + } + + if (valueType === this.dh.ValueType.DATETIME) { + return this.makeQuickDateFilterWithOperation( + column, + DateUtils.trimDateTimeStringTimeZone(searchText), + 'eq', + timeZone + ); + } + + if (valueType === this.dh.ValueType.NUMBER) { + const maybeFilterCondition = this.makeQuickNumberFilter( + column, + searchText + ); + assertNotNull(maybeFilterCondition); + return maybeFilterCondition; + } + + // Treat big numbers as strings + if ( + TableUtils.isBigDecimalType(column.type) || + TableUtils.isBigIntegerType(column.type) + ) { + return column + .filter() + .eq(this.makeFilterValue(column.type, searchText)); + } + + return column + .filter() + .containsIgnoreCase(this.makeFilterValue(column.type, searchText)); + } catch { + return this.makeNeverFilter(column); + } + } + /** * Apply a filter to a table that won't match anything. * @table The table to apply the filter to diff --git a/packages/utils/src/Asserts.test.ts b/packages/utils/src/Asserts.test.ts index de285fc87..26609d2b4 100644 --- a/packages/utils/src/Asserts.test.ts +++ b/packages/utils/src/Asserts.test.ts @@ -1,4 +1,6 @@ +/* eslint-disable max-classes-per-file */ import { + assertInstanceOf, assertNever, assertNotEmpty, assertNotNaN, @@ -6,6 +8,24 @@ import { getOrThrow, } from './Asserts'; +describe('assertInstanceOf', () => { + class SomeClass {} + class OtherClass {} + + it('should not throw if an instance of', () => { + expect(() => assertInstanceOf(new SomeClass(), SomeClass)).not.toThrow(); + }); + + it.each([{}, 'test', 1, true, new OtherClass()])( + 'should throw if not an instance of', + instance => { + expect(() => assertInstanceOf(instance, SomeClass)).toThrowError( + 'Expected instance of SomeClass' + ); + } + ); +}); + describe('assertNever', () => { it.each([undefined, 'mock.name'])('should throw if called', name => { const value = 'mock.value'; diff --git a/packages/utils/src/Asserts.ts b/packages/utils/src/Asserts.ts index 2d76f4c32..95f9225bb 100644 --- a/packages/utils/src/Asserts.ts +++ b/packages/utils/src/Asserts.ts @@ -1,3 +1,18 @@ +/** + * Throws if given object is not an instance of a given type. + * @param instance + * @param type + */ +export function assertInstanceOf( + instance: unknown, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type: new (...args: any[]) => T +): asserts instance is T { + if (!(instance instanceof type)) { + throw new Error(`Expected instance of ${type.name}`); + } +} + /** * Throws an error if excecuted. Useful for exhaustive switch statements. *