diff --git a/package-lock.json b/package-lock.json index 40b99bef2..30e6de14a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4588,8 +4588,9 @@ } }, "node_modules/@internationalized/date": { - "version": "3.5.4", - "license": "Apache-2.0", + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.5.5.tgz", + "integrity": "sha512-H+CfYvOZ0LTJeeLOqm19E3uj/4YjrmOFtBufDHPfvtI80hFAMqtrp7oCACpe4Cil5l8S0Qu/9dYfZc/5lY8WQQ==", "dependencies": { "@swc/helpers": "^0.5.0" } @@ -32569,6 +32570,7 @@ "@deephaven/redux": "^0.86.0", "@deephaven/utils": "^0.87.0", "@fortawesome/react-fontawesome": "^0.2.0", + "@internationalized/date": "^3.5.5", "classnames": "^2.5.1", "json-rpc-2.0": "^1.6.0", "nanoid": "^5.0.7", @@ -37603,6 +37605,7 @@ "@deephaven/redux": "^0.86.0", "@deephaven/utils": "^0.87.0", "@fortawesome/react-fontawesome": "^0.2.0", + "@internationalized/date": "^3.5.5", "@types/react": "^17.0.2", "@vitejs/plugin-react-swc": "^3.0.0", "classnames": "^2.5.1", @@ -39230,7 +39233,9 @@ "dev": true }, "@internationalized/date": { - "version": "3.5.4", + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.5.5.tgz", + "integrity": "sha512-H+CfYvOZ0LTJeeLOqm19E3uj/4YjrmOFtBufDHPfvtI80hFAMqtrp7oCACpe4Cil5l8S0Qu/9dYfZc/5lY8WQQ==", "requires": { "@swc/helpers": "^0.5.0" } diff --git a/plugins/ui/DESIGN.md b/plugins/ui/DESIGN.md index d1d68068c..9c6d4986b 100644 --- a/plugins/ui/DESIGN.md +++ b/plugins/ui/DESIGN.md @@ -1020,11 +1020,12 @@ A tabs component can be used to organize content in a collection of tabs, allowi ###### Parameters -| Parameter | Type | Description | -| ----------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `*children` | `Tab \| TabList \| TabPanels` | The tab panels to render within the tabs component. | -| `on_change` | `Callable[[Key], None] \| None` | Alias of `on_selection_change`. Handler that is called when the tab selection changes. | -| `**props` | `Any` | Any other [Tabs](https://react-spectrum.adobe.com/react-spectrum/Tabs.html#tabs-props) prop +| Parameter | Type | Description | +| ----------- | ------------------------------- | ------------------------------------------------------------------------------------------- | +| `*children` | `Tab \| TabList \| TabPanels` | The tab panels to render within the tabs component. | +| `on_change` | `Callable[[Key], None] \| None` | Alias of `on_selection_change`. Handler that is called when the tab selection changes. | +| `**props` | `Any` | Any other [Tabs](https://react-spectrum.adobe.com/react-spectrum/Tabs.html#tabs-props) prop | + | | @@ -1314,14 +1315,24 @@ list_view5 = ui.list_view( A date picker that can be used to select a date. -There are three types that can be passed in to the props that control the date format: +The date picker accepts the following date types as inputs: +`None`, `LocalDate`, `ZoneDateTime`, `Instant`, `int`, `str`, `datetime.datetime`, `numpy.datetime64`, `pandas.Timestamp` + +The input will be converted to one of three Java date types: 1. `LocalDate`: A LocalDate is a date without a time zone in the ISO-8601 system, such as "2007-12-03" or "2057-01-28". This will create a date picker with a granularity of days. 2. `Instant`: An Instant represents an unambiguous specific point on the timeline, such as 2021-04-12T14:13:07 UTC. - This will create a date picker with a granularity of seconds in UTC. + This will create a date picker with a granularity of seconds in UTC. The time zone will be rendered as the time zone in user settings. 3. `ZonedDateTime`: A ZonedDateTime represents an unambiguous specific point on the timeline with an associated time zone, such as 2021-04-12T14:13:07 America/New_York. - This will create a date picker with a granularity of seconds in the specified time zone. + This will create a date picker with a granularity of seconds in the specified time zone. The time zone will be rendered as the specified time zone. + +The input is coverted according to the following rules: + +1. If the input is one of the three Java date types, use that type. +2. A date string such as "2007-12-03" will parse to a `LocalDate` +3. A string with a date, time, and timezone such as "2021-04-12T14:13:07 America/New_York" will parse to a `ZonedDateTime` +4. All other types will attempt to convert in this order: `Instant`, `ZonedDateTime`, `LocalDate` The format of the date picker and the type of the value passed to the `on_change` handler is determined by the type of the following props in order of precedence: @@ -1340,7 +1351,6 @@ ui.date_picker( default_value: Date | None = None, min_value: Date | None = None, max_value: Date | None = None, - unavailable_values: Sequence[Date] | None = None, granularity: Granularity | None = None, on_change: Callable[[Date], None] | None = None, **props: Any @@ -1349,24 +1359,23 @@ ui.date_picker( ###### Parameters -| Parameter | Type | Description | -| -------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `placeholder_value` | `Date \| None` | A placeholder date that influences the format of the placeholder shown when no value is selected. Defaults to today at midnight in the user's timezone. | -| `value` | `Date \| None` | The current value (controlled). | -| `default_value` | `Date \| None` | The default value (uncontrolled). | -| `min_value` | `Date \| None` | The minimum allowed date that a user may select. | -| `max_value` | `Date \| None` | The maximum allowed date that a user may select. | -| `unavailable_values` | `Sequence[Date] \| None` | A list of dates that cannot be selected. | -| `granularity` | `Granularity \| None` | Determines the smallest unit that is displayed in the date picker. By default, this is `"DAY"` for `LocalDate`, and `"SECOND"` otherwise. | -| `on_change` | `Callable[[Date], None] \| None` | Handler that is called when the value changes. The exact `Date` type will be the same as the type passed to `value`, `default_value` or `placeholder_value`, in that order of precedence. | -| `**props` | `Any` | Any other [DatePicker](https://react-spectrum.adobe.com/react-spectrum/DatePicker.html) prop, with the exception of `isDateUnavailable`, `validate`, and `errorMessage` (as a callback) | +| Parameter | Type | Description | +| ------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- | +| `placeholder_value` | `Date \| None` | A placeholder date that influences the format of the placeholder shown when no value is selected. Defaults to today at the current time on the server machine time zone. | +| `value` | `Date \| None` | The current value (controlled). | +| `default_value` | `Date \| None` | The default value (uncontrolled). | +| `min_value` | `Date \| None` | The minimum allowed date that a user may select. | +| `max_value` | `Date \| None` | The maximum allowed date that a user may select. | | +| `granularity` | `Granularity \| None` | Determines the smallest unit that is displayed in the date picker. By default, this is `"DAY"` for `LocalDate`, and `"SECOND"` otherwise. | +| `on_change` | `Callable[[Date], None] \| None` | Handler that is called when the value changes. The exact `Date` type will be the same as the type passed to `value`, `default_value` or `placeholder_value`, in that order of precedence. | +| `**props` | `Any` | Any other [DatePicker](https://react-spectrum.adobe.com/react-spectrum/DatePicker.html) prop, with the exception of `isDateUnavailable`, `validate`, and `errorMessage` (as a callback) | ```py import deephaven.ui as ui from deephaven.time import to_j_local_date, dh_today, to_j_instant, to_j_zdt -zoned_date_time = to_j_zdt("1995-03-22T11:11:11.23142 UTC") +zoned_date_time = to_j_zdt("1995-03-22T11:11:11.23142 America/New_York") instant = to_j_instant("2022-01-01T00:00:00 ET") local_date = to_j_local_date(dh_today()) diff --git a/plugins/ui/docs/components/date_picker.md b/plugins/ui/docs/components/date_picker.md new file mode 100644 index 000000000..017e81acc --- /dev/null +++ b/plugins/ui/docs/components/date_picker.md @@ -0,0 +1,172 @@ +# Date Picker + +Date Pickers allow users to select a Date and Time from a pop up Calendar. + +## Example + +```python +from deephaven import ui + +dp = ui.date_picker( + label="Date Picker", + default_value="2024-01-02T10:30:00 UTC", + on_change=print, +) +``` + +## Date types + +The date picker accepts the following date types as inputs: +`None`, `LocalDate`, `ZoneDateTime`, `Instant`, `int`, `str`, `datetime.datetime`, `numpy.datetime64`, `pandas.Timestamp` + +The input will be converted to one of three Java date types: + +1. `LocalDate`: A LocalDate is a date without a time zone in the ISO-8601 system, such as "2007-12-03" or "2057-01-28". + This will create a date picker with a granularity of days. +2. `Instant`: An Instant represents an unambiguous specific point on the timeline, such as 2021-04-12T14:13:07 UTC. + This will create a date picker with a granularity of seconds in UTC. The time zone will be rendered as the time zone in user settings. +3. `ZonedDateTime`: A ZonedDateTime represents an unambiguous specific point on the timeline with an associated time zone, such as 2021-04-12T14:13:07 America/New_York. + This will create a date picker with a granularity of seconds in the specified time zone. The time zone will be rendered as the specified time zone. + +The input is coverted according to the following rules: + +1. If the input is one of the three Java date types, use that type. +2. A date string such as "2007-12-03" will parse to a `LocalDate` +3. A string with a date, time, and timezone such as "2021-04-12T14:13:07 America/New_York" will parse to a `ZonedDateTime` +4. All other types will attempt to convert in this order: `Instant`, `ZonedDateTime`, `LocalDate` + +The format of the date picker and the type of the value passed to the `on_change` handler +is determined by the type of the following props in order of precedence: + +1. `value` +2. `default_value` +3. `placeholder_value` + +If none of these are provided, the `on_change` handler will be passed an `Instant`. + +## Controlled mode with value + +Setting the `value` prop will put the date_picker in controlled mode. Selecting a new date will call the `on_change` callback. +Then `value` must be updated programatically to render the new value. This can be done using the `use_state` hook. + +```python +from deephaven import ui +from deephaven.time import to_j_local_date, dh_today, to_j_instant, to_j_zdt + +zoned_date_time = to_j_zdt("1995-03-22T11:11:11.23142 America/New_York") +instant = to_j_instant("2022-01-01T00:00:00 ET") +local_date = to_j_local_date(dh_today()) + + +@ui.component +def date_picker_test(value): + date, set_date = ui.use_state(value) + return [ui.date_picker(on_change=set_date, value=date), ui.text(str(date))] + + +zoned_date_picker = date_picker_test(zoned_date_time) +instant_date_picker = date_picker_test(instant) +local_date_picker = date_picker_test(local_date) +``` + +## Uncontrolled mode with default_value + +If the `value` prop is omitted, the date_picker will be in uncontrolled mode. It will store its state internally and automatically update when a new date is selected. +In this mode, setting the `default_value` prop will determine the initial value displayed by the date_picker. + +```python +from deephaven.time import dh_now +from deephaven import ui + +dp = ui.date_picker( + label="Date Picker", + default_value=dh_now(), + on_change=print, +) +``` + +## Uncontrolled mode with placeholder_value + +If both `value` and `default_value` are omitted, the date_picker will be in uncontrolled mode displaying no date selected. When opened, the date picker will suggest the date from the `placeholder_value` prop. +Omitting `placeholder_value` will default it to today at the current time on the server machine time zone. + +```python +from deephaven import ui + +dp1 = ui.date_picker( + label="Date Picker", + placeholder_value="2022-10-01T08:30:00 ET", + on_change=print, +) + +dp2 = ui.date_picker( + label="Date Picker", + on_change=print, +) +``` + +## Events + +Date Pickers accept a value to display and can trigger actions based on events such as setting state when changed. See the [API Reference](#api-reference) for a full list of available events. + +## Variants + +Date Pickers can have different variants to indicate their purpose. + +```python +from deephaven import ui + + +@ui.component +def date_picker_variants(): + return [ + ui.date_picker(description="description"), + ui.date_picker(error_message="error", validation_state="valid"), + ui.date_picker(error_message="error", validation_state="invalid"), + ui.date_picker(min_value="2024-01-01", max_value="2024-01-05"), + ui.date_picker(value="2024-07-27T16:10:10 America/New_York", hour_cycle=24), + ui.date_picker(granularity="YEAR"), + ui.date_picker(granularity="MONTH"), + ui.date_picker(granularity="DAY"), + ui.date_picker(granularity="HOUR"), + ui.date_picker(granularity="MINUTE"), + ui.date_picker(granularity="SECOND"), + ] + + +date_picker_variants_example = date_picker_variants() +``` + +## Time table filtering + +Date Pickers can be used to filter tables with time columns. + +```python +from deephaven.time import dh_now +from deephaven import time_table, ui + + +@ui.component +def date_table_filter(table, start_date, end_date, time_col="Timestamp"): + after_date, set_after_date = ui.use_state(start_date) + before_date, set_before_date = ui.use_state(end_date) + return [ + ui.date_picker(label="Start Date", value=after_date, on_change=set_after_date), + ui.date_picker(label="End Date", value=before_date, on_change=set_before_date), + table.where(f"{time_col} >= after_date && {time_col} < before_date"), + ] + + +SECONDS_IN_DAY = 86400 +today = dh_now() +_table = time_table("PT1s").update_view( + ["Timestamp=today.plusSeconds(SECONDS_IN_DAY*i)", "Row=i"] +) +date_filter = date_table_filter(_table, today, today.plusSeconds(SECONDS_IN_DAY * 10)) +``` + +## API Reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.date_picker +``` diff --git a/plugins/ui/src/deephaven/ui/_internal/utils.py b/plugins/ui/src/deephaven/ui/_internal/utils.py index 534deca51..25f8e0909 100644 --- a/plugins/ui/src/deephaven/ui/_internal/utils.py +++ b/plugins/ui/src/deephaven/ui/_internal/utils.py @@ -5,6 +5,7 @@ import sys from functools import partial from deephaven.time import to_j_instant, to_j_zdt, to_j_local_date +from deephaven.dtypes import ZonedDateTime, Instant from ..types import Date, JavaDate @@ -18,6 +19,8 @@ "java.time.LocalDate": to_j_local_date, } +_LOCAL_DATE = "java.time.LocalDate" + def get_component_name(component: Any) -> str: """ @@ -225,6 +228,15 @@ def _convert_to_java_date( Returns: The Java date type. """ + # For strings, parseInstant and parseZonedDateTime both succeed for the same strings + # Try parsing as a ZonedDateTime first per the documentation + if isinstance(date, str): + try: + return to_j_zdt(date) # type: ignore + except Exception: + # ignore, try next + pass + try: return to_j_instant(date) # type: ignore except Exception: @@ -288,7 +300,16 @@ def _wrap_date_callable( Returns: The wrapped callable. """ - return lambda date: wrap_callable(date_callable)(converter(date)) + # When the user is typing a date, they may enter a value that does not parse + # This will skip those errors rather than printing them to the screen + def no_error_date_callable(date: Date) -> None: + wrapped_date_callable = wrap_callable(date_callable) + try: + wrapped_date_callable(converter(date)) + except Exception: + pass + + return no_error_date_callable def _get_first_set_key(props: dict[str, Any], sequence: Sequence[str]) -> str | None: @@ -341,7 +362,7 @@ def _prioritized_callable_converter( def convert_list_prop( key: str, value: list[Date] | None, -) -> list[JavaDate] | None: +) -> list[str] | None: """ Convert a list of Dates to Java date types. @@ -357,7 +378,7 @@ def convert_list_prop( if not isinstance(value, list): raise TypeError(f"{key} must be a list of Dates") - return [_convert_to_java_date(date) for date in value] + return [str(_convert_to_java_date(date)) for date in value] def convert_date_props( @@ -365,6 +386,7 @@ def convert_date_props( simple_date_props: set[str], callable_date_props: set[str], priority: Sequence[str], + granularity_key: str, default_converter: Callable[[Date], Any] = to_j_instant, ) -> None: """ @@ -376,6 +398,7 @@ def convert_date_props( callable_date_props: A set of callable date keys to convert. The prop value should be a callable that takes a Date. priority: The priority of the props to check. + granularity_key: The key for the granularity default_converter: The default converter to use if none of the priority props are present. Returns: @@ -388,6 +411,16 @@ def convert_date_props( # the simple props must be converted before this to simplify the callable conversion converter = _prioritized_callable_converter(props, priority, default_converter) + # based on the convert set the granularity if it is not set + # Local Dates will default to DAY but we need to default to SECOND for the other types + if props.get(granularity_key) is None and converter != to_j_local_date: + props[granularity_key] = "SECOND" + + # now that the converter is set, we can convert simple props to strings + for key in simple_date_props: + if props.get(key) is not None: + props[key] = str(props[key]) + for key in callable_date_props: if props.get(key) is not None: if not callable(props[key]): diff --git a/plugins/ui/src/deephaven/ui/components/date_picker.py b/plugins/ui/src/deephaven/ui/components/date_picker.py index 51e47e131..fba8aa4d6 100644 --- a/plugins/ui/src/deephaven/ui/components/date_picker.py +++ b/plugins/ui/src/deephaven/ui/components/date_picker.py @@ -30,6 +30,8 @@ ) from ..types import Date, Granularity from .basic import component_element +from .make_component import make_component +from deephaven.time import dh_now DatePickerElement = Element @@ -43,6 +45,7 @@ } _LIST_DATE_PROPS = {"unavailable_values"} _CALLABLE_DATE_PROPS = {"on_change"} +_GRANULARITY_KEY = "granularity" # The priority of the date props to determine the format of the date passed to the callable date props _DATE_PROPS_PRIORITY = ["value", "default_value", "placeholder_value"] @@ -66,18 +69,21 @@ def _convert_date_picker_props( _SIMPLE_DATE_PROPS, _CALLABLE_DATE_PROPS, _DATE_PROPS_PRIORITY, + _GRANULARITY_KEY, ) return props +@make_component def date_picker( - placeholder_value: Date | None = None, + placeholder_value: Date | None = dh_now(), value: Date | None = None, default_value: Date | None = None, min_value: Date | None = None, max_value: Date | None = None, - unavailable_values: Sequence[Date] | None = None, + # TODO (issue # 698) we need to implement unavailable_values + # unavailable_values: Sequence[Date] | None = None, granularity: Granularity | None = None, page_behavior: PageBehavior | None = None, hour_cycle: HourCycle | None = None, @@ -167,7 +173,6 @@ def date_picker( default_value: The default value (uncontrolled). min_value: The minimum allowed date that a user may select. max_value: The maximum allowed date that a user may select. - unavailable_values: A list of dates that cannot be selected. granularity: Determines the smallest unit that is displayed in the date picker. By default, this is `"DAY"` for `LocalDate`, and `"SECOND"` otherwise. page_behavior: Controls the behavior of paging. Pagination either works by @@ -266,9 +271,9 @@ def date_picker( _convert_date_picker_props(props) - props["unavailable_values"] = use_memo( - lambda: convert_list_prop("unavailable_values", props["unavailable_values"]), - [unavailable_values], - ) + # props["unavailable_values"] = use_memo( + # lambda: convert_list_prop("unavailable_values", props["unavailable_values"]), + # [unavailable_values], + # ) return component_element("DatePicker", **props) diff --git a/plugins/ui/src/js/package.json b/plugins/ui/src/js/package.json index de3455007..ec91fc381 100644 --- a/plugins/ui/src/js/package.json +++ b/plugins/ui/src/js/package.json @@ -59,6 +59,7 @@ "@deephaven/redux": "^0.86.0", "@deephaven/utils": "^0.87.0", "@fortawesome/react-fontawesome": "^0.2.0", + "@internationalized/date": "^3.5.5", "classnames": "^2.5.1", "json-rpc-2.0": "^1.6.0", "nanoid": "^5.0.7", diff --git a/plugins/ui/src/js/src/elements/DatePicker.tsx b/plugins/ui/src/js/src/elements/DatePicker.tsx new file mode 100644 index 000000000..1ab5d2f3f --- /dev/null +++ b/plugins/ui/src/js/src/elements/DatePicker.tsx @@ -0,0 +1,95 @@ +import React, { useCallback, useState, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { + DatePicker as DHCDatePicker, + DatePickerProps as DHCDatePickerProps, +} from '@deephaven/components'; +import { useDebouncedCallback, usePrevious } from '@deephaven/react-hooks'; +import { getSettings, RootState } from '@deephaven/redux'; +import { DateValue, toTimeZone, ZonedDateTime } from '@internationalized/date'; +import { + SerializedDatePickerProps, + useDatePickerProps, +} from './hooks/useDatepickerProps'; + +const VALUE_CHANGE_DEBOUNCE = 250; + +const EMPTY_FUNCTION = () => undefined; + +function isStringInstant(value?: string | null): boolean { + return value != null && value.endsWith('Z'); +} + +function isDatePickerInstant( + props: SerializedDatePickerProps> +): boolean { + const { value, defaultValue, placeholderValue } = props; + if (value != null) { + return isStringInstant(value); + } + if (defaultValue != null) { + return isStringInstant(defaultValue); + } + return isStringInstant(placeholderValue); +} + +export function DatePicker( + props: SerializedDatePickerProps> +): JSX.Element { + const isDatePickerInstantValue = isDatePickerInstant(props); + const settings = useSelector(getSettings); + const { timeZone } = settings; + + const { + defaultValue = null, + value: propValue, + onChange: propOnChange = EMPTY_FUNCTION, + ...otherProps + } = useDatePickerProps(props, timeZone); + + const [value, setValue] = useState(propValue ?? defaultValue); + + const debouncedOnChange = useDebouncedCallback( + propOnChange, + VALUE_CHANGE_DEBOUNCE + ); + + const onChange = useCallback( + newValue => { + setValue(newValue); + debouncedOnChange(newValue); + }, + [debouncedOnChange] + ); + + // When the time zone changes, the serialized prop value will change, so we need to update the value state + const prevTimeZone = usePrevious(timeZone); + useEffect(() => { + // The timezone is intially undefined, so we don't want to trigger a change in that case + if ( + isDatePickerInstantValue && + prevTimeZone !== undefined && + timeZone !== prevTimeZone && + value instanceof ZonedDateTime + ) { + const newValue = toTimeZone(value, timeZone); + setValue(toTimeZone(value, timeZone)); + debouncedOnChange(newValue); + } + }, [ + isDatePickerInstantValue, + value, + debouncedOnChange, + timeZone, + prevTimeZone, + ]); + + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + ); +} + +DatePicker.displayName = 'DatePicker'; + +export default DatePicker; diff --git a/plugins/ui/src/js/src/elements/hooks/useDatepickerProps.test.ts b/plugins/ui/src/js/src/elements/hooks/useDatepickerProps.test.ts new file mode 100644 index 000000000..435ba2b3c --- /dev/null +++ b/plugins/ui/src/js/src/elements/hooks/useDatepickerProps.test.ts @@ -0,0 +1,67 @@ +import { parseDateValue, parseNullableDateValue } from './useDatepickerProps'; + +const DEFAULT_TIME_ZONE = 'UTC'; +const NY_TIME_ZONE = 'America/New_York'; + +describe('parseDateValue', () => { + const isoDate = '2021-02-03'; + const isoDateTime = '2021-03-03T04:05:06'; + const isoZonedDateTime = '2021-04-04T05:06:07-04:00[America/New_York]'; + const nonIsoZonedDateTime = '2021-04-04T05:06:07 America/New_York'; + const instantString = '2021-03-03T04:05:06Z'; + const instantStringUTC = '2021-03-03T04:05:06Z[UTC]'; + const utcOutput = '2021-03-03T04:05:06+00:00[UTC]'; + const nyOutput = '2021-03-02T23:05:06-05:00[America/New_York]'; + const invalidDate = 'invalid-date'; + + it('should return null if the value is null', () => { + expect(parseNullableDateValue(DEFAULT_TIME_ZONE, null)).toBeNull(); + }); + + it('should return undefined if the value is undefined', () => { + expect(parseDateValue(DEFAULT_TIME_ZONE, undefined)).toBeUndefined(); + }); + + it('should parse an ISO 8601 date string', () => { + expect(parseDateValue(DEFAULT_TIME_ZONE, isoDate)?.toString()).toEqual( + isoDate + ); + }); + + it('should parse an ISO 8601 date time string', () => { + expect(parseDateValue(DEFAULT_TIME_ZONE, isoDateTime)?.toString()).toEqual( + isoDateTime + ); + }); + + it('should parse an ISO 8601 zoned date time string', () => { + expect( + parseDateValue(DEFAULT_TIME_ZONE, isoZonedDateTime)?.toString() + ).toEqual(isoZonedDateTime); + }); + + it('should parse a non-ISO 8601 zoned date time string', () => { + expect( + parseDateValue(DEFAULT_TIME_ZONE, nonIsoZonedDateTime)?.toString() + ).toEqual(isoZonedDateTime); + }); + + it('should parse an instant string', () => { + expect( + parseDateValue(DEFAULT_TIME_ZONE, instantString)?.toString() + ).toEqual(utcOutput); + expect( + parseDateValue(DEFAULT_TIME_ZONE, instantStringUTC)?.toString() + ).toEqual(utcOutput); + }); + + it('should throw an error if the value is invalid', () => { + expect(() => parseDateValue(DEFAULT_TIME_ZONE, invalidDate)).toThrow(); + }); + + it('should parse an instant time string with a different time zone', () => { + expect(parseDateValue(NY_TIME_ZONE, instantString)?.toString()).toEqual( + nyOutput + ); + }); +}); diff --git a/plugins/ui/src/js/src/elements/hooks/useDatepickerProps.ts b/plugins/ui/src/js/src/elements/hooks/useDatepickerProps.ts new file mode 100644 index 000000000..7c03fddcb --- /dev/null +++ b/plugins/ui/src/js/src/elements/hooks/useDatepickerProps.ts @@ -0,0 +1,363 @@ +import { useCallback, useMemo } from 'react'; +import { + DateValue, + CalendarDate, + CalendarDateTime, + ZonedDateTime, + parseDate, + parseDateTime, + parseZonedDateTime, + toTimeZone, +} from '@internationalized/date'; +import { + DeserializedFocusEventCallback, + SerializedFocusEventCallback, + useFocusEventCallback, +} from './useFocusEventCallback'; +import { + DeserializedKeyboardEventCallback, + SerializedKeyboardEventCallback, + useKeyboardEventCallback, +} from './useKeyboardEventCallback'; + +type MappedDateValue = T extends ZonedDateTime + ? ZonedDateTime + : T extends CalendarDateTime + ? CalendarDateTime + : T extends CalendarDate + ? CalendarDate + : never; + +type Granularity = 'day' | 'hour' | 'minute' | 'second'; + +export type SerializedDateValue = string | null; + +export type SerializedDateValueCallback = (value: SerializedDateValue) => void; + +export type DeserializedDateValueCallback = ( + value: MappedDateValue +) => void; + +export interface SerializedDatePickerPropsInterface { + /** Handler that is called when the element receives focus. */ + onFocus?: SerializedFocusEventCallback; + + /** Handler that is called when the element loses focus. */ + onBlur?: SerializedFocusEventCallback; + + /** Handler that is called when a key is pressed */ + onKeyDown?: SerializedKeyboardEventCallback; + + /** Handler that is called when a key is released */ + onKeyUp?: SerializedKeyboardEventCallback; + + /** Handler that is called when the value changes */ + onChange?: SerializedDateValueCallback; + + /** The current value (controlled) */ + value?: string | null; + + /** The default value (uncontrolled) */ + defaultValue?: string | null; + + /** The minimum allowed date that a user may select */ + minValue?: string; + + /** The maximum allowed date that a user may select */ + maxValue?: string; + + /** A placeholder date that influences the format of the placeholder shown when no value is selected */ + placeholderValue?: string; + + /** Dates that are unavailable */ + unavailableValues?: string[] | null; + + /** Determines the smallest unit that is displayed in the date picker. */ + granularity?: Granularity; +} + +export interface DeserializedDatePickerPropsInterface { + /** Handler that is called when the element receives focus. */ + onFocus?: DeserializedFocusEventCallback; + + /** Handler that is called when the element loses focus. */ + onBlur?: DeserializedFocusEventCallback; + + /** Handler that is called when a key is pressed */ + onKeyDown?: DeserializedKeyboardEventCallback; + + /** Handler that is called when a key is released */ + onKeyUp?: DeserializedKeyboardEventCallback; + + /** Handler that is called when the value changes */ + onChange?: DeserializedDateValueCallback; + + /** The current value (controlled) */ + value?: DateValue | null; + + /** The default value (uncontrolled) */ + defaultValue?: DateValue | null; + + /** The minimum allowed date that a user may select */ + minValue?: DateValue; + + /** The maximum allowed date that a user may select */ + maxValue?: DateValue; + + /** A placeholder date that influences the format of the placeholder shown when no value is selected */ + placeholderValue?: DateValue; + + /** Callback that is called for each date of the calendar. If it returns true, then the date is unavailable */ + isDateUnavailable?: (date: DateValue) => boolean; + + /** Determines the smallest unit that is displayed in the date picker. */ + granularity?: Granularity; +} + +export type SerializedDatePickerProps = TProps & + SerializedDatePickerPropsInterface; + +export type DeserializedDatePickerProps = Omit< + TProps, + keyof SerializedDatePickerPropsInterface +> & + DeserializedDatePickerPropsInterface; + +/** + * Uses the toString representation of the DateValue as the serialized value. + * @param value DateValue to serialize + * @returns Serialized DateValue + */ +export function serializeDateValue( + value: MappedDateValue +): SerializedDateValue { + if (value == null) { + return null; + } + + return value.toString(); +} + +/** + * Get a callback function that can be passed to the onChange event handler + * props of a Spectrum DatePicker. + * @param callback Callback to be called with the serialized value + * @returns A callback to be passed into the Spectrum component that transforms + * the value and calls the provided callback + */ +export function useOnChangeCallback( + callback?: SerializedDateValueCallback +): (value: MappedDateValue) => void { + return useCallback( + (value: MappedDateValue) => { + if (callback == null) { + return; + } + callback(serializeDateValue(value)); + }, + [callback] + ); +} + +/** + * Use memo to get a DateValue from a nullable string. + * + * @param value the string date value + * @returns DateValue or null + */ +export function useNullableDateValueMemo( + timeZone: string, + value?: string | null +): DateValue | null | undefined { + return useMemo( + () => parseNullableDateValue(timeZone, value), + [timeZone, value] + ); +} + +export function parseNullableDateValue( + timeZone: string, + value?: string | null +): DateValue | null | undefined { + if (value === null) { + return value; + } + + return parseDateValue(timeZone, value); +} + +/** + * Use memo to get a DateValue from a string. + * + * @param value the string date value + * @returns DateValue + */ +export function useDateValueMemo( + timeZone: string, + value?: string +): DateValue | undefined { + return useMemo(() => parseDateValue(timeZone, value), [timeZone, value]); +} + +/** + * Parses a date value string into a DateValue. + * + * @param value the string date value + * @returns DateValue + */ +export function parseDateValue( + timeZone: string, + value?: string +): DateValue | undefined { + if (value === undefined) { + return value; + } + + // Try to parse and ISO 8601 date string, e.g. "2021-02-03" + try { + return parseDate(value); + } catch (ignore) { + // ignore + } + + // Note that the Python API will never send a string like this. This is here for correctness. + // Try to parse an ISO 8601 date time string, e.g. "2021-03-03T04:05:06" + try { + return parseDateTime(value); + } catch (ignore) { + // ignore + } + + // Try to parse an ISO 8601 zoned date time string, e.g. "2021-04-04T05:06:07[America/New_York]" + try { + return parseZonedDateTime(value); + } catch (ignore) { + // ignore + } + + // Try to parse a non-ISO 8601 zoned date time string, e.g. "2021-04-04T05:06:07 America/New_York" + const parts = value.split(' '); + if (parts.length === 2) { + const isoString = `${parts[0]}[${parts[1]}]`; + try { + return parseZonedDateTime(isoString); + } catch (ignore) { + // ignore + } + } + + // This is an edge case. The Python API will parse these to an Instant, + // but the user may explicitly create a ZonedDateTime with a UTC offset. + // Try to parse an ZonedDateTime "2021-04-04T05:06:07Z[UTC]" + if (value.endsWith('Z[UTC]')) { + try { + return parseZonedDateTime(value.replace('Z', '')); + } catch (ignore) { + // ignore + } + } + + // Try to parse an Instant "2021-04-04T05:06:07Z" + if (value.endsWith('Z')) { + try { + return toTimeZone( + parseZonedDateTime(`${value.slice(0, -1)}[UTC]`), + timeZone + ); + } catch (ignore) { + // ignore + } + } + + throw new Error(`Invalid date value string: ${value}`); +} + +/** + * Get a callback function that can be passed to the isDateUnavailable prop of a Spectrum DatePicker. + * + * @param unavailableSet Set of unavailable date strings + * @returns A callback to be passed into the Spectrum component that checks if the date is unavailable + */ +export function useIsDateUnavailableCallback( + unavailableSet: Set +): (date: DateValue) => boolean { + return useCallback( + (date: DateValue) => unavailableSet.has(date.toString()), + [unavailableSet] + ); +} + +/** + * Wrap DatePicker props with the appropriate serialized event callbacks. + * @param props Props to wrap + * @returns Wrapped props + */ +export function useDatePickerProps( + { + onFocus, + onBlur, + onKeyDown, + onKeyUp, + onChange: serializedOnChange, + value: serializedValue, + defaultValue: serializedDefaultValue, + minValue: serializedMinValue, + maxValue: serializedMaxValue, + placeholderValue: serializedPlaceholderValue, + unavailableValues, + granularity: upperCaseGranularity, + ...otherProps + }: SerializedDatePickerProps, + timeZone: string +): DeserializedDatePickerProps { + const serializedOnFocus = useFocusEventCallback(onFocus); + const serializedOnBlur = useFocusEventCallback(onBlur); + const serializedOnKeyDown = useKeyboardEventCallback(onKeyDown); + const serializedOnKeyUp = useKeyboardEventCallback(onKeyUp); + const onChange = useOnChangeCallback(serializedOnChange); + const deserializedValue = useNullableDateValueMemo(timeZone, serializedValue); + const deserializedDefaultValue = useNullableDateValueMemo( + timeZone, + serializedDefaultValue + ); + const deserializedMinValue = useDateValueMemo(timeZone, serializedMinValue); + const deserializedMaxValue = useDateValueMemo(timeZone, serializedMaxValue); + const deserializedPlaceholderValue = useDateValueMemo( + timeZone, + serializedPlaceholderValue + ); + // TODO (issue #698) currently unavailableValues is commented out in Python + // The problem is that the dates need to match down to the second (or millisecond) + // using this approach. We should restrict them to LocalDate then convert + // the input to this function to a CalendarDate to check for availability. + const unavailableSet = useMemo(() => { + if (unavailableValues == null) { + return new Set(); + } + const set = new Set(); + unavailableValues.forEach(value => { + const valueForTZ = parseDateValue(timeZone, value)?.toString(); + if (valueForTZ != null) { + set.add(valueForTZ); + } + }); + return set; + }, [unavailableValues, timeZone]); + const isDateUnavailable = useIsDateUnavailableCallback(unavailableSet); + + return { + onFocus: serializedOnFocus, + onBlur: serializedOnBlur, + onKeyDown: serializedOnKeyDown, + onKeyUp: serializedOnKeyUp, + onChange: serializedOnChange == null ? undefined : onChange, + value: deserializedValue, + defaultValue: deserializedDefaultValue, + minValue: deserializedMinValue, + maxValue: deserializedMaxValue, + placeholderValue: deserializedPlaceholderValue, + isDateUnavailable, + granularity: upperCaseGranularity?.toLowerCase() as Granularity, + ...otherProps, + }; +} diff --git a/plugins/ui/src/js/src/elements/index.ts b/plugins/ui/src/js/src/elements/index.ts index d33e06393..d3f8aa3bd 100644 --- a/plugins/ui/src/js/src/elements/index.ts +++ b/plugins/ui/src/js/src/elements/index.ts @@ -2,6 +2,7 @@ export * from './ActionButton'; export * from './ActionGroup'; export * from './Button'; export * from './ComboBox'; +export * from './DatePicker'; export * from './Form'; export * from './hooks'; export * from './HTMLElementView'; diff --git a/plugins/ui/src/js/src/elements/model/ElementConstants.ts b/plugins/ui/src/js/src/elements/model/ElementConstants.ts index 1b74e80c5..9dbfc6c6a 100644 --- a/plugins/ui/src/js/src/elements/model/ElementConstants.ts +++ b/plugins/ui/src/js/src/elements/model/ElementConstants.ts @@ -32,6 +32,7 @@ export const ELEMENT_NAME = { comboBox: uiComponentName('ComboBox'), content: uiComponentName('Content'), contextualHelp: uiComponentName('ContextualHelp'), + datePicker: uiComponentName('DatePicker'), flex: uiComponentName('Flex'), form: uiComponentName('Form'), fragment: uiComponentName('Fragment'), diff --git a/plugins/ui/src/js/src/widget/WidgetUtils.tsx b/plugins/ui/src/js/src/widget/WidgetUtils.tsx index 7b6a5ed98..6673cae95 100644 --- a/plugins/ui/src/js/src/widget/WidgetUtils.tsx +++ b/plugins/ui/src/js/src/widget/WidgetUtils.tsx @@ -51,6 +51,7 @@ import { ActionGroup, Button, ComboBox, + DatePicker, Form, IllustratedMessage, Image, @@ -103,6 +104,7 @@ export const elementComponentMap = { [ELEMENT_NAME.comboBox]: ComboBox, [ELEMENT_NAME.content]: Content, [ELEMENT_NAME.contextualHelp]: ContextualHelp, + [ELEMENT_NAME.datePicker]: DatePicker, [ELEMENT_NAME.flex]: Flex, [ELEMENT_NAME.form]: Form, [ELEMENT_NAME.fragment]: React.Fragment, diff --git a/plugins/ui/test/deephaven/ui/test_date_picker.py b/plugins/ui/test/deephaven/ui/test_date_picker.py index d7a2d9624..34bf84a11 100644 --- a/plugins/ui/test/deephaven/ui/test_date_picker.py +++ b/plugins/ui/test/deephaven/ui/test_date_picker.py @@ -7,16 +7,27 @@ class DatePickerTest(BaseTestCase): def test_convert_date_props(self): from deephaven.time import to_j_instant, to_j_zdt, to_j_local_date from deephaven.ui.components.date_picker import _convert_date_picker_props - from deephaven.ui._internal.utils import get_jclass_name, convert_list_prop + from deephaven.ui._internal.utils import ( + get_jclass_name, + convert_list_prop, + _convert_to_java_date, + ) - def verify_is_local_date(date): - self.assertEqual(get_jclass_name(date), "java.time.LocalDate") + def verify_is_local_date(dateStr): + self.assertEqual( + get_jclass_name(_convert_to_java_date(dateStr)), "java.time.LocalDate" + ) - def verify_is_instant(date): - self.assertEqual(get_jclass_name(date), "java.time.Instant") + def verify_is_instant(dateStr): + self.assertEqual( + get_jclass_name(_convert_to_java_date(dateStr)), "java.time.Instant" + ) - def verify_is_zdt(date): - self.assertEqual(get_jclass_name(date), "java.time.ZonedDateTime") + def verify_is_zdt(dateStr): + self.assertEqual( + get_jclass_name(_convert_to_java_date(dateStr)), + "java.time.ZonedDateTime", + ) def empty_on_change(): pass @@ -68,10 +79,10 @@ def empty_on_change(): verify_is_local_date(props1["max_value"]) verify_is_zdt(props1["min_value"]) - verify_is_instant(props1["unavailable_dates"][0]) + verify_is_zdt(props1["unavailable_dates"][0]) verify_is_local_date(props1["unavailable_dates"][1]) - verify_is_instant(props1["value"]) - verify_is_instant(props1["default_value"]) + verify_is_zdt(props1["value"]) + verify_is_zdt(props1["default_value"]) verify_is_local_date(props1["placeholder_value"]) props2["on_change"]("2021-01-01") diff --git a/tests/app.d/ui_render_all.py b/tests/app.d/ui_render_all.py index 187303de1..e7b7fb40a 100644 --- a/tests/app.d/ui_render_all.py +++ b/tests/app.d/ui_render_all.py @@ -55,7 +55,7 @@ def ui_components(): # TODO: #201 ui.combo_box("Combo Box"), ui.content("Content"), ui.contextual_help("Contextual Help"), - # TODO: #367 ui.date_picker("Date Picker"), + ui.date_picker(label="Date Picker"), ui.flex("Flex default child A", "Flex default child B"), ui.flex("Flex column child A", "Flex column child B", direction="column"), ui.form("Form"), diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-chromium-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-chromium-linux.png index d602a813f..f4c2a82fd 100644 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-chromium-linux.png and b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-chromium-linux.png differ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-firefox-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-firefox-linux.png index 48a45797e..e5675fd56 100644 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-firefox-linux.png and b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-firefox-linux.png differ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-webkit-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-webkit-linux.png index 3ae27ab9a..8bcdfa15b 100644 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-webkit-linux.png and b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-webkit-linux.png differ