diff --git a/plugins/ui/DESIGN.md b/plugins/ui/DESIGN.md index 9c6d4986b..c436dd542 100644 --- a/plugins/ui/DESIGN.md +++ b/plugins/ui/DESIGN.md @@ -1452,6 +1452,150 @@ date_picker8 = ui.date_picker( ) ``` +###### ui.date_range_picker + +A date range picker can be used to select a range of dates. + +The range is a dictionary with a `start` date and an `end` date; e.g., `{ "start": "2024-01-02", "end": "2024-01-05" }` + +The date range picker accepts the following date types as inputs: + +- `None` +- `LocalDate` +- `ZoneDateTime` +- `Instant` +- `int` +- `str` +- `datetime.datetime` +- `numpy.datetime64` +- `pandas.Timestamp` + +The `start` and `end` dates should be input using the same type. + +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 range 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 range 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 range picker with a granularity of seconds in the specified time zone. The time zone will be rendered as the specified time zone. + +The `start` and `end` inputs are converted 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 range 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 passes a range of `Instant`. + +```py +import deephaven.ui as ui +ui.date_range_picker( + placeholder_value: Date | None = None, + value: { "start": Date, "end": Date } | None = None, + default_value: { "start": Date, "end": Date } | None = None, + min_value: Date | None = None, + max_value: Date | None = None, + granularity: Granularity | None = None, + on_change: Callable[[{ "start": Date, "end": Date }], None] | None = None, + **props: Any +) -> DateRangePickerElement +``` + +###### 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 the current time on the server machine time zone. | +| `value` | `{ "start": Date, "end": Date } \| None` | The current value (controlled). | +| `default_value` | `{ "start": Date, "end": 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[[{ "start": Date, "end": 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 [DateRangePicker](https://react-spectrum.adobe.com/react-spectrum/DateRangePicker.html) prop, except `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 + +zdt_start = to_j_zdt("1995-03-22T11:11:11.23142 America/New_York") +zdt_end = to_j_zdt("1995-03-25T11:11:11.23142 America/New_York") +instant_start = to_j_instant("2022-01-01T00:00:00 ET") +instant_end = to_j_instant("2022-01-05T00:00:00 ET") +local_start = to_j_local_date("2024-05-06") +local_end = to_j_local_date("2024-05-10") + +# simple date picker that takes ui.items and is uncontrolled +# this creates a date picker with a granularity of days +date_range_picker1 = ui.date_range_picker( + default_value={"start": local_start, "end": local_end} +) + +# simple date picker that takes list view items directly and is controlled +# this creates a date picker with a granularity of seconds in UTC +# the on_change handler is passed a range of instants +dates2, set_dates2 = ui.use_state({"start": instant_start, "end": instant_end}) + +date_range_picker2 = ui.date_range_picker( + value=dates2, + on_change=set_dates2 +) + +# this creates a date picker with a granularity of seconds in the specified time zone +# the on_change handler is passed a zoned date time +dates3, set_dates3 = ui.use_state(None) + +date_range_picker3 = ui.date_range_picker( + placeholder_value=zdt_start, + on_change=set_dates3 +) + +# this creates a date picker with a granularity of seconds in UTC +# the on_change handler is passed an instant +dates4, set_dates4 = ui.use_state(None) + +date_range_picker4 = ui.date_range_picker( + placeholder_value=instant_start, + on_change=set_dates4 +) + +# this creates a date picker with a granularity of days +# the on_change handler is passed a local date +dates5, set_dates5 = ui.use_state(None) + +date_range_picker5 = ui.date_range_picker( + placeholder_value=local_start, + on_change=set_date5 +) + +# this creates a date picker with a granularity of days, but the on_change handler is still passed an instant +dates6, set_dates6 = ui.use_state(None) + +date_range_picker6 = ui.date_range_picker( + placeholder_value=instant_start, + granularity="day", + on_change=set_date6 +) + +# this creates a date picker with a granularity of seconds and the on_change handler is passed an instant +dates7, set_dates7 = ui.use_state(None) + +date_range_picker7 = ui.date_range_picker( + on_change=set_dates7 +) +``` + ##### ui.combo_box A combo_box that can be used to search or select from a list. Children should be one of five types: diff --git a/plugins/ui/docs/components/date_range_picker.md b/plugins/ui/docs/components/date_range_picker.md new file mode 100644 index 000000000..a599b1617 --- /dev/null +++ b/plugins/ui/docs/components/date_range_picker.md @@ -0,0 +1,476 @@ +# Date Range Picker + +Date range pickers allow users to select a range dates and times from a pop up calendar. + +## Example + +```python +from deephaven import ui + +my_date_range_picker_basic = ui.date_range_picker(label="Date range") +``` + +## Date types + +A date range picker can be used to select a `range` of dates. + +The `range` is a dictionary with a `start` date and an `end` date. e.g. `{ "start": "2024-01-02", "end": "2024-01-05" }` + +The date range picker accepts the following date types as inputs: + +- `None` +- `LocalDate` +- `ZoneDateTime` +- `Instant` +- `int` +- `str` +- `datetime.datetime` +- `numpy.datetime64` +- `pandas.Timestamp` + +The `start` and `end` dates should be the same type. + +The input will be converted to one of three Java date types: + +1. `LocalDate`: in the ISO-8601 system, a LocalDate is a date without a time zone, such as "2007-12-03" or "2057-01-28". + This will create a date range 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 range 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 range picker with a granularity of seconds in the specified time zone. The time zone will be rendered as the specified time zone. + +The `start` and `end` inputs are converted 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 time zone 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 range picker and the type of the value passed to the `on_change` handler +are 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 a range of `Instant`. + +```python +from deephaven import ui +from deephaven.time import to_j_local_date, dh_today, to_j_instant, to_j_zdt + + +@ui.component +def date_range_picker_example(start, end): + dates, set_dates = ui.use_state({"start": start, "end": end}) + return [ui.date_range_picker(on_change=set_dates, value=dates), ui.text(str(dates))] + + +zdt_start = to_j_zdt("1995-03-22T11:11:11.23142 America/New_York") +zdt_end = to_j_zdt("1995-03-25T11:11:11.23142 America/New_York") +instant_start = to_j_instant("2022-01-01T00:00:00 ET") +instant_end = to_j_instant("2022-01-05T00:00:00 ET") +local_start = to_j_local_date("2024-05-06") +local_end = to_j_local_date("2024-05-10") + +my_zoned_example = date_range_picker_example(zdt_start, zdt_end) +my_instant_example = date_range_picker_example(instant_start, instant_end) +my_local_example = date_range_picker_example(local_start, local_end) +``` + +## Value + +A date range picker displays a `placeholder` by default. An initial, uncontrolled value can be provided to the date range picker using the `defaultValue` prop. Alternatively, a controlled value can be provided using the `value` prop. + +```python +from deephaven import ui + + +@ui.component +def example(): + value, set_value = ui.use_state({"start": "2020-02-03", "end": "2020-02-08"}) + return ui.flex( + ui.date_range_picker( + label="Date range (uncontrolled)", + default_value={"start": "2020-02-03", "end": "2020-02-08"}, + ), + ui.date_range_picker( + label="Date range (controlled)", value=value, on_change=set_value + ), + gap="size-150", + wrap=True, + ) + + +my_example = example() +``` + +## Time zones + +Date range picker is time zone aware when `ZonedDateTime` or `Instant` objects are provided as the value. In this case, the time zone abbreviation is displayed, and time zone concerns such as daylight saving time are taken into account when the value is manipulated. + +In most cases, your data will come from and be sent to a server as an `ISO 8601` formatted string. + +For `ZonedDateTime` objects, the date range picker will display the specified time zone. + +For `Instant` objects, the date range picker will display the time zone from the user settings. + +```python +from deephaven import ui +from deephaven.time import to_j_instant + +my_zoned_date_time = ui.date_range_picker( + label="Date range", + default_value={ + "start": "2022-11-07T00:45 America/Los_Angeles", + "end": "2022-11-08T11:15 America/Los_Angeles", + }, +) + +my_instant = ui.date_range_picker( + label="Date range", + default_value={ + "start": to_j_instant("2022-11-07T00:45Z"), + "end": to_j_instant("2022-11-08T11:15Z"), + }, +) +``` + +## Granularity + +The `granularity` prop allows you to control the smallest unit that is displayed by a date range picker. By default, `LocalDate` values are displayed with "DAY" granularity (year, month, and day), and `ZonedDateTime` and `Instant` values are displayed with "SECOND" granularity. + +In addition, when a value with a time is provided but you wish to display only the date, you can set the granularity to "DAY". This has no effect on the actual value (it still has a time component), only on what fields are displayed. In the following example, two date range pickers are synchronized with the same value but display different granularities. + +```python +from deephaven import ui + + +@ui.component +def granularity_example(): + value, set_value = ui.use_state( + {"start": "2021-04-07T18:45:22 UTC", "end": "2021-04-08T20:00:00 UTC"} + ) + return ui.flex( + ui.date_range_picker( + label="Date range and time range", + granularity="SECOND", + value=value, + on_change=set_value, + ), + ui.date_range_picker( + label="Date range", granularity="DAY", value=value, on_change=set_value + ), + gap="size-150", + wrap=True, + ) + + +my_granularity_example = granularity_example() +``` + +## HTML forms + +Date range picker supports the `start_name` and `end_name` props for integration with HTML forms. The values will be submitted to the server as `ISO 8601` formatted strings according to the granularity of the value. For example, if the date range picker allows selecting only dates, then strings such as "2023-02-03" will be submitted, and if it allows selecting times, then strings such as "2023-02-03T08:45:00" + +```python +from deephaven import ui + +my_date_range_picker_forms = ui.form( + ui.date_range_picker( + label="Trip dates", start_name="startDate", end_name="endDate" + ), + ui.button("Submit", type="submit"), + on_submit=print, +) +``` + +## Labeling + +A visual label should be provided for the date range picker using the `label` prop. If the date range picker is required, the `is_required` and `necessity_indicator` props can be used to show a required state. + +```python +from deephaven import ui + +my_date_range_picker_labeling = ui.flex( + ui.date_range_picker(label="Date range"), + ui.date_range_picker( + label="Date range", is_required=True, necessity_indicator="icon" + ), + ui.date_range_picker( + label="Date range", is_required=True, necessity_indicator="label" + ), + ui.date_range_picker(label="Date range", necessity_indicator="label"), +) +``` + +## Events + +Date range pickers support selection through mouse, keyboard, and touch inputs via the `on_change` prop, which receives the value as an argument. + +```python +from deephaven import ui + + +@ui.component +def event_example(): + value, set_value = ui.use_state({"start": "2020-02-03", "end": "2020-02-08"}) + return ui.date_range_picker( + label="Date range (controlled)", value=value, on_change=set_value + ) + + +my_event_example = event_example() +``` + +## Validation + +The `is_required` prop ensures that the user selects a date range. The related `validation_behaviour` prop allows the user to specify aria or native verification. + +When the prop is set to "native", the validation errors block form submission and are displayed as help text automatically. + +```python +from deephaven import ui + + +@ui.component +def date_range_picker_validation_behaviour_example(): + return ui.form( + ui.date_range_picker( + validation_behavior="native", + is_required=True, + ) + ) + + +my_date_range_picker_validation_behaviour_example = ( + date_range_picker_validation_behaviour_example() +) +``` + +## Minimum and maximum values + +The `min_value` and `max_value` props can also be used to ensure the value is within a specific range. Date range picker also validates that the end date is after the start date. + +```python +from deephaven import ui + +my_date_range_picker_basic = ui.date_range_picker( + label="Date range", + min_value="2024-01-01", + default_value={"start": "2022-02-03", "end": "2022-05-03"}, +) +``` + +## Label position + +By default, the position of a date range picker's label is above the date range picker, but it can be moved to the side using the `label_position` prop. + +```python +from deephaven import ui + + +@ui.component +def date_range_picker_label_position_examples(): + return [ + ui.date_range_picker( + label="Test Label", + ), + ui.date_range_picker( + label="Test Label", + label_position="side", + ), + ] + + +my_date_range_picker_label_position_examples = ( + date_range_picker_label_position_examples() +) +``` + +## Quiet state + +The `is_quiet` prop makes a date range picker "quiet". This can be useful when the picker and its corresponding styling should not distract users from surrounding content. + +```python +from deephaven import ui + + +my_date_range_picker_is_quiet_example = ui.date_range_picker( + is_quiet=True, +) +``` + +## Disabled state + +The `is_disabled` prop disables a picker to prevent user interaction. This is useful when the date range picker should be visible but not available for selection. + +```python +from deephaven import ui + + +my_date_range_picker_is_disabled_example = ui.date_range_picker( + is_disabled=True, +) +``` + +## Help text + +A date range picker can have both a `description` and an `error_message`. Use the error message to offer specific guidance on how to correct the input. + +The `validation_state` prop can be used to set whether the current date range picker state is `valid` or `invalid`. + +```python +from deephaven import ui + + +@ui.component +def date_range_picker_help_text_examples(): + return [ + ui.date_range_picker( + label="Sample Label", + description="Enter a date range.", + ), + ui.date_range_picker( + label="Sample Label", + validation_state="valid", + error_message="Sample invalid error message.", + ), + ui.date_range_picker( + label="Sample Label", + validation_state="invalid", + error_message="Sample invalid error message.", + ), + ] + + +my_date_range_picker_help_text_examples = date_range_picker_help_text_examples() +``` + +## Contextual help + +Using the `contextual_help` prop, a `ui.contextual_help` can be placed next to the label to provide additional information about the date range picker. + +```python +from deephaven import ui + + +date_range_picker_contextual_help_example = ui.date_range_picker( + label="Sample Label", + contextual_help=ui.contextual_help(ui.heading("Content tips")), +) +``` + +## Custom width + +The `width` prop adjusts the width of a date range picker, and the `max_width` prop enforces a maximum width. + +```python +from deephaven import ui + + +@ui.component +def date_range_picker_width_examples(): + return [ + ui.date_range_picker( + width="size-6000", + ), + ui.date_range_picker( + width="size-6000", + max_width="100%", + ), + ] + + +my_date_range_picker_width_examples = date_range_picker_width_examples() +``` + +## Maximum visible months + +By default, the calendar popover displays a single month. The `max_visible_months` prop allows displaying up to 3 months at a time, if screen space permits. + +```python +from deephaven import ui + + +date_range_picker_months_example = ui.date_range_picker( + label="Date range", max_visible_months=3 +) +``` + +## Page behavior + +By default, when pressing the next or previous buttons, pagination advances by the `max_visible_months` value. This behavior can be changed to page by single months by setting `page_behavior` to single. + +```python +from deephaven import ui + + +date_range_picker_page_example = ui.date_range_picker( + label="Date range", max_visible_months=3, page_behavior="single" +) +``` + +## Hide time zone + +The time zone can be hidden using the `hide_time_zone` option. + +```python +from deephaven import ui + +my_hide_time_zone_example = ui.date_range_picker( + label="Date range", + default_value={ + "start": "2022-11-07T00:45 America/Los_Angeles", + "end": "2022-11-08T11:15 America/Los_Angeles", + }, + hide_time_zone=True, +) +``` + +## Hour cycle + +By default, date range picker displays times in either a `12` or `24` hour format depending on the user's locale. However, this can be overridden using the `hour_cycle` prop. + +```python +from deephaven import ui + + +date_range_picker_hour_cycle_example = ui.date_range_picker( + label="Date range", hour_cycle=24 +) +``` + +## Time table filtering + +Date range 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"): + dates, set_dates = ui.use_state({"start": start_date, "end": end_date}) + start = dates["start"] + end = dates["end"] + return [ + ui.date_range_picker(label="Dates", value=dates, on_change=set_dates), + table.where(f"{time_col} >= start && {time_col} < end"), + ] + + +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_range_picker +``` diff --git a/plugins/ui/src/deephaven/ui/_internal/utils.py b/plugins/ui/src/deephaven/ui/_internal/utils.py index 25f8e0909..691abe679 100644 --- a/plugins/ui/src/deephaven/ui/_internal/utils.py +++ b/plugins/ui/src/deephaven/ui/_internal/utils.py @@ -7,7 +7,7 @@ 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 +from ..types import Date, JavaDate, DateRange _UNSAFE_PREFIX = "UNSAFE_" _ARIA_PREFIX = "aria_" @@ -330,6 +330,21 @@ def _get_first_set_key(props: dict[str, Any], sequence: Sequence[str]) -> str | return None +def _date_or_range(value: JavaDate | DateRange) -> Any: + """ + Gets the Java Date from a Java Date or DateRange. + + Args: + value: the Java Date or DateRange + + Returns: + The Java Date. + """ + if isinstance(value, dict): + return value["start"] + return value + + def _prioritized_callable_converter( props: dict[str, Any], priority: Sequence[str], @@ -353,7 +368,7 @@ def _prioritized_callable_converter( first_set_key = _get_first_set_key(props, priority) return ( - _jclass_converter(props[first_set_key]) + _jclass_converter(_date_or_range(props[first_set_key])) if first_set_key is not None else default_converter ) @@ -381,9 +396,57 @@ def convert_list_prop( return [str(_convert_to_java_date(date)) for date in value] +def convert_date_range( + date_range: DateRange, + converter: Callable[[Date], Any], +) -> DateRange: + """ + Convert a DateRange to Java date types. + + Args: + date_range: The DateRange to convert to Java date types. + converter: The date converter to use. + + Returns: + The Java date types. + """ + return DateRange( + start=converter(date_range["start"]), + end=converter(date_range["end"]), + ) + + +def _wrap_date_range_callable( + date_callable: Callable[[DateRange], None], + converter: Callable[[Date], Any], +) -> Callable[[DateRange], None]: + """ + Wrap a callable to convert the Date argument to a Java date type. + This maintains the original callable signature so that the Date argument can be dropped. + + Args: + date_callable: The callable to wrap. + converter: The date converter to use. + + Returns: + The wrapped callable. + """ + # 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_range: DateRange) -> None: + wrapped_date_callable = wrap_callable(date_callable) + try: + wrapped_date_callable(convert_date_range(date_range, converter)) + except Exception: + pass + + return no_error_date_callable + + def convert_date_props( props: dict[str, Any], simple_date_props: set[str], + date_range_props: set[str], callable_date_props: set[str], priority: Sequence[str], granularity_key: str, @@ -395,6 +458,7 @@ def convert_date_props( Args: props: The props passed to the component. simple_date_props: A set of simple date keys to convert. The prop value should be a single Date. + date_range_props: A set of date range keys to convert. 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. @@ -408,6 +472,10 @@ def convert_date_props( if props.get(key) is not None: props[key] = _convert_to_java_date(props[key]) + for key in date_range_props: + if props.get(key) is not None: + props[key] = convert_date_range(props[key], _convert_to_java_date) + # the simple props must be converted before this to simplify the callable conversion converter = _prioritized_callable_converter(props, priority, default_converter) @@ -421,11 +489,21 @@ def convert_date_props( if props.get(key) is not None: props[key] = str(props[key]) + # and convert the date range props to strings + for key in date_range_props: + if props.get(key) is not None: + props[key] = convert_date_range(props[key], str) + + # wrap the date callable with the convert + # if there are date range props, we need to convert as a date range for key in callable_date_props: if props.get(key) is not None: if not callable(props[key]): raise TypeError(f"{key} must be a callable") - props[key] = _wrap_date_callable(props[key], converter) + if len(date_range_props) > 0: + props[key] = _wrap_date_range_callable(props[key], converter) + else: + props[key] = _wrap_date_callable(props[key], converter) def unpack_item_table_source( diff --git a/plugins/ui/src/deephaven/ui/components/__init__.py b/plugins/ui/src/deephaven/ui/components/__init__.py index 4be75dba4..dc8b6f957 100644 --- a/plugins/ui/src/deephaven/ui/components/__init__.py +++ b/plugins/ui/src/deephaven/ui/components/__init__.py @@ -13,6 +13,7 @@ from .contextual_help import contextual_help from .dashboard import dashboard from .date_picker import date_picker +from .date_range_picker import date_range_picker from .flex import flex from .form import form from .fragment import fragment @@ -67,6 +68,7 @@ "contextual_help", "dashboard", "date_picker", + "date_range_picker", "flex", "form", "fragment", diff --git a/plugins/ui/src/deephaven/ui/components/date_picker.py b/plugins/ui/src/deephaven/ui/components/date_picker.py index fba8aa4d6..f2182b9f3 100644 --- a/plugins/ui/src/deephaven/ui/components/date_picker.py +++ b/plugins/ui/src/deephaven/ui/components/date_picker.py @@ -43,6 +43,7 @@ "min_value", "max_value", } +_RANGE_DATE_PROPS = set() _LIST_DATE_PROPS = {"unavailable_values"} _CALLABLE_DATE_PROPS = {"on_change"} _GRANULARITY_KEY = "granularity" @@ -67,6 +68,7 @@ def _convert_date_picker_props( convert_date_props( props, _SIMPLE_DATE_PROPS, + _RANGE_DATE_PROPS, _CALLABLE_DATE_PROPS, _DATE_PROPS_PRIORITY, _GRANULARITY_KEY, diff --git a/plugins/ui/src/deephaven/ui/components/date_range_picker.py b/plugins/ui/src/deephaven/ui/components/date_range_picker.py new file mode 100644 index 000000000..a852bed2d --- /dev/null +++ b/plugins/ui/src/deephaven/ui/components/date_range_picker.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +from typing import Any, Sequence, Callable + +from .types import ( + FocusEventCallable, + KeyboardEventCallable, + LayoutFlex, + DimensionValue, + AlignSelf, + JustifySelf, + Position, + AriaPressed, + CSSProperties, + LabelPosition, + ValidationBehavior, + NecessityIndicator, + ValidationState, + PageBehavior, + HourCycle, + Alignment, +) + +from ..hooks import use_memo +from ..elements import Element +from .._internal.utils import ( + create_props, + convert_date_props, + convert_list_prop, +) +from ..types import Date, Granularity, DateRange +from .basic import component_element +from .make_component import make_component +from deephaven.time import dh_now + +DatePickerElement = Element + +# All the props that can be date types +_SIMPLE_DATE_PROPS = { + "placeholder_value", + "min_value", + "max_value", +} +_RANGE_DATE_PROPS = {"value", "default_value"} +_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"] + + +def _convert_date_range_picker_props( + props: dict[str, Any], +) -> dict[str, Any]: + """ + Convert date range picker props to Java date types. + + Args: + props: The props passed to the date range picker. + + Returns: + The converted props. + """ + + convert_date_props( + props, + _SIMPLE_DATE_PROPS, + _RANGE_DATE_PROPS, + _CALLABLE_DATE_PROPS, + _DATE_PROPS_PRIORITY, + _GRANULARITY_KEY, + ) + + return props + + +@make_component +def date_range_picker( + placeholder_value: Date | None = dh_now(), + value: DateRange | None = None, + default_value: DateRange | None = None, + min_value: Date | None = None, + max_value: 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, + hide_time_zone: bool = False, + should_force_leading_zeros: bool | None = None, + is_disabled: bool | None = None, + is_read_only: bool | None = None, + is_required: bool | None = None, + validation_behavior: ValidationBehavior | None = None, + auto_focus: bool | None = None, + label: Element | None = None, + description: Element | None = None, + error_message: Element | None = None, + is_open: bool | None = None, + default_open: bool | None = None, + allows_non_contiguous_ranges: bool | None = None, + start_name: str | None = None, + end_name: str | None = None, + max_visible_months: int | None = None, + should_flip: bool | None = None, + is_quiet: bool | None = None, + show_format_help_text: bool | None = None, + label_position: LabelPosition | None = None, + label_align: Alignment | None = None, + necessity_indicator: NecessityIndicator | None = None, + contextual_help: Element | None = None, + validation_state: ValidationState | None = None, + on_focus: FocusEventCallable | None = None, + on_blur: FocusEventCallable | None = None, + on_focus_change: Callable[[bool], None] | None = None, + on_key_down: KeyboardEventCallable | None = None, + on_key_up: KeyboardEventCallable | None = None, + on_open_change: Callable[[bool], None] | None = None, + on_change: Callable[[DateRange], None] | None = None, + flex: LayoutFlex | None = None, + flex_grow: float | None = None, + flex_shrink: float | None = None, + flex_basis: DimensionValue | None = None, + align_self: AlignSelf | None = None, + justify_self: JustifySelf | None = None, + order: int | None = None, + grid_area: str | None = None, + grid_row: str | None = None, + grid_row_start: str | None = None, + grid_row_end: str | None = None, + grid_column: str | None = None, + grid_column_start: str | None = None, + grid_column_end: str | None = None, + margin: DimensionValue | None = None, + margin_top: DimensionValue | None = None, + margin_bottom: DimensionValue | None = None, + margin_start: DimensionValue | None = None, + margin_end: DimensionValue | None = None, + margin_x: DimensionValue | None = None, + margin_y: DimensionValue | None = None, + width: DimensionValue | None = None, + height: DimensionValue | None = None, + min_width: DimensionValue | None = None, + min_height: DimensionValue | None = None, + max_width: DimensionValue | None = None, + max_height: DimensionValue | None = None, + position: Position | None = None, + top: DimensionValue | None = None, + bottom: DimensionValue | None = None, + start: DimensionValue | None = None, + end: DimensionValue | None = None, + left: DimensionValue | None = None, + right: DimensionValue | None = None, + z_index: int | None = None, + is_hidden: bool | None = None, + id: str | None = None, + aria_label: str | None = None, + aria_labelledby: str | None = None, + aria_describedby: str | None = None, + aria_pressed: AriaPressed | None = None, + aria_details: str | None = None, + UNSAFE_class_name: str | None = None, + UNSAFE_style: CSSProperties | None = None, +) -> DatePickerElement: + """ + A date range picker allows the user to select a range of dates. + + + Args: + placeholder_value: 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: The current value (controlled). + 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. + 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 + advancing the visible page by visibleDuration (default) + or one unit of visibleDuration. + hour_cycle: Whether to display the time in 12 or 24 hour format. + By default, this is determined by the user's locale. + hide_time_zone: Whether to hide the time zone abbreviation. + should_force_leading_zeros: Whether to always show leading zeros in the + month, day, and hour fields. + By default, this is determined by the user's locale. + is_disabled: Whether the input is disabled. + is_read_only: Whether the input can be selected but not changed by the user. + is_required: Whether user input is required on the input before form submission. + validation_behavior: Whether to use native HTML form validation to prevent form + submission when the value is missing or invalid, + or mark the field as required or invalid via ARIA. + auto_focus: Whether the element should receive focus on render. + label: The content to display as the label. + description: A description for the field. + Provides a hint such as specific requirements for what to choose. + error_message: An error message for the field. + is_open: Whether the overlay is open by default (controlled). + default_open: Whether the overlay is open by default (uncontrolled). + allows_non_contiguous_ranges: When combined with unavailable_values, determines + whether non-contiguous ranges, i.e. ranges containing unavailable dates, may be selected. + start_name: The name of the start date input element, used when submitting an HTML form. + end_name: The name of the end date input element, used when submitting an HTML form. + max_visible_months: The maximum number of months to display at + once in the calendar popover, if screen space permits. + should_flip: Whether the calendar popover should automatically flip direction + when space is limited. + is_quiet: Whether the date picker should be displayed with a quiet style. + show_format_help_text: Whether to show the localized date format as help + text below the field. + label_position: The label's overall position relative to the element it is labeling. + label_align: The label's horizontal alignment relative to the element it is labeling. + necessity_indicator: Whether the required state should be shown as an icon or text. + contextual_help: A ContextualHelp element to place next to the label. + validation_state: Whether the input should display its "valid" or "invalid" visual styling. + on_focus: Function called when the button receives focus. + on_blur: Function called when the button loses focus. + on_focus_change: Function called when the focus state changes. + on_key_down: Function called when a key is pressed. + on_key_up: Function called when a key is released. + on_open_change: Handler that is called when the overlay's open state changes. + on_change: 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. + flex: When used in a flex layout, specifies how the element will grow or shrink to fit the space available. + flex_grow: When used in a flex layout, specifies how much the element will grow to fit the space available. + flex_shrink: When used in a flex layout, specifies how much the element will shrink to fit the space available. + flex_basis: When used in a flex layout, specifies the initial size of the element. + align_self: Overrides the align_items property of a flex or grid container. + justify_self: Specifies how the element is justified inside a flex or grid container. + order: The layout order for the element within a flex or grid container. + grid_area: The name of the grid area to place the element in. + grid_row: The name of the grid row to place the element in. + grid_row_start: The name of the grid row to start the element in. + grid_row_end: The name of the grid row to end the element in. + grid_column: The name of the grid column to place the element in. + grid_column_start: The name of the grid column to start the element in. + grid_column_end: The name of the grid column to end the element in. + margin: The margin to apply around the element. + margin_top: The margin to apply above the element. + margin_bottom: The margin to apply below the element. + margin_start: The margin to apply before the element. + margin_end: The margin to apply after the element. + margin_x: The margin to apply to the left and right of the element. + margin_y: The margin to apply to the top and bottom of the element. + width: The width of the element. + height: The height of the element. + min_width: The minimum width of the element. + min_height: The minimum height of the element. + max_width: The maximum width of the element. + max_height: The maximum height of the element. + position: Specifies how the element is positioned. + top: The distance from the top of the containing element. + bottom: The distance from the bottom of the containing element. + start: The distance from the start of the containing element. + end: The distance from the end of the containing element. + left: The distance from the left of the containing element. + right: The distance from the right of the containing element. + z_index: The stack order of the element. + is_hidden: Whether the element is hidden. + id: A unique identifier for the element. + aria_label: The label for the element. + aria_labelledby: The id of the element that labels the element. + aria_describedby: The id of the element that describes the element. + aria_pressed: Whether the element is pressed. + aria_details: The details for the element. + UNSAFE_class_name: A CSS class to apply to the element. + UNSAFE_style: A CSS style to apply to the element. + + Returns: + The date range picker element. + """ + _, props = create_props(locals()) + + _convert_date_range_picker_props(props) + + return component_element("DateRangePicker", **props) diff --git a/plugins/ui/src/deephaven/ui/types/types.py b/plugins/ui/src/deephaven/ui/types/types.py index fcca8aaa9..e4f0086bd 100644 --- a/plugins/ui/src/deephaven/ui/types/types.py +++ b/plugins/ui/src/deephaven/ui/types/types.py @@ -507,6 +507,23 @@ class SliderChange(TypedDict): Dependencies = Union[Tuple[Any], List[Any]] Selection = Sequence[Key] + +class DateRange(TypedDict): + """ + Range of date values for a date range picker. + """ + + start: Date + """ + Start value for the date range. + """ + + end: Date + """ + End value for the date range. + """ + + DataBarAxis = Literal["PROPORTIONAL", "MIDDLE", "DIRECTIONAL"] DataBarDirection = Literal["LTR", "RTL"] DataBarValuePlacement = Literal["BESIDE", "OVERLAP", "HIDE"] diff --git a/plugins/ui/src/js/src/elements/DatePicker.tsx b/plugins/ui/src/js/src/elements/DatePicker.tsx index 1ab5d2f3f..9e4c426f5 100644 --- a/plugins/ui/src/js/src/elements/DatePicker.tsx +++ b/plugins/ui/src/js/src/elements/DatePicker.tsx @@ -1,25 +1,21 @@ -import React, { useCallback, useState, useEffect } from 'react'; +import React, { 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 { usePrevious } from '@deephaven/react-hooks'; import { getSettings, RootState } from '@deephaven/redux'; import { DateValue, toTimeZone, ZonedDateTime } from '@internationalized/date'; +import useDebouncedOnChange from './hooks/useDebouncedOnChange'; import { SerializedDatePickerProps, useDatePickerProps, -} from './hooks/useDatepickerProps'; - -const VALUE_CHANGE_DEBOUNCE = 250; +} from './hooks/useDatePickerProps'; +import { isStringInstant } from './utils/DateTimeUtils'; const EMPTY_FUNCTION = () => undefined; -function isStringInstant(value?: string | null): boolean { - return value != null && value.endsWith('Z'); -} - function isDatePickerInstant( props: SerializedDatePickerProps> ): boolean { @@ -47,19 +43,9 @@ export function DatePicker( ...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] + const [value, onChange] = useDebouncedOnChange( + propValue ?? defaultValue, + propOnChange ); // When the time zone changes, the serialized prop value will change, so we need to update the value state @@ -73,16 +59,9 @@ export function DatePicker( value instanceof ZonedDateTime ) { const newValue = toTimeZone(value, timeZone); - setValue(toTimeZone(value, timeZone)); - debouncedOnChange(newValue); + onChange(newValue); } - }, [ - isDatePickerInstantValue, - value, - debouncedOnChange, - timeZone, - prevTimeZone, - ]); + }, [isDatePickerInstantValue, value, onChange, timeZone, prevTimeZone]); return ( // eslint-disable-next-line react/jsx-props-no-spreading diff --git a/plugins/ui/src/js/src/elements/DateRangePicker.tsx b/plugins/ui/src/js/src/elements/DateRangePicker.tsx new file mode 100644 index 000000000..25724e565 --- /dev/null +++ b/plugins/ui/src/js/src/elements/DateRangePicker.tsx @@ -0,0 +1,80 @@ +import React, { useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { + DateRangePicker as DHCDateRangePicker, + DateRangePickerProps as DHCDateRangePickerProps, +} from '@deephaven/components'; +import { usePrevious } from '@deephaven/react-hooks'; +import { getSettings, RootState } from '@deephaven/redux'; +import { DateValue, toTimeZone, ZonedDateTime } from '@internationalized/date'; +import useDebouncedOnChange from './hooks/useDebouncedOnChange'; +import { + RangeValue, + SerializedDateRangePickerProps, + useDateRangePickerProps, +} from './hooks/useDateRangePickerProps'; +import { isStringInstant } from './utils/DateTimeUtils'; + +const EMPTY_FUNCTION = () => undefined; + +function isDateRangePickerInstant( + props: SerializedDateRangePickerProps> +): boolean { + const { value, defaultValue, placeholderValue } = props; + if (value != null) { + return isStringInstant(value.start); + } + if (defaultValue != null) { + return isStringInstant(defaultValue.start); + } + return isStringInstant(placeholderValue); +} + +export function DateRangePicker( + props: SerializedDateRangePickerProps> +): JSX.Element { + const isDateRangePickerInstantValue = isDateRangePickerInstant(props); + const settings = useSelector(getSettings); + const { timeZone } = settings; + + const { + defaultValue = null, + value: propValue, + onChange: propOnChange = EMPTY_FUNCTION, + ...otherProps + } = useDateRangePickerProps(props, timeZone); + + const [value, onChange] = useDebouncedOnChange | null>( + propValue ?? defaultValue, + propOnChange + ); + + // When the time zone changes, the serialized prop value will change, so we need to update the value state + const prevTimeZone = usePrevious(timeZone); + // The timezone is intially undefined, so we don't want to trigger a change in that case + useEffect(() => { + // The timezone is intially undefined, so we don't want to trigger a change in that case + if ( + isDateRangePickerInstantValue && + prevTimeZone !== undefined && + timeZone !== prevTimeZone && + value != null && + value.start instanceof ZonedDateTime && + value.end instanceof ZonedDateTime + ) { + const newStart = toTimeZone(value.start, timeZone); + const newEnd = toTimeZone(value.end, timeZone); + const newValue = { start: newStart, end: newEnd }; + onChange(newValue); + } + }, [isDateRangePickerInstantValue, value, onChange, timeZone, prevTimeZone]); + + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + ); +} + +DateRangePicker.displayName = 'DateRangePicker'; + +export default DateRangePicker; diff --git a/plugins/ui/src/js/src/elements/hooks/index.ts b/plugins/ui/src/js/src/elements/hooks/index.ts index 6ff897593..8bfeca75a 100644 --- a/plugins/ui/src/js/src/elements/hooks/index.ts +++ b/plugins/ui/src/js/src/elements/hooks/index.ts @@ -1,4 +1,7 @@ export * from './useButtonProps'; +export * from './useDatePickerProps'; +export * from './useDateRangePickerProps'; +export * from './useDateValueMemo'; export * from './useFocusEventCallback'; export * from './useKeyboardEventCallback'; export * from './useListViewProps'; diff --git a/plugins/ui/src/js/src/elements/hooks/useDatepickerProps.ts b/plugins/ui/src/js/src/elements/hooks/useDatePickerProps.ts similarity index 71% rename from plugins/ui/src/js/src/elements/hooks/useDatepickerProps.ts rename to plugins/ui/src/js/src/elements/hooks/useDatePickerProps.ts index 7c03fddcb..1194d5bd4 100644 --- a/plugins/ui/src/js/src/elements/hooks/useDatepickerProps.ts +++ b/plugins/ui/src/js/src/elements/hooks/useDatePickerProps.ts @@ -1,14 +1,5 @@ import { useCallback, useMemo } from 'react'; -import { - DateValue, - CalendarDate, - CalendarDateTime, - ZonedDateTime, - parseDate, - parseDateTime, - parseZonedDateTime, - toTimeZone, -} from '@internationalized/date'; +import { DateValue } from '@internationalized/date'; import { DeserializedFocusEventCallback, SerializedFocusEventCallback, @@ -19,24 +10,21 @@ import { 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'; +import useDateValueMemo from './useDateValueMemo'; +import { + MappedDateValue, + Granularity, + parseDateValue, + parseNullableDateValue, +} from '../utils/DateTimeUtils'; export type SerializedDateValue = string | null; export type SerializedDateValueCallback = (value: SerializedDateValue) => void; -export type DeserializedDateValueCallback = ( - value: MappedDateValue -) => void; +export type DeserializedDateValueCallback = + | (() => void) + | ((value: MappedDateValue | null) => Promise); export interface SerializedDatePickerPropsInterface { /** Handler that is called when the element receives focus. */ @@ -129,7 +117,7 @@ export type DeserializedDatePickerProps = Omit< * @returns Serialized DateValue */ export function serializeDateValue( - value: MappedDateValue + value?: MappedDateValue ): SerializedDateValue { if (value == null) { return null; @@ -145,11 +133,11 @@ export function serializeDateValue( * @returns A callback to be passed into the Spectrum component that transforms * the value and calls the provided callback */ -export function useOnChangeCallback( +export function useOnChangeDateCallback( callback?: SerializedDateValueCallback -): (value: MappedDateValue) => void { +): DeserializedDateValueCallback { return useCallback( - (value: MappedDateValue) => { + (value?: MappedDateValue) => { if (callback == null) { return; } @@ -175,103 +163,6 @@ export function useNullableDateValueMemo( ); } -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. * @@ -314,7 +205,7 @@ export function useDatePickerProps( const serializedOnBlur = useFocusEventCallback(onBlur); const serializedOnKeyDown = useKeyboardEventCallback(onKeyDown); const serializedOnKeyUp = useKeyboardEventCallback(onKeyUp); - const onChange = useOnChangeCallback(serializedOnChange); + const onChange = useOnChangeDateCallback(serializedOnChange); const deserializedValue = useNullableDateValueMemo(timeZone, serializedValue); const deserializedDefaultValue = useNullableDateValueMemo( timeZone, diff --git a/plugins/ui/src/js/src/elements/hooks/useDateRangePickerProps.ts b/plugins/ui/src/js/src/elements/hooks/useDateRangePickerProps.ts new file mode 100644 index 000000000..996f3a9a8 --- /dev/null +++ b/plugins/ui/src/js/src/elements/hooks/useDateRangePickerProps.ts @@ -0,0 +1,240 @@ +import { useCallback, useMemo } from 'react'; +import { DateValue } from '@internationalized/date'; +import { + DeserializedFocusEventCallback, + SerializedFocusEventCallback, + useFocusEventCallback, +} from './useFocusEventCallback'; +import { + DeserializedKeyboardEventCallback, + SerializedKeyboardEventCallback, + useKeyboardEventCallback, +} from './useKeyboardEventCallback'; +import useDateValueMemo from './useDateValueMemo'; +import { + MappedDateValue, + Granularity, + parseDateValue, +} from '../utils/DateTimeUtils'; + +export interface RangeValue { + start: T; + end: T; +} + +export type SerializedDateRangeValue = RangeValue | null; + +export type SerializedDateRangeValueCallback = ( + value: SerializedDateRangeValue +) => void; + +export type DeserializedDateRangeValueCallback = + | (() => void) + | ((value: RangeValue> | null) => Promise); + +export interface SerializedDateRangePickerPropsInterface { + /** 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?: SerializedDateRangeValueCallback; + + /** The current value (controlled) */ + value?: RangeValue | null; + + /** The default value (uncontrolled) */ + defaultValue?: RangeValue | 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; + + /** Determines the smallest unit that is displayed in the date picker. */ + granularity?: Granularity; +} + +export interface DeserializedDateRangePickerPropsInterface { + /** 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?: DeserializedDateRangeValueCallback; + + /** The current value (controlled) */ + value?: RangeValue | null; + + /** The default value (uncontrolled) */ + defaultValue?: RangeValue | 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; + + /** Determines the smallest unit that is displayed in the date picker. */ + granularity?: Granularity; +} + +export type SerializedDateRangePickerProps = TProps & + SerializedDateRangePickerPropsInterface; + +export type DeserializedDateRangePickerProps = Omit< + TProps, + keyof SerializedDateRangePickerPropsInterface +> & + DeserializedDateRangePickerPropsInterface; + +/** + * Uses the toString representation of the DateValue as the serialized value. + * @param value RangeValue Date to serialize + * @returns Serialized RangeValue Date + */ +export function serializeDateRangeValue( + value?: RangeValue> +): RangeValue | null { + if (value == null) { + return null; + } + + const start = value.start.toString(); + const end = value.end.toString(); + + return { start, end }; +} + +/** + * Get a callback function that can be passed to the onChange event handler + * props of a Spectrum DateRangePicker. + * @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 useOnChangeDateRangeCallback( + callback?: SerializedDateRangeValueCallback +): DeserializedDateRangeValueCallback { + return useCallback( + (value?: RangeValue>) => { + if (callback == null) { + return; + } + callback(serializeDateRangeValue(value)); + }, + [callback] + ); +} + +/** + * Use memo to get a RangeValue Date from a nullable string. + * + * @param value the RangeValue string date value + * @returns RangeValue Date or null + */ +export function useDateRangeValueMemo( + timeZone: string, + value?: RangeValue | null +): RangeValue | null | undefined { + return useMemo( + () => parseNullableDateRangeValue(timeZone, value), + [timeZone, value] + ); +} + +export function parseNullableDateRangeValue( + timeZone: string, + value?: RangeValue | null +): RangeValue | null | undefined { + if (value == null) { + return value; + } + + const start = parseDateValue(timeZone, value.start); + const end = parseDateValue(timeZone, value.end); + + if (start === undefined || end === undefined) { + return undefined; + } + + return { start, end }; +} + +/** + * Wrap DateRangePicker props with the appropriate serialized event callbacks. + * @param props Props to wrap + * @returns Wrapped props + */ +export function useDateRangePickerProps( + { + onFocus, + onBlur, + onKeyDown, + onKeyUp, + onChange: serializedOnChange, + value: serializedValue, + defaultValue: serializedDefaultValue, + minValue: serializedMinValue, + maxValue: serializedMaxValue, + placeholderValue: serializedPlaceholderValue, + granularity: upperCaseGranularity, + ...otherProps + }: SerializedDateRangePickerProps, + timeZone: string +): DeserializedDateRangePickerProps { + const serializedOnFocus = useFocusEventCallback(onFocus); + const serializedOnBlur = useFocusEventCallback(onBlur); + const serializedOnKeyDown = useKeyboardEventCallback(onKeyDown); + const serializedOnKeyUp = useKeyboardEventCallback(onKeyUp); + const onChange = useOnChangeDateRangeCallback(serializedOnChange); + const deserializedValue = useDateRangeValueMemo(timeZone, serializedValue); + const deserializedDefaultValue = useDateRangeValueMemo( + timeZone, + serializedDefaultValue + ); + const deserializedMinValue = useDateValueMemo(timeZone, serializedMinValue); + const deserializedMaxValue = useDateValueMemo(timeZone, serializedMaxValue); + const deserializedPlaceholderValue = useDateValueMemo( + timeZone, + serializedPlaceholderValue + ); + + return { + onFocus: serializedOnFocus, + onBlur: serializedOnBlur, + onKeyDown: serializedOnKeyDown, + onKeyUp: serializedOnKeyUp, + onChange: serializedOnChange == null ? undefined : onChange, + value: deserializedValue, + defaultValue: deserializedDefaultValue, + minValue: deserializedMinValue, + maxValue: deserializedMaxValue, + placeholderValue: deserializedPlaceholderValue, + granularity: upperCaseGranularity?.toLowerCase() as Granularity, + ...otherProps, + }; +} diff --git a/plugins/ui/src/js/src/elements/hooks/useDateValueMemo.ts b/plugins/ui/src/js/src/elements/hooks/useDateValueMemo.ts new file mode 100644 index 000000000..d9781404d --- /dev/null +++ b/plugins/ui/src/js/src/elements/hooks/useDateValueMemo.ts @@ -0,0 +1,16 @@ +import { useMemo } from 'react'; +import { DateValue } from '@internationalized/date'; +import { parseDateValue } from '../utils/DateTimeUtils'; + +/** + * Use memo to get a DateValue from a string. + * + * @param value the string date value + * @returns DateValue + */ +export default function useDateValueMemo( + timeZone: string, + value?: string +): DateValue | undefined { + return useMemo(() => parseDateValue(timeZone, value), [timeZone, value]); +} diff --git a/plugins/ui/src/js/src/elements/index.ts b/plugins/ui/src/js/src/elements/index.ts index d3f8aa3bd..6bdbe809c 100644 --- a/plugins/ui/src/js/src/elements/index.ts +++ b/plugins/ui/src/js/src/elements/index.ts @@ -3,6 +3,7 @@ export * from './ActionGroup'; export * from './Button'; export * from './ComboBox'; export * from './DatePicker'; +export * from './DateRangePicker'; 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 9dbfc6c6a..f89090628 100644 --- a/plugins/ui/src/js/src/elements/model/ElementConstants.ts +++ b/plugins/ui/src/js/src/elements/model/ElementConstants.ts @@ -33,6 +33,7 @@ export const ELEMENT_NAME = { content: uiComponentName('Content'), contextualHelp: uiComponentName('ContextualHelp'), datePicker: uiComponentName('DatePicker'), + dateRangePicker: uiComponentName('DateRangePicker'), flex: uiComponentName('Flex'), form: uiComponentName('Form'), fragment: uiComponentName('Fragment'), diff --git a/plugins/ui/src/js/src/elements/hooks/useDatepickerProps.test.ts b/plugins/ui/src/js/src/elements/utils/DateTimeUtils.test.ts similarity index 81% rename from plugins/ui/src/js/src/elements/hooks/useDatepickerProps.test.ts rename to plugins/ui/src/js/src/elements/utils/DateTimeUtils.test.ts index 435ba2b3c..77cda081d 100644 --- a/plugins/ui/src/js/src/elements/hooks/useDatepickerProps.test.ts +++ b/plugins/ui/src/js/src/elements/utils/DateTimeUtils.test.ts @@ -1,8 +1,24 @@ -import { parseDateValue, parseNullableDateValue } from './useDatepickerProps'; +import { + parseDateValue, + parseNullableDateValue, + isStringInstant, +} from './DateTimeUtils'; const DEFAULT_TIME_ZONE = 'UTC'; const NY_TIME_ZONE = 'America/New_York'; +describe('isStringInstant', () => { + it('should return true for an instant string', () => { + expect(isStringInstant('2021-03-03T04:05:06Z')).toBeTruthy(); + }); + it('should return false for a non-instant string', () => { + expect(isStringInstant('2021-03-03T04:05:06')).toBeFalsy(); + }); + it('should return false for null', () => { + expect(isStringInstant(null)).toBeFalsy(); + }); +}); + describe('parseDateValue', () => { const isoDate = '2021-02-03'; const isoDateTime = '2021-03-03T04:05:06'; diff --git a/plugins/ui/src/js/src/elements/utils/DateTimeUtils.ts b/plugins/ui/src/js/src/elements/utils/DateTimeUtils.ts new file mode 100644 index 000000000..9c377245f --- /dev/null +++ b/plugins/ui/src/js/src/elements/utils/DateTimeUtils.ts @@ -0,0 +1,122 @@ +import { + DateValue, + CalendarDate, + CalendarDateTime, + ZonedDateTime, + parseDate, + parseDateTime, + parseZonedDateTime, + toTimeZone, +} from '@internationalized/date'; + +export type MappedDateValue = T extends ZonedDateTime + ? ZonedDateTime + : T extends CalendarDateTime + ? CalendarDateTime + : T extends CalendarDate + ? CalendarDate + : never; + +export type Granularity = 'day' | 'hour' | 'minute' | 'second'; + +/** + * Checks if a string is an Instant. + * + * @param value the string date value + * @returns true if the string is an Instant + */ +export function isStringInstant(value?: string | null): boolean { + return value != null && value.endsWith('Z'); +} + +/** + * Parses a date value string into a DateValue. + * + * @param timeZone the time zone to use + * @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}`); +} + +/** + * Parses a date value string into a DateValue. Allows null. + * + * @param timeZone the time zone to use + * @param value the string date value + * @returns DateValue or null + */ +export function parseNullableDateValue( + timeZone: string, + value?: string | null +): DateValue | null | undefined { + if (value === null) { + return value; + } + + return parseDateValue(timeZone, value); +} diff --git a/plugins/ui/src/js/src/elements/utils/index.ts b/plugins/ui/src/js/src/elements/utils/index.ts index 9945404e1..557500972 100644 --- a/plugins/ui/src/js/src/elements/utils/index.ts +++ b/plugins/ui/src/js/src/elements/utils/index.ts @@ -1,3 +1,4 @@ +export * from './DateTimeUtils'; export * from './ElementUtils'; export * from './EventUtils'; export * from './HTMLElementUtils'; diff --git a/plugins/ui/src/js/src/widget/WidgetUtils.tsx b/plugins/ui/src/js/src/widget/WidgetUtils.tsx index 6673cae95..5f31508d7 100644 --- a/plugins/ui/src/js/src/widget/WidgetUtils.tsx +++ b/plugins/ui/src/js/src/widget/WidgetUtils.tsx @@ -52,6 +52,7 @@ import { Button, ComboBox, DatePicker, + DateRangePicker, Form, IllustratedMessage, Image, @@ -105,6 +106,7 @@ export const elementComponentMap = { [ELEMENT_NAME.content]: Content, [ELEMENT_NAME.contextualHelp]: ContextualHelp, [ELEMENT_NAME.datePicker]: DatePicker, + [ELEMENT_NAME.dateRangePicker]: DateRangePicker, [ELEMENT_NAME.flex]: Flex, [ELEMENT_NAME.form]: Form, [ELEMENT_NAME.fragment]: React.Fragment, diff --git a/plugins/ui/test/deephaven/ui/test_date_range_picker.py b/plugins/ui/test/deephaven/ui/test_date_range_picker.py new file mode 100644 index 000000000..4164873d4 --- /dev/null +++ b/plugins/ui/test/deephaven/ui/test_date_range_picker.py @@ -0,0 +1,101 @@ +import unittest + +from .BaseTest import BaseTestCase + + +class DateRangePickerTest(BaseTestCase): + def test_convert_date_range_picker_props(self): + from deephaven.time import to_j_instant, to_j_zdt, to_j_local_date + from deephaven.ui.components.date_range_picker import ( + _convert_date_range_picker_props, + ) + from deephaven.ui._internal.utils import ( + get_jclass_name, + convert_list_prop, + _convert_to_java_date, + ) + + def verify_is_local_date(dateStr): + self.assertEqual( + get_jclass_name(_convert_to_java_date(dateStr)), "java.time.LocalDate" + ) + + def verify_is_instant(dateStr): + self.assertEqual( + get_jclass_name(_convert_to_java_date(dateStr)), "java.time.Instant" + ) + + def verify_is_zdt(dateStr): + self.assertEqual( + get_jclass_name(_convert_to_java_date(dateStr)), + "java.time.ZonedDateTime", + ) + + def empty_on_change(): + pass + + props1 = { + "placeholder_value": "2021-01-01", + "value": {"start": "2021-01-01 UTC", "end": "2021-01-02 UTC"}, + "default_value": {"start": "2021-01-01 ET", "end": "2021-01-02 ET"}, + "min_value": to_j_zdt("2021-01-01 ET"), + "max_value": to_j_local_date("2021-01-01"), + } + + props2 = { + "value": { + "start": to_j_local_date("2021-01-01"), + "end": to_j_local_date("2021-01-02"), + }, + "default_value": { + "start": to_j_zdt("2021-01-01 ET"), + "end": to_j_zdt("2021-01-02 ET"), + }, + "placeholder_value": to_j_instant("2021-01-01 UTC"), + "on_change": verify_is_local_date, + } + + props3 = { + "default_value": { + "start": to_j_instant("2021-01-01 UTC"), + "end": to_j_instant("2021-01-02 UTC"), + }, + "placeholder_value": to_j_zdt("2021-01-01 ET"), + "on_change": verify_is_instant, + } + + props4 = { + "placeholder_value": to_j_zdt("2021-01-01 ET"), + "on_change": verify_is_zdt, + } + + props5 = {"on_change": verify_is_instant} + + props6 = {"on_change": empty_on_change} + + _convert_date_range_picker_props(props1) + _convert_date_range_picker_props(props2) + _convert_date_range_picker_props(props3) + _convert_date_range_picker_props(props4) + _convert_date_range_picker_props(props5) + _convert_date_range_picker_props(props6) + + verify_is_local_date(props1["max_value"]) + verify_is_zdt(props1["min_value"]) + verify_is_zdt(props1["value"]["start"]) + verify_is_zdt(props1["value"]["end"]) + verify_is_zdt(props1["default_value"]["start"]) + verify_is_zdt(props1["default_value"]["end"]) + verify_is_local_date(props1["placeholder_value"]) + + props2["on_change"]("2021-01-01") + props3["on_change"]("2021-01-01 UTC") + props4["on_change"]("2021-01-01 ET") + props5["on_change"]("2021-01-01 UTC") + + # pass an Instant but it should be dropped with no error + props6["on_change"]("2021-01-01 UTC") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/app.d/ui_render_all.py b/tests/app.d/ui_render_all.py index 60858a71e..75e7b7cfe 100644 --- a/tests/app.d/ui_render_all.py +++ b/tests/app.d/ui_render_all.py @@ -43,7 +43,7 @@ @ui.component -def ui_components(): +def ui_components1(): return ( ui.action_button("Action Button"), ui.action_group("Aaa", "Bbb", "Ccc"), @@ -56,12 +56,22 @@ def ui_components(): ui.content("Content"), ui.contextual_help("Contextual Help"), ui.date_picker(label="Date Picker", value="2021-01-01"), + ui.date_range_picker( + label="Date Range Picker", + value={"start": "2021-01-01", "end": "2021-01-02"}, + ), 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"), ui.fragment("Fragment"), ui.grid("Grid A", "Grid B"), ui.heading("Heading"), + ) + + +@ui.component +def ui_components2(): + return ( ui.icon("vsSymbolMisc"), ui.illustrated_message( ui.icon("vsWarning"), @@ -126,15 +136,16 @@ def ui_html_elements(): ui.html.div("div"), -_my_components = ui_components() +_my_components1 = ui_components1() +_my_components2 = ui_components2() _my_html_elements = ui_html_elements() -ui_render_all = ui.dashboard( +ui_render_all1 = ui.dashboard( ui.stack( ui.panel( ui.table(_column_types), ui.grid( - _my_components, + _my_components1, _my_html_elements, columns=["1fr", "1fr", "1fr"], width="100%", @@ -143,3 +154,16 @@ def ui_html_elements(): ), ) ) + +ui_render_all2 = ui.dashboard( + ui.stack( + ui.panel( + ui.grid( + _my_components2, + columns=["1fr", "1fr", "1fr"], + width="100%", + ), + title="Panel C", + ), + ) +) diff --git a/tests/ui.spec.ts b/tests/ui.spec.ts index b07180fc5..c22138e73 100644 --- a/tests/ui.spec.ts +++ b/tests/ui.spec.ts @@ -48,8 +48,14 @@ test('boom counter component shows error overlay after clicking the button twice await expect(panelLocator.getByText('BOOM! Value too big.')).toBeVisible(); }); -test('UI all components render', async ({ page }) => { +test('UI all components render 1', async ({ page }) => { await gotoPage(page, ''); - await openPanel(page, 'ui_render_all', selector.REACT_PANEL_VISIBLE); + await openPanel(page, 'ui_render_all1', selector.REACT_PANEL_VISIBLE); + await expect(page.locator(selector.REACT_PANEL_VISIBLE)).toHaveScreenshot(); +}); + +test('UI all components render 2', async ({ page }) => { + await gotoPage(page, ''); + await openPanel(page, 'ui_render_all2', selector.REACT_PANEL_VISIBLE); await expect(page.locator(selector.REACT_PANEL_VISIBLE)).toHaveScreenshot(); }); diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-chromium-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-chromium-linux.png new file mode 100644 index 000000000..8df267638 Binary files /dev/null and b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-chromium-linux.png differ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-firefox-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-firefox-linux.png new file mode 100644 index 000000000..cdf613be3 Binary files /dev/null and b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-firefox-linux.png differ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-webkit-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-webkit-linux.png new file mode 100644 index 000000000..0bd7f0354 Binary files /dev/null and b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-webkit-linux.png differ 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 deleted file mode 100644 index 7dec3ca00..000000000 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-chromium-linux.png and /dev/null 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 deleted file mode 100644 index fb9dbb3de..000000000 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-firefox-linux.png and /dev/null 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 deleted file mode 100644 index 12a39e6d6..000000000 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-webkit-linux.png and /dev/null differ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-chromium-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-chromium-linux.png new file mode 100644 index 000000000..b940507a1 Binary files /dev/null and b/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-chromium-linux.png differ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-firefox-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-firefox-linux.png new file mode 100644 index 000000000..8185a3bc0 Binary files /dev/null and b/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-firefox-linux.png differ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-webkit-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-webkit-linux.png new file mode 100644 index 000000000..e4fd2649b Binary files /dev/null and b/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-webkit-linux.png differ