Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add undefined type option #1026

Merged
merged 16 commits into from
Dec 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
wusteven815 marked this conversation as resolved.
Show resolved Hide resolved
),
]


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:
wusteven815 marked this conversation as resolved.
Show resolved Hide resolved
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,
wusteven815 marked this conversation as resolved.
Show resolved Hide resolved
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
Loading