Skip to content

Commit

Permalink
feat: add undefined type option (#1026)
Browse files Browse the repository at this point in the history
- 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 <mikebender@deephaven.io>
Co-authored-by: Brian Ingles <github@emeraldwalk.com>
Co-authored-by: margaretkennedy <82049573+margaretkennedy@users.noreply.github.com>
4 people authored Dec 16, 2024
1 parent 21e8c5d commit ef7e741
Showing 19 changed files with 315 additions and 64 deletions.
19 changes: 19 additions & 0 deletions plugins/ui/docs/architecture.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 31 additions & 1 deletion plugins/ui/docs/components/picker.md
Original file line number Diff line number Diff line change
@@ -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

81 changes: 60 additions & 21 deletions plugins/ui/src/deephaven/ui/_internal/utils.py
Original file line number Diff line number Diff line change
@@ -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,20 +769,20 @@ 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
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:
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)
11 changes: 6 additions & 5 deletions plugins/ui/src/deephaven/ui/components/calendar.py
Original file line number Diff line number Diff line change
@@ -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)
12 changes: 8 additions & 4 deletions plugins/ui/src/deephaven/ui/components/combo_box.py
Original file line number Diff line number Diff line change
@@ -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
)
10 changes: 6 additions & 4 deletions plugins/ui/src/deephaven/ui/components/date_field.py
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 6 additions & 4 deletions plugins/ui/src/deephaven/ui/components/date_picker.py
Original file line number Diff line number Diff line change
@@ -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)
12 changes: 8 additions & 4 deletions plugins/ui/src/deephaven/ui/components/date_range_picker.py
Original file line number Diff line number Diff line change
@@ -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
)
10 changes: 7 additions & 3 deletions plugins/ui/src/deephaven/ui/components/picker.py
Original file line number Diff line number Diff line change
@@ -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
)
12 changes: 9 additions & 3 deletions plugins/ui/src/deephaven/ui/components/radio_group.py
Original file line number Diff line number Diff line change
@@ -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
)
17 changes: 12 additions & 5 deletions plugins/ui/src/deephaven/ui/components/range_calendar.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 6 additions & 2 deletions plugins/ui/src/deephaven/ui/components/tabs.py
Original file line number Diff line number Diff line change
@@ -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,
)
7 changes: 6 additions & 1 deletion plugins/ui/src/deephaven/ui/components/text_area.py
Original file line number Diff line number Diff line change
@@ -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,
)
7 changes: 6 additions & 1 deletion plugins/ui/src/deephaven/ui/components/text_field.py
Original file line number Diff line number Diff line change
@@ -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,
)
10 changes: 6 additions & 4 deletions plugins/ui/src/deephaven/ui/components/time_field.py
Original file line number Diff line number Diff line change
@@ -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)
17 changes: 15 additions & 2 deletions plugins/ui/src/deephaven/ui/elements/BaseElement.py
Original file line number Diff line number Diff line change
@@ -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:
29 changes: 29 additions & 0 deletions plugins/ui/src/deephaven/ui/types/types.py
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions plugins/ui/test/deephaven/ui/test_types.py
Original file line number Diff line number Diff line change
@@ -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()
39 changes: 39 additions & 0 deletions plugins/ui/test/deephaven/ui/test_utils.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit ef7e741

Please sign in to comment.