diff --git a/plugins/ui/DESIGN.md b/plugins/ui/DESIGN.md index f101165ac..cbdb538bb 100644 --- a/plugins/ui/DESIGN.md +++ b/plugins/ui/DESIGN.md @@ -1385,7 +1385,7 @@ ui.date_picker( granularity: Granularity | None = None, on_change: Callable[[Date], None] | None = None, **props: Any -) -> ListViewElement +) -> DatePickerElement ``` ###### Parameters diff --git a/plugins/ui/src/deephaven/ui/_internal/utils.py b/plugins/ui/src/deephaven/ui/_internal/utils.py index 1cd6b66e3..3ee0fe706 100644 --- a/plugins/ui/src/deephaven/ui/_internal/utils.py +++ b/plugins/ui/src/deephaven/ui/_internal/utils.py @@ -4,11 +4,20 @@ 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 ..types import Date, JavaDate _UNSAFE_PREFIX = "UNSAFE_" _ARIA_PREFIX = "aria_" _ARIA_PREFIX_REPLACEMENT = "aria-" +_CONVERTERS = { + "java.time.Instant": to_j_instant, + "java.time.ZonedDateTime": to_j_zdt, + "java.time.LocalDate": to_j_local_date, +} + def get_component_name(component: Any) -> str: """ @@ -194,6 +203,190 @@ def create_props(args: dict[str, Any]) -> tuple[tuple[Any], dict[str, Any]]: Returns: A tuple of children and props """ - children, props = args.pop("children"), args.pop("props") + children, props = args.pop("children", tuple()), args.pop("props", {}) props.update(args) return children, props + + +def _convert_to_java_date( + date: Date, +) -> JavaDate: + """ + Convert a Date to a Java date type. + In order of preference, tries to convert to Instant, ZonedDateTime, and LocalDate. + If none of these work, raises a TypeError. + + Args: + date: The date to convert to a Java date type. + + Returns: + The Java date type. + """ + try: + return to_j_instant(date) # type: ignore + except Exception: + # ignore, try next + pass + + try: + return to_j_zdt(date) # type: ignore + except Exception: + # ignore, try next + pass + + try: + return to_j_local_date(date) # type: ignore + except Exception: + raise TypeError( + f"Could not convert {date} to one of Instant, ZonedDateTime, or LocalDate." + ) + + +def get_jclass_name(value: Any) -> str: + """ + Get the name of the Java class of the value. + + Args: + value: The value to get the Java class name of. + + Returns: + The name of the Java class of the value. + """ + return str(value.jclass)[6:] + + +def _jclass_converter( + value: JavaDate, +) -> Callable[[Date], Any]: + """ + Get the converter for the Java date type. + + Args: + value: The Java date type to get the converter for. + + Returns: + The converter for the Java date type. + """ + return _CONVERTERS[get_jclass_name(value)] + + +def _wrap_date_callable( + date_callable: Callable[[Date], None], + converter: Callable[[Date], Any], +) -> Callable[[Date], None]: + """ + Wrap a callable to convert the Date argument to a Java date type. + This maintains the original callable signature so that the Date argument can be dropped. + + Args: + date_callable: The callable to wrap. + converter: The date converter to use. + + Returns: + The wrapped callable. + """ + return lambda date: wrap_callable(date_callable)(converter(date)) + + +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. + If none of the keys have a non-None value, return None. + + Args: + props: The props to check for non-None values. + sequence: The sequence to check. + + Returns: + The first non-None prop, or None if all props are None. + """ + for key in sequence: + if props.get(key) is not None: + return key + return None + + +def _prioritized_callable_converter( + props: dict[str, Any], + priority: Sequence[str], + default_converter: Callable[[Date], Any], +) -> Callable[[Date], Any]: + """ + Get a callable date 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 date 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 date converter. + """ + + first_set_key = _get_first_set_key(props, priority) + return ( + _jclass_converter(props[first_set_key]) + if first_set_key is not None + else default_converter + ) + + +def convert_list_prop( + key: str, + value: list[Date] | None, +) -> list[JavaDate] | None: + """ + Convert a list of Dates to Java date types. + + Args: + key: The key of the prop. + value: A list of Dates to convert to Java date types. + + Returns: + The list of Java date types. + """ + if value is None: + return None + + if not isinstance(value, list): + raise TypeError(f"{key} must be a list of Dates") + return [_convert_to_java_date(date) for date in value] + + +def convert_date_props( + props: dict[str, Any], + simple_date_props: set[str], + callable_date_props: set[str], + priority: Sequence[str], + default_converter: Callable[[Date], Any] = to_j_instant, +) -> None: + """ + Convert date props to Java date types in place. + + Args: + props: The props passed to the component. + simple_date_props: A set of simple date keys to convert. The prop value should be a single Date. + callable_date_props: A set of callable date keys to convert. + The prop value should be a callable that takes a Date. + priority: The priority of the props to check. + default_converter: The default converter to use if none of the priority props are present. + + Returns: + The converted props. + """ + for key in simple_date_props: + if props.get(key) is not None: + props[key] = _convert_to_java_date(props[key]) + + # the simple props must be converted before this to simplify the callable conversion + converter = _prioritized_callable_converter(props, priority, default_converter) + + for key in callable_date_props: + if props.get(key) is not None: + if not callable(props[key]): + raise TypeError(f"{key} must be a callable") + props[key] = _wrap_date_callable(props[key], converter) diff --git a/plugins/ui/src/deephaven/ui/components/__init__.py b/plugins/ui/src/deephaven/ui/components/__init__.py index f0678f892..d18981f10 100644 --- a/plugins/ui/src/deephaven/ui/components/__init__.py +++ b/plugins/ui/src/deephaven/ui/components/__init__.py @@ -14,6 +14,7 @@ from .list_view import list_view from .list_action_group import list_action_group from .list_action_menu import list_action_menu +from .date_picker import date_picker from . import html @@ -28,6 +29,7 @@ "content", "contextual_help", "dashboard", + "date_picker", "flex", "form", "fragment", diff --git a/plugins/ui/src/deephaven/ui/components/date_picker.py b/plugins/ui/src/deephaven/ui/components/date_picker.py new file mode 100644 index 000000000..98160cf42 --- /dev/null +++ b/plugins/ui/src/deephaven/ui/components/date_picker.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +from typing import Any, Sequence, Callable + +from .spectrum import ( + FocusEventCallable, + KeyboardEventCallable, + LayoutFlex, + Number, + DimensionValue, + AlignSelf, + JustifySelf, + Position, + AriaPressed, + CSSProperties, + LabelPosition, + Alignment, + ValidationBehavior, + NecessityIndicator, + ValidationState, + PageBehavior, + HourCycle, +) +from ..hooks import use_memo +from ..elements import Element, BaseElement +from .._internal.utils import ( + create_props, + convert_date_props, + convert_list_prop, +) +from ..types import Date, Granularity + +DatePickerElement = Element + +# All the props that can be date types +_SIMPLE_DATE_PROPS = { + "placeholder_value", + "value", + "default_value", + "min_value", + "max_value", +} +_LIST_DATE_PROPS = {"unavailable_values"} +_CALLABLE_DATE_PROPS = {"on_change"} + +# 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_picker_props( + props: dict[str, Any], +) -> dict[str, Any]: + """ + Convert date picker props to Java date types. + + Args: + props: The props passed to the date picker. + + Returns: + The converted props. + """ + + convert_date_props( + props, + _SIMPLE_DATE_PROPS, + _CALLABLE_DATE_PROPS, + _DATE_PROPS_PRIORITY, + ) + + return props + + +def date_picker( + placeholder_value: Date | None = None, + value: Date | None = None, + default_value: Date | None = None, + min_value: Date | None = None, + max_value: Date | None = None, + unavailable_values: Sequence[Date] | None = None, + granularity: Granularity | None = None, + page_behavior: PageBehavior | None = None, + hour_cycle: HourCycle | None = None, + hide_time_zone: bool = False, + should_force_leading_zeros: bool | None = None, + is_disabled: bool | None = None, + is_read_only: bool | None = None, + is_required: bool | None = None, + validation_behavior: ValidationBehavior | None = None, + auto_focus: bool | None = None, + label: Element | None = None, + description: Element | None = None, + error_message: Element | None = None, + is_open: bool | None = None, + default_open: bool | None = None, + name: str | None = None, + max_visible_months: int | None = None, + should_flip: bool | None = None, + is_quiet: bool | None = None, + show_format_help_text: bool | None = None, + label_position: LabelPosition | None = None, + label_align: Alignment | None = None, + necessity_indicator: NecessityIndicator | None = None, + contextual_help: Element | None = None, + validation_state: ValidationState | None = None, + on_focus: FocusEventCallable | None = None, + on_blur: FocusEventCallable | None = None, + on_focus_change: Callable[[bool], None] | None = None, + on_key_down: KeyboardEventCallable | None = None, + on_key_up: KeyboardEventCallable | None = None, + on_open_change: Callable[[bool], None] | None = None, + on_change: Callable[[Date], None] | None = None, + flex: LayoutFlex | None = None, + flex_grow: Number | None = None, + flex_shrink: Number | None = None, + flex_basis: DimensionValue | None = None, + align_self: AlignSelf | None = None, + justify_self: JustifySelf | None = None, + order: Number | 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: Number | None = None, + is_hidden: bool | None = None, + id: str | None = None, + aria_label: str | None = None, + aria_labelledby: str | None = None, + aria_describedby: str | None = None, + aria_pressed: AriaPressed | None = None, + aria_details: str | None = None, + UNSAFE_class_name: str | None = None, + UNSAFE_style: CSSProperties | None = None, +) -> DatePickerElement: + """ + A date picker 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. + unavailable_values: A list of dates that cannot be selected. + granularity: Determines the smallest unit that is displayed in the date picker. + By default, this is `"DAY"` for `LocalDate`, and `"SECOND"` otherwise. + page_behavior: Controls the behavior of paging. Pagination either works by + advancing the visible page by visibleDuration (default) + or one unit of visibleDuration. + hour_cycle: Whether to display the time in 12 or 24 hour format. + By default, this is determined by the user's locale. + hide_time_zone: Whether to hide the time zone abbreviation. + should_force_leading_zeros: Whether to always show leading zeros in the + month, day, and hour fields. + By default, this is determined by the user's locale. + is_disabled: Whether the input is disabled. + is_read_only: Whether the input can be selected but not changed by the user. + is_required: Whether user input is required on the input before form submission. + validation_behavior: Whether to use native HTML form validation to prevent form + submission when the value is missing or invalid, + or mark the field as required or invalid via ARIA. + auto_focus: Whether the element should receive focus on render. + label: The content to display as the label. + description: A description for the field. + Provides a hint such as specific requirements for what to choose. + error_message: An error message for the field. + is_open: Whether the overlay is open by default (controlled). + default_open: Whether the overlay is open by default (uncontrolled). + name: The name of the input element, used when submitting an HTML form. + max_visible_months: The maximum number of months to display at + once in the calendar popover, if screen space permits. + should_flip: Whether the calendar popover should automatically flip direction + when space is limited. + is_quiet: Whether the date picker should be displayed with a quiet style. + show_format_help_text: Whether to show the localized date format as help + text below the field. + label_position: The label's overall position relative to the element it is labeling. + label_align: The label's horizontal alignment relative to the element it is labeling. + necessity_indicator: Whether the required state should be shown as an icon or text. + contextual_help: A ContextualHelp element to place next to the label. + validation_state: Whether the input should display its "valid" or "invalid" visual styling. + on_focus: Function called when the button receives focus. + on_blur: Function called when the button loses focus. + on_focus_change: Function called when the focus state changes. + on_key_down: Function called when a key is pressed. + on_key_up: Function called when a key is released. + on_open_change: Handler that is called when the overlay's open state changes. + on_change: Handler that is called when the value changes. + The exact `Date` type will be the same as the type passed to + `value`, `default_value` or `placeholder_value`, in that order of precedence. + flex: When used in a flex layout, specifies how the element will grow or shrink to fit the space available. + flex_grow: When used in a flex layout, specifies how much the element will grow to fit the space available. + flex_shrink: When used in a flex layout, specifies how much the element will shrink to fit the space available. + flex_basis: When used in a flex layout, specifies the initial size of the element. + align_self: Overrides the align_items property of a flex or grid container. + justify_self: Specifies how the element is justified inside a flex or grid container. + order: The layout order for the element within a flex or grid container. + grid_area: The name of the grid area to place the element in. + grid_row: The name of the grid row to place the element in. + grid_row_start: The name of the grid row to start the element in. + grid_row_end: The name of the grid row to end the element in. + grid_column: The name of the grid column to place the element in. + grid_column_start: The name of the grid column to start the element in. + grid_column_end: The name of the grid column to end the element in. + margin: The margin to apply around the element. + margin_top: The margin to apply above the element. + margin_bottom: The margin to apply below the element. + margin_start: The margin to apply before the element. + margin_end: The margin to apply after the element. + margin_x: The margin to apply to the left and right of the element. + margin_y: The margin to apply to the top and bottom of the element. + width: The width of the element. + height: The height of the element. + min_width: The minimum width of the element. + min_height: The minimum height of the element. + max_width: The maximum width of the element. + max_height: The maximum height of the element. + position: Specifies how the element is positioned. + top: The distance from the top of the containing element. + bottom: The distance from the bottom of the containing element. + start: The distance from the start of the containing element. + end: The distance from the end of the containing element. + left: The distance from the left of the containing element. + right: The distance from the right of the containing element. + z_index: The stack order of the element. + is_hidden: Whether the element is hidden. + id: A unique identifier for the element. + aria_label: The label for the element. + aria_labelledby: The id of the element that labels the element. + aria_describedby: The id of the element that describes the element. + aria_pressed: Whether the element is pressed. + aria_details: The details for the element. + UNSAFE_class_name: A CSS class to apply to the element. + UNSAFE_style: A CSS style to apply to the element. + + Returns: + The date picker element. + """ + _, props = create_props(locals()) + + _convert_date_picker_props(props) + + props["unavailable_values"] = use_memo( + lambda: convert_list_prop("unavailable_values", props["unavailable_values"]), + [unavailable_values], + ) + + return BaseElement("deephaven.ui.components.DatePicker", **props) diff --git a/plugins/ui/src/deephaven/ui/components/spectrum/__init__.py b/plugins/ui/src/deephaven/ui/components/spectrum/__init__.py index 3f5ce97a6..999367be5 100644 --- a/plugins/ui/src/deephaven/ui/components/spectrum/__init__.py +++ b/plugins/ui/src/deephaven/ui/components/spectrum/__init__.py @@ -5,3 +5,4 @@ from .text_field import * from .toggle_button import * from .flex import * +from .date_picker import * diff --git a/plugins/ui/src/deephaven/ui/components/spectrum/date_picker.py b/plugins/ui/src/deephaven/ui/components/spectrum/date_picker.py new file mode 100644 index 000000000..3c8be378d --- /dev/null +++ b/plugins/ui/src/deephaven/ui/components/spectrum/date_picker.py @@ -0,0 +1,8 @@ +from typing import Literal + +PageBehavior = Literal["single", "visible"] +HourCycle = Literal[12, 24] +ValidationBehavior = Literal["aria", "native"] +Alignment = Literal["start", "end"] +NecessityIndicator = Literal["label", "icon"] +ValidationState = Literal["valid", "invalid"] diff --git a/plugins/ui/src/deephaven/ui/types/types.py b/plugins/ui/src/deephaven/ui/types/types.py index 90bfd8480..9641d5aab 100644 --- a/plugins/ui/src/deephaven/ui/types/types.py +++ b/plugins/ui/src/deephaven/ui/types/types.py @@ -1,5 +1,9 @@ +import datetime +import pandas +import numpy from typing import Any, Dict, Literal, Union, List, Tuple, Callable, TypedDict, Sequence from deephaven import SortDirection +from deephaven.dtypes import DType class CellData(TypedDict): @@ -106,6 +110,34 @@ class RowDataValue(CellData): Stringable = Union[str, int, float, bool] Key = Stringable ActionKey = Key +LocalDate = DType +Instant = DType +ZonedDateTime = DType +JavaDate = Union[LocalDate, Instant, ZonedDateTime] +LocalDateConvertible = Union[ + None, + LocalDate, + str, + datetime.date, + datetime.datetime, + numpy.datetime64, + pandas.Timestamp, +] +InstantConvertible = Union[ + None, Instant, int, str, datetime.datetime, numpy.datetime64, pandas.Timestamp # type: ignore +] +ZonedDateTimeConvertible = Union[ + None, ZonedDateTime, str, datetime.datetime, numpy.datetime64, pandas.Timestamp # type: ignore +] +Date = Union[ + Instant, + LocalDate, + ZonedDateTime, + LocalDateConvertible, + InstantConvertible, + ZonedDateTimeConvertible, +] +Granularity = Literal["DAY", "HOUR", "MINUTE", "SECOND"] Dependencies = Union[Tuple[Any], List[Any]] Selection = Sequence[Key] diff --git a/plugins/ui/test/deephaven/ui/test_date_picker.py b/plugins/ui/test/deephaven/ui/test_date_picker.py new file mode 100644 index 000000000..d7a2d9624 --- /dev/null +++ b/plugins/ui/test/deephaven/ui/test_date_picker.py @@ -0,0 +1,88 @@ +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_picker import _convert_date_picker_props + from deephaven.ui._internal.utils import get_jclass_name, convert_list_prop + + def verify_is_local_date(date): + self.assertEqual(get_jclass_name(date), "java.time.LocalDate") + + def verify_is_instant(date): + self.assertEqual(get_jclass_name(date), "java.time.Instant") + + def verify_is_zdt(date): + self.assertEqual(get_jclass_name(date), "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_picker_props(props1) + props1["unavailable_dates"] = convert_list_prop( + "unavailable_dates", props1["unavailable_dates"] + ) + _convert_date_picker_props(props2) + props2["unavailable_dates"] = convert_list_prop( + "unavailable_dates", props2["unavailable_dates"] + ) + _convert_date_picker_props(props3) + _convert_date_picker_props(props4) + _convert_date_picker_props(props5) + _convert_date_picker_props(props6) + + verify_is_local_date(props1["max_value"]) + verify_is_zdt(props1["min_value"]) + verify_is_instant(props1["unavailable_dates"][0]) + verify_is_local_date(props1["unavailable_dates"][1]) + verify_is_instant(props1["value"]) + verify_is_instant(props1["default_value"]) + verify_is_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() diff --git a/plugins/ui/test/deephaven/ui/test_utils.py b/plugins/ui/test/deephaven/ui/test_utils.py index f67f2bb80..d98bca7e0 100644 --- a/plugins/ui/test/deephaven/ui/test_utils.py +++ b/plugins/ui/test/deephaven/ui/test_utils.py @@ -217,6 +217,50 @@ def test_func_with_all_args(a, /, b, *args, c=1, **kwargs): # Test that wrapping a function without a signature doesn't throw an error wrapped = wrap_callable(print) + def test_create_props(self): + from deephaven.ui._internal.utils import create_props + + children1, props1 = create_props( + { + "foo": "bar", + "baz": 42, + "fizz": "buzz", + } + ) + + self.assertEqual(children1, tuple()) + self.assertDictEqual( + props1, + { + "foo": "bar", + "baz": 42, + "fizz": "buzz", + }, + ) + + children2, props2 = create_props( + { + "children": ["item1", "item2"], + "test": "value", + "props": { + "foo": "bar", + "baz": 42, + "fizz": "buzz", + }, + } + ) + + self.assertEqual(children2, ["item1", "item2"]) + self.assertDictEqual( + props2, + { + "foo": "bar", + "baz": 42, + "fizz": "buzz", + "test": "value", + }, + ) + if __name__ == "__main__": unittest.main()