diff --git a/plugins/ui/DESIGN.md b/plugins/ui/DESIGN.md index c8d16decc..af28d9266 100644 --- a/plugins/ui/DESIGN.md +++ b/plugins/ui/DESIGN.md @@ -2415,6 +2415,141 @@ ui_table.sort( | `by` | `str \| Sequence[str]` | The column(s) to sort by. May be a single column name, or a list of column names. | | `direction` | `TableSortDirection \| Sequence[TableSortDirection] \| None` | The sort direction(s) to use. If provided, that must match up with the columns provided. Defaults to "ASC". | +###### ui.time_field + +A time field that can be used to select a time. + +The time field accepts the following time types as inputs: + +- `None` +- `LocaTime` +- `ZonedDateTime` +- `Instant` +- `int` +- `str` +- `datetime.datetime` +- `numpy.datetime64` +- `pandas.Timestamp` + +The input will be converted to one of three Java time types: + +1. `LocalTime`: A LocalTime is a time without a time zone in the ISO-8601 system, such as "10:30:45" or "16:10:00". + This will create a time field with a granularity of seconds. +2. `Instant`: An Instant represents an unambiguous specific point on the timeline, such as 2021-04-12T14:13:07 UTC. + This will create a time 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 time field with a granularity of seconds in the specified time zone. The time zone will be rendered as the specified time zone. + +4. If the input is one of the three Java time types, use that type. +5. A time string such as "10:30:45" will parse to a `LocaTime` +6. A string with a date, time, and timezone such as "2021-04-12T14:13:07 America/New_York" will parse to a `ZonedDateTime` +7. All other types will attempt to convert in this order: `LocaTime`, `Instant`, `ZonedDateTime` + +The format of the time field 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 `LocaTime`. + +```py +import deephaven.ui as ui +ui.time_field( + placeholder_value: Time | None = None, + value: Time | None = None, + default_value: Time | None = None, + min_value: Time | None = None, + max_value: Time | None = None, + granularity: Granularity | None = None, + on_change: Callable[[Time], None] | None = None, + **props: Any +) -> TimeFieldElement +``` + +###### Parameters + +| Parameter | Type | Description | +| ------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `placeholder_value` | `Time \| None` | A placeholder time that influences the format of the placeholder shown when no value is selected. Defaults to 12:00 AM or 00:00 depending on the hour cycle | +| `value` | `Time \| None` | The current value (controlled). | +| `default_value` | `Time \| None` | The default value (uncontrolled). | +| `min_value` | `Time \| None` | The minimum allowed time that a user may select. | +| `max_value` | `Time \| None` | The maximum allowed time that a user may select. | +| `granularity` | `Granularity \| None` | Determines the smallest unit that is displayed in the time field. By default, this is `"SECOND". | +| `on_change` | `Callable[[Time], None] \| None` | Handler that is called when the value changes. The exact `Time` 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 [TimeField](https://react-spectrum.adobe.com/react-spectrum/TimeField.html) prop, with the exception of `validate`, and `errorMessage` (as a callback) | + +```py + +import deephaven.ui as ui +from deephaven.time import to_j_local_time, 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_time = to_j_local_time("12:30:45") + +# simple time field that takes ui.items and is uncontrolled +time_field1 = ui.time_field( + default_value=local_time +) + +# simple time field that takes list view items directly and is controlled +# this creates a time field with a granularity of seconds in UTC +# the on_change handler is passed an instant +time, set_time = ui.use_state(instant) + +time_field2 = ui.time_field( + value=time, + on_change=set_time +) + +# this creates a time field with a granularity of seconds in the specified time zone +# the on_change handler is passed a zoned date time +time, set_time = ui.use_state(None) + +time_field3 = ui.time_field( + placeholder_value=zoned_date_time, + on_change=set_time +) + +# this creates a time field with a granularity of seconds in UTC +# the on_change handler is passed an instant +time, set_time = ui.use_state(None) + +time_field4 = ui.time_field( + placeholder_value=instant, + on_change=set_time +) + +# this creates a time field with a granularity of seconds +# the on_change handler is passed a local time +time, set_time = ui.use_state(None) + +time_field5 = ui.time_field( + placeholder_value=local_time, + on_change=set_time +) + +# this creates a time field with a granularity of hours, but the on_change handler is still passed an instant +time, set_time = ui.use_state(None) + +time_field6 = ui.time_field( + placeholder_value=instant, + granularity="hour", + on_change=set_time +) + +# this creates a time field with a granularity of seconds and the on_change handler is passed an instant +time, set_time = ui.use_state(None) + +time_field7 = ui.time_field( + on_change=set_time +) + +``` + #### ui.fragment A fragment maps to a [React.Fragment](https://react.dev/reference/react/Fragment). This lets you group elements without using a wrapper node. It only takes children, and does not take any additional props. diff --git a/plugins/ui/docs/components/time_field.md b/plugins/ui/docs/components/time_field.md new file mode 100644 index 000000000..53c099b48 --- /dev/null +++ b/plugins/ui/docs/components/time_field.md @@ -0,0 +1,440 @@ +# Time field + +Time fields allow users to input a time using a text field. + +## Example + +```python +from deephaven import ui + +my_time_field_basic = ui.time_field(label="Time field") +``` + +## Time types + +A time field can be used to input a time. + +The time field accepts the following time types as inputs: + +- `None` +- `LocaTime` +- `ZonedDateTime` +- `Instant` +- `int` +- `str` +- `datetime.datetime` +- `numpy.datetime64` +- `pandas.Timestamp` + +The input will be converted to one of three Java time types: + +1. `LocalTime`: A LocalTime is a time without a time zone in the ISO-8601 system, such as "10:30:45" or "16:10:00". + This will create a time field with a granularity of seconds. +2. `Instant`: An Instant represents an unambiguous specific point on the timeline, such as 2021-04-12T14:13:07 UTC. + This will create a time 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 time field with a granularity of seconds in the specified time zone. The time zone will be rendered as the specified time zone. + +4. If the input is one of the three Java time types, use that type. +5. A time string such as "10:30:45" will parse to a `LocaTime`. +6. A string with a date, time, and timezone such as "2021-04-12T14:13:07 America/New_York" will parse to a `ZonedDateTime`. +7. All other types will attempt to convert in this order: `LocaTime`, `Instant`, `ZonedDateTime`. + +The format of the time field 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 `LocalTime`. + +```python +from deephaven import ui +from deephaven.time import to_j_local_time, dh_now, 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_time = to_j_local_time("12:30:45") + + +@ui.component +def time_field_test(value): + time, set_time = ui.use_state(value) + return ui.time_field(on_change=set_time, value=time) + + +zoned_time_field = time_field_test(zoned_date_time) +instant_time_field = time_field_test(instant) +local_time_field = time_field_test(local_time) +``` + +## Value + +A time field displays a `placeholder` by default. An initial, uncontrolled value can be provided to the time 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("11:45") + return ui.flex( + ui.time_field( + label="Time field (uncontrolled)", + default_value="11:45", + ), + ui.time_field( + label="Time field (controlled)", value=value, on_change=set_value + ), + gap="size-150", + wrap=True, + ) + + +my_example = example() +``` + +## Time zones + +Time field is time zone aware when `ZonedTimeTime` 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 `ZonedTimeTime` objects, the time field displays the specified time zone. +- For `Instant` objects, the time field displays the time zone from the user settings. + +```python +from deephaven import ui +from deephaven.time import to_j_instant + +my_zoned_time_time = ui.time_field( + label="Time field", + default_value="2022-11-07T00:45 America/Los_Angeles", +) + +my_instant = ui.time_field( + label="Time field", + default_value=to_j_instant("2022-11-07T00:45Z"), +) +``` + +## Granularity + +The `granularity` prop allows you to control the smallest unit displayed by a time field. By default, values are displayed with "SECOND" granularity. + +In addition, when a value with a time is provided but you wish to display only the time, 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 time 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.time_field( + label="Time field and time field", + granularity="SECOND", + value=value, + on_change=set_value, + ), + ui.time_field( + label="Time field", granularity="HOUR", value=value, on_change=set_value + ), + gap="size-150", + wrap=True, + ) + + +my_granularity_example = granularity_example() +``` + +## HTML forms + +The time field supports the `name` prop for integration with HTML forms. The value will be submitted to the server as an `ISO 8601` formatted string, e.g., "08:45:00." + +```python +from deephaven import ui + +my_time_field_forms = ui.form( + ui.time_field(label="Meeting time", name="meetingTime"), + ui.button("Submit", type="submit"), + on_submit=print, +) +``` + +## Labeling + +A visual label should be provided for the time field using the `label` prop. If the time field is required, the `is_required` and `necessity_indicator` props can be used to show a required state. + +```python +from deephaven import ui + +my_time_field_labeling = ui.flex( + ui.time_field(label="Time field"), + ui.time_field(label="Time field", is_required=True, necessity_indicator="icon"), + ui.time_field(label="Time field", is_required=True, necessity_indicator="label"), + ui.time_field(label="Time field", necessity_indicator="label"), +) +``` + +## Events + +Time 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("11:45") + return ui.time_field( + label="Time field (controlled)", value=value, on_change=set_value + ) + + +my_event_example = event_example() +``` + +## Validation + +The `is_required` prop ensures that the user selects a time 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 time_field_validation_behaviour_example(): + return ui.form( + ui.time_field( + validation_behavior="native", + is_required=True, + ) + ) + + +my_time_field_validation_behaviour_example = time_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. Time field also validates that the end time is after the start time. + +```python +from deephaven import ui + +my_time_field_basic = ui.time_field( + label="Time field", + min_value="11:00", + default_value="11:45", +) +``` + +## Label position + +By default, the position of a time field's label is above the time field, but it can be moved to the side using the `label_position` prop. + +```python +from deephaven import ui + + +@ui.component +def time_field_label_position_examples(): + return [ + ui.time_field( + label="Test Label", + ), + ui.time_field( + label="Test Label", + label_position="side", + ), + ] + + +my_time_field_label_position_examples = time_field_label_position_examples() +``` + +## Quiet state + +The `is_quiet` prop makes a time field "quiet". This can be useful when its corresponding styling should not distract users from surrounding content. + +```python +from deephaven import ui + + +my_time_field_is_quiet_example = ui.time_field( + is_quiet=True, +) +``` + +## Disabled state + +The `is_disabled` prop disables the time field to prevent user interaction. This is useful when the time field should be visible but not available for selection. + +```python +from deephaven import ui + + +my_time_field_is_disabled_example = ui.time_field( + is_disabled=True, +) +``` + +## Read only + +The `is_read_only` prop makes the time field's value immutable. The time field remains focusable, unlike when `is_disabled` is used. + +```python +from deephaven import ui + + +my_time_field_is_read_only_example = ui.time_field( + is_read_only=True, +) +``` + +## Help text + +A time 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 time field state is `valid` or `invalid`. + +```python +from deephaven import ui + + +@ui.component +def time_field_help_text_examples(): + return [ + ui.time_field( + label="Sample Label", + description="Enter a time field.", + ), + ui.time_field( + label="Sample Label", + validation_state="valid", + error_message="Sample invalid error message.", + ), + ui.time_field( + label="Sample Label", + validation_state="invalid", + error_message="Sample invalid error message.", + ), + ] + + +my_time_field_help_text_examples = time_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 time field. + +```python +from deephaven import ui + + +time_field_contextual_help_example = ui.time_field( + label="Sample Label", + contextual_help=ui.contextual_help(ui.heading("Content tips")), +) +``` + +## Custom width + +The `width` prop adjusts the width of a time field, and the `max_width` prop enforces a maximum width. + +```python +from deephaven import ui + + +@ui.component +def time_field_width_examples(): + return [ + ui.time_field( + width="size-3600", + ), + ui.time_field( + width="size-3600", + max_width="100%", + ), + ] + + +my_time_field_width_examples = time_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.time_field( + label="Time field", + default_value="2022-11-07T00:45 America/Los_Angeles", + hide_time_zone=True, +) +``` + +## Hour cycle + +By default, time 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 + + +time_field_hour_cycle_example = ui.time_field(label="Time field", hour_cycle=24) +``` + +## Time table filtering + +Time 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 time_table_filter(table, start_time, end_time, time_col="Timestamp"): + after_time, set_after_time = ui.use_state(start_time) + before_time, set_before_time = ui.use_state(end_time) + return [ + ui.time_field( + label="Start Time", + value=after_time, + on_change=set_after_time, + hour_cycle=24, + ), + ui.time_field( + label="End Time", + value=before_time, + on_change=set_before_time, + hour_cycle=24, + ), + table.where(f"{time_col} >= after_time && {time_col} < before_time"), + ] + + +SECONDS_IN_HOUR = 3600 +today = dh_now() +_table = time_table("PT1s").update_view( + ["Timestamp=today.plusSeconds(SECONDS_IN_HOUR*i)", "Row=i"] +) +time_filter = time_table_filter(_table, today, today.plusSeconds(SECONDS_IN_HOUR * 10)) +``` + +## API Reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.time_field +``` diff --git a/plugins/ui/src/deephaven/ui/_internal/utils.py b/plugins/ui/src/deephaven/ui/_internal/utils.py index 691abe679..59f480dd3 100644 --- a/plugins/ui/src/deephaven/ui/_internal/utils.py +++ b/plugins/ui/src/deephaven/ui/_internal/utils.py @@ -4,22 +4,26 @@ from inspect import signature import sys from functools import partial -from deephaven.time import to_j_instant, to_j_zdt, to_j_local_date +from deephaven.time import to_j_instant, to_j_zdt, to_j_local_date, to_j_local_time from deephaven.dtypes import ZonedDateTime, Instant -from ..types import Date, JavaDate, DateRange +from ..types import Date, JavaDate, DateRange, Time, JavaTime _UNSAFE_PREFIX = "UNSAFE_" _ARIA_PREFIX = "aria_" _ARIA_PREFIX_REPLACEMENT = "aria-" -_CONVERTERS = { +_DATE_CONVERTERS = { "java.time.Instant": to_j_instant, "java.time.ZonedDateTime": to_j_zdt, "java.time.LocalDate": to_j_local_date, } -_LOCAL_DATE = "java.time.LocalDate" +_TIME_CONVERTERS = { + "java.time.ZonedDateTime": to_j_zdt, + "java.time.Instant": to_j_instant, + "java.time.LocalTime": to_j_local_time, +} def get_component_name(component: Any) -> str: @@ -257,6 +261,49 @@ def _convert_to_java_date( ) +def _convert_to_java_time( + time: Time, +) -> JavaTime: + """ + Convert a Time to a Java time type. + In order of preference, tries to convert to LocalTime, Instant, ZonedDateTime. + If none of these work, raises a TypeError. + + Args: + time: The time to convert to a Java time type. + + Returns: + The Java time type. + """ + try: + return to_j_local_time(time) # type: ignore + except Exception: + # ignore, try next + pass + + # For strings, parseInstant and parseZonedDateTime both succeed for the same strings + # Try parsing as a ZonedDateTime first per the documentation + if isinstance(time, str): + try: + return to_j_zdt(time) # type: ignore + except Exception: + # ignore, try next + pass + + try: + return to_j_instant(time) # type: ignore + except Exception: + # ignore, try next + pass + + try: + return to_j_zdt(time) # type: ignore + except Exception: + raise TypeError( + f"Could not convert {time} to one of LocalTime, Instant, or ZonedDateTime." + ) + + def get_jclass_name(value: Any) -> str: """ Get the name of the Java class of the value. @@ -270,7 +317,7 @@ def get_jclass_name(value: Any) -> str: return str(value.jclass)[6:] -def _jclass_converter( +def _jclass_date_converter( value: JavaDate, ) -> Callable[[Date], Any]: """ @@ -282,7 +329,22 @@ def _jclass_converter( Returns: The converter for the Java date type. """ - return _CONVERTERS[get_jclass_name(value)] + return _DATE_CONVERTERS[get_jclass_name(value)] + + +def _jclass_time_converter( + value: JavaTime, +) -> Callable[[Time], Any]: + """ + Get the converter for the Java time type. + + Args: + value: The Java time type to get the converter for. + + Returns: + The converter for the Java time type. + """ + return _TIME_CONVERTERS[get_jclass_name(value)] def _wrap_date_callable( @@ -312,6 +374,33 @@ def no_error_date_callable(date: Date) -> None: return no_error_date_callable +def _wrap_time_callable( + time_callable: Callable[[Time], None], + converter: Callable[[Time], Any], +) -> Callable[[Time], None]: + """ + Wrap a callable to convert the Time argument to a Java time type. + This maintains the original callable signature so that the Time argument can be dropped. + + Args: + time_callable: The callable to wrap. + converter: The time converter to use. + + Returns: + The wrapped callable. + """ + # When the user is typing a time, they may enter a value that does not parse + # This will skip those errors rather than printing them to the screen + def no_error_time_callable(time: Time) -> None: + wrapped_time_callable = wrap_callable(time_callable) + try: + wrapped_time_callable(converter(time)) + except Exception: + pass + + return no_error_time_callable + + def _get_first_set_key(props: dict[str, Any], sequence: Sequence[str]) -> str | None: """ Of the keys in sequence, get the first key that has a non-None value in props. @@ -345,7 +434,7 @@ def _date_or_range(value: JavaDate | DateRange) -> Any: return value -def _prioritized_callable_converter( +def _prioritized_date_callable_converter( props: dict[str, Any], priority: Sequence[str], default_converter: Callable[[Date], Any], @@ -368,7 +457,36 @@ def _prioritized_callable_converter( first_set_key = _get_first_set_key(props, priority) return ( - _jclass_converter(_date_or_range(props[first_set_key])) + _jclass_date_converter(_date_or_range(props[first_set_key])) + if first_set_key is not None + else default_converter + ) + + +def _prioritized_time_callable_converter( + props: dict[str, Any], + priority: Sequence[str], + default_converter: Callable[[Time], Any], +) -> Callable[[Time], Any]: + """ + Get a callable time converter based on the type of the first non-None prop set. + Checks the props in the order provided by the `priority` sequence. + All the props in `priority` should be Java time types already. + We do this so conversion so that the type returned on callbacks matches the type passed in by the user. + If none of the props in `priority` are present, returns the default converter. + + Args: + props: The props passed to the component. + priority: The priority of the props to check. + default_converter: The default converter to use if none of the priority props are present. + + Returns: + The callable time converter. + """ + + first_set_key = _get_first_set_key(props, priority) + return ( + _jclass_time_converter(props[first_set_key]) if first_set_key is not None else default_converter ) @@ -477,7 +595,7 @@ def convert_date_props( 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) + converter = _prioritized_date_callable_converter(props, priority, default_converter) # based on the convert set the granularity if it is not set # Local Dates will default to DAY but we need to default to SECOND for the other types @@ -506,6 +624,48 @@ def convert_date_props( props[key] = _wrap_date_callable(props[key], converter) +def convert_time_props( + props: dict[str, Any], + simple_time_props: set[str], + callable_time_props: set[str], + priority: Sequence[str], + default_converter: Callable[[Time], Any] = to_j_local_time, +) -> None: + """ + Convert time props to Java time types in place. + + Args: + props: The props passed to the component. + simple_time_props: A set of simple time keys to convert. The prop value should be a single Time. + callable_time_props: A set of callable time keys to convert. + The prop value should be a callable that takes a Time. + priority: The priority of the props to check. + granularity_key: The key for the granularity + default_converter: The default converter to use if none of the priority props are present. + + Returns: + The converted props. + """ + for key in simple_time_props: + if props.get(key) is not None: + props[key] = _convert_to_java_time(props[key]) + + # the simple props must be converted before this to simplify the callable conversion + converter = _prioritized_time_callable_converter(props, priority, default_converter) + + # now that the converter is set, we can convert simple props to strings + for key in simple_time_props: + if props.get(key) is not None: + props[key] = str(props[key]) + + # wrap the date callable with the convert + for key in callable_time_props: + if props.get(key) is not None: + if not callable(props[key]): + raise TypeError(f"{key} must be a callable") + props[key] = _wrap_time_callable(props[key], converter) + + def unpack_item_table_source( children: tuple[Any, ...], props: dict[str, Any], diff --git a/plugins/ui/src/deephaven/ui/components/__init__.py b/plugins/ui/src/deephaven/ui/components/__init__.py index 04c4f84e5..9c81acfb5 100644 --- a/plugins/ui/src/deephaven/ui/components/__init__.py +++ b/plugins/ui/src/deephaven/ui/components/__init__.py @@ -48,6 +48,7 @@ from .text import text from .text_area import text_area from .text_field import text_field +from .time_field import time_field from .toggle_button import toggle_button from .view import view @@ -104,6 +105,7 @@ "text", "text_area", "text_field", + "time_field", "toggle_button", "view", ] diff --git a/plugins/ui/src/deephaven/ui/components/time_field.py b/plugins/ui/src/deephaven/ui/components/time_field.py new file mode 100644 index 000000000..db5d5a98d --- /dev/null +++ b/plugins/ui/src/deephaven/ui/components/time_field.py @@ -0,0 +1,248 @@ +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_time_props, +) +from ..types import Time, TimeGranularity +from .basic import component_element +from .make_component import make_component + +TimeFieldElement = Element + +# All the props that can be time types +_SIMPLE_TIME_PROPS = { + "placeholder_value", + "value", + "default_value", + "min_value", + "max_value", +} +_CALLABLE_TIME_PROPS = {"on_change"} + +# The priority of the time props to determine the format of the time passed to the callable time props +_TIME_PROPS_PRIORITY = ["value", "default_value", "placeholder_value"] + + +def _convert_time_field_props( + props: dict[str, Any], +) -> dict[str, Any]: + """ + Convert time field props to Java time types. + + Args: + props: The props passed to the time field. + + Returns: + The converted props. + """ + + convert_time_props( + props, + _SIMPLE_TIME_PROPS, + _CALLABLE_TIME_PROPS, + _TIME_PROPS_PRIORITY, + ) + + return props + + +@make_component +def time_field( + placeholder_value: Time | None = None, + value: Time | None = None, + default_value: Time | None = None, + min_value: Time | None = None, + max_value: Time | None = None, + granularity: TimeGranularity | None = "SECOND", + 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, + name: str | None = None, + is_quiet: 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[[Time], 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, + key: str | None = None, +) -> TimeFieldElement: + """ + A time field allows the user to select a time. + + + Args: + placeholder_value: A placeholder time that influences the format of the + placeholder shown when no value is selected. Defaults to 12:00 AM or + 00:00 depending on the hour cycle. + value: The current value (controlled). + default_value: The default value (uncontrolled). + min_value: The minimum allowed time that a user may select. + max_value: The maximum allowed time that a user may select. + granularity: Determines the smallest unit that is displayed in the time field. + By default, this is `"SECOND"`. + 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 force leading zeros in the time field. + 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. + name: The name of the input element, used when submitting an HTML form. + is_quiet: Whether the time field should be displayed with a quiet style. + 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 `Time` 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. + key: A unique identifier used by React to render elements in a list. + + Returns: + The time field element. + """ + _, props = create_props(locals()) + + _convert_time_field_props(props) + + return component_element("TimeField", **props) diff --git a/plugins/ui/src/deephaven/ui/types/types.py b/plugins/ui/src/deephaven/ui/types/types.py index e4f0086bd..d0fa28513 100644 --- a/plugins/ui/src/deephaven/ui/types/types.py +++ b/plugins/ui/src/deephaven/ui/types/types.py @@ -506,6 +506,27 @@ class SliderChange(TypedDict): TabDensity = Literal["compact", "regular"] Dependencies = Union[Tuple[Any], List[Any]] Selection = Sequence[Key] +LocalTime = DType +JavaTime = Union[LocalTime, Instant, ZonedDateTime] +LocalTimeConvertible = Union[ + None, + LocalTime, + int, + str, + datetime.time, + datetime.datetime, + numpy.datetime64, + pandas.Timestamp, +] +Time = Union[ + LocalTime, + Instant, + ZonedDateTime, + LocalTimeConvertible, + InstantConvertible, + ZonedDateTimeConvertible, +] +TimeGranularity = Literal["HOUR", "MINUTE", "SECOND"] class DateRange(TypedDict): diff --git a/plugins/ui/src/js/src/elements/DateField.tsx b/plugins/ui/src/js/src/elements/DateField.tsx index 1ae0a50cf..60f79b59a 100644 --- a/plugins/ui/src/js/src/elements/DateField.tsx +++ b/plugins/ui/src/js/src/elements/DateField.tsx @@ -59,7 +59,7 @@ export function DateField( value instanceof ZonedDateTime ) { const newValue = toTimeZone(value, timeZone); - onChange(newValue); + onChange?.(newValue); } }, [isDateFieldInstantValue, value, onChange, timeZone, prevTimeZone]); diff --git a/plugins/ui/src/js/src/elements/DatePicker.tsx b/plugins/ui/src/js/src/elements/DatePicker.tsx index 0c3567c59..00005075f 100644 --- a/plugins/ui/src/js/src/elements/DatePicker.tsx +++ b/plugins/ui/src/js/src/elements/DatePicker.tsx @@ -59,7 +59,7 @@ export function DatePicker( value instanceof ZonedDateTime ) { const newValue = toTimeZone(value, timeZone); - onChange(newValue); + onChange?.(newValue); } }, [isDatePickerInstantValue, value, onChange, timeZone, prevTimeZone]); diff --git a/plugins/ui/src/js/src/elements/DateRangePicker.tsx b/plugins/ui/src/js/src/elements/DateRangePicker.tsx index 25724e565..38c492b61 100644 --- a/plugins/ui/src/js/src/elements/DateRangePicker.tsx +++ b/plugins/ui/src/js/src/elements/DateRangePicker.tsx @@ -65,7 +65,7 @@ export function DateRangePicker( const newStart = toTimeZone(value.start, timeZone); const newEnd = toTimeZone(value.end, timeZone); const newValue = { start: newStart, end: newEnd }; - onChange(newValue); + onChange?.(newValue); } }, [isDateRangePickerInstantValue, value, onChange, timeZone, prevTimeZone]); diff --git a/plugins/ui/src/js/src/elements/TimeField.tsx b/plugins/ui/src/js/src/elements/TimeField.tsx new file mode 100644 index 000000000..ce5a9fd80 --- /dev/null +++ b/plugins/ui/src/js/src/elements/TimeField.tsx @@ -0,0 +1,74 @@ +import React, { useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { + TimeField as DHCTimeField, + TimeFieldProps as DHCTimeFieldProps, +} from '@deephaven/components'; +import { usePrevious } from '@deephaven/react-hooks'; +import { getSettings, RootState } from '@deephaven/redux'; +import { toTimeZone, ZonedDateTime } from '@internationalized/date'; +import useDebouncedOnChange from './hooks/useDebouncedOnChange'; +import { + SerializedTimeComponentProps, + useTimeComponentProps, +} from './hooks/useTimeComponentProps'; +import { TimeValue, isStringInstant } from './utils/DateTimeUtils'; + +const EMPTY_FUNCTION = () => undefined; + +function isTimeFieldInstant( + props: SerializedTimeComponentProps> +): boolean { + const { value, defaultValue, placeholderValue } = props; + if (value != null) { + return isStringInstant(value); + } + if (defaultValue != null) { + return isStringInstant(defaultValue); + } + return isStringInstant(placeholderValue); +} + +export function TimeField( + props: SerializedTimeComponentProps> +): JSX.Element { + const isTimeFieldInstantValue = isTimeFieldInstant(props); + const settings = useSelector(getSettings); + const { timeZone } = settings; + + const { + defaultValue = null, + value: propValue, + onChange: propOnChange = EMPTY_FUNCTION, + ...otherProps + } = useTimeComponentProps(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 ( + isTimeFieldInstantValue && + prevTimeZone !== undefined && + timeZone !== prevTimeZone && + value instanceof ZonedDateTime + ) { + const newValue = toTimeZone(value, timeZone); + onChange?.(newValue); + } + }, [isTimeFieldInstantValue, value, onChange, timeZone, prevTimeZone]); + + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + ); +} + +TimeField.displayName = 'TimeField'; + +export default TimeField; diff --git a/plugins/ui/src/js/src/elements/hooks/index.ts b/plugins/ui/src/js/src/elements/hooks/index.ts index 8658d4a98..035e577bc 100644 --- a/plugins/ui/src/js/src/elements/hooks/index.ts +++ b/plugins/ui/src/js/src/elements/hooks/index.ts @@ -9,4 +9,6 @@ export * from './usePickerProps'; export * from './usePressEventCallback'; export * from './useReExportedTable'; export * from './useSelectionProps'; +export * from './useTimeComponentProps'; +export * from './useTimeValueMemo'; export * from './useDebouncedOnChange'; diff --git a/plugins/ui/src/js/src/elements/hooks/useTimeComponentProps.ts b/plugins/ui/src/js/src/elements/hooks/useTimeComponentProps.ts new file mode 100644 index 000000000..45b1e5c08 --- /dev/null +++ b/plugins/ui/src/js/src/elements/hooks/useTimeComponentProps.ts @@ -0,0 +1,212 @@ +import { useCallback, useMemo } from 'react'; +import { + DeserializedFocusEventCallback, + SerializedFocusEventCallback, + useFocusEventCallback, +} from './useFocusEventCallback'; +import { + DeserializedKeyboardEventCallback, + SerializedKeyboardEventCallback, + useKeyboardEventCallback, +} from './useKeyboardEventCallback'; +import useTimeValueMemo from './useTimeValueMemo'; +import { + TimeValue, + TimeGranularity, + MappedTimeValue, + parseNullableTimeValue, +} from '../utils/DateTimeUtils'; + +export type SerializedTimeValue = string | null; + +export type SerializedTimeValueCallback = (value: SerializedTimeValue) => void; + +export type DeserializedTimeValueCallback = + | (() => void) + | ((value: MappedTimeValue | null) => Promise); + +export interface SerializedTimeComponentPropsInterface { + /** 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?: SerializedTimeValueCallback; + + /** The current value (controlled) */ + value?: string | null; + + /** The default value (uncontrolled) */ + defaultValue?: string | null; + + /** The minimum allowed time that a user may select */ + minValue?: string; + + /** The maximum allowed time that a user may select */ + maxValue?: string; + + /** A placeholder time that influences the format of the placeholder shown when no value is selected */ + placeholderValue?: string; + + /** Determines the smallest unit that is displayed in the time component. */ + granularity?: TimeGranularity; +} + +export interface DeserializedTimeComponentPropsInterface { + /** 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?: DeserializedTimeValueCallback; + + /** The current value (controlled) */ + value?: TimeValue | null; + + /** The default value (uncontrolled) */ + defaultValue?: TimeValue | null; + + /** The minimum allowed time that a user may select */ + minValue?: TimeValue; + + /** The maximum allowed time that a user may select */ + maxValue?: TimeValue; + + /** A placeholder time that influences the format of the placeholder shown when no value is selected */ + placeholderValue?: TimeValue; + + /** Determines the smallest unit that is displayed in the time component. */ + granularity?: TimeGranularity; +} + +export type SerializedTimeComponentProps = TProps & + SerializedTimeComponentPropsInterface; + +export type DeserializedTimeComponentProps = Omit< + TProps, + keyof SerializedTimeComponentPropsInterface +> & + DeserializedTimeComponentPropsInterface; + +/** + * Uses the toString representation of the TimeValue as the serialized value. + * @param value TimeValue to serialize + * @returns Serialized TimeValue + */ +export function serializeTimeValue( + value?: MappedTimeValue +): SerializedTimeValue { + if (value == null) { + return null; + } + + return value.toString(); +} + +/** + * Get a callback function that can be passed to the onChange event handler + * props of a Spectrum Time 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 + */ +export function useOnChangeTimeCallback( + callback?: SerializedTimeValueCallback +): DeserializedTimeValueCallback { + return useCallback( + (value?: MappedTimeValue) => { + if (callback == null) { + return; + } + callback(serializeTimeValue(value)); + }, + [callback] + ); +} + +/** + * Use memo to get a TimeValue from a nullable string. + * + * @param value the string time value + * @returns TimeValue or null + */ +export function useNullableTimeValueMemo( + timeZone: string, + value?: string | null +): TimeValue | null | undefined { + return useMemo( + () => parseNullableTimeValue(timeZone, value), + [timeZone, value] + ); +} + +/** + * Wrap Time component props with the appropriate serialized event callbacks. + * @param props Props to wrap + * @returns Wrapped props + */ +export function useTimeComponentProps( + { + onFocus, + onBlur, + onKeyDown, + onKeyUp, + onChange: serializedOnChange, + value: serializedValue, + defaultValue: serializedDefaultValue, + minValue: serializedMinValue, + maxValue: serializedMaxValue, + placeholderValue: serializedPlaceholderValue, + granularity: upperCaseTimeGranularity, + ...otherProps + }: SerializedTimeComponentProps, + timeZone: string +): DeserializedTimeComponentProps { + const serializedOnFocus = useFocusEventCallback(onFocus); + const serializedOnBlur = useFocusEventCallback(onBlur); + const serializedOnKeyDown = useKeyboardEventCallback(onKeyDown); + const serializedOnKeyUp = useKeyboardEventCallback(onKeyUp); + const onChange = useOnChangeTimeCallback(serializedOnChange); + const deserializedValue = useNullableTimeValueMemo(timeZone, serializedValue); + const deserializedDefaultValue = useNullableTimeValueMemo( + timeZone, + serializedDefaultValue + ); + const deserializedMinValue = useTimeValueMemo(timeZone, serializedMinValue); + const deserializedMaxValue = useTimeValueMemo(timeZone, serializedMaxValue); + const deserializedPlaceholderValue = useTimeValueMemo( + 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: upperCaseTimeGranularity?.toLowerCase() as TimeGranularity, + ...otherProps, + }; +} diff --git a/plugins/ui/src/js/src/elements/hooks/useTimeValueMemo.ts b/plugins/ui/src/js/src/elements/hooks/useTimeValueMemo.ts new file mode 100644 index 000000000..6ffb13c3f --- /dev/null +++ b/plugins/ui/src/js/src/elements/hooks/useTimeValueMemo.ts @@ -0,0 +1,15 @@ +import { useMemo } from 'react'; +import { TimeValue, parseTimeValue } from '../utils/DateTimeUtils'; + +/** + * Use memo to get a TimeValue from a string. + * + * @param value the string time value + * @returns TimeValue + */ +export default function useDateTimeMemo( + timeZone: string, + value?: string +): TimeValue | undefined { + return useMemo(() => parseTimeValue(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 8decb03b7..cd0502066 100644 --- a/plugins/ui/src/js/src/elements/index.ts +++ b/plugins/ui/src/js/src/elements/index.ts @@ -23,6 +23,7 @@ export * from './Tabs'; export * from './TabPanels'; export * from './TextField'; export * from './TextArea'; +export * from './TimeField'; export * from './ToggleButton'; export * from './UITable/UITable'; export * from './utils'; diff --git a/plugins/ui/src/js/src/elements/model/ElementConstants.ts b/plugins/ui/src/js/src/elements/model/ElementConstants.ts index c1b8cd3d6..737e2072a 100644 --- a/plugins/ui/src/js/src/elements/model/ElementConstants.ts +++ b/plugins/ui/src/js/src/elements/model/ElementConstants.ts @@ -61,6 +61,7 @@ export const ELEMENT_NAME = { text: uiComponentName('Text'), textArea: uiComponentName('TextArea'), textField: uiComponentName('TextField'), + timeField: uiComponentName('TimeField'), toggleButton: uiComponentName('ToggleButton'), view: uiComponentName('View'), } as const; diff --git a/plugins/ui/src/js/src/elements/utils/DateTimeUtils.test.ts b/plugins/ui/src/js/src/elements/utils/DateTimeUtils.test.ts index 77cda081d..c61524f1c 100644 --- a/plugins/ui/src/js/src/elements/utils/DateTimeUtils.test.ts +++ b/plugins/ui/src/js/src/elements/utils/DateTimeUtils.test.ts @@ -2,6 +2,8 @@ import { parseDateValue, parseNullableDateValue, isStringInstant, + parseTimeValue, + parseNullableTimeValue, } from './DateTimeUtils'; const DEFAULT_TIME_ZONE = 'UTC'; @@ -81,3 +83,66 @@ describe('parseDateValue', () => { ); }); }); + +describe('parseTimeValue', () => { + const isoTime = '04:05:06'; + const isoDateTime = '2021-03-03T04:05:06'; + const isoZonedDateTime = '2021-04-04T05:06:07-04:00[America/New_York]'; + const nonIsoZonedDateTime = '2021-04-04T05:06:07 America/New_York'; + const instantString = '2021-03-03T04:05:06Z'; + const instantStringUTC = '2021-03-03T04:05:06Z[UTC]'; + const utcOutput = '2021-03-03T04:05:06+00:00[UTC]'; + const nyOutput = '2021-03-02T23:05:06-05:00[America/New_York]'; + const invalidTime = 'invalid-time'; + + it('should return null if the value is null', () => { + expect(parseNullableTimeValue(DEFAULT_TIME_ZONE, null)).toBeNull(); + }); + + it('should return undefined if the value is undefined', () => { + expect(parseTimeValue(DEFAULT_TIME_ZONE, undefined)).toBeUndefined(); + }); + + it('should parse an ISO 8601 time string', () => { + expect(parseTimeValue(DEFAULT_TIME_ZONE, isoTime)?.toString()).toEqual( + isoTime + ); + }); + + it('should parse an ISO 8601 date time string', () => { + expect(parseTimeValue(DEFAULT_TIME_ZONE, isoDateTime)?.toString()).toEqual( + isoDateTime + ); + }); + + it('should parse an ISO 8601 zoned date time string', () => { + expect( + parseTimeValue(DEFAULT_TIME_ZONE, isoZonedDateTime)?.toString() + ).toEqual(isoZonedDateTime); + }); + + it('should parse a non-ISO 8601 zoned date time string', () => { + expect( + parseTimeValue(DEFAULT_TIME_ZONE, nonIsoZonedDateTime)?.toString() + ).toEqual(isoZonedDateTime); + }); + + it('should parse an instant string', () => { + expect( + parseTimeValue(DEFAULT_TIME_ZONE, instantString)?.toString() + ).toEqual(utcOutput); + expect( + parseTimeValue(DEFAULT_TIME_ZONE, instantStringUTC)?.toString() + ).toEqual(utcOutput); + }); + + it('should throw an error if the value is invalid', () => { + expect(() => parseTimeValue(DEFAULT_TIME_ZONE, invalidTime)).toThrow(); + }); + + it('should parse an instant time string with a different time zone', () => { + expect(parseTimeValue(NY_TIME_ZONE, instantString)?.toString()).toEqual( + nyOutput + ); + }); +}); diff --git a/plugins/ui/src/js/src/elements/utils/DateTimeUtils.ts b/plugins/ui/src/js/src/elements/utils/DateTimeUtils.ts index 9c377245f..6123108e6 100644 --- a/plugins/ui/src/js/src/elements/utils/DateTimeUtils.ts +++ b/plugins/ui/src/js/src/elements/utils/DateTimeUtils.ts @@ -3,9 +3,11 @@ import { CalendarDate, CalendarDateTime, ZonedDateTime, + Time, parseDate, parseDateTime, parseZonedDateTime, + parseTime, toTimeZone, } from '@internationalized/date'; @@ -19,6 +21,20 @@ export type MappedDateValue = T extends ZonedDateTime export type Granularity = 'day' | 'hour' | 'minute' | 'second'; +export type TimeValue = Time | CalendarDateTime | ZonedDateTime; + +export type TimeGranularity = 'hour' | 'minute' | 'second'; + +export type MappedTimeValue = T extends ZonedDateTime + ? ZonedDateTime + : T extends CalendarDateTime + ? CalendarDateTime + : T extends Time + ? Time + : never; + +type DateTimeValue = CalendarDateTime | ZonedDateTime; + /** * Checks if a string is an Instant. * @@ -51,6 +67,43 @@ export function parseDateValue( // ignore } + const dateTime = parseDateTimeInternal(timeZone, value); + if (dateTime != null) { + return dateTime; + } + + 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); +} + +/** + * Common parsing used for both DateTimes and Times. + * + * @param timeZone the time zone to use + * @param value the string date value + * @returns a DateTimeValue or null + */ +function parseDateTimeInternal( + timeZone: string, + value: string +): DateTimeValue | null { // 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 { @@ -100,23 +153,53 @@ export function parseDateValue( } } - throw new Error(`Invalid date value string: ${value}`); + return null; } /** - * Parses a date value string into a DateValue. Allows null. + * Parses a time value string into a TimeValue. * * @param timeZone the time zone to use * @param value the string date value - * @returns DateValue or null + * @returns TimeValue */ -export function parseNullableDateValue( +export function parseTimeValue( + timeZone: string, + value?: string +): TimeValue | undefined { + if (value === undefined) { + return value; + } + + // Try to parse and ISO 8601 time string, e.g. "04:05:06" + try { + return parseTime(value); + } catch (ignore) { + // ignore + } + + const dateTime = parseDateTimeInternal(timeZone, value); + if (dateTime != null) { + return dateTime; + } + + throw new Error(`Invalid time value string: ${value}`); +} + +/** + * Parses a time value string into a TimeValue. Allows null. + * + * @param timeZone the time zone to use + * @param value the string time value + * @returns TimeValue or null + */ +export function parseNullableTimeValue( timeZone: string, value?: string | null -): DateValue | null | undefined { +): TimeValue | null | undefined { if (value === null) { return value; } - return parseDateValue(timeZone, value); + return parseTimeValue(timeZone, value); } diff --git a/plugins/ui/src/js/src/widget/WidgetUtils.tsx b/plugins/ui/src/js/src/widget/WidgetUtils.tsx index 85d031dd3..56f7f43f2 100644 --- a/plugins/ui/src/js/src/widget/WidgetUtils.tsx +++ b/plugins/ui/src/js/src/widget/WidgetUtils.tsx @@ -66,6 +66,7 @@ import { TabPanels, TextField, TextArea, + TimeField, ToggleButton, UITable, Tabs, @@ -136,6 +137,7 @@ export const elementComponentMap = { [ELEMENT_NAME.text]: Text, [ELEMENT_NAME.textArea]: TextArea, [ELEMENT_NAME.textField]: TextField, + [ELEMENT_NAME.timeField]: TimeField, [ELEMENT_NAME.toggleButton]: ToggleButton, [ELEMENT_NAME.view]: View, } as const satisfies Record, unknown>; diff --git a/plugins/ui/test/deephaven/ui/test_time_field.py b/plugins/ui/test/deephaven/ui/test_time_field.py new file mode 100644 index 000000000..60ee64550 --- /dev/null +++ b/plugins/ui/test/deephaven/ui/test_time_field.py @@ -0,0 +1,85 @@ +import unittest + +from .BaseTest import BaseTestCase + + +class TimeFieldTest(BaseTestCase): + def test_convert_time_props(self): + from deephaven.time import to_j_instant, to_j_zdt, to_j_local_time + from deephaven.ui.components.time_field import _convert_time_field_props + from deephaven.ui._internal.utils import ( + get_jclass_name, + _convert_to_java_time, + ) + + def verify_is_local_time(timeStr): + self.assertEqual( + get_jclass_name(_convert_to_java_time(timeStr)), "java.time.LocalTime" + ) + + def verify_is_instant(timeStr): + self.assertEqual( + get_jclass_name(_convert_to_java_time(timeStr)), "java.time.Instant" + ) + + def verify_is_zdt(timeStr): + self.assertEqual( + get_jclass_name(_convert_to_java_time(timeStr)), + "java.time.ZonedDateTime", + ) + + def empty_on_change(): + pass + + props1 = { + "placeholder_value": "10:30:45", + "value": "10:30:45", + "default_value": "10:30:45", + "min_value": to_j_zdt("2021-01-01T10:30:45 ET"), + "max_value": to_j_local_time("10:30:45"), + } + + props2 = { + "value": to_j_local_time("10:30:45"), + "default_value": to_j_zdt("2021-01-01T10:30:45 ET"), + "placeholder_value": to_j_instant("2021-01-01T10:30:45 UTC"), + "on_change": verify_is_local_time, + } + + props3 = { + "default_value": to_j_instant("2021-01-01T10:30:45 UTC"), + "placeholder_value": to_j_zdt("2021-01-01T10:30:45 ET"), + "on_change": verify_is_instant, + } + + props4 = { + "placeholder_value": to_j_zdt("2021-01-01T10:30:45 ET"), + "on_change": verify_is_zdt, + } + + props5 = {"on_change": verify_is_instant} + + props6 = {"on_change": empty_on_change} + + _convert_time_field_props(props3) + _convert_time_field_props(props4) + _convert_time_field_props(props5) + _convert_time_field_props(props6) + + verify_is_local_time(props1["max_value"]) + verify_is_zdt(props1["min_value"]) + verify_is_local_time(props1["value"]) + verify_is_local_time(props1["default_value"]) + verify_is_local_time(props1["placeholder_value"]) + + props2["on_change"]("10:30:45") + props3["on_change"]("2021-01-01T10:30:45 UTC") + props4["on_change"]("2021-01-01T10:30:45 ET") + props5["on_change"]("2021-01-01T10:30:45 UTC") + + # pass an Instant but it should be dropped with no error + props6["on_change"]("2021-01-01T10:30:45 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 75e7b7cfe..592bf5835 100644 --- a/tests/app.d/ui_render_all.py +++ b/tests/app.d/ui_render_all.py @@ -122,6 +122,7 @@ def ui_components2(): ui.text_field( ui.icon("vsSymbolMisc"), default_value="Text Field", label="Text Field" ), + ui.time_field(default_value="12:30:00", hour_cycle=24), ui.toggle_button( ui.icon("vsBell"), "By Exchange", 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 index 615285e21..b9fc7c5da 100644 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-chromium-linux.png 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 index 1468d660d..93b19b015 100644 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-firefox-linux.png 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 index a2c75ecc2..79536c50d 100644 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-webkit-linux.png and b/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-webkit-linux.png differ