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>
  • Loading branch information
4 people authored Dec 16, 2024
1 parent 21e8c5d commit ef7e741
Show file tree
Hide file tree
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
Expand Up @@ -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.
Expand Down
32 changes: 31 additions & 1 deletion plugins/ui/docs/components/picker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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

Expand Down
81 changes: 60 additions & 21 deletions plugins/ui/src/deephaven/ui/_internal/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -15,6 +14,7 @@
JavaTime,
LocalDateConvertible,
LocalDate,
Undefined,
)

T = TypeVar("T")
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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(
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
)

Expand All @@ -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
)

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 6 additions & 5 deletions plugins/ui/src/deephaven/ui/components/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -43,6 +42,8 @@
"default_focused_value",
]

_NULLABLE_PROPS = ["value", "default_value"]


def _convert_calendar_props(
props: dict[str, Any],
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Up @@ -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
Expand All @@ -42,6 +42,8 @@
"title_column",
}

_NULLABLE_PROPS = ["selected_key"]


def combo_box(
*children: Item | SectionElement | Table | PartitionedTable | ItemTableSource,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Up @@ -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
Expand All @@ -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],
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Loading

0 comments on commit ef7e741

Please sign in to comment.