From 9a72d2d463424ec2649875c60ba32b387e948ad7 Mon Sep 17 00:00:00 2001 From: dgodinez-dh <77981300+dgodinez-dh@users.noreply.github.com> Date: Thu, 5 Sep 2024 11:28:18 -0600 Subject: [PATCH] feat: Date Field Implementation (#804) Closes https://github.com/deephaven/deephaven-plugins/issues/648 --------- Co-authored-by: margaretkennedy <82049573+margaretkennedy@users.noreply.github.com> --- plugins/ui/DESIGN.md | 150 ++++++ plugins/ui/docs/components/date_field.md | 433 ++++++++++++++++++ .../src/deephaven/ui/components/__init__.py | 2 + .../src/deephaven/ui/components/date_field.py | 262 +++++++++++ plugins/ui/src/js/src/elements/DateField.tsx | 74 +++ plugins/ui/src/js/src/elements/DatePicker.tsx | 12 +- plugins/ui/src/js/src/elements/hooks/index.ts | 2 +- ...ickerProps.ts => useDateComponentProps.ts} | 30 +- plugins/ui/src/js/src/elements/index.ts | 1 + .../js/src/elements/model/ElementConstants.ts | 1 + plugins/ui/src/js/src/widget/WidgetUtils.tsx | 2 + .../ui/test/deephaven/ui/test_date_field.py | 99 ++++ 12 files changed, 1046 insertions(+), 22 deletions(-) create mode 100644 plugins/ui/docs/components/date_field.md create mode 100644 plugins/ui/src/deephaven/ui/components/date_field.py create mode 100644 plugins/ui/src/js/src/elements/DateField.tsx rename plugins/ui/src/js/src/elements/hooks/{useDatePickerProps.ts => useDateComponentProps.ts} (91%) create mode 100644 plugins/ui/test/deephaven/ui/test_date_field.py diff --git a/plugins/ui/DESIGN.md b/plugins/ui/DESIGN.md index c436dd542..c8d16decc 100644 --- a/plugins/ui/DESIGN.md +++ b/plugins/ui/DESIGN.md @@ -1311,6 +1311,156 @@ list_view5 = ui.list_view( ``` +###### ui.date_field + +A date field that can be used to select a date. + +The date field 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 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_field( + placeholder_value: Date | None = None, + value: Date | None = None, + default_value: Date | None = None, + min_value: Date | None = None, + max_value: Date | None = None, + granularity: Granularity | None = None, + on_change: Callable[[Date], None] | None = None, + **props: Any +) -> DateFieldElement +``` + +###### 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` | `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 field. 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 [DateField](https://react-spectrum.adobe.com/react-spectrum/DateField.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 America/New_York") +instant = to_j_instant("2022-01-01T00:00:00 ET") +local_date = to_j_local_date(dh_today()) + +# simple date field that takes ui.items and is uncontrolled +# this creates a date field with a granularity of days with a default value of today +date_field1 = ui.date_field( + default_value=local_date +) + +# simple date field that takes list view items directly and is controlled +# this creates a date field with a granularity of seconds in UTC +# the on_change handler is passed an instant +date, set_date = ui.use_state(instant) + +date_field2 = ui.date_field( + value=date, + on_change=set_date +) + +# this creates a date field with a granularity of seconds in the specified time zone +# the on_change handler is passed a zoned date time +date, set_date = ui.use_state(None) + +date_field3 = ui.date_field( + placeholder_value=zoned_date_time, + on_change=set_date +) + +# this creates a date field with a granularity of seconds in UTC +# the on_change handler is passed an instant +date, set_date = ui.use_state(None) + +date_field4 = ui.date_field( + placeholder_value=instant, + on_change=set_date +) + +# this creates a date field with a granularity of days +# the on_change handler is passed a local date +date, set_date = ui.use_state(None) + +date_field5 = ui.date_field( + placeholder_value=local_date, + on_change=set_date +) + +# this creates a date field with a granularity of days, but the on_change handler is still passed an instant +date, set_date = ui.use_state(None) + +date_field6 = ui.date_field( + placeholder_value=instant, + granularity="day", + on_change=set_date +) + +# this creates a date field with a granularity of seconds and the on_change handler is passed an instant +date, set_date = ui.use_state(None) + +date_field7 = ui.date_field( + on_change=set_date +) + +# this create a date field with a granularity of days, a min and max value, and unavailable dates +min_value = to_j_local_date("2022-01-01") +max_value = to_j_local_date("2022-12-31") +unavailable_dates = [to_j_local_date("2022-03-15"), to_j_local_date("2022-03-17")] +date, set_date = ui.use_state(to_j_local_date("2022-03-16")) +date_field8 = ui.date_field( + value=date, + min_value=min_value, + max_value=max_value, + unavailable_values=unavailable_dates, + on_change=set_date +) +``` + ###### ui.date_picker A date picker that can be used to select a date. diff --git a/plugins/ui/docs/components/date_field.md b/plugins/ui/docs/components/date_field.md new file mode 100644 index 000000000..5efbd9265 --- /dev/null +++ b/plugins/ui/docs/components/date_field.md @@ -0,0 +1,433 @@ +# Date field + +Date fields allow users to input a date using a text field. + +## Example + +```python +from deephaven import ui + +my_date_field_basic = ui.date_field(label="Date field") +``` + +## Date types + +A date field can be used to input a date. + +The date field 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`: 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 field 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 field 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 field 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 field 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 field of `Instant`. + +```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_field_test(value): + date, set_date = ui.use_state(value) + return [ui.date_field(on_change=set_date, value=date), ui.text(str(date))] + + +zoned_date_field = date_field_test(zoned_date_time) +instant_date_field = date_field_test(instant) +local_date_field = date_field_test(local_date) +``` + +## Value + +A date field displays a `placeholder` by default. An initial, uncontrolled value can be provided to the date field 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("2020-02-03") + return ui.flex( + ui.date_field( + label="Date field (uncontrolled)", + default_value="2020-02-03", + ), + ui.date_field( + label="Date field (controlled)", value=value, on_change=set_value + ), + gap="size-150", + wrap=True, + ) + + +my_example = example() +``` + +## Time zones + +Date field 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 field will display the specified time zone. + +For `Instant` objects, the date field 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_field( + label="Date field", + default_value="2022-11-07T00:45 America/Los_Angeles", +) + +my_instant = ui.date_field( + label="Date field", + default_value=to_j_instant("2022-11-07T00:45Z"), +) +``` + +## Granularity + +The `granularity` prop allows you to control the smallest unit that is displayed by a date field . 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 fields 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("2021-04-07T18:45:22 UTC") + return ui.flex( + ui.date_field( + label="Date field and time field", + granularity="SECOND", + value=value, + on_change=set_value, + ), + ui.date_field( + label="Date field", granularity="DAY", value=value, on_change=set_value + ), + gap="size-150", + wrap=True, + ) + + +my_granularity_example = granularity_example() +``` + +## HTML forms + +Date field supports the `name` prop 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 field 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_field_forms = ui.form( + ui.date_field(label="Birth date", name="birthday"), + ui.button("Submit", type="submit"), + on_submit=print, +) +``` + +## Labeling + +A visual label should be provided for the date field using the `label` prop. If the date field is required, the `is_required` and `necessity_indicator` props can be used to show a required state. + +```python +from deephaven import ui + +my_date_field_labeling = ui.flex( + ui.date_field(label="Date field"), + ui.date_field(label="Date field", is_required=True, necessity_indicator="icon"), + ui.date_field(label="Date field", is_required=True, necessity_indicator="label"), + ui.date_field(label="Date field", necessity_indicator="label"), +) +``` + +## Events + +Date fields 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("2020-02-03") + return ui.date_field( + label="Date field (controlled)", value=value, on_change=set_value + ) + + +my_event_example = event_example() +``` + +## Validation + +The `is_required` prop ensures that the user selects a date field. 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_field_validation_behaviour_example(): + return ui.form( + ui.date_field( + validation_behavior="native", + is_required=True, + ) + ) + + +my_date_field_validation_behaviour_example = date_field_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 field. Date field also validates that the end date is after the start date. + +```python +from deephaven import ui + +my_date_field_basic = ui.date_field( + label="Date field", + min_value="2024-01-01", + default_value="2022-02-03", +) +``` + +## Label position + +By default, the position of a date field's label is above the date field , but it can be moved to the side using the `label_position` prop. + +```python +from deephaven import ui + + +@ui.component +def date_field_label_position_examples(): + return [ + ui.date_field( + label="Test Label", + ), + ui.date_field( + label="Test Label", + label_position="side", + ), + ] + + +my_date_field_label_position_examples = date_field_label_position_examples() +``` + +## Quiet state + +The `is_quiet` prop makes a date field "quiet". This can be useful when its corresponding styling should not distract users from surrounding content. + +```python +from deephaven import ui + + +my_date_field_is_quiet_example = ui.date_field( + is_quiet=True, +) +``` + +## Disabled state + +The `is_disabled` prop disables the date field to prevent user interaction. This is useful when the date field should be visible but not available for selection. + +```python +from deephaven import ui + + +my_date_field_is_disabled_example = ui.date_field( + is_disabled=True, +) +``` + +## Read only + +The `is_read_only` prop makes the date field's value immutable. Unlike `is_disabled`, the date field remains focusable. + +```python +from deephaven import ui + + +my_date_field_is_read_only_example = ui.date_field( + is_read_only=True, +) +``` + +## Help text + +A date field 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 field state is `valid` or `invalid`. + +```python +from deephaven import ui + + +@ui.component +def date_field_help_text_examples(): + return [ + ui.date_field( + label="Sample Label", + description="Enter a date field.", + ), + ui.date_field( + label="Sample Label", + validation_state="valid", + error_message="Sample invalid error message.", + ), + ui.date_field( + label="Sample Label", + validation_state="invalid", + error_message="Sample invalid error message.", + ), + ] + + +my_date_field_help_text_examples = date_field_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 field. + +```python +from deephaven import ui + + +date_field_contextual_help_example = ui.date_field( + label="Sample Label", + contextual_help=ui.contextual_help(ui.heading("Content tips")), +) +``` + +## Custom width + +The `width` prop adjusts the width of a date field, and the `max_width` prop enforces a maximum width. + +```python +from deephaven import ui + + +@ui.component +def date_field_width_examples(): + return [ + ui.date_field( + width="size-3600", + ), + ui.date_field( + width="size-3600", + max_width="100%", + ), + ] + + +my_date_field_width_examples = date_field_width_examples() +``` + +## 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_field( + label="Date field", + default_value="2022-11-07T00:45 America/Los_Angeles", + hide_time_zone=True, +) +``` + +## Hour cycle + +By default, date field 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_field_hour_cycle_example = ui.date_field(label="Date field", hour_cycle=24) +``` + +## Time table filtering + +Date fields 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_field(label="Start Date", value=after_date, on_change=set_after_date), + ui.date_field(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_field +``` diff --git a/plugins/ui/src/deephaven/ui/components/__init__.py b/plugins/ui/src/deephaven/ui/components/__init__.py index dc8b6f957..04c4f84e5 100644 --- a/plugins/ui/src/deephaven/ui/components/__init__.py +++ b/plugins/ui/src/deephaven/ui/components/__init__.py @@ -12,6 +12,7 @@ from .content import content from .contextual_help import contextual_help from .dashboard import dashboard +from .date_field import date_field from .date_picker import date_picker from .date_range_picker import date_range_picker from .flex import flex @@ -67,6 +68,7 @@ "content", "contextual_help", "dashboard", + "date_field", "date_picker", "date_range_picker", "flex", diff --git a/plugins/ui/src/deephaven/ui/components/date_field.py b/plugins/ui/src/deephaven/ui/components/date_field.py new file mode 100644 index 000000000..7426b8f5d --- /dev/null +++ b/plugins/ui/src/deephaven/ui/components/date_field.py @@ -0,0 +1,262 @@ +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, + HourCycle, + Alignment, +) + +from ..elements import Element +from .._internal.utils import ( + create_props, + convert_date_props, +) +from ..types import Date, Granularity +from .basic import component_element +from .make_component import make_component +from deephaven.time import dh_now + +DateFieldElement = Element + +# All the props that can be date types +_SIMPLE_DATE_PROPS = { + "placeholder_value", + "value", + "default_value", + "min_value", + "max_value", +} +_RANGE_DATE_PROPS = set() +_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_field_props( + props: dict[str, Any], +) -> dict[str, Any]: + """ + Convert date field props to Java date types. + + Args: + props: The props passed to the date field. + + 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_field( + 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, + # TODO (issue # 698) we need to implement unavailable_values + # unavailable_values: Sequence[Date] | None = None, + granularity: Granularity | 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, + name: str | 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[[Date], 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, +) -> DateFieldElement: + """ + A date field allows the user to select a date. + + + 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 field. + By default, this is `"DAY"` for `LocalDate`, and `"SECOND"` otherwise. + 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). + name: The name of the input element, used when submitting an HTML form. + is_quiet: Whether the date field 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 field element. + """ + _, props = create_props(locals()) + + _convert_date_field_props(props) + + return component_element("DateField", **props) diff --git a/plugins/ui/src/js/src/elements/DateField.tsx b/plugins/ui/src/js/src/elements/DateField.tsx new file mode 100644 index 000000000..1ae0a50cf --- /dev/null +++ b/plugins/ui/src/js/src/elements/DateField.tsx @@ -0,0 +1,74 @@ +import React, { useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { + DateField as DHCDateField, + DateFieldProps as DHCDateFieldProps, +} 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 { + SerializedDateComponentProps, + useDateComponentProps, +} from './hooks/useDateComponentProps'; +import { isStringInstant } from './utils/DateTimeUtils'; + +const EMPTY_FUNCTION = () => undefined; + +function isDateFieldInstant( + props: SerializedDateComponentProps> +): boolean { + const { value, defaultValue, placeholderValue } = props; + if (value != null) { + return isStringInstant(value); + } + if (defaultValue != null) { + return isStringInstant(defaultValue); + } + return isStringInstant(placeholderValue); +} + +export function DateField( + props: SerializedDateComponentProps> +): JSX.Element { + const isDateFieldInstantValue = isDateFieldInstant(props); + const settings = useSelector(getSettings); + const { timeZone } = settings; + + const { + defaultValue = null, + value: propValue, + onChange: propOnChange = EMPTY_FUNCTION, + ...otherProps + } = useDateComponentProps(props, timeZone); + + 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 + const prevTimeZone = usePrevious(timeZone); + useEffect(() => { + // The timezone is intially undefined, so we don't want to trigger a change in that case + if ( + isDateFieldInstantValue && + prevTimeZone !== undefined && + timeZone !== prevTimeZone && + value instanceof ZonedDateTime + ) { + const newValue = toTimeZone(value, timeZone); + onChange(newValue); + } + }, [isDateFieldInstantValue, value, onChange, timeZone, prevTimeZone]); + + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + ); +} + +DateField.displayName = 'DateField'; + +export default DateField; diff --git a/plugins/ui/src/js/src/elements/DatePicker.tsx b/plugins/ui/src/js/src/elements/DatePicker.tsx index 9e4c426f5..0c3567c59 100644 --- a/plugins/ui/src/js/src/elements/DatePicker.tsx +++ b/plugins/ui/src/js/src/elements/DatePicker.tsx @@ -9,15 +9,15 @@ import { getSettings, RootState } from '@deephaven/redux'; import { DateValue, toTimeZone, ZonedDateTime } from '@internationalized/date'; import useDebouncedOnChange from './hooks/useDebouncedOnChange'; import { - SerializedDatePickerProps, - useDatePickerProps, -} from './hooks/useDatePickerProps'; + SerializedDateComponentProps, + useDateComponentProps, +} from './hooks/useDateComponentProps'; import { isStringInstant } from './utils/DateTimeUtils'; const EMPTY_FUNCTION = () => undefined; function isDatePickerInstant( - props: SerializedDatePickerProps> + props: SerializedDateComponentProps> ): boolean { const { value, defaultValue, placeholderValue } = props; if (value != null) { @@ -30,7 +30,7 @@ function isDatePickerInstant( } export function DatePicker( - props: SerializedDatePickerProps> + props: SerializedDateComponentProps> ): JSX.Element { const isDatePickerInstantValue = isDatePickerInstant(props); const settings = useSelector(getSettings); @@ -41,7 +41,7 @@ export function DatePicker( value: propValue, onChange: propOnChange = EMPTY_FUNCTION, ...otherProps - } = useDatePickerProps(props, timeZone); + } = useDateComponentProps(props, timeZone); const [value, onChange] = useDebouncedOnChange( propValue ?? defaultValue, diff --git a/plugins/ui/src/js/src/elements/hooks/index.ts b/plugins/ui/src/js/src/elements/hooks/index.ts index 8bfeca75a..8658d4a98 100644 --- a/plugins/ui/src/js/src/elements/hooks/index.ts +++ b/plugins/ui/src/js/src/elements/hooks/index.ts @@ -1,5 +1,5 @@ export * from './useButtonProps'; -export * from './useDatePickerProps'; +export * from './useDateComponentProps'; export * from './useDateRangePickerProps'; export * from './useDateValueMemo'; export * from './useFocusEventCallback'; diff --git a/plugins/ui/src/js/src/elements/hooks/useDatePickerProps.ts b/plugins/ui/src/js/src/elements/hooks/useDateComponentProps.ts similarity index 91% rename from plugins/ui/src/js/src/elements/hooks/useDatePickerProps.ts rename to plugins/ui/src/js/src/elements/hooks/useDateComponentProps.ts index 1194d5bd4..23a698672 100644 --- a/plugins/ui/src/js/src/elements/hooks/useDatePickerProps.ts +++ b/plugins/ui/src/js/src/elements/hooks/useDateComponentProps.ts @@ -26,7 +26,7 @@ export type DeserializedDateValueCallback = | (() => void) | ((value: MappedDateValue | null) => Promise); -export interface SerializedDatePickerPropsInterface { +export interface SerializedDateComponentPropsInterface { /** Handler that is called when the element receives focus. */ onFocus?: SerializedFocusEventCallback; @@ -60,11 +60,11 @@ export interface SerializedDatePickerPropsInterface { /** Dates that are unavailable */ unavailableValues?: string[] | null; - /** Determines the smallest unit that is displayed in the date picker. */ + /** Determines the smallest unit that is displayed in the date component. */ granularity?: Granularity; } -export interface DeserializedDatePickerPropsInterface { +export interface DeserializedDateComponentPropsInterface { /** Handler that is called when the element receives focus. */ onFocus?: DeserializedFocusEventCallback; @@ -98,18 +98,18 @@ export interface DeserializedDatePickerPropsInterface { /** 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. */ + /** Determines the smallest unit that is displayed in the date component. */ granularity?: Granularity; } -export type SerializedDatePickerProps = TProps & - SerializedDatePickerPropsInterface; +export type SerializedDateComponentProps = TProps & + SerializedDateComponentPropsInterface; -export type DeserializedDatePickerProps = Omit< +export type DeserializedDateComponentProps = Omit< TProps, - keyof SerializedDatePickerPropsInterface + keyof SerializedDateComponentPropsInterface > & - DeserializedDatePickerPropsInterface; + DeserializedDateComponentPropsInterface; /** * Uses the toString representation of the DateValue as the serialized value. @@ -128,7 +128,7 @@ export function serializeDateValue( /** * Get a callback function that can be passed to the onChange event handler - * props of a Spectrum DatePicker. + * props of a Spectrum Date component. * @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 @@ -164,7 +164,7 @@ export function useNullableDateValueMemo( } /** - * Get a callback function that can be passed to the isDateUnavailable prop of a Spectrum DatePicker. + * Get a callback function that can be passed to the isDateUnavailable prop of a Spectrum Date component. * * @param unavailableSet Set of unavailable date strings * @returns A callback to be passed into the Spectrum component that checks if the date is unavailable @@ -179,11 +179,11 @@ export function useIsDateUnavailableCallback( } /** - * Wrap DatePicker props with the appropriate serialized event callbacks. + * Wrap Date component props with the appropriate serialized event callbacks. * @param props Props to wrap * @returns Wrapped props */ -export function useDatePickerProps( +export function useDateComponentProps( { onFocus, onBlur, @@ -198,9 +198,9 @@ export function useDatePickerProps( unavailableValues, granularity: upperCaseGranularity, ...otherProps - }: SerializedDatePickerProps, + }: SerializedDateComponentProps, timeZone: string -): DeserializedDatePickerProps { +): DeserializedDateComponentProps { const serializedOnFocus = useFocusEventCallback(onFocus); const serializedOnBlur = useFocusEventCallback(onBlur); const serializedOnKeyDown = useKeyboardEventCallback(onKeyDown); diff --git a/plugins/ui/src/js/src/elements/index.ts b/plugins/ui/src/js/src/elements/index.ts index 6bdbe809c..92e68adce 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 './DateField'; export * from './DatePicker'; export * from './DateRangePicker'; export * from './Form'; diff --git a/plugins/ui/src/js/src/elements/model/ElementConstants.ts b/plugins/ui/src/js/src/elements/model/ElementConstants.ts index f89090628..c1b8cd3d6 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'), + dateField: uiComponentName('DateField'), datePicker: uiComponentName('DatePicker'), dateRangePicker: uiComponentName('DateRangePicker'), flex: uiComponentName('Flex'), diff --git a/plugins/ui/src/js/src/widget/WidgetUtils.tsx b/plugins/ui/src/js/src/widget/WidgetUtils.tsx index 5f31508d7..09c4b77ec 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, + DateField, DatePicker, DateRangePicker, Form, @@ -105,6 +106,7 @@ export const elementComponentMap = { [ELEMENT_NAME.comboBox]: ComboBox, [ELEMENT_NAME.content]: Content, [ELEMENT_NAME.contextualHelp]: ContextualHelp, + [ELEMENT_NAME.dateField]: DateField, [ELEMENT_NAME.datePicker]: DatePicker, [ELEMENT_NAME.dateRangePicker]: DateRangePicker, [ELEMENT_NAME.flex]: Flex, diff --git a/plugins/ui/test/deephaven/ui/test_date_field.py b/plugins/ui/test/deephaven/ui/test_date_field.py new file mode 100644 index 000000000..53db5305e --- /dev/null +++ b/plugins/ui/test/deephaven/ui/test_date_field.py @@ -0,0 +1,99 @@ +import unittest + +from .BaseTest import BaseTestCase + + +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_field import _convert_date_field_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": "2021-01-01 UTC", + "default_value": "2021-01-01 ET", + "unavailable_dates": [to_j_instant("2021-01-01 UTC"), "2021-01-01"], + "min_value": to_j_zdt("2021-01-01 ET"), + "max_value": to_j_local_date("2021-01-01"), + } + + props2 = { + "value": to_j_local_date("2021-01-01"), + "default_value": to_j_zdt("2021-01-01 ET"), + "placeholder_value": to_j_instant("2021-01-01 UTC"), + "on_change": verify_is_local_date, + "unavailable_dates": None, + } + + props3 = { + "default_value": to_j_instant("2021-01-01 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_field_props(props1) + props1["unavailable_dates"] = convert_list_prop( + "unavailable_dates", props1["unavailable_dates"] + ) + _convert_date_field_props(props2) + props2["unavailable_dates"] = convert_list_prop( + "unavailable_dates", props2["unavailable_dates"] + ) + _convert_date_field_props(props3) + _convert_date_field_props(props4) + _convert_date_field_props(props5) + _convert_date_field_props(props6) + + verify_is_local_date(props1["max_value"]) + verify_is_zdt(props1["min_value"]) + verify_is_zdt(props1["unavailable_dates"][0]) + verify_is_local_date(props1["unavailable_dates"][1]) + verify_is_zdt(props1["value"]) + verify_is_zdt(props1["default_value"]) + verify_is_local_date(props1["placeholder_value"]) + + props2["on_change"]("2021-01-01") + self.assertIsNone(props2["unavailable_dates"]) + 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()