From ef7e74198460588d31b9b31a2acad21741d140ff Mon Sep 17 00:00:00 2001 From: Steven Wu Date: Mon, 16 Dec 2024 16:36:00 -0500 Subject: [PATCH] feat: add undefined type option (#1026) - Closes #549 - Add a new `Undefined` object for nullable props - Nullable props can be declared with `_nullable_props` passed to `component_element`/`BaseElement` - For these props, `None` will be translated to `null` and `Undefined` will not exist in the props object --------- Co-authored-by: Mike Bender Co-authored-by: Brian Ingles Co-authored-by: margaretkennedy <82049573+margaretkennedy@users.noreply.github.com> --- plugins/ui/docs/architecture.md | 19 +++++ plugins/ui/docs/components/picker.md | 32 +++++++- .../ui/src/deephaven/ui/_internal/utils.py | 81 ++++++++++++++----- .../src/deephaven/ui/components/calendar.py | 11 +-- .../src/deephaven/ui/components/combo_box.py | 12 ++- .../src/deephaven/ui/components/date_field.py | 10 ++- .../deephaven/ui/components/date_picker.py | 10 ++- .../ui/components/date_range_picker.py | 12 ++- .../ui/src/deephaven/ui/components/picker.py | 10 ++- .../deephaven/ui/components/radio_group.py | 12 ++- .../deephaven/ui/components/range_calendar.py | 17 ++-- .../ui/src/deephaven/ui/components/tabs.py | 8 +- .../src/deephaven/ui/components/text_area.py | 7 +- .../src/deephaven/ui/components/text_field.py | 7 +- .../src/deephaven/ui/components/time_field.py | 10 ++- .../src/deephaven/ui/elements/BaseElement.py | 17 +++- plugins/ui/src/deephaven/ui/types/types.py | 29 +++++++ plugins/ui/test/deephaven/ui/test_types.py | 36 +++++++++ plugins/ui/test/deephaven/ui/test_utils.py | 39 +++++++++ 19 files changed, 315 insertions(+), 64 deletions(-) create mode 100644 plugins/ui/test/deephaven/ui/test_types.py diff --git a/plugins/ui/docs/architecture.md b/plugins/ui/docs/architecture.md index 1da0c0fb3..b36c3cbc2 100644 --- a/plugins/ui/docs/architecture.md +++ b/plugins/ui/docs/architecture.md @@ -31,6 +31,25 @@ def my_app(): app = my_app() ``` +## Props + +For almost all components, Python positional arguments are mapped to React children and keyword-only arguments are mapped to React props. Rarely, some arguments are positional and keyword. For example, in `contextual_help`, the footer argument is positional and keyword since it has a default of `None`. It will still be passed as a child. + +```python +from deephaven import ui + + +my_prop_variations = ui.flex("Hello", "World", direction="column") +footer_as_positional = ui.contextual_help("Heading", "Content", "Footer") +footer_as_keyword = ui.contextual_help("Heading", "Content", footer="Footer") +``` + +The strings `"Hello"` and `"World"` will be passed to flex as a child, while `"column"` is passed as the value to the `direction` prop. `"Footer"` is passed as a child even if it's used in a keyword-manner. For more information, see the [`contextual_help`](./components/contextual_help.md) doc. + +### Handling `null` vs `undefined` + +Python has one nullish value (`None`) while JavaScript has two (`null` and `undefined`). In most cases, a distinction is not needed and `None` is mapped to `undefined`. However, for some props, such as `picker`'s `selected_value`, we differentiate between `null` and `undefined` with `None` and `ui.types.Undefined`, respectively. A list of props that need the distinction is passed through the `_nullable_props` parameter to `component_element`/`BaseElement`. + ## Rendering When you call a function decorated by `@ui.component`, it will return an `Element` object that references the function it is decorated by; that is to say, the function does _not_ run immediately. The function runs when the `Element` is rendered by the client, and the result is sent back to the client. This allows the `@ui.component` decorator to execute the function with the appropriate rendering context. The client must also set the initial state before rendering, allowing the client to persist the state and re-render in the future. diff --git a/plugins/ui/docs/components/picker.md b/plugins/ui/docs/components/picker.md index ad375fdc0..0720dddb5 100644 --- a/plugins/ui/docs/components/picker.md +++ b/plugins/ui/docs/components/picker.md @@ -10,7 +10,7 @@ from deephaven import ui @ui.component def ui_picker_basic(): - option, set_option = ui.use_state("") + option, set_option = ui.use_state(None) return ui.picker( "Rarely", @@ -182,6 +182,36 @@ def ui_picker_selected_key_examples(): my_picker_selected_key_examples = ui_picker_selected_key_examples() ``` +Providing a value to the `selected_key` prop runs the component in "controlled" mode where the selection state is driven from the provided value. A value of `None` can be used to indicate nothing is selected while keeping the component in controlled mode. The default value is `ui.types.Undefined`, which causes the component to run in "uncontrolled" mode. + +```python +from deephaven import ui + + +@ui.component +def ui_picker_key_variations(): + controlled_value, set_controlled_value = ui.use_state(None) + + return [ + ui.picker( + "Option 1", + "Option 2", + selected_key=controlled_value, + on_change=set_controlled_value, + label="Key: Controlled", + ), + ui.picker( + "Option 1", + "Option 2", + on_change=lambda x: print(x), + label="Key: Undefined", + ), + ] + + +my_picker_key_variations = ui_picker_key_variations() +``` + ## HTML Forms diff --git a/plugins/ui/src/deephaven/ui/_internal/utils.py b/plugins/ui/src/deephaven/ui/_internal/utils.py index 22a3ec1c5..633fbf850 100644 --- a/plugins/ui/src/deephaven/ui/_internal/utils.py +++ b/plugins/ui/src/deephaven/ui/_internal/utils.py @@ -5,7 +5,6 @@ import sys from functools import partial 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, @@ -15,6 +14,7 @@ JavaTime, LocalDateConvertible, LocalDate, + Undefined, ) T = TypeVar("T") @@ -36,6 +36,19 @@ } +def is_nullish(value: Any) -> bool: + """ + Check if a value is nullish (`None` or `Undefined`). + + Args: + value: The value to check. + + Returns: + Checks if the value is nullish. + """ + return value is None or value is Undefined + + def get_component_name(component: Any) -> str: """ Get the name of the component @@ -138,7 +151,9 @@ def dict_to_camel_case( return convert_dict_keys(dict, to_camel_case) -def dict_to_react_props(dict: dict[str, Any]) -> dict[str, Any]: +def dict_to_react_props( + dict: dict[str, Any], _nullable_props: list[str] = [] +) -> dict[str, Any]: """ Convert a dict to React-style prop names ready for the web. Converts snake_case to camelCase with the exception of special props like `UNSAFE_` or `aria_` props. @@ -150,20 +165,36 @@ def dict_to_react_props(dict: dict[str, Any]) -> dict[str, Any]: Returns: The React props dict. """ - return convert_dict_keys(remove_empty_keys(dict), to_react_prop_case) + return convert_dict_keys( + remove_empty_keys(dict, _nullable_props), to_react_prop_case + ) -def remove_empty_keys(dict: dict[str, Any]) -> dict[str, Any]: +def remove_empty_keys( + dict: dict[str, Any], _nullable_props: list[str] = [] +) -> dict[str, Any]: """ - Remove keys from a dict that have a value of None. + Remove keys from a dict that have a value of None, or Undefined if in _nullable_props. Args: dict: The dict to remove keys from. + _nullable_props: A list of props that get removed if they are Undefined (instead of None). Returns: The dict with keys removed. """ - return {k: v for k, v in dict.items() if v is not None} + cleaned = {} + for k, v in dict.items(): + if k in _nullable_props: + if v is not Undefined: + cleaned[k] = v + else: + if v is Undefined: + raise ValueError("UndefinedType found in a non-nullable prop.") + elif v is not None: + cleaned[k] = v + + return cleaned def _wrapped_callable( @@ -478,10 +509,10 @@ def _get_first_set_key(props: dict[str, Any], sequence: Sequence[str]) -> str | sequence: The sequence to check. Returns: - The first non-None prop, or None if all props are None. + The first non-nullish prop, or None if all props are None. """ for key in sequence: - if props.get(key) is not None: + if not is_nullish(props.get(key)): return key return None @@ -523,9 +554,14 @@ def _prioritized_date_callable_converter( """ first_set_key = _get_first_set_key(props, priority) + # type ignore because pyright is not recognizing the nullish check return ( - _jclass_date_converter(_date_or_range(props[first_set_key])) - if first_set_key is not None + _jclass_date_converter( + _date_or_range( + props[first_set_key] # pyright: ignore[reportGeneralTypeIssues] + ) + ) + if not is_nullish(first_set_key) else default_converter ) @@ -552,9 +588,12 @@ def _prioritized_time_callable_converter( """ first_set_key = _get_first_set_key(props, priority) + # type ignore because pyright is not recognizing the nullish check return ( - _jclass_time_converter(props[first_set_key]) - if first_set_key is not None + _jclass_time_converter( + props[first_set_key] # pyright: ignore[reportGeneralTypeIssues] + ) + if not is_nullish(first_set_key) else default_converter ) @@ -666,11 +705,11 @@ def convert_date_props( The converted props. """ for key in simple_date_props: - if props.get(key) is not None: + if not is_nullish(props.get(key)): props[key] = _convert_to_java_date(props[key]) for key in date_range_props: - if props.get(key) is not None: + if not is_nullish(props.get(key)): props[key] = convert_date_range(props[key], _convert_to_java_date) # the simple props must be converted before this to simplify the callable conversion @@ -680,25 +719,25 @@ def convert_date_props( # Local Dates will default to DAY but we need to default to SECOND for the other types if ( granularity_key is not None - and props.get(granularity_key) is None + and is_nullish(props.get(granularity_key)) and converter != to_j_local_date ): props[granularity_key] = "SECOND" # now that the converter is set, we can convert simple props to strings for key in simple_date_props: - if props.get(key) is not None: + if not is_nullish(props.get(key)): props[key] = str(props[key]) # and convert the date range props to strings for key in date_range_props: - if props.get(key) is not None: + if not is_nullish(props.get(key)): props[key] = convert_date_range(props[key], str) # wrap the date callable with the convert # if there are date range props, we need to convert as a date range for key in callable_date_props: - if props.get(key) is not None: + if not is_nullish(props.get(key)): if not callable(props[key]): raise TypeError(f"{key} must be a callable") if len(date_range_props) > 0: @@ -730,7 +769,7 @@ def convert_time_props( The converted props. """ for key in simple_time_props: - if props.get(key) is not None: + if not is_nullish(props.get(key)): props[key] = _convert_to_java_time(props[key]) # the simple props must be converted before this to simplify the callable conversion @@ -738,12 +777,12 @@ def convert_time_props( # 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: + if not is_nullish(props.get(key)): 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 is_nullish(props.get(key)): if not callable(props[key]): raise TypeError(f"{key} must be a callable") props[key] = _wrap_time_callable(props[key], converter) diff --git a/plugins/ui/src/deephaven/ui/components/calendar.py b/plugins/ui/src/deephaven/ui/components/calendar.py index 4e3e1d76f..400c54494 100644 --- a/plugins/ui/src/deephaven/ui/components/calendar.py +++ b/plugins/ui/src/deephaven/ui/components/calendar.py @@ -15,10 +15,9 @@ from ..elements import Element from .._internal.utils import create_props, convert_date_props, wrap_local_date_callable -from ..types import Date, LocalDateConvertible +from ..types import Date, LocalDateConvertible, Undefined, UndefinedType from .basic import component_element from .make_component import make_component -from deephaven.time import dh_now CalendarElement = Element @@ -43,6 +42,8 @@ "default_focused_value", ] +_NULLABLE_PROPS = ["value", "default_value"] + def _convert_calendar_props( props: dict[str, Any], @@ -75,8 +76,8 @@ def _convert_calendar_props( @make_component def calendar( - value: Date | None = None, - default_value: Date | None = None, + value: Date | None | UndefinedType = Undefined, + default_value: Date | None | UndefinedType = Undefined, focused_value: Date | None = None, default_focused_value: Date | None = None, min_value: Date | None = None, @@ -213,4 +214,4 @@ def calendar( _convert_calendar_props(props) - return component_element("Calendar", **props) + return component_element("Calendar", _nullable_props=_NULLABLE_PROPS, **props) diff --git a/plugins/ui/src/deephaven/ui/components/combo_box.py b/plugins/ui/src/deephaven/ui/components/combo_box.py index 80defb967..e7ddadc60 100644 --- a/plugins/ui/src/deephaven/ui/components/combo_box.py +++ b/plugins/ui/src/deephaven/ui/components/combo_box.py @@ -29,7 +29,7 @@ from .item_table_source import ItemTableSource from ..elements import BaseElement, Element from .._internal.utils import create_props, unpack_item_table_source -from ..types import Key +from ..types import Key, Undefined, UndefinedType from .basic import component_element ComboBoxElement = BaseElement @@ -42,6 +42,8 @@ "title_column", } +_NULLABLE_PROPS = ["selected_key"] + def combo_box( *children: Item | SectionElement | Table | PartitionedTable | ItemTableSource, @@ -58,7 +60,7 @@ def combo_box( default_input_value: str | None = None, allows_custom_value: bool | None = None, disabled_keys: list[Key] | None = None, - selected_key: Key | None = None, + selected_key: Key | None | UndefinedType = Undefined, default_selected_key: Key | None = None, is_disabled: bool | None = None, is_read_only: bool | None = None, @@ -75,7 +77,7 @@ def combo_box( necessity_indicator: NecessityIndicator | None = None, contextual_help: Element | None = None, on_open_change: Callable[[bool, MenuTriggerAction], None] | None = None, - on_selection_change: Callable[[Key], None] | None = None, + on_selection_change: Callable[[Key | None], None] | None = None, on_change: Callable[[Key], None] | None = None, on_input_change: Callable[[str], None] | None = None, on_focus: Callable[[FocusEventCallable], None] | None = None, @@ -241,4 +243,6 @@ def combo_box( children, props = unpack_item_table_source(children, props, SUPPORTED_SOURCE_ARGS) - return component_element("ComboBox", *children, **props) + return component_element( + "ComboBox", *children, _nullable_props=_NULLABLE_PROPS, **props + ) diff --git a/plugins/ui/src/deephaven/ui/components/date_field.py b/plugins/ui/src/deephaven/ui/components/date_field.py index b24451fdb..14fecfd08 100644 --- a/plugins/ui/src/deephaven/ui/components/date_field.py +++ b/plugins/ui/src/deephaven/ui/components/date_field.py @@ -25,7 +25,7 @@ create_props, convert_date_props, ) -from ..types import Date, Granularity +from ..types import Date, Granularity, Undefined, UndefinedType from .basic import component_element from .make_component import make_component from deephaven.time import dh_now @@ -47,6 +47,8 @@ # 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"] +_NULLABLE_PROPS = ["value", "default_value"] + def _convert_date_field_props( props: dict[str, Any], @@ -76,8 +78,8 @@ def _convert_date_field_props( @make_component def date_field( placeholder_value: Date | None = dh_now(), - value: Date | None = None, - default_value: Date | None = None, + value: Date | None | UndefinedType = Undefined, + default_value: Date | None | UndefinedType = Undefined, min_value: Date | None = None, max_value: Date | None = None, # TODO (issue # 698) we need to implement unavailable_values @@ -261,4 +263,4 @@ def date_field( _convert_date_field_props(props) - return component_element("DateField", **props) + return component_element("DateField", _nullable_props=_NULLABLE_PROPS, **props) diff --git a/plugins/ui/src/deephaven/ui/components/date_picker.py b/plugins/ui/src/deephaven/ui/components/date_picker.py index 1763f8c8b..305248db6 100644 --- a/plugins/ui/src/deephaven/ui/components/date_picker.py +++ b/plugins/ui/src/deephaven/ui/components/date_picker.py @@ -28,7 +28,7 @@ convert_date_props, convert_list_prop, ) -from ..types import Date, Granularity +from ..types import Date, Granularity, Undefined, UndefinedType from .basic import component_element from .make_component import make_component from deephaven.time import dh_now @@ -51,6 +51,8 @@ # 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"] +_NULLABLE_PROPS = ["value", "default_value"] + def _convert_date_picker_props( props: dict[str, Any], @@ -80,8 +82,8 @@ def _convert_date_picker_props( @make_component def date_picker( placeholder_value: Date | None = dh_now(), - value: Date | None = None, - default_value: Date | None = None, + value: Date | None | UndefinedType = Undefined, + default_value: Date | None | UndefinedType = Undefined, min_value: Date | None = None, max_value: Date | None = None, # TODO (issue # 698) we need to implement unavailable_values @@ -280,4 +282,4 @@ def date_picker( # [unavailable_values], # ) - return component_element("DatePicker", **props) + return component_element("DatePicker", _nullable_props=_NULLABLE_PROPS, **props) diff --git a/plugins/ui/src/deephaven/ui/components/date_range_picker.py b/plugins/ui/src/deephaven/ui/components/date_range_picker.py index 86768abbc..6f8a1f99b 100644 --- a/plugins/ui/src/deephaven/ui/components/date_range_picker.py +++ b/plugins/ui/src/deephaven/ui/components/date_range_picker.py @@ -28,7 +28,7 @@ convert_date_props, convert_list_prop, ) -from ..types import Date, Granularity, DateRange +from ..types import Date, Granularity, DateRange, Undefined, UndefinedType from .basic import component_element from .make_component import make_component from deephaven.time import dh_now @@ -49,6 +49,8 @@ # 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"] +_NULLABLE_PROPS = ["value", "default_value"] + def _convert_date_range_picker_props( props: dict[str, Any], @@ -78,8 +80,8 @@ def _convert_date_range_picker_props( @make_component def date_range_picker( placeholder_value: Date | None = dh_now(), - value: DateRange | None = None, - default_value: DateRange | None = None, + value: DateRange | None | UndefinedType = Undefined, + default_value: DateRange | None | UndefinedType = Undefined, min_value: Date | None = None, max_value: Date | None = None, # TODO (issue # 698) we need to implement unavailable_values @@ -278,4 +280,6 @@ def date_range_picker( _convert_date_range_picker_props(props) - return component_element("DateRangePicker", **props) + return component_element( + "DateRangePicker", _nullable_props=_NULLABLE_PROPS, **props + ) diff --git a/plugins/ui/src/deephaven/ui/components/picker.py b/plugins/ui/src/deephaven/ui/components/picker.py index 1f3fa4471..f24f1c96b 100644 --- a/plugins/ui/src/deephaven/ui/components/picker.py +++ b/plugins/ui/src/deephaven/ui/components/picker.py @@ -8,7 +8,7 @@ from .item_table_source import ItemTableSource from ..elements import BaseElement, Element from .._internal.utils import create_props, unpack_item_table_source -from ..types import Key +from ..types import Key, Undefined, UndefinedType from .types import ( AlignSelf, CSSProperties, @@ -35,11 +35,13 @@ "title_column", } +_NULLABLE_PROPS = ["selected_key"] + def picker( *children: Item | SectionElement | Table | PartitionedTable | ItemTableSource, default_selected_key: Key | None = None, - selected_key: Key | None = None, + selected_key: Key | None | UndefinedType = Undefined, on_selection_change: Callable[[Key], None] | None = None, on_change: Callable[[Key], None] | None = None, is_quiet: bool | None = None, @@ -227,4 +229,6 @@ def picker( children, props = unpack_item_table_source(children, props, SUPPORTED_SOURCE_ARGS) - return component_element("Picker", *children, **props) + return component_element( + "Picker", *children, _nullable_props=_NULLABLE_PROPS, **props + ) diff --git a/plugins/ui/src/deephaven/ui/components/radio_group.py b/plugins/ui/src/deephaven/ui/components/radio_group.py index 2567fe9ce..4477f6f23 100644 --- a/plugins/ui/src/deephaven/ui/components/radio_group.py +++ b/plugins/ui/src/deephaven/ui/components/radio_group.py @@ -19,15 +19,19 @@ ) from .basic import component_element from ..elements import Element +from ..types import Undefined, UndefinedType from .._internal.utils import create_props +_NULLABLE_PROPS = ["value", "default_value"] + + def radio_group( *children: Any, is_emphasized: bool | None = None, orientation: Orientation = "vertical", - value: str | None = None, - default_value: str | None = None, + value: str | None | UndefinedType = Undefined, + default_value: str | None | UndefinedType = Undefined, is_disabled: bool | None = None, is_read_only: bool | None = None, name: str | None = None, @@ -174,4 +178,6 @@ def radio_group( children, props = create_props(locals()) - return component_element(f"RadioGroup", *children, **props) + return component_element( + f"RadioGroup", *children, _nullable_props=_NULLABLE_PROPS, **props + ) diff --git a/plugins/ui/src/deephaven/ui/components/range_calendar.py b/plugins/ui/src/deephaven/ui/components/range_calendar.py index a3082fa83..f006e3f9e 100644 --- a/plugins/ui/src/deephaven/ui/components/range_calendar.py +++ b/plugins/ui/src/deephaven/ui/components/range_calendar.py @@ -15,10 +15,15 @@ from ..elements import Element from .._internal.utils import create_props, convert_date_props, wrap_local_date_callable -from ..types import Date, LocalDateConvertible, DateRange +from ..types import ( + Date, + LocalDateConvertible, + DateRange, + Undefined, + UndefinedType, +) from .basic import component_element from .make_component import make_component -from deephaven.time import dh_now RangeCalendarElement = Element @@ -41,6 +46,8 @@ "default_focused_value", ] +_NULLABLE_PROPS = ["value", "default_value"] + def _convert_range_calendar_props( props: dict[str, Any], @@ -73,8 +80,8 @@ def _convert_range_calendar_props( @make_component def range_calendar( - value: DateRange | None = None, - default_value: DateRange | None = None, + value: DateRange | None | UndefinedType = Undefined, + default_value: DateRange | None | UndefinedType = Undefined, focused_value: Date | None = None, default_focused_value: Date | None = None, min_value: Date | None = None, @@ -211,4 +218,4 @@ def range_calendar( _convert_range_calendar_props(props) - return component_element("RangeCalendar", **props) + return component_element("RangeCalendar", _nullable_props=_NULLABLE_PROPS, **props) diff --git a/plugins/ui/src/deephaven/ui/components/tabs.py b/plugins/ui/src/deephaven/ui/components/tabs.py index ebed99672..354db8d48 100644 --- a/plugins/ui/src/deephaven/ui/components/tabs.py +++ b/plugins/ui/src/deephaven/ui/components/tabs.py @@ -14,12 +14,15 @@ Position, ) -from ..types import Key, TabDensity +from ..types import Key, TabDensity, Undefined, UndefinedType from ..elements import BaseElement TabElement = BaseElement +_NULLABLE_PROPS = ["selected_key"] + + def tabs( *children: Any, disabled_keys: Iterable[Key] | None = None, @@ -30,7 +33,7 @@ def tabs( keyboard_activation: KeyboardActivationType | None = "automatic", orientation: Orientation | None = "horizontal", disallow_empty_selection: bool | None = None, - selected_key: Key | None = None, + selected_key: Key | None | UndefinedType = Undefined, default_selected_key: Key | None = None, on_selection_change: Callable[[Key], None] | None = None, on_change: Callable[[Key], None] | None = None, @@ -231,4 +234,5 @@ def tabs( UNSAFE_class_name=UNSAFE_class_name, UNSAFE_style=UNSAFE_style, key=key, + _nullable_props=_NULLABLE_PROPS, ) diff --git a/plugins/ui/src/deephaven/ui/components/text_area.py b/plugins/ui/src/deephaven/ui/components/text_area.py index 67b394947..9df997110 100644 --- a/plugins/ui/src/deephaven/ui/components/text_area.py +++ b/plugins/ui/src/deephaven/ui/components/text_area.py @@ -25,12 +25,16 @@ from .types import IconTypes from .basic import component_element from ..elements import Element +from ..types import Undefined, UndefinedType from .icon import icon as icon_component +_NULLABLE_PROPS = ["icon"] + + def text_area( - icon: Element | IconTypes | None = None, + icon: Element | IconTypes | None | UndefinedType = Undefined, is_quiet: bool | None = None, is_disabled: bool | None = None, is_read_only: bool | None = None, @@ -271,4 +275,5 @@ def text_area( UNSAFE_class_name=UNSAFE_class_name, UNSAFE_style=UNSAFE_style, key=key, + _nullable_props=_NULLABLE_PROPS, ) diff --git a/plugins/ui/src/deephaven/ui/components/text_field.py b/plugins/ui/src/deephaven/ui/components/text_field.py index 52debc960..41985445c 100644 --- a/plugins/ui/src/deephaven/ui/components/text_field.py +++ b/plugins/ui/src/deephaven/ui/components/text_field.py @@ -24,10 +24,14 @@ ) from .basic import component_element from ..elements import Element +from ..types import Undefined, UndefinedType + + +_NULLABLE_PROPS = ["icon"] def text_field( - icon: Element | None = None, + icon: Element | None | UndefinedType = Undefined, is_quiet: bool | None = None, is_disabled: bool | None = None, is_read_only: bool | None = None, @@ -274,4 +278,5 @@ def text_field( UNSAFE_class_name=UNSAFE_class_name, UNSAFE_style=UNSAFE_style, key=key, + _nullable_props=_NULLABLE_PROPS, ) diff --git a/plugins/ui/src/deephaven/ui/components/time_field.py b/plugins/ui/src/deephaven/ui/components/time_field.py index db5d5a98d..7e4002d8c 100644 --- a/plugins/ui/src/deephaven/ui/components/time_field.py +++ b/plugins/ui/src/deephaven/ui/components/time_field.py @@ -25,7 +25,7 @@ create_props, convert_time_props, ) -from ..types import Time, TimeGranularity +from ..types import Time, TimeGranularity, Undefined, UndefinedType from .basic import component_element from .make_component import make_component @@ -44,6 +44,8 @@ # 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"] +_NULLABLE_PROPS = ["value", "default_value"] + def _convert_time_field_props( props: dict[str, Any], @@ -71,8 +73,8 @@ def _convert_time_field_props( @make_component def time_field( placeholder_value: Time | None = None, - value: Time | None = None, - default_value: Time | None = None, + value: Time | None | UndefinedType = Undefined, + default_value: Time | None | UndefinedType = Undefined, min_value: Time | None = None, max_value: Time | None = None, granularity: TimeGranularity | None = "SECOND", @@ -245,4 +247,4 @@ def time_field( _convert_time_field_props(props) - return component_element("TimeField", **props) + return component_element("TimeField", _nullable_props=_NULLABLE_PROPS, **props) diff --git a/plugins/ui/src/deephaven/ui/elements/BaseElement.py b/plugins/ui/src/deephaven/ui/elements/BaseElement.py index c6a425c0f..9cc5a0163 100644 --- a/plugins/ui/src/deephaven/ui/elements/BaseElement.py +++ b/plugins/ui/src/deephaven/ui/elements/BaseElement.py @@ -9,10 +9,23 @@ class BaseElement(Element): """ Base class for basic UI Elements that don't have any special rendering logic. Must provide a name for the element. + + Args: + name: The name of the element, e.g. "div", "span", "deephaven.ui.button", etc. + children: The children + key: The key for the element + _nullable_props: A list of props that can be nullable + props: The props for the element """ def __init__( - self, name: str, /, *children: Any, key: str | None = None, **props: Any + self, + name: str, + /, + *children: Any, + key: str | None = None, + _nullable_props: list[str] = [], + **props: Any, ): self._name = name self._key = key @@ -27,7 +40,7 @@ def __init__( # If there's only one child, we pass it as a single child, not a list # There are many React elements that expect only a single child, and will fail if they get a list (even if it only has one element) props["children"] = children[0] - self._props = dict_to_react_props(props) + self._props = dict_to_react_props(props, _nullable_props) @property def name(self) -> str: diff --git a/plugins/ui/src/deephaven/ui/types/types.py b/plugins/ui/src/deephaven/ui/types/types.py index 9fb60b648..314dd5527 100644 --- a/plugins/ui/src/deephaven/ui/types/types.py +++ b/plugins/ui/src/deephaven/ui/types/types.py @@ -573,3 +573,32 @@ class DateRange(TypedDict): ToastVariant = Literal["positive", "negative", "neutral", "info"] + + +_DISABLE_NULLISH_CONSTRUCTORS = False + + +class UndefinedType: + """ + Placeholder for undefined values. + """ + + def __init__(self) -> None: + if _DISABLE_NULLISH_CONSTRUCTORS: + raise NotImplementedError + + def __bool__(self) -> bool: + return False + + def __copy__(self) -> "UndefinedType": + return self + + def __deepcopy__(self, _: Any) -> "UndefinedType": + return self + + def __eq__(self, other: object) -> bool: + return isinstance(other, UndefinedType) or other is None + + +Undefined = UndefinedType() +_DISABLE_NULLISH_CONSTRUCTORS = True diff --git a/plugins/ui/test/deephaven/ui/test_types.py b/plugins/ui/test/deephaven/ui/test_types.py new file mode 100644 index 000000000..eb6086547 --- /dev/null +++ b/plugins/ui/test/deephaven/ui/test_types.py @@ -0,0 +1,36 @@ +import unittest + +from .BaseTest import BaseTestCase + + +class TypesTest(BaseTestCase): + def test_nullish_equivalences(self): + from deephaven.ui.types import Undefined + + self.assertEqual(Undefined, None) + self.assertEqual(None, Undefined) + + self.assertIsNot(Undefined, None) + self.assertIsNot(None, Undefined) + + def test_nullish_bool(self): + from deephaven.ui.types import Undefined + + self.assertFalse(Undefined) + + def test_nullish_init(self): + from deephaven.ui.types import UndefinedType + + with self.assertRaises(NotImplementedError): + UndefinedType() + + def test_copy(self): + from copy import copy, deepcopy + from deephaven.ui.types import Undefined + + self.assertIs(Undefined, copy(Undefined)) + self.assertIs(Undefined, deepcopy(Undefined)) + + +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 21872969e..51c6412a4 100644 --- a/plugins/ui/test/deephaven/ui/test_utils.py +++ b/plugins/ui/test/deephaven/ui/test_utils.py @@ -117,11 +117,50 @@ def test_dict_to_react_props(self): def test_remove_empty_keys(self): from deephaven.ui._internal.utils import remove_empty_keys + from deephaven.ui.types import Undefined self.assertDictEqual( remove_empty_keys({"foo": "bar", "biz": None, "baz": 0}), {"foo": "bar", "baz": 0}, ) + self.assertDictEqual( + remove_empty_keys( + { + "foo": "bar", + "biz": None, + "baz": 0, + "is_undefined": Undefined, + }, + _nullable_props={"is_undefined"}, + ), + {"foo": "bar", "baz": 0}, + ) + self.assertDictEqual( + remove_empty_keys( + { + "foo": "bar", + "biz": None, + "baz": 0, + "is_undefined": Undefined, + }, + _nullable_props={"biz", "is_undefined"}, + ), + {"foo": "bar", "biz": None, "baz": 0}, + ) + + with self.assertRaises(ValueError) as err: + remove_empty_keys( + { + "foo": "bar", + "biz": None, + "baz": 0, + "is_undefined": Undefined, + } + ) + self.assertEqual( + str(err.exception), + "UndefinedType found in a non-nullable prop.", + ) def test_wrap_callable(self): from deephaven.ui._internal.utils import wrap_callable