diff --git a/plugins/plotly-express/conf.py b/plugins/plotly-express/conf.py index e20cfdb4b..ca057cb27 100644 --- a/plugins/plotly-express/conf.py +++ b/plugins/plotly-express/conf.py @@ -39,6 +39,8 @@ # options for sphinx_autodoc_typehints always_use_bars_union = True +myst_all_links_external = True + from deephaven_server import Server # need a server instance to pull types from the autodocs diff --git a/plugins/plotly-express/docs/sidebar.json b/plugins/plotly-express/docs/sidebar.json index b0276dc7e..eecfa63a4 100644 --- a/plugins/plotly-express/docs/sidebar.json +++ b/plugins/plotly-express/docs/sidebar.json @@ -137,8 +137,8 @@ "path": "multiple-axes.md" }, { - "label": "`unsafe_update_figure` Chart Customization", - "path": "unsafe_update_figure.md" + "label": "Chart Customization", + "path": "unsafe-update-figure.md" } ] } diff --git a/plugins/plotly-express/setup.cfg b/plugins/plotly-express/setup.cfg index 501796c6c..c4c675606 100644 --- a/plugins/plotly-express/setup.cfg +++ b/plugins/plotly-express/setup.cfg @@ -27,7 +27,7 @@ packages=find_namespace: install_requires = deephaven-core>=0.37.0 deephaven-plugin>=0.6.0 - plotly + plotly>=5.15.0,<6.0.0 deephaven-plugin-utilities>=0.0.2 include_package_data = True diff --git a/plugins/ui/conf.py b/plugins/ui/conf.py index e111513dc..3452986d8 100644 --- a/plugins/ui/conf.py +++ b/plugins/ui/conf.py @@ -43,5 +43,7 @@ from deephaven_server import Server +myst_all_links_external = True + # need a server instance to pull types from the autodocs Server(port=10075) diff --git a/plugins/ui/docs/_assets/deephaven-ui-crash-course/about_markdown.png b/plugins/ui/docs/_assets/deephaven-ui-crash-course/about_markdown.png new file mode 100644 index 000000000..465211db7 Binary files /dev/null and b/plugins/ui/docs/_assets/deephaven-ui-crash-course/about_markdown.png differ diff --git a/plugins/ui/docs/_assets/deephaven-ui-crash-course/about_panel.png b/plugins/ui/docs/_assets/deephaven-ui-crash-course/about_panel.png new file mode 100644 index 000000000..ae3a5e246 Binary files /dev/null and b/plugins/ui/docs/_assets/deephaven-ui-crash-course/about_panel.png differ diff --git a/plugins/ui/docs/_assets/deephaven-ui-crash-course/custom_panel.png b/plugins/ui/docs/_assets/deephaven-ui-crash-course/custom_panel.png new file mode 100644 index 000000000..f0669387c Binary files /dev/null and b/plugins/ui/docs/_assets/deephaven-ui-crash-course/custom_panel.png differ diff --git a/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris.png b/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris.png new file mode 100644 index 000000000..8ead550cf Binary files /dev/null and b/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris.png differ diff --git a/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris_dashboard.png b/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris_dashboard.png new file mode 100644 index 000000000..4f716b1ec Binary files /dev/null and b/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris_dashboard.png differ diff --git a/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris_dashboard_column.png b/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris_dashboard_column.png new file mode 100644 index 000000000..3c6004547 Binary files /dev/null and b/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris_dashboard_column.png differ diff --git a/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris_dashboard_row.png b/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris_dashboard_row.png new file mode 100644 index 000000000..dcc1e5540 Binary files /dev/null and b/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris_dashboard_row.png differ diff --git a/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris_dashboard_stack.png b/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris_dashboard_stack.png new file mode 100644 index 000000000..c81093bb3 Binary files /dev/null and b/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris_dashboard_stack.png differ diff --git a/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris_species_dashboard.png b/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris_species_dashboard.png new file mode 100644 index 000000000..6f65d1849 Binary files /dev/null and b/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris_species_dashboard.png differ diff --git a/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris_species_dashboard_final.png b/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris_species_dashboard_final.png new file mode 100644 index 000000000..fd11d7013 Binary files /dev/null and b/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris_species_dashboard_final.png differ diff --git a/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris_species_dashboard_resized.png b/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris_species_dashboard_resized.png new file mode 100644 index 000000000..b9a53ea2c Binary files /dev/null and b/plugins/ui/docs/_assets/deephaven-ui-crash-course/iris_species_dashboard_resized.png differ diff --git a/plugins/ui/docs/_assets/deephaven-ui-crash-course/picker_panel.png b/plugins/ui/docs/_assets/deephaven-ui-crash-course/picker_panel.png new file mode 100644 index 000000000..dad44dd94 Binary files /dev/null and b/plugins/ui/docs/_assets/deephaven-ui-crash-course/picker_panel.png differ diff --git a/plugins/ui/docs/_assets/deephaven-ui-crash-course/scatter_by_species.png b/plugins/ui/docs/_assets/deephaven-ui-crash-course/scatter_by_species.png new file mode 100644 index 000000000..76494a359 Binary files /dev/null and b/plugins/ui/docs/_assets/deephaven-ui-crash-course/scatter_by_species.png differ diff --git a/plugins/ui/docs/_assets/deephaven-ui-crash-course/sepal_flex.png b/plugins/ui/docs/_assets/deephaven-ui-crash-course/sepal_flex.png new file mode 100644 index 000000000..a1c2817d2 Binary files /dev/null and b/plugins/ui/docs/_assets/deephaven-ui-crash-course/sepal_flex.png differ diff --git a/plugins/ui/docs/_assets/deephaven-ui-crash-course/sepal_flex_column.png b/plugins/ui/docs/_assets/deephaven-ui-crash-course/sepal_flex_column.png new file mode 100644 index 000000000..59cb8d96e Binary files /dev/null and b/plugins/ui/docs/_assets/deephaven-ui-crash-course/sepal_flex_column.png differ diff --git a/plugins/ui/docs/_assets/deephaven-ui-crash-course/sepal_flex_tabs.png b/plugins/ui/docs/_assets/deephaven-ui-crash-course/sepal_flex_tabs.png new file mode 100644 index 000000000..4609b7582 Binary files /dev/null and b/plugins/ui/docs/_assets/deephaven-ui-crash-course/sepal_flex_tabs.png differ diff --git a/plugins/ui/docs/_assets/deephaven-ui-crash-course/sepal_panel.png b/plugins/ui/docs/_assets/deephaven-ui-crash-course/sepal_panel.png new file mode 100644 index 000000000..c83004060 Binary files /dev/null and b/plugins/ui/docs/_assets/deephaven-ui-crash-course/sepal_panel.png differ diff --git a/plugins/ui/docs/_assets/deephaven-ui-crash-course/sepal_text.png b/plugins/ui/docs/_assets/deephaven-ui-crash-course/sepal_text.png new file mode 100644 index 000000000..1a60d60ff Binary files /dev/null and b/plugins/ui/docs/_assets/deephaven-ui-crash-course/sepal_text.png differ diff --git a/plugins/ui/docs/_assets/deephaven-ui-crash-course/species_picker_panel.png b/plugins/ui/docs/_assets/deephaven-ui-crash-course/species_picker_panel.png new file mode 100644 index 000000000..39fb9cef0 Binary files /dev/null and b/plugins/ui/docs/_assets/deephaven-ui-crash-course/species_picker_panel.png differ diff --git a/plugins/ui/docs/_assets/deephaven-ui-crash-course/species_picker_panel_controlled.png b/plugins/ui/docs/_assets/deephaven-ui-crash-course/species_picker_panel_controlled.png new file mode 100644 index 000000000..6080c76fd Binary files /dev/null and b/plugins/ui/docs/_assets/deephaven-ui-crash-course/species_picker_panel_controlled.png differ diff --git a/plugins/ui/docs/_assets/deephaven-ui-crash-course/species_picker_panel_illustrated.png b/plugins/ui/docs/_assets/deephaven-ui-crash-course/species_picker_panel_illustrated.png new file mode 100644 index 000000000..0e376df9e Binary files /dev/null and b/plugins/ui/docs/_assets/deephaven-ui-crash-course/species_picker_panel_illustrated.png differ diff --git a/plugins/ui/docs/_assets/deephaven-ui-crash-course/species_picker_panel_investigate.png b/plugins/ui/docs/_assets/deephaven-ui-crash-course/species_picker_panel_investigate.png new file mode 100644 index 000000000..3ee076ecf Binary files /dev/null and b/plugins/ui/docs/_assets/deephaven-ui-crash-course/species_picker_panel_investigate.png differ diff --git a/plugins/ui/docs/_assets/deephaven-ui-crash-course/ui_iris.png b/plugins/ui/docs/_assets/deephaven-ui-crash-course/ui_iris.png new file mode 100644 index 000000000..159cb5ef0 Binary files /dev/null and b/plugins/ui/docs/_assets/deephaven-ui-crash-course/ui_iris.png differ diff --git a/plugins/ui/docs/add-interactivity/render-cycle.md b/plugins/ui/docs/add-interactivity/render-cycle.md new file mode 100644 index 000000000..8d5d27b3b --- /dev/null +++ b/plugins/ui/docs/add-interactivity/render-cycle.md @@ -0,0 +1,124 @@ +# Render Cycle + +Before your components are displayed on screen, they must be rendered. Understanding the steps in this process will help you think about how your code executes and explain its behavior. + +Think of your components as chefs in a kitchen, preparing delicious meals from various ingredients. In this analogy, `deephaven.ui` acts as the waiter, taking orders from customers and delivering the finished dishes. This process of handling UI requests and rendering them involves three main steps: + +1. Triggering a render (delivering the guest’s order to the kitchen) +2. Rendering the component (preparing the order in the kitchen) +3. Committing to the DOM (placing the order on the table) + +## Step 1: Trigger a render + +There are two reasons for a component to render: + +1. It is the component’s initial render. +2. The component’s state has been updated or one of it's ancestor's state has been updated. + +### Initial Render + +Opening a component to view it causes the component to be mounted, which means adding nodes to the DOM. When your component first mounts, it triggers an initial render. It is rendered with it's props and initial state. + +```python +from deephaven import ui + + +@ui.component +def example_renderer(): + text = "Initial Render" + return ui.text(text) + + +example = example_renderer() +``` + +### Re-renders when state updates + +Once the component has been initially rendered, you can trigger further renders by updating its state with the set function. Updating your component’s state automatically queues a render. (You can imagine these as a restaurant guest ordering tea, dessert, and all sorts of things after putting in their first order, depending on the state of their thirst or hunger.) + +```python +from deephaven import ui + + +@ui.component +def example_renderer(): + num, set_num = ui.use_state(0) + + def handle_press(): + set_num(num + 1) + + text = "Initial Render" if num == 0 else f"Re-render {num}" + + return [ + ui.button("Render", on_press=handle_press), + ui.text(text), + ] + + +example = example_renderer() +``` + +## Step 2: `deephaven.ui` renders your components + +After you trigger a render, `deephaven.ui` runs the component functions and then encodes your components as JSON which is sent from the server to the web client UI. The client decodes the JSON into a document which is rendered by React. + +This process is recursive: if the updated component returns some other component, `deephaven.ui` will render that component next, and if that component also returns something, it will render that component next, and so on. The process will continue until there are no more nested components. + +Rendering must always be a [pure](../describing/pure_components.md) calculation: + +- Same inputs, same output. Given the same inputs, a component should always return the output. +- It minds its own business. It should not change any objects or variables that existed before rendering. + +Otherwise, you can encounter confusing bugs and unpredictable behavior as your codebase grows in complexity. + +### Step 3: Commit changes to the DOM + +After rendering your components, `deephaven.ui` sends the components to client and React renders and will modify the DOM. + +-For the initial render, React will use the `appendChild()` DOM API to put all the DOM nodes it has created on screen. +-For re-renders, React will apply the minimal necessary operations (calculated while rendering!) to make the DOM match the latest rendering output. + +React only changes the DOM nodes if there’s a difference between renders. For example, here is a component that re-renders with different props passed from its parent every second. Notice how you can add some text into the `ui.text_field`, updating its value, but the text doesn’t disappear when the component re-renders: + +```python +import time, threading +from deephaven import ui + + +@ui.component +def clock(t): + return [ui.heading(t), ui.text_field()] + + +@ui.component +def clock_wrapper(): + clock_time, set_clock_time = ui.use_state(time.ctime()) + is_cancelled = False + + def periodic_update(): + if is_cancelled: + return + set_clock_time(time.ctime()) + threading.Timer(1, periodic_update).start() + + def start_update(): + periodic_update() + + def cancel_timer(): + nonlocal is_cancelled + is_cancelled = True + + return cancel_timer + + start_timer = ui.use_callback(start_update, [set_clock_time]) + ui.use_effect(start_timer, []) + + return clock(clock_time) + + +clock_example = clock_wrapper() +``` + +This works because during this last step, React only updates the content of `ui.header` with the new time. It sees that the `ui.text_field` appears in the JSX in the same place as last time, so React doesn’t touch the `ui.text_field` or its value. + +After rendering is done and React updated the DOM, the browser will repaint the screen. diff --git a/plugins/ui/docs/add-interactivity/respond-to-events.md b/plugins/ui/docs/add-interactivity/respond-to-events.md new file mode 100644 index 000000000..d24161e8f --- /dev/null +++ b/plugins/ui/docs/add-interactivity/respond-to-events.md @@ -0,0 +1,227 @@ +# Respond to Events + +`deephaven.ui` lets you add event handlers to your components. Event handlers are your own functions that will be triggered in response to interactions like clicking, hovering, focusing form inputs, and so on. + +## Add event handlers + +To add an event handler, first define a function and then pass it as a prop to the appropriate component. For example, here is a button that doesn’t do anything yet: + +```python +from deephaven import ui + + +@ui.component +def my_button(): + return ui.button("I don't do anything") + + +no_button_event = my_button() +``` + +You can make it print a message when a user clicks by following these three steps: + +1. Declare a function called `handle_press`. +2. Implement the logic inside that function. +3. Add `on_press=handle_press` to the button component. + +```python +from deephaven import ui + + +@ui.component +def my_button(): + def handle_press(): + print("You clicked me!") + + return ui.button("Click me", on_press=handle_press) + + +button_with_event = my_button() +``` + +You defined the `handle_press` function and then passed it as a prop to `ui.button`. `handle_press` is an event handler. Event handler functions: + +- Are usually defined inside your components. +- Have names that start with handle, followed by the name of the event. + +By convention, it is common to name event handlers as "handle" followed by the event name. You’ll often see `on_press=handle_press`, `on_mouse_enter=handle_mouse_enter`, and so on. + +Alternatively, you can define an event handler inline with a lambda in the component: + +```python +from deephaven import ui + + +@ui.component +def my_button(): + return ui.button("Click me", on_press=lambda: print("You clicked me!")) + + +button_with_inline_event = my_button() +``` + +These styles are equivalent. Inline event handlers are convenient for short functions. + +## Functions must be passed, not called + +Functions passed to event handlers must be passed, not called. For example: + +| Passing a function (correct) | Calling a function (incorrect) | +| ---------------------------------- | ------------------------------------------------ | +| `ui.button(on_press=handle_press)` | `ui.button("Click me", on_press=handle_press())` | + +The difference is subtle. In the first example, the `handle_press` function is passed as an `on_press` event handler. This tells `deephaven.ui` to remember it and only call your function when the user clicks the button. + +In the second example, the `()` at the end of `handle_press()` fires the function immediately during rendering, without any clicks. + +When you write code inline, the same pitfall presents itself in a different way: + +| Passing a function (correct) | Calling a function (incorrect) | +| -------------------------------------------- | --------------------------------------------------------- | +| `ui.button(on_press=lambda: print("click"))` | `ui.button("Click me", on_press=on_press=print("click"))` | + +The first example uses lambda to create an anonymous function that is called every time the button is clicked. + +The second example will execute the code every time the component renders. + +In both cases, you should pass a function: + +- `ui.button(on_press=handle_press)` passes the `handle_press` function. +- `ui.button(on_press=lambda: print("click"))` passes the `lambda: print("click")` function. + +## Read props in event handlers + +Because event handlers are declared inside of a component, they have access to the component’s props. Here is a button that, when clicked, prints its message prop: + +```python +from deephaven import ui + + +@ui.component +def custom_button(label, message): + return ui.button(label, on_press=lambda: print(message)) + + +@ui.component +def toolbar(): + return [ + custom_button("Play Movie", "Playing!"), + custom_button("Upload Image", "Uploading!"), + ] + + +read_props_example = toolbar() +``` + +This lets these two buttons show different messages. Try changing the messages passed to them. + +## Pass event handlers as props + +Often, you’ll want the parent component to specify a child’s event handler. Consider buttons: depending on where you’re using a button component, you might want to execute a different function — perhaps one plays a movie and another uploads an image. + +To do this, pass a prop the component receives from its parent as the event handler like so: + +```python +from deephaven import ui + + +@ui.component +def custom_button(*children, on_press): + return ui.button(children, on_press=on_press) + + +@ui.component +def play_button(movie_name): + def handle_play_press(): + print(f"Playing {movie_name}") + + return custom_button(f"Play {movie_name}", on_press=handle_play_press) + + +@ui.component +def upload_button(): + return custom_button("Upload Image", on_press=lambda: print("Uploading!")) + + +@ui.component +def toolbar(): + return [play_button("Alice in Wonderland"), upload_button()] + + +pass_event_handlers = toolbar() +``` + +Here, the `toolbar` component renders a `play_button` and an `upload_button`: + +- `play_button` passes `handle_play_press` as the `on_press` prop to the `custom_button` inside. +- `upload_button` passes `lambda: print("Uploading!")` as the `on_press` prop to the `custom_button` inside. + +Finally, `custom_button` component accepts a prop called `on_press`. It passes that prop directly to the `ui.button` with `on_press=on_press`. This tells `deephaven.ui` to call the passed function on press. + +## Name event handler props + +When building your own components, you can name their event handler props any way that you like. + +By convention, event handler props should start with `on`, followed by an underscore. + +For example, the `custom_button` component’s `on_press` prop could have been called `on_smash`: + +```python +from deephaven import ui + + +@ui.component +def custom_button(*children, on_smash): + return ui.button(children, on_press=on_smash) + + +@ui.component +def toolbar(): + return [ + custom_button("Play Movie", on_smash=lambda: print("Playing!")), + custom_button("Upload Image", on_smash=lambda: print("Uploading!")), + ] + + +handler_name_example = toolbar() +``` + +In this example, `ui.button(children, on_press=on_smash)` shows that the `ui.button` still needs a prop called `on_press`, but the prop name received by your `custom_button` component is up to you. + +When your component supports multiple interactions, you might name event handler props for app-specific concepts. For example, this `toolbar` component receives `on_play_movie` and `on_upload_image` event handlers: + +```python +from deephaven import ui + + +@ui.component +def custom_button(*children, on_press): + return ui.button(children, on_press=on_press) + + +@ui.component +def toolbar(on_play_movie, on_upload_image): + return [ + custom_button("Play Movie", on_press=on_play_movie), + custom_button("Upload Image", on_press=on_upload_image), + ] + + +@ui.component +def app(): + return toolbar( + on_play_movie=lambda: print("Playing!"), + on_upload_image=lambda: print("Uploading!"), + ) + + +app_example = app() +``` + +Notice how the `app` component does not need to know what `toolbar` will do with `on_play_movie` or `on_upload_image`. That is an implementation detail of the `toolbar`. Here, `toolbar` passes them down as `on_press` handlers to its buttons, but it could later also trigger them on a keyboard shortcut. Naming props after app-specific interactions like `on_play_movie` gives you the flexibility to change how they’re used later. + +## Can event handlers have side effects? + +Yes. Event handlers are the best place for side effects. + +Unlike rendering functions, event handlers do not need to be [pure](../describing/pure_components.md), so it’s a great place to change something. For example, you can change an input’s value in response to typing, or change a list in response to a button press. diff --git a/plugins/ui/docs/add-interactivity/state-a-components-memory.md b/plugins/ui/docs/add-interactivity/state-a-components-memory.md new file mode 100644 index 000000000..a5ca1ed62 --- /dev/null +++ b/plugins/ui/docs/add-interactivity/state-a-components-memory.md @@ -0,0 +1,241 @@ +# State: A Component's Memory + +Components often need to change what’s on the screen as a result of an interaction. Typing into a form should update the input field, clicking “Next” on an image carousel should change which image is displayed, and clicking “Buy” should put a product in the shopping cart. Components need to “remember” things: the current input value, the current image, the shopping cart. In `deephaven.ui`, this kind of component-specific memory is called state. + +## When a regular variable is not enough + +Here’s a component that renders a word from a list. Clicking the “Next” button should show the next word by changing the index to 1, then 2, and so on. However, this does not work. + +```python +from deephaven import ui + +word_list = ["apple", "banana", "cherry", "orange", "kiwi", "strawberry"] + + +@ui.component +def word_display(): + index = 0 + + def handle_press(): + nonlocal index + index = index + 1 + + word = word_list[index] + + return [ + ui.button("Next", on_press=handle_press), + ui.text(f"({index+1} of {len(word_list)})"), + ui.heading(word), + ] + + +word_display_example = word_display() +``` + +The `handle_press` event handler is updating a local variable, `index`. But two things prevent that change from being visible: + +1. Local variables do not persist between renders. When `deephaven.ui` renders this component a second time, it renders it from scratch. It does not consider any changes to the local variables. +2. Changes to local variables do not trigger renders. `deephaven.ui` does not realize it needs to render the component again with the new data. + +To update a component with new data, two things need to happen: + +1. Retain the data between renders. +2. Trigger `deephaven.ui` to render the component with new data (re-rendering). + +The [`use_state`](../hooks/use_state.md) hook provides those two things: + +1. A state variable to retain the data between renders. +2. A state setter function to update the variable and trigger `deephaven.ui` to render the component again. + +## Add a state variable + +To add a state variable, replace this line: + +```python +index = 0 +``` + +with + +```python +index, set_index = ui.use_state(0) +``` + +`index` is a state variable and `set_index` is the setter function. + +This is how they work together in `handle_press`: + +```python +set_index(index + 1) +``` + +Now clicking the “Next” button switches the current word: + +```python +from deephaven import ui + +word_list = ["apple", "banana", "cherry", "orange", "kiwi", "strawberry"] + + +@ui.component +def word_display(): + index, set_index = ui.use_state(0) + + def handle_press(): + set_index(index + 1) + + word = word_list[index] + + return [ + ui.button("Next", on_press=handle_press), + ui.text(f"({index+1} of {len(word_list)})"), + ui.heading(word), + ] + + +word_display_example = word_display() +``` + +## Meet your first hook + +In `deephaven.ui`, [`use_state`](../hooks/use_state.md), as well as any other function starting with “use”, is called a [`hook`](../describing/use_hooks.md). + +Hooks are special functions that are only available while `deephaven.ui` is rendering. They let you “hook into” different `deephaven.ui` features. + +State is just one of those features, but you will meet the other hooks later. + +Hooks can only be called at the top level of your components or your own hooks. You cannot call hooks inside conditions, loops, or other nested functions. Hooks are functions, but it is helpful to think of them as unconditional declarations about your component’s needs. You “use” `deephaven.ui` features at the top of your component similar to how you “import” at the top of your file. + +## Anatomy of `use_state` + +When you call `use_state`, you are telling `deephaven.ui` that you want this component to remember something: + +```python +index, set_index = ui.use_state(0) +``` + +In this case, you want `deephaven.ui` to remember `index`. + +The convention is to name this pair consistently, like `something` and `set_something`. You can name it anything you like, but conventions make things easier to understand across projects. + +The only argument to `use_state` is the initial value of your state variable. In this example, the index’s initial value is set to `0` with `ui.use_state(0)`. + +Every time your component renders, `use_state` gives you an array containing two values: + +1. The state variable `index` with the value you stored. +2. The state setter function `set_index`, which can update the state variable and trigger `deephaven.ui` to render the component again. + +Here’s how that happens in action: + +```python +index, set_index = ui.use_state(0) +``` + +1. Your component renders the first time. Because you passed `0` to `use_state` as the initial value for `index`, it will return `0`, `set_index`. `deephaven.ui` remembers 0 is the latest state value. +2. You update the state. When a user clicks the button, it calls `set_index(index + 1)`. Since `index` is `0`, it becomes `set_index(1)`. This instructs `deephaven.ui` to remember that `index` is now `1` and triggers another render. +3. During your component’s second render, `deephaven.ui` still sees `use_state(0)`, but because `deephaven.ui` remembers that you set `index` to `1`, it returns `1`, `set_index` instead. +4. And so on with each render. + +## Give a component multiple state variables + +You can have as many state variables of as many types as you like in one component. This component has two state variables, a number `index` and a boolean `show_more` that is toggled when you click “Show details”: + +```python +from deephaven import ui + +word_list = ["apple", "banana", "cherry", "orange", "kiwi", "strawberry"] +detail_list = [ + "An apple is a round, edible fruit produced by an apple tree.", + "A banana is an elongated, edible fruit.", + "A cherry is the fruit of many plants of the genus Prunus.", + "The oranges are the fruit of a tree in the family Rutaceae.", + "Kiwi has a thin, fuzzy, fibrous, tart but edible, light brown skin and light green or golden flesh with rows of tiny, black, edible seeds.", + "The genus Fragaria, strawberries, is in the rose family, Rosaceae.", +] + + +@ui.component +def word_display(): + index, set_index = ui.use_state(0) + show_more, set_show_more = ui.use_state(False) + + def handle_press(): + set_index(index + 1) + + def handle_more_press(): + set_show_more(not show_more) + + word = word_list[index] + detail = detail_list[index] + + return [ + ui.button("Next", on_press=handle_press), + ui.text(f"({index+1} of {len(word_list)})"), + ui.heading(word), + ui.button( + f"{'Hide' if show_more else 'Show'} Details", on_press=handle_more_press + ), + show_more and ui.text(detail), + ] + + +word_display_example = word_display() +``` + +It is a good idea to have multiple state variables if their state is unrelated, like `index` and `show_more` in this example. But if you find that you often change two state variables together, it might be easier to combine them into one. For example, if you have a form with many fields, it’s more convenient to have a single state variable that holds a dictionary than a state variable per field. + +# State is isolated and private + +State is local to a component instance on the screen. In other words, if you render the same component twice, each copy will have completely isolated state! Changing one of them will not affect the other. + +In this example, the `word_display` component from earlier is rendered twice with no changes to its logic. Try clicking the buttons inside each of the component. Notice that their state is independent: + +```python +from deephaven import ui + +word_list = ["apple", "banana", "cherry", "orange", "kiwi", "strawberry"] +detail_list = [ + "An apple is a round, edible fruit produced by an apple tree.", + "A banana is an elongated, edible fruit.", + "A cherry is the fruit of many plants of the genus Prunus.", + "The oranges the fruit of a tree in the family Rutaceae.", + "Kiwi has a thin, fuzzy, fibrous, tart but edible, light brown skin and light green or golden flesh with rows of tiny, black, edible seeds.", + "The genus Fragaria, the strawberries, is in the rose family, Rosaceae.", +] + + +@ui.component +def word_display(): + index, set_index = ui.use_state(0) + show_more, set_show_more = ui.use_state(False) + + def handle_press(): + set_index(index + 1) + + def handle_more_press(): + set_show_more(not show_more) + + word = word_list[index] + detail = detail_list[index] + + return ui.flex( + ui.button("Next", on_press=handle_press), + ui.text(f"({index+1} of {len(word_list)})"), + ui.heading(word), + ui.button("Show Details", on_press=handle_more_press), + show_more and ui.text(detail), + direction="column", + ) + + +@ui.component +def page(): + return ui.flex(word_display(), word_display()) + + +page_example = page() +``` + +This is what makes state different from regular variables that you might declare at the top of your script. State is not tied to a particular function call or a place in the code, but it’s “local” to the specific place on the screen. You rendered two `word_display` components, so their state is stored separately. + +Also notice how the `page` component does not “know” anything about the `word_display` state or even whether it has any. Unlike props, state is fully private to the component declaring it. The parent component can’t change it. This lets you add state to or remove it from any component without impacting the rest of the components. diff --git a/plugins/ui/docs/add-interactivity/state-as-a-snapshot.md b/plugins/ui/docs/add-interactivity/state-as-a-snapshot.md new file mode 100644 index 000000000..669d45330 --- /dev/null +++ b/plugins/ui/docs/add-interactivity/state-as-a-snapshot.md @@ -0,0 +1,221 @@ +# State as a Snapshot + +State variables might look like regular Python variables that you can read and write to. However, state behaves more like a snapshot. Setting it does not change the state variable you already have, but instead triggers a re-render. + +## Updates to state trigger renders + +You might think of your user interface as changing directly in response to the user event like a click. In `deephaven.ui`, it works a little differently from this mental model. In the previous section, you saw that setting state requests a re-render from `deephaven.ui`. This means that for an interface to react to the event, you need to update the state. + +In this example, when you press “send”, `set_is_sent(True)` tells `deephaven.ui` to re-render the UI: + +```python +from deephaven import ui + + +def send_message(message: str): + print("Message sent: ", message) + + +@ui.component +def form(): + is_sent, set_is_sent = ui.use_state(False) + + def handle_submit(form_data): + set_is_sent(True) + send_message(form_data["message"]) + + if is_sent: + return ui.heading("Your message is on its way!") + + return ui.form( + ui.text_area(default_value="Hi!", name="message"), + ui.button("Send", type="submit"), + on_submit=handle_submit, + ) + + +example_form = form() +``` + +Here’s what happens when you click the button: + +1. The `on_submit` event handler executes. +2. `set_is_sent(True)` sets `is_sent` to `True` and queues a new render. +3. `deephaven.ui` re-renders the component according to the new `is_sent` value. + +Let’s take a closer look at the relationship between state and rendering. + +## A render takes one snapshot in time + +“Rendering” means that `deephaven.ui` is calling your component, which is a function. The components you return from that function are like a snapshot of the UI in time. Its props, event handlers, and local variables were all calculated using its state at the time of the render. + +Unlike a photograph or a movie frame, the UI “snapshot” you return is interactive. It includes logic, like event handlers that specify what happens in response to inputs. `deephaven.ui` updates the screen to match this snapshot and connects the event handlers. As a result, pressing a button will trigger the click handler from your components. + +When `deephaven.ui` re-renders a component: + +1. `deephaven.ui` calls your function again. +2. Your function returns a new snapshot of components. +3. `deephaven.ui` then converts the snapshot to JSON and sends it to the web client UI to display. + +As a component’s memory, state is not like a regular variable that disappears after your function returns. State actually “lives” in a `deephaven.ui` context outside of your function. When React calls your component, it gives you a snapshot of the state for that particular render. Your component returns a snapshot of the UI with a fresh set of props and event handlers, all calculated using the state values from that render! + +Here is a little experiment to show you how this works. In this example, you might expect that clicking the “+3” button would increment the counter three times because it calls `set_number(number + 1)` three times. + +See what happens when you click the “+3” button: + +```python +from deephaven import ui + + +@ui.component +def counter(): + number, set_number = ui.use_state(0) + + def handle_press(): + set_number(number + 1) + set_number(number + 1) + set_number(number + 1) + + return [ui.heading(f"{number}"), ui.button("+3", on_press=handle_press)] + + +example_counter = counter() +``` + +Notice that `number` only increments once per click. + +Setting state only changes it for the next render. During the first render, `number` was `0`. This is why, in that render’s `on_press` handler, the value of `number` is still `0` even after `set_number(number + 1)` was called. + +Here is what `handle_press` tells `deephaven.ui` to do: + +1. `set_number(number + 1)`: number is 0 so `set_number(0 + 1)`. + +- `deephaven.ui` prepares to change number to 1 on the next render. + +2. `set_number(number + 1)`: number is 0 so `set_number(0 + 1)`. + +- `deephaven.ui` prepares to change number to 1 on the next render. + +3. `set_number(number + 1)`: number is 0 so `set_number(0 + 1)`. + +- `deephaven.ui` prepares to change number to 1 on the next render. + +Even though you called `set_number(number + 1)` three times, in this render’s event handler, `number` is always `0`, so you set the state to `1` three times. This is why, after your event handler finishes, React re-renders the component with number equal to `1` rather than `3`. + +You can also visualize this by mentally substituting state variables with their values in your code. Since the number state variable is 0 for this render, its event handler looks like this: + +```python +def handle_press(): + set_number(0 + 1) + set_number(0 + 1) + set_number(0 + 1) +``` + +For the next render, `number` is `1`, so that render’s click handler looks like this: + +```python +def handle_press(): + set_number(1 + 1) + set_number(1 + 1) + set_number(1 + 1) +``` + +This is why clicking the button again will set the counter to `2`, then to `3` on the next click, and so on. + +## State over time + +What number will this print when clicking the button? + +```python +from deephaven import ui + + +@ui.component +def counter(): + number, set_number = ui.use_state(0) + + def handle_press(): + set_number(number + 5) + print(number) + + return [ui.heading(f"{number}"), ui.button("+5", on_press=handle_press)] + + +example_counter = counter() +``` + +If you use the substitution method from before, you can guess that the alert shows “0”: + +```python +def handle_press(): + set_number(0 + 5) + print(5) +``` + +What if you put a timer on the alert, so it only fires after the component re-rendered? Will it say “0” or “5”? + +```python +from threading import Timer +from deephaven import ui + + +@ui.component +def counter(): + number, set_number = ui.use_state(0) + + def handle_press(): + set_number(number + 5) + Timer(3, lambda: print(number)).start() + + return [ui.heading(f"{number}"), ui.button("+5", on_press=handle_press)] + + +example_counter = counter() +``` + +If you use the substitution method, you can see the “snapshot” of the state passed to the alert. + +```python +def handle_press(): + set_number(0 + 5) + Timer(3, lambda: print(0)).start() +``` + +The state stored in `deephaven.ui` may have changed by the time the alert runs, but it was scheduled using a snapshot of the state at the time the user interacted with it. + +A state variable’s value never changes within a render, even if its event handler’s code is asynchronous. Inside that render’s `on_press`, the value of number continues to be 0 even after `set_number(number + 5)` was called. Its value was “fixed” when `deephaven.ui` “took the snapshot” of the UI by calling your component. + +Here is an example of how that makes your event handlers less prone to timing mistakes. Below is a form that sends a message with a five-second delay. Imagine this scenario: + +1. You press the “Send” button, sending “Hello” to Alice. +2. Before the five-second delay ends, you change the value of the “To” field to “Bob”. + +What do you expect the alert to display? Would it display, “You said Hello to Alice”? Or would it display, “You said Hello to Bob”? + +```python +from threading import Timer +from deephaven import ui + + +@ui.component +def form(): + to, set_to = ui.use_state("Alice") + message, set_message = ui.use_state("Hello") + + def handle_submit(): + Timer(5, lambda: print(f"You said {message} to {to}")).start() + + return ui.form( + ui.picker( + "Alice", "Bob", label="To", selected_key=to, on_selection_change=set_to + ), + ui.text_area(value=message, on_change=set_message), + ui.button("Send", type="submit"), + on_submit=handle_submit, + ) + + +example_form = form() +``` + +`deephaven.ui` keeps the state values “fixed” within one render’s event handlers. You do not need to worry whether the state has changed while the code is running. diff --git a/plugins/ui/docs/add-interactivity/update-dictionaries-in-state.md b/plugins/ui/docs/add-interactivity/update-dictionaries-in-state.md new file mode 100644 index 000000000..2ebaae4b8 --- /dev/null +++ b/plugins/ui/docs/add-interactivity/update-dictionaries-in-state.md @@ -0,0 +1,383 @@ +# Update Dictionaries in State + +State can hold any kind of Python value, including dictionaries. However, you should avoid modifying objects stored in the `deephaven.ui` state directly. Instead, when you want to update a dictionary, create a new one or make a copy of the existing dictionary, and then set the state to use that new or copied version. + +## What is a mutation? + +You can store any kind of Python value in state. + +```python +x, set_x = ui.use_state(0) +``` + +Python data types like numbers, strings, and booleans are "immutable", meaning unchangeable or "read-only". You can trigger a re-render to replace a value: + +```python +set_x(5) +``` + +The `x` state changed from `0` to `5`, but the number `0` itself did not change. It is not possible to make any changes to the built-in data types like numbers, strings, and booleans in Python. + +Now consider a dictionary in state: + +```python +position, set_position = ui.use_state({"x": 0, "y": 0}) +``` + +It is possible to change the contents of the dictionary itself. This is called a mutation: + +```python +position["x"] = 5 +``` + +Although dictionaries in `deephaven.ui` state are technically mutable, you should treat them as if they were immutable like numbers, booleans, and strings. Instead of mutating them, you should always replace them. + +## Treat state as read-only + +You should treat any Python dictionary that you put into state as read-only. + +This example holds a dictionary in state to represent a range. Clicking the button should increment the end of the range, but the range does not update: + +```python +from deephaven import ui + + +@ui.component +def range_example(): + value, set_value = ui.use_state({"start": 0, "end": 50}) + + def handle_press(): + value["end"] = value["end"] + 1 + + return [ + ui.range_slider(value=value, label="Range"), + ui.button("Update", on_press=handle_press), + ] + + +my_range_example = range_example() +``` + +The problem is with this bit of code. + +```python +def handle_press(): + value["end"] = value["end"] + 1 +``` + +This code modifies the dictionary assigned to `value` from the previous render. However, since we are not using the state-setting function, `deephaven.ui` is unaware that the dictionary has changed. As a result, `deephaven.ui` does not respond to the modifications. While it is possible to mutate state in certain cases, we do not recommend this. Treat the state value you have access to during a render as read-only. + +To actually trigger a re-render in this case, create a new dictionary and pass it to the state setting function: + +```python +def handle_press(): + set_value({"start": 0, "end": value["end"] + 1}) +``` + +With `set_value`, you’re telling `deephaven.ui`: + +- Replace `value` with this new dictionary +- And render this component again + +Notice how the range updates when you click the button: + +```python +from deephaven import ui + + +@ui.component +def range_example(): + value, set_value = ui.use_state({"start": 0, "end": 50}) + + def handle_press(): + set_value({"start": 0, "end": value["end"] + 1}) + + return [ + ui.range_slider(value=value, label="Range"), + ui.button("Update", on_press=handle_press), + ] + + +my_range_example = range_example() +``` + +## Copy Dictionaries + +In the previous example, the `value` dictionary is always created from new data. But often, you will want to include existing data as a part of the new dictionary you are creating. For example, you may want to update only one field in a form, but keep the previous values for all other fields. + +These input fields don’t work because the `on_change` handlers mutate the state: + +```python +from deephaven import ui + + +@ui.component +def form(): + person, set_person = ui.use_state( + { + "first_name": "John", + "last_name": "Doe", + "email": "jondoe@domain.com", + } + ) + + def handle_first_name_change(value): + person["first_name"] = value + + def handle_last_name_change(value): + person["last_name"] = value + + def handle_email_change(value): + person["email"] = value + + return [ + ui.text_field( + label="First name", + value=person["first_name"], + on_change=handle_first_name_change, + ), + ui.text_field( + label="Last name", + value=person["last_name"], + on_change=handle_last_name_change, + ), + ui.text_field( + label="Email", value=person["email"], on_change=handle_email_change + ), + ui.text(f'{person["first_name"]} {person["last_name"]} {person["email"]}'), + ] + + +form_example = form() +``` + +For example, this line mutates the state from a past render: + +```python +person["first_name"] = value +``` + +To achieve the desired behavior, it is best to create a new dictionary and pass it to `set_person`. Since only one of the fields has changed, you'll want to copy the existing data into this new dictionary. + +```python +set_person( + { + "first_name": value, + "last_name": person["last_name"], + "email": person["email"], + } +) +``` + +You can use dictionary `unpacking` so that you do not need to copy every property separately. + +```python +set_person({**person, "first_name": value}) +``` + +Now the form works. + +Notice you did not need to declare a separate state variable for each input field. For large forms, keeping all data grouped in a dictionary is convenient if updated correctly. + +```python +from deephaven import ui + + +@ui.component +def form(): + person, set_person = ui.use_state( + { + "first_name": "John", + "last_name": "Doe", + "email": "jondoe@domain.com", + } + ) + + return [ + ui.text_field( + label="First name", + value=person["first_name"], + on_change=lambda new_first_name: set_person( + { + **person, + "first_name": new_first_name, + } + ), + ), + ui.text_field( + label="Last name", + value=person["last_name"], + on_change=lambda new_last_name: set_person( + { + **person, + "last_name": new_last_name, + } + ), + ), + ui.text_field( + label="Email", + value=person["email"], + on_change=lambda new_email: set_person({**person, "email": new_email}), + ), + ui.text(f'{person["first_name"]} {person["last_name"]} {person["email"]}'), + ] + + +form_example = form() +``` + +Note that the dictionary `unpacking` is “shallow”. It only copies things one level deep. This makes it fast, but it also means that if you want to update a nested property, you’ll have to use it more than once. + +## Update a nested dictionary + +Consider a nested dictionary structure like this: + +```python +person, set_person = ui.use_state( + { + "first_name": "John", + "last_name": "Doe", + "contact": {"email": "jondoe@domain.com", "phone": "555-5555"}, + } +) +``` + +If you wanted to update `email`, it’s clear how to do it with mutation: + +```python +person["contact"]["email"] = "jdoe@domain.net" +``` + +But in `deephaven.ui`, you should treat state as immutable. In order to change `email`, you first need to produce the new `contact` dictionary (pre-populated with data from the previous one), and then produce the new `person` dictionary, which points at the new artwork: + +```python +new_person = {**person, "contact": {**person["contact"], "email": "jdoe@domain.net"}} +``` + +This gets a bit wordy, but it works fine for many cases: + +```python +from deephaven import ui + + +@ui.component +def form(): + person, set_person = ui.use_state( + { + "first_name": "John", + "last_name": "Doe", + "contact": {"email": "jondoe@domain.com", "phone": "555-5555"}, + } + ) + + return [ + ui.text_field( + label="First name", + value=person["first_name"], + on_change=lambda new_first_name: set_person( + { + **person, + "first_name": new_first_name, + } + ), + ), + ui.text_field( + label="Last name", + value=person["last_name"], + on_change=lambda new_last_name: set_person( + {**person, "last_name": new_last_name} + ), + ), + ui.text_field( + label="Email", + value=person["contact"]["email"], + on_change=lambda new_email: set_person( + {**person, "contact": {**person["contact"], "email": new_email}} + ), + ), + ui.text_field( + label="Phone", + value=person["contact"]["phone"], + on_change=lambda new_phone: set_person( + {**person, "contact": {**person["contact"], "phone": new_phone}} + ), + ), + ui.text( + f'{person["first_name"]} {person["last_name"]} {person["contact"]["email"]} {person["contact"]["phone"]}' + ), + ] + + +form_example = form() +``` + +## Write concise update logic with `deepcopy` + +If your state is deeply nested, you might consider flattening it. If you do not want to change your state structure, you might prefer to use `deepcopy`. The Python `copy` library includes a `deepcopy` function that constructs a new dictionary and recursively inserts copies of dictionaries found in the original. + +```python +import copy +from deephaven import ui + + +@ui.component +def form(): + person, set_person = ui.use_state( + { + "first_name": "John", + "last_name": "Doe", + "contact": {"email": "jondoe@domain.com", "phone": "555-5555"}, + } + ) + + def handle_first_name_change(value): + person_copy = copy.deepcopy(person) + person_copy["first_name"] = value + set_person(person_copy) + + def handle_last_name_change(value): + person_copy = copy.deepcopy(person) + person_copy["last_name"] = value + set_person(person_copy) + + def handle_email_change(value): + person_copy = copy.deepcopy(person) + person_copy["contact"]["email"] = value + set_person(person_copy) + + def handle_phone_change(value): + person_copy = copy.deepcopy(person) + person_copy["contact"]["phone"] = value + set_person(person_copy) + + return [ + ui.text_field( + label="First name", + value=person["first_name"], + on_change=handle_first_name_change, + ), + ui.text_field( + label="Last name", + value=person["last_name"], + on_change=handle_last_name_change, + ), + ui.text_field( + label="Email", + value=person["contact"]["email"], + on_change=handle_email_change, + ), + ui.text_field( + label="Phone", + value=person["contact"]["phone"], + on_change=handle_phone_change, + ), + ui.text( + f'{person["first_name"]} {person["last_name"]} {person["contact"]["email"]} {person["contact"]["phone"]}' + ), + ] + + +form_example = form() +``` + +Notice how much more concise the event handlers have become. `deepcopy` is a great way to keep the update handlers if there is nesting in your state. diff --git a/plugins/ui/docs/components/breadcrumbs.md b/plugins/ui/docs/components/breadcrumbs.md new file mode 100644 index 000000000..399be1aaf --- /dev/null +++ b/plugins/ui/docs/components/breadcrumbs.md @@ -0,0 +1,309 @@ +# Breadcrumbs + +Breadcrumbs show hierarchy and navigational context for a user's location within an application. + +```python +from deephaven import ui + + +breadcrumbs_example = ui.view( + ui.breadcrumbs( + ui.item("Deephaven", key="deephaven"), + ui.item("Products", key="products"), + ui.item("Community Core", key="community_core"), + ), + width="100%", +) +``` + +## Content + +`ui.breadcrumbs` accepts `item` elements as children, each with a `key` prop. Basic usage of breadcrumbs, seen in the example above, shows multiple items populated with a string. + +## Events + +Use the `on_action` prop to specify a callback to handle press events on items. + +```python +from deephaven import ui + + +@ui.component +def breadcrumbs_action_example(): + selected, set_selected = ui.use_state("None") + + return ( + ui.view( + ui.breadcrumbs( + ui.item("Deephaven", key="deephaven"), + ui.item("Products", key="products"), + ui.item("Community Core", key="community_core"), + on_action=set_selected, + ), + ui.text(f"{selected} clicked"), + width="100%", + ), + ) + + +my_breadcrumbs_action_example = breadcrumbs_action_example() +``` + +## Links + +By default, interacting with an item in breadcrumbs triggers `on_action`. By passing the `href` prop to the `ui.item` component, items may also be links to another page or website. The target window to open the link in can be configured using the `target` prop. + +```python +from deephaven import ui + + +breadcrumbs_link_example = ui.view( + ui.breadcrumbs( + ui.item( + "Deephaven", + key="deephaven", + href="https://deephaven.io/", + target="_blank", + ), + ui.item( + "Community Core", + key="community_core", + href="https://deephaven.io/community/", + target="_blank", + ), + ui.item( + "Getting Started", + key="getting_started", + href="https://deephaven.io/core/docs/getting-started/quickstart/", + target="_blank", + ), + ), + width="100%", +) +``` + +## Size + +The size of the breadcrumbs including spacing and layout can be set using the `size` prop. By default this is set to `"L"`. + +```python +from deephaven import ui + + +breadcrumbs_size_example = ui.view( + ui.breadcrumbs( + ui.item("Deephaven", key="deephaven"), + ui.item("Products", key="products"), + ui.item("Community Core", key="community_core"), + ), + ui.breadcrumbs( + ui.item("Deephaven", key="deephaven"), + ui.item("Products", key="products"), + ui.item("Community Core", key="community_core"), + size="M", + ), + ui.breadcrumbs( + ui.item("Deephaven", key="deephaven"), + ui.item("Products", key="products"), + ui.item("Community Core", key="community_core"), + size="S", + ), + width="100%", +) +``` + +## Multiline + +Use the `is_multiline` prop to place the last item below the other items. This adds emphasis to the current location as a page title or heading. + +```python +from deephaven import ui + + +breadcrumbs_multiline_example = ui.view( + ui.breadcrumbs( + ui.item("Deephaven", key="deephaven"), + ui.item("Products", key="products"), + ui.item("Community Core", key="community_core"), + is_multiline=True, + ), + width="100%", +) +``` + +## Root context + +Some applications find that always displaying the root item is useful to orient users. Use the `show_root` prop to keeps the root visible when other items are truncated into the menu. + +```python +from deephaven import ui + + +breadcrumbs_root_context_example = ui.view( + ui.breadcrumbs( + ui.item("Deephaven", key="deephaven"), + ui.item("Products", key="products"), + ui.item("Community Core", key="community_core"), + ui.item("Getting Started", key="getting_started"), + ui.item("Create Tables", key="create_tables"), + show_root=True, + ), + width="300px", +) +``` + +## Disabled + +Use the `is_disabled` prop to show items but indicate that navigation is not available. This can be used to maintain layout continuity. + +```python +from deephaven import ui + + +breadcrumbs_disabled_example = ui.view( + ui.breadcrumbs( + ui.item("Deephaven", key="deephaven"), + ui.item("Products", key="products"), + ui.item("Community Core", key="community_core"), + is_disabled=True, + ), + width="100%", +) +``` + +## Overflow behavior + +Breadcrumbs collapses items into a menu when space is limited. It will only show a maximum of 4 visible items including the root and menu button, if either are visible. + +If the root item cannot be rendered in the available horizontal space, it will be collapsed into the menu regardless of the `show_root` prop. + +Note that the last breadcrumb item will automatically truncate with an ellipsis instead of collapsing into the menu. + +```python +from deephaven import ui + + +@ui.component +def breadcrumbs_overflow_example(): + return [ + ui.view( + ui.breadcrumbs( + ui.item("Deephaven", key="deephaven"), + ui.item("Products", key="products"), + ui.item("Community Core", key="community_core"), + ui.item("Getting Started", key="getting_started"), + ui.item("Create Tables", key="create_tables"), + show_root=True, + ), + border_width="thin", + border_color="accent-400", + width="100%", + ), + ui.view( + ui.breadcrumbs( + ui.item("Deephaven", key="deephaven"), + ui.item("Products", key="products"), + ui.item("Community Core", key="community_core"), + ui.item("Getting Started", key="getting_started"), + ui.item("Create Tables", key="create_tables"), + show_root=True, + ), + border_width="thin", + border_color="accent-400", + width="200px", + ), + ui.view( + ui.breadcrumbs( + ui.item("Deephaven", key="deephaven"), + ui.item("Products", key="products"), + ui.item("Community Core", key="community_core"), + ui.item("Getting Started", key="getting_started"), + ui.item("Create Tables", key="create_tables"), + ), + border_width="thin", + border_color="accent-400", + width="100px", + ), + ] + + +my_breadcrumbs_overflow_example = breadcrumbs_overflow_example() +``` + +## Detailed example + +Below is an example using the generated `tips` dataset from the Deephaven Express API. It allows you to explore the data in a hierarchical order of day, time, sex, and smoker status. + +```python +import deephaven.plot.express as dx +from deephaven.table import Table +from deephaven import ui + + +@ui.component +def table_breadcrumb_filterer( + table: Table, filter_columns: list[str], all_item_text="All" +): + items, set_items = ui.use_state([ui.item(all_item_text)]) + option_column, set_option_column = ui.use_state(filter_columns[0]) + filters, set_filters = ui.use_state([]) + + filtered_table = ui.use_memo(lambda: table.where(filters), [table, filters]) + column_value_table = ui.use_memo( + lambda: filtered_table.select_distinct(option_column), + [filtered_table, option_column], + ) + column_values = ui.use_column_data(column_value_table) + + def handle_action(key): + current_index = filter_columns.index(option_column) + set_items(items + [ui.item(f"{key}", key=option_column)]) + if current_index < len(filter_columns) - 1: + set_option_column(filter_columns[current_index + 1]) + set_filters(filters + [f"{option_column} == '{key}'"]) + + def handle_back(key): + if key not in filter_columns: + set_items([ui.item(all_item_text)]) + set_option_column(filter_columns[0]) + set_filters([]) + return + + selected_index = filter_columns.index(key) + set_items(items[: selected_index + 2]) + set_option_column(filter_columns[selected_index + 1]) + set_filters(filters[: selected_index + 1]) + + show_filter = len(filters) < len(filter_columns) + + return ui.flex( + ui.flex( + ui.breadcrumbs(*items, show_root=True, on_action=handle_back, flex_grow=1), + ui.view( + ui.menu_trigger( + ui.action_button(f"Filter by {option_column}", ui.icon("filter")), + ui.menu( + *[ui.item(value) for value in column_values], + on_action=handle_action, + ), + ), + ) + if show_filter + else None, + ), + filtered_table.view( + formulas=["TotalBill", "Tip", "Size"] + filter_columns[len(filters) :] + ), + direction="column", + ) + + +_tips = dx.data.tips() +my_tips = table_breadcrumb_filterer(_tips, ["Day", "Time", "Sex", "Smoker"], "All Tips") +``` + +## API reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.breadcrumbs +``` diff --git a/plugins/ui/docs/components/divider.md b/plugins/ui/docs/components/divider.md new file mode 100644 index 000000000..4e1c39ac6 --- /dev/null +++ b/plugins/ui/docs/components/divider.md @@ -0,0 +1,68 @@ +# Divider + +Dividers enhance layout clarity by grouping and separating nearby content, helping to establish structure and hierarchy. + +## Example + +```python +from deephaven import ui + + +@ui.component +def ui_divider_basic_example(): + return ["Content above", ui.divider(), "Content below"] + + +my_divider_basic_example = ui_divider_basic_example() +``` + +## Orientation + +While aligned horizontally by default, the alignment of the divider can be set using the `orientation` prop. + +```python +from deephaven import ui + + +@ui.component +def ui_divider_orientation_example(): + return ui.flex( + "Content before", + ui.divider(orientation="vertical"), + "Content after", + flex_grow=0, + ) + + +my_ui_divider_orientation_example = ui_divider_orientation_example() +``` + +## Sizing + +The thickness of the divider can be set using the `size` prop. + +```python +from deephaven import ui + + +@ui.component +def ui_divider_size_example(): + return ui.flex( + "Content below", + ui.divider(size="L"), + "Content above", + ui.divider(size="M"), + "More content above", + ui.divider(size="S"), + direction="column", + ) + + +my_divider_size_example = ui_divider_size_example() +``` + +## API reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.divider +``` diff --git a/plugins/ui/docs/components/labeled_value.md b/plugins/ui/docs/components/labeled_value.md new file mode 100644 index 000000000..02aacfba7 --- /dev/null +++ b/plugins/ui/docs/components/labeled_value.md @@ -0,0 +1,108 @@ +# Labeled Value + +A labeled value displays a non-editable value with a label. + +## Example + +```python +from deephaven import ui + + +my_labeled_value_basic = ui.labeled_value(label="File name", value="Budget.xls") +``` + + +## Value + +A labeled value accepts numbers, strings, and lists of strings in the `value` prop. + +```python +from deephaven import ui + + +@ui.component +def ui_labeled_value_examples(): + return [ + ui.labeled_value(label="File name", value="Budget.xls"), + ui.labeled_value(label="Number of expenses in Budget file", value=123), + ui.labeled_value( + label="Pizza toppings", value=["Pizza", "Pineapple", "Mushroom", "Garlic"] + ), + ] + + +my_labeled_value_values_examples = ui_labeled_value_examples() +``` + +## Numbers + +When passing a number into a labeled value, the `format_options` prop dictates how the value is displayed. There are 3 styles supported by this parameter: Percentage, Currency, and Units. + +Note that this prop is compatible with the option parameter of [Intl.NumberFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/NumberFormat). + +```python +from deephaven import ui + + +@ui.component +def ui_labeled_value_numbers_example(): + return [ + ui.labeled_value( + label="Percent completed", + value=0.89, + format_options={"style": "percent"}, + ), + ui.labeled_value( + label="Withdrawal amount", + value=2350.50, + format_options={"style": "currency", "currency": "USD"}, + ), + ui.labeled_value( + label="Height of Burj Khalifa", + value=32600, + format_options={"style": "unit", "unit": "inch"}, + ), + ] + + +my_labeled_value_numbers_example = ui_labeled_value_numbers_example() +``` + + +## Label position + +By default, the label is positioned above the labeled value, but it can be moved to the side using the `label_position` prop. + +```python +from deephaven import ui + + +my_labeled_value_label_position_example = ui.labeled_value( + label="File name", value="Onboarding.pdf", label_position="side", label_align="end" +) +``` + + +## Contextual Help + +Using the `contextual_help` prop, a `ui.contextual_help` can be placed next to the labeled value to provide additional information. + +```python +from deephaven import ui + + +my_labeled_value_contextual_help_example = ui.labeled_value( + label="File name", + value="Onboarding.pdf", + contextual_help=ui.contextual_help( + heading="Info about the onboarding document", content="Sample content" + ), +) +``` + + +## API reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.labeled_value +``` \ No newline at end of file diff --git a/plugins/ui/docs/components/menu_trigger.md b/plugins/ui/docs/components/menu_trigger.md index 962092be2..0e412f8ac 100644 --- a/plugins/ui/docs/components/menu_trigger.md +++ b/plugins/ui/docs/components/menu_trigger.md @@ -63,7 +63,7 @@ from deephaven import ui my_long_press_example = ui.menu_trigger( - ui.action_button("Crop tool", on_press=print("Cropping!")), + ui.action_button("Crop tool", on_press=lambda _: print("Cropping!")), ui.menu( ui.item("Crop Rotate"), ui.item("Slice"), diff --git a/plugins/ui/docs/components/tag_group.md b/plugins/ui/docs/components/tag_group.md new file mode 100644 index 000000000..54f8fce2d --- /dev/null +++ b/plugins/ui/docs/components/tag_group.md @@ -0,0 +1,272 @@ +# Tag Group + +Tags allow users to categorize content. They can represent keywords or people, and are grouped to describe an item or a search request. + +## Example + +```python +from deephaven import ui + + +tag_group_example = ui.tag_group( + ui.item("News", key="news"), + ui.item("Travel", key="travel"), + ui.item("Gaming", key="gaming"), + ui.item("Shopping", key="shopping"), +) +``` + +## Content + +`tag_group` accepts `item` elements as children, each with a `key` prop. Basic usage of `tag_group`, seen in the example above, shows multiple items populated with a string. + +## Labeling + +To provide a visual label for the tag group, use the `label` prop. + +```python +from deephaven import ui + + +tag_group_label_example = ui.tag_group( + ui.item("News", key="news"), + ui.item("Travel", key="travel"), + ui.item("Gaming", key="gaming"), + ui.item("Shopping", key="shopping"), + label="Categories", +) +``` + +### Label Position + +By default, the position of the label is above the tag group, but it can be moved to the side using the `label_position` prop. + +```python +from deephaven import ui + + +tag_group_label_example = ui.tag_group( + ui.item("News", key="news"), + ui.item("Travel", key="travel"), + ui.item("Gaming", key="gaming"), + ui.item("Shopping", key="shopping"), + label="Categories", + label_position="side", +) +``` + +### Label Alignment + +By default, the label is horizontally aligned to the start of the tag group element, but it can be moved to the end by using the `label_align` prop. + +```python +from deephaven import ui + + +tag_group_label_example = ui.tag_group( + ui.item("News", key="news"), + ui.item("Travel", key="travel"), + ui.item("Gaming", key="gaming"), + ui.item("Shopping", key="shopping"), + label="Categories", + label_align="end", +) +``` + +## Events + +Removing tags can be enabled by providing the `on_remove` prop to the tag group, which will receive the set of keys to remove. + +```python +from deephaven import ui + + +@ui.component +def tag_group_remove_example(): + items, set_items = ui.use_state( + [ + ui.item("News", key="news"), + ui.item("Travel", key="travel"), + ui.item("Gaming", key="gaming"), + ui.item("Shopping", key="shopping"), + ] + ) + + return ( + ui.tag_group( + *items, + on_remove=lambda keys: set_items( + [item for item in items if item.key not in keys] + ) + ), + ) + + +my_tag_group_remove_example = tag_group_remove_example() +``` + +Use the prop `action_label` to display an action button with that label at the end of the tags. The custom action that will be performed is specified by the `on_action` prop. + +```python +from deephaven import ui + + +@ui.component +def tag_group_action_example(): + items, set_items = ui.use_state( + [ + ui.item("News", key="news"), + ui.item("Travel", key="travel"), + ui.item("Gaming", key="gaming"), + ui.item("Shopping", key="shopping"), + ] + ) + + return ui.tag_group( + *items, + action_label="Delete Shopping", + on_action=lambda: set_items([item for item in items if item.key != "shopping"]) + ) + + +my_tag_group_action_example = tag_group_action_example() +``` + +## Links + +Tags can become links to another page or website by passing the `href` prop to the `ui.item` component. The target window to open the link in can be configured using the `target` prop. + +```python +from deephaven import ui + + +tag_group_links_example = ui.tag_group( + ui.item("Adobe", key="adobe", href="https://adobe.com/", target="_blank"), + ui.item("Apple", key="apple", href="https://apple.com/", target="_blank"), + ui.item("Google", key="google", href="https://google.com/", target="_blank"), +) +``` + +## Help text + +A tag group can have both a `description` and an `error_message`. The error message should offer specific guidance on how to correct the input. + +The `is_invalid` prop can be used to set whether the current tag group state is valid or invalid. + +```python +from deephaven import ui + + +@ui.component +def tag_group_help_text_example(): + items, set_items = ui.use_state( + [ + ui.item("News", key="news"), + ui.item("Travel", key="travel"), + ui.item("Gaming", key="gaming"), + ui.item("Shopping", key="shopping"), + ] + ) + + return ( + ui.tag_group( + *items, + on_remove=lambda keys: set_items( + [item for item in items if item.key not in keys] + ), + is_invalid=len(items) > 3, + description="Please include tags for related categories.", + error_message="Must contain no more than 3 tags. Please remove some.", + ), + ) + + +my_tag_group_help_text_example = tag_group_help_text_example() +``` + +## Contextual help + +Using the `contextual_help` prop, a `ui.contextual_help` can be placed next to the label to provide additional information about the tag group. + +```python +from deephaven import ui + + +tag_group_contextual_help_example = ui.tag_group( + ui.item("News", key="news"), + ui.item("Travel", key="travel"), + ui.item("Gaming", key="gaming"), + ui.item("Shopping", key="shopping"), + label="Categories", + contextual_help=ui.contextual_help( + heading="Hint", content="Pick your favorite category" + ), +) +``` + +## Limit rows + +To limit the number of rows initially shown, use the `max_rows` prop. A button to allow the user to expand to show all tags will be displayed if the tags would overflow the number of rows. + +```python +from deephaven import ui + +tag_group_max_rows_example = ui.flex( + ui.view( + ui.tag_group( + ui.item("News", key="news"), + ui.item("Travel", key="travel"), + ui.item("Gaming", key="gaming"), + ui.item("Shopping", key="shopping"), + ), + border_width="thin", + border_color="accent-400", + width="size-2000", + ), + ui.view( + ui.tag_group( + ui.item("News", key="news"), + ui.item("Travel", key="travel"), + ui.item("Gaming", key="gaming"), + ui.item("Shopping", key="shopping"), + max_rows=1, + ), + border_width="thin", + border_color="accent-400", + width="size-2000", + ), + direction="column", +) +``` + +## Empty state + +By default the empty state displays the text "None". + +```python +from deephaven import ui + + +my_tag_group_empty_default = ui.tag_group() +``` + +Use the `render_empty_state` prop to specify the element to be displayed when the tag group will display when no tags are provided. + +```python +from deephaven import ui + + +my_tag_group_empty_custom = ui.tag_group( + render_empty_state=ui.flex( + ui.icon("dh_warning_circle_filled", size="S"), + ui.text("No tags here"), + align_items="center", + ), +) +``` + +## API reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.tag_group +``` diff --git a/plugins/ui/docs/describing/ui_with_tables.md b/plugins/ui/docs/describing/ui_with_tables.md new file mode 100644 index 000000000..10bfa32e4 --- /dev/null +++ b/plugins/ui/docs/describing/ui_with_tables.md @@ -0,0 +1,286 @@ +# UI with Tables + +`deephaven.ui` allows you to programmatically create your own custom UIs. However, the real power of `deephaven.ui` is in its most unique feature: the ability to combine those UIs with Deephaven tables. + +The Deephaven table is the key data structure for working with and analyzing large real-time data. By combining tables with `deephaven.ui`, you can create a UI that allows you to visualize and work with data in a way that best suits your own unique needs. + +For more information, see the quickstart guide on [Working with Deephaven Tables](/core/docs/getting-started/quickstart/#4-working-with-deephaven-tables). + +## Display a table in a component + +You can display a Deephaven table in a component by doing one of the following: + +- Return a table directly from a component. +- Return a table as part of a `list` or `tuple`. +- Add a table to a container such as a `flex` or `panel`. +- [Use `ui.table`](#use-ui.table). + +```python +from deephaven import new_table, ui +from deephaven.column import int_col + +# Prepend name with an underscore to avoid displaying the source table +_source = new_table([int_col("IntegerColumn", [1, 2, 3])]) + + +@ui.component +def single_table(t): + ui.use_effect(lambda: print("displaying table"), []) + return t + + +@ui.component +def list_table(t): + return [ui.text("list table"), t] + + +@ui.component +def flex_table(t): + return ui.flex(ui.text("flex table"), t) + + +my_single_table = single_table(_source) +my_list_table = list_table(_source) +my_flex_table = flex_table(_source) +``` + +![Display a table in a component](../_assets/work_with_tables1.png) + +## Use `ui.table` + +[`ui.table`](../components/table.md) is a wrapper for Deephaven tables that allows you to change how the table is displayed in the UI and how to handle user events. Here is an example of adding custom color formatting. + +```py +from deephaven import ui +import deephaven.plot.express as dx + +_stocks_table = dx.data.stocks() + +t = ui.table( + _stocks_table, + format_=[ + ui.TableFormat(color="fg"), + ui.TableFormat(cols="Sym", color="white"), + ], +) +``` + +![Use ui.table](../_assets/work_with_tables2.png) + +## Memoize table operations + +If you are working with a table, memoize the table operation. This stores the result in a memoized value and prevents the table from being re-computed on every render. This can be done with the [`use_memo`](../hooks/use_memo.md) hook. + +```python +from deephaven import time_table, ui +from deephaven.table import Table + + +theme_options = ["accent-200", "red-200", "green-200"] + + +@ui.component +def ui_memo_table_app(): + n, set_n = ui.use_state(1) + theme, set_theme = ui.use_state(theme_options[0]) + + # ✅ Memoize the table operation, only recompute when the dependency `n` changes + result_table = ui.use_memo( + lambda: time_table("PT1s").update(f"x=i*{n}").reverse(), [n] + ) + + return ui.view( + ui.flex( + ui.picker( + *theme_options, label="Theme", selected_key=theme, on_change=set_theme + ), + ui.slider(value=n, min_value=1, max_value=999, on_change=set_n, label="n"), + result_table, + direction="column", + height="100%", + ), + background_color=theme, + align_self="stretch", + flex_grow=1, + ) + + +memo_table_app = ui_memo_table_app() +``` + +## Hooks for tables + +The [`use_table_data`](../hooks/use_table_data.md) hook lets you use a table's data. This is useful when you want to listen to an updating table and use the data in your component. This example uses the table data to populate two list views. + +```python +from deephaven import time_table, ui + + +@ui.component +def ui_table_data(table): + table_data = ui.use_table_data(table) + return ui.flex( + table, + ui.list_view( + [ui.item(str(timestamp)) for timestamp in table_data["Timestamp"]], + selection_mode=None, + ), + ui.list_view( + [ui.item(x) for x in table_data["x"]], + selection_mode=None, + ), + ) + + +table_data_example = ui_table_data(time_table("PT1s").update("x=i").tail(5)) +``` + +The [`use_row_data`](../hooks/use_row_data.md) hook lets you use the first row of table data as a dictionary. This example displays the latest row of data in a ticking time table. + +```python +from deephaven import time_table, ui + + +@ui.component +def ui_table_first_cell(table): + row_data = ui.use_row_data(table) + return [ + ui.heading("Latest data"), + ui.text(f"Timestamp: {row_data['Timestamp']}"), + ui.text(f"x: {row_data['x']}"), + ] + + +table_first_cell2 = ui_table_first_cell(time_table("PT1s").update("x=i").reverse()) +``` + +The [`use_cell_data`](../hooks/use_cell_data.md) hook lets you use the cell data of the first cell (first row in the first column) in a table. This value can be used for conditional rendering as shown in this example. + +```python +from deephaven import time_table, ui + + +@ui.component +def ui_table_first_cell(table): + cell_value = ui.use_cell_data(table) + is_even = cell_value % 2 == 0 + return [ + ui.heading(f"The first cell value is {cell_value}"), + ui.text(f"Is {cell_value} even?", " ✅" if is_even else " ❌"), + ] + + +table_first_cell2 = ui_table_first_cell( + time_table("PT1s").update("x=i").drop_columns("Timestamp").tail(1) +) +``` + +If the previous hooks do not fit your use case, you can use the [`use_table_listener`](../hooks/use_table_listener.md) hook. This allows you to listen to the raw updates from a table and perform a custom action when the table updates. The update is a dictionary containing dictionaries for data that is `added`, `removed`, or `modified`. Additionally, there is flag indicating if the data is a [replay](core/docs/how-to-guides/replay-data/). + +```python +from deephaven import time_table, ui +from deephaven.table import Table + +_source = time_table("PT1s").update("X = i") + + +@ui.component +def ui_table_monitor(t: Table): + def listener_function(update, is_replay): + print(f"Table updated: {update}, is_replay: {is_replay}") + + ui.use_table_listener(t, listener_function, []) + return t + + +table_monitor = ui_table_monitor(_source) +``` + +## Use tables directly with components + +Some `deephaven.ui` components support the use of tables directly or through an `item_table_source`. + +This example shows a [`list_view`](../components/list_view.md) populated directly from a table. + +```python +from deephaven import ui, new_table +from deephaven.column import string_col + +_colors = new_table( + [ + string_col("Colors", ["Red", "Blue", "Green"]), + ] +) + + +@ui.component +def ui_list_view_table(): + return ui.list_view(_colors) + + +list_view_table_example = ui_list_view_table() +``` + +In this example, an `item_table_source` is used to create complex items from a table (i.e., defining which columns are the data's keys/labels). These complex items are displayed in a `picker`. + +```python +from deephaven import ui, empty_table + +icon_names = ["vsAccount"] +columns = [ + "Key=new Integer(i)", + "Label=new String(`Display `+i)", + "Icon=(String) icon_names[0]", +] +_column_types = empty_table(20).update(columns) + +item_table_source = ui.item_table_source( + _column_types, + key_column="Key", + label_column="Label", + icon_column="Icon", +) + +picker_item_table_source_example = ui.picker(item_table_source, label="User Picker") +``` + +## Update tables and plots from user input + +Tables and plots can update in response to user input. The following examples allows a user to pick two dates on a [`date_range_picker](../components/date_range_picker.md). This updates a state variable which causes the component to re-render with a filtered table and plot. + +```python +from deephaven.time import dh_now +from deephaven import time_table, ui +import deephaven.plot.express as dx + + +@ui.component +def date_table_filter(table, start_date, end_date, time_col="Timestamp"): + dates, set_dates = ui.use_state({"start": start_date, "end": end_date}) + + def filter_by_dates(): + start = dates["start"] + end = dates["end"] + return table.where(f"{time_col} >= start && {time_col} < end") + + filtered_table = ui.use_memo(filter_by_dates, [table, dates]) + plot = ui.use_memo( + lambda: dx.line(filtered_table, x="Timestamp", y="Row"), [filtered_table] + ) + + return [ + ui.date_range_picker( + label="Dates", value=dates, on_change=set_dates, max_visible_months=2 + ), + filtered_table, + plot, + ] + + +SECONDS_IN_DAY = 86400 +today = dh_now() +_table = time_table("PT1s").update_view( + ["Timestamp=today.plusSeconds(SECONDS_IN_DAY*i)", "Row=i"] +) +date_filter = date_table_filter(_table, today, today.plusSeconds(SECONDS_IN_DAY * 10)) +``` diff --git a/plugins/ui/docs/describing/work_with_tables.md b/plugins/ui/docs/describing/work_with_tables.md deleted file mode 100644 index e283d2983..000000000 --- a/plugins/ui/docs/describing/work_with_tables.md +++ /dev/null @@ -1,139 +0,0 @@ -# Work with Tables - -The Deephaven table is the key abstraction that unites static and real-time data for a seamless, integrated experience. Combining tables with `deephaven.ui` components allows you to create your own powerful, data centered workflows. - -For more information, see [Working with Deephaven Tables](/core/docs/getting-started/quickstart/#4-working-with-deephaven-tables). - -## Display a table in a component - -You can display a Deephaven table in a component by doing one of the following: - -- return a table directly from a component -- return a table as part of a `list` or `tuple` -- add a table to a container such as a `flex` or `panel` -- [use ui.table](#use-ui.table) - -```python -from deephaven import new_table, ui -from deephaven.column import int_col - -# Prepend name with an underscore to avoid displaying the source table -_source = new_table([int_col("IntegerColumn", [1, 2, 3])]) - - -@ui.component -def single_table(t): - ui.use_effect(lambda: print("displaying table"), []) - return t - - -@ui.component -def list_table(t): - return [ui.text("list table"), t] - - -@ui.component -def flex_table(t): - return ui.flex(ui.text("flex table"), t) - - -my_single_table = single_table(_source) -my_list_table = list_table(_source) -my_flex_table = flex_table(_source) -``` - -![Display a table in a component](../_assets/work_with_tables1.png) - -## Use `ui.table` - -[`ui.table`](../components/table.md) is a wrapper for Deephaven tables that allows you to change how the table is displayed in the UI and how to handle user events. Here is an example of adding custom color formatting. - -```py -from deephaven import ui -import deephaven.plot.express as dx - -_stocks_table = dx.data.stocks() - -t = ui.table( - _stocks_table, - format_=[ - ui.TableFormat(color="fg"), - ui.TableFormat(cols="Sym", color="white"), - ], -) -``` - -![Use ui.table](../_assets/work_with_tables2.png) - -## Memoize table operations - -If you are working with a table, memoize the table operation. This stores the result in a memoized value and prevents the table from being re-computed on every render. This can be done with the [use_memo](../hooks/use_memo.md) hook. - -```python -from deephaven import time_table, ui -from deephaven.table import Table - - -theme_options = ["accent-200", "red-200", "green-200"] - - -@ui.component -def ui_memo_table_app(): - n, set_n = ui.use_state(1) - theme, set_theme = ui.use_state(theme_options[0]) - - # ✅ Memoize the table operation, only recompute when the dependency `n` changes - result_table = ui.use_memo( - lambda: time_table("PT1s").update(f"x=i*{n}").reverse(), [n] - ) - - return ui.view( - ui.flex( - ui.picker( - *theme_options, label="Theme", selected_key=theme, on_change=set_theme - ), - ui.slider(value=n, min_value=1, max_value=999, on_change=set_n, label="n"), - result_table, - direction="column", - height="100%", - ), - background_color=theme, - align_self="stretch", - flex_grow=1, - ) - - -memo_table_app = ui_memo_table_app() -``` - -## Hooks for tables - -The [`use_table_data`](../hooks/use_table_data.md) hook lets you use a table's data. This is useful when you want to listen to an updating table and use the data in your component. - -```python -from deephaven import time_table, ui - - -@ui.component -def ui_table_data(table): - table_data = ui.use_table_data(table) - return ui.heading(f"The table data is {table_data}") - - -table_data = ui_table_data(time_table("PT1s").update("x=i").tail(5)) -``` - -The [`use_cell_data`](../hooks/use_cell_data.md) hook lets you use the cell data of the first cell (first row in the first column) in a table. This is useful when you want to listen to an updating table and use the data in your component. - -```python -from deephaven import time_table, ui - - -@ui.component -def ui_table_first_cell(table): - cell_value = ui.use_cell_data(table) - return ui.heading(f"The first cell value is {cell_value}") - - -table_first_cell = ui_table_first_cell(time_table("PT1s").tail(1)) -``` diff --git a/plugins/ui/docs/hooks/use_row_list.md b/plugins/ui/docs/hooks/use_row_list.md index ef6539ead..2be3c1bb1 100644 --- a/plugins/ui/docs/hooks/use_row_list.md +++ b/plugins/ui/docs/hooks/use_row_list.md @@ -98,5 +98,5 @@ table_row_list = ui_table_row_list( ## API reference ```{eval-rst} -.. dhaufunction:: deephaven.ui.use_row_list +.. dhautofunction:: deephaven.ui.use_row_list ``` diff --git a/plugins/ui/docs/sidebar.json b/plugins/ui/docs/sidebar.json index 972ff60c5..6f56bb6b7 100644 --- a/plugins/ui/docs/sidebar.json +++ b/plugins/ui/docs/sidebar.json @@ -18,6 +18,10 @@ "label": "Installation", "path": "installation.md" }, + { + "label": "Tutorial", + "path": "tutorial.md" + }, { "label": "Architecture", "path": "architecture.md" @@ -43,8 +47,8 @@ "path": "describing/use_hooks.md" }, { - "label": "Working with Tables", - "path": "describing/work_with_tables.md" + "label": "UI with Tables", + "path": "describing/ui_with_tables.md" }, { "label": "Conditional Rendering", @@ -64,6 +68,31 @@ } ] }, + { + "label": "Add Interactivity", + "items": [ + { + "label": "Respond to Events", + "path": "add-interactivity/respond-to-events.md" + }, + { + "label": "State: A Component's Memory", + "path": "add-interactivity/state-a-components-memory.md" + }, + { + "label": "Render Cycle", + "path": "add-interactivity/render-cycle.md" + }, + { + "label": "State as a Snapshot", + "path": "add-interactivity/state-as-a-snapshot.md" + }, + { + "label": "Update Dictionaries in State", + "path": "add-interactivity/update-dictionaries-in-state.md" + } + ] + }, { "label": "Components", "items": [ @@ -87,6 +116,10 @@ "label": "badge", "path": "components/badge.md" }, + { + "label": "breadcrumbs", + "path": "components/breadcrumbs.md" + }, { "label": "button", "path": "components/button.md" @@ -139,6 +172,10 @@ "label": "dialog_trigger", "path": "components/dialog_trigger.md" }, + { + "label": "divider", + "path": "components/divider.md" + }, { "label": "flex", "path": "components/flex.md" @@ -171,6 +208,10 @@ "label": "inline_alert", "path": "components/inline_alert.md" }, + { + "label": "labeled_value", + "path": "components/labeled_value.md" + }, { "label": "link", "path": "components/link.md" @@ -251,6 +292,10 @@ "label": "tabs", "path": "components/tabs.md" }, + { + "label": "tag_group", + "path": "components/tag_group.md" + }, { "label": "text", "path": "components/text.md" diff --git a/plugins/ui/docs/tutorial.md b/plugins/ui/docs/tutorial.md new file mode 100644 index 000000000..f5203fa25 --- /dev/null +++ b/plugins/ui/docs/tutorial.md @@ -0,0 +1,816 @@ +# Create a Dashboard with `deephaven.ui` + +This guide shows you how to build a dashboard with [`deephaven.ui`](https://github.com/deephaven/deephaven-plugins/tree/main/plugins/ui), Deephaven’s Python library to create user interfaces. You’ll use a wide range of components supported by the library to familiarize you with what `deephaven.ui` provides and dive deep into simulated data with live dataframes, visualizations, and interactivity seamlessly integrated into a dashboard. + +- First, you'll learn the basic `deephaven.ui` components. Basic components use panels, which are individual and adjustable windows within Deephaven. Tables, charts, and other components are rendered in panels. +- After creating panels, you'll create a dashboard. A dashboard is a collection of panels that open in a separate window within Deephaven. +- Then, you'll create a custom panel component. Custom components enable rich interactivity within your panels and dashboards. +- Finally, you'll embed your custom component into your dashboard. + +![img](_assets/deephaven-ui-crash-course/iris_species_dashboard_final.png) +To follow along, you need the [`deephaven.ui`](https://pypi.org/project/deephaven-plugin-ui/) package and simulated data and charts from [`deephaven.plot.express`](https://pypi.org/project/deephaven-plugin-ui/). Both of these packages are included in the default setup. + +Import the simulated `iris` data with this script: + +```py +from deephaven import ui +import deephaven.plot.express as dx + +iris = dx.data.iris() +``` + +![img](_assets/deephaven-ui-crash-course/iris.png) + +In this dataset, the `Species` column is a categorical column with three values: `setosa`, `Versicolor`, and `Virginia`. The `SepalLength`, `SepalWidth`, `PetalLength`, and `PetalWidth` columns are continuous numerical columns that contain measurements of the sepal and petal of an iris flower. The `Timestamp` column is also useful for ordering the data. + +You'll mostly focus on `SepalLength` and `SepalWidth` in this guide. + +## Basic components + +Components are the building blocks of `deephaven.ui`. Each component takes parameters that control how the component appears. By default, a component renders in a panel. + +### `ui.table` + +Wrapping a table in [`ui.table`](components/table.md) unlocks visual functionality on the table. +Since you're investigating `SepalLength` and `SepalWidth`, create a `ui.table` that accentuates the latest filtered data. +With `iris`, create a `ui.table` that: + +1. Reverses the order so that the newest rows are shown first. +2. Pulls the `Species` column to the front along with `Timestamp`. +3. Hides the `PetalLength`, `PetalWidth`, and `SpeciesID` columns. +4. Uses the compact table density so you can see as many rows as possible. + +```py +ui_iris = ui.table( + iris, + reverse=True, + front_columns=["Timestamp", "Species"], + hidden_columns=["PetalLength", "PetalWidth", "SpeciesID"], + density="compact" +) +``` + +![img](_assets/deephaven-ui-crash-course/ui_iris.png) + +### Charts + +Charts from Deephaven Plotly Express (`dx`) have no `deephaven.ui` specific wrapping and are added directly. Create a [`dx.scatter`](../../plotly-express/main/scatter.md) chart that compares `SepalLength` and `SepalWidth` by `Species`. + +```py +scatter_by_species = dx.scatter(iris, x = "SepalLength", y = "SepalWidth", by="Species") +``` + +![img](_assets/deephaven-ui-crash-course/scatter_by_species.png) + +### `ui.text` + +The [`ui.text`](components/text.md) component adds basic text. Create text to accompany the chart and table. + +```py +sepal_text = ui.text("SepalLength vs. SepalWidth By Species") +``` + +![img](_assets/deephaven-ui-crash-course/sepal_text.png) + +### `ui.flex` + +Wrap your chart and `ui.table` in a [`ui.flex`](components/flex.md) component. `ui.flex` is an implementation of [Flexbox](https://developer.mozilla.org/en-US/docs/Learn/CSS/CSS_layout/Flexbox) that enables responsive layouts that adjust as you resize panels. +Items within a `ui.flex` component stretch and shrink based on available space. + +```py +sepal_flex = ui.flex(ui_iris, scatter_by_species) +``` + +![img](_assets/deephaven-ui-crash-course/sepal_flex.png) + +The `direction` of `sepal_flex` is `"row"`. Add `sepal_text` and `sepal_flex` to another panel, with a `direction` of `"column"`. + +```py +sepal_flex_column = ui.flex(sepal_text, sepal_flex, direction="column") +``` + +![img](_assets/deephaven-ui-crash-course/sepal_flex_column.png) + +### Tabs + +The [`ui.tabs`](components/tabs.md) component enables tabs within a panel. Create histograms of `SepalLength` to display in tabs. +Histograms are useful to display comparisons of data distributions, so create [`dx.histogram`](../../plotly-express/main/histogram.md) charts of the columns of interest, `SepalLength` and `SepalWidth`, by `Species`. +Create `ui.tab` elements for `sepal_flex`, `sepal_length_hist`, and `sepal_width_hist`, then pass them to `ui.tabs` to switch between different views. + +```py +sepal_length_hist = dx.histogram(iris, x="SepalLength", by="Species") +sepal_width_hist = dx.histogram(iris, x="SepalWidth", by="Species") + +sepal_tabs = ui.tabs( + ui.tab(sepal_flex, title="Sepal Length vs. Sepal Width"), + ui.tab(sepal_length_hist, title="Sepal Length Histogram"), + ui.tab(sepal_width_hist, title="Sepal Width Histogram"), +) +sepal_flex_tabs = ui.flex(sepal_text, sepal_tabs, direction="column") +``` + +![img](_assets/deephaven-ui-crash-course/sepal_flex_tabs.png) + +### Markdown + +[`ui.markdown`](components/markdown.md) components allow you to provide text in a markdown format. Create an informational panel with markdown text. + +```py +about_markdown = ui.markdown(r""" +### Iris Dashboard + +Explore **SepalLength** and **SepalWidth** from the Iris dataset with **deephaven.ui** +- The data powering this dashboard is simulated Iris data +- Charts are from Deephaven Plotly Express +- Other components are from **deephaven.ui** +""") +``` + +![img](_assets/deephaven-ui-crash-course/about_markdown.png) + +Now you have two responsive panels with fundamental components of `deephaven.ui` that explore `SepalLength` and `SepalWidth` by `Species`. + +## Dashboard + +By default, components are rendered in a panel, but a dashboard enables more complex layouts in an isolated window. +When you have multiple panels, you can create a dashboard to contain them so that you can add even more panels later for your `iris` data exploration. + +
+Expand for complete code up to this point + +```py skip-test +from deephaven import ui +import deephaven.plot.express as dx +from deephaven import agg + +iris = dx.data.iris() + +ui_iris = ui.table( + iris, + reverse=True, + front_columns=["Timestamp", "Species"], + hidden_columns=["PetalLength", "PetalWidth", "SpeciesID"], + density="compact", +) + +scatter_by_species = dx.scatter(iris, x="SepalLength", y="SepalWidth", by="Species") + +sepal_text = ui.text("SepalLength vs. SepalWidth By Species Panel") + +sepal_flex = ui.flex(ui_iris, scatter_by_species) + +sepal_flex_column = ui.flex(sepal_text, sepal_flex, direction="column") + +sepal_length_hist = dx.histogram(iris, x="SepalLength", by="Species") +sepal_width_hist = dx.histogram(iris, x="SepalWidth", by="Species") + +sepal_tabs = ui.tabs( + ui.tab(sepal_flex, title="Sepal Length vs. Sepal Width"), + ui.tab(sepal_length_hist, title="Sepal Length Histogram"), + ui.tab(sepal_width_hist, title="Sepal Width Histogram"), +) +sepal_flex_tabs = ui.flex(sepal_text, sepal_tabs, direction="column") + +about_markdown = ui.markdown(r""" +### Iris Dashboard + +Explore the Iris dataset with **deephaven.ui** + +- The data powering this dashboard is simulated Iris data +- Charts are from Deephaven Plotly Express +- Other components are from **deephaven.ui** + """) +``` + +
+ +Before that, create a `ui.panel` manually to provide a `title`. + +```py +sepal_panel = ui.panel(sepal_flex_tabs, title="Sepal Panel") +iris_dashboard = ui.dashboard(sepal_panel) +``` + +![img](_assets/deephaven-ui-crash-course/iris_dashboard.png) + +You now have a one-panel dashboard. + +You can create a default layout with multiple panels. Create a panel for `about_markdown` so you can give it a `title`. + +```py +about_panel = ui.panel(about_markdown, title="About") +``` + +![img](_assets/deephaven-ui-crash-course/about_panel.png) + +### Row + +One way to create a default layout is by wrapping your panels in a [`ui.row`](components/dashboard.md) component. + +```py +iris_dashboard_row = ui.dashboard(ui.row(about_panel, sepal_panel)) +``` + +![img](_assets/deephaven-ui-crash-course/iris_dashboard_row.png) + +### Column + +Another way to create a default layout is with [`ui.column`](components/dashboard.md). + +```py +iris_dashboard_column = ui.dashboard(ui.column(about_panel, sepal_panel)) +``` + +![img](_assets/deephaven-ui-crash-course/iris_dashboard_column.png) + +### Stack + +One more way to create a default layout is with [`ui.stack`](components/dashboard.md). The last panel is active by default. +`ui.stack` is useful if you want a tabbed layout but also the ability to move the individual components around, which is not possible with `ui.tabs`. +Create tabs that show the average, max, and min values of `SepalLength`, `SepalWidth`, `PetalLength`, and `PetalWidth` by `Species`. +You can compare a specific statistic across the different `Species` within the same panel or move between panels to compare different statistics across `Species`. + +```py +from deephaven import agg + +iris_avg = iris.agg_by([agg.avg(cols=["SepalLength", "SepalWidth", "PetalLength", "PetalWidth"])], by=["Species"]) +iris_max = iris.agg_by([agg.max_(cols=["SepalLength", "SepalWidth", "PetalLength", "PetalWidth"])], by=["Species"]) +iris_min = iris.agg_by([agg.min_(cols=["SepalLength", "SepalWidth", "PetalLength", "PetalWidth"])], by=["Species"]) + +ui_iris_avg = ui.panel(iris_avg, title="Average") +ui_iris_max = ui.panel(iris_max, title="Max") +ui_iris_min = ui.panel(iris_min, title="Min") + +iris_agg_stack = ui.stack(ui_iris_avg, ui_iris_max, ui_iris_min) + +iris_dashboard_stack = ui.dashboard(iris_agg_stack) +``` + +![img](_assets/deephaven-ui-crash-course/iris_dashboard_stack.png) + +## Interactivity + +So far, you’ve worked with `deephaven.ui` components that don’t interact with each other. Now, you’ll create your own component with interactivity and embed it into your dashboard. +Since this walkthrough investigates `SepalLength` and `SepalWidth`, this section shows you how to create a [`dx.densityheatmap`](../../plotly-express/main/density_heatmap.md) chart that shows the density of `SepalLength` and `SepalWidth`, with a `Species` filter. + +### `ui.component` + +A custom component uses the `ui.component` decorator. The decorator signals to the `deephaven.ui` rendering engine that this component needs to be rendered. Create a function with the `ui.component` decorator that returns `"Hello, World!"`. + +```py +@ui.component +def custom_component(): + return "Hello, World!" + +custom_panel = custom_component() +``` + +![img](_assets/deephaven-ui-crash-course/custom_panel.png) + +### Picker + +A [`ui.picker`](components/picker.md) allows you to select options from a list. + +```py +@ui.component +def species_panel(): + species_picker = ui.picker("setosa", "versicolor", "virginica") + + return species_picker + +picker_panel = species_panel() +``` + +![img](_assets/deephaven-ui-crash-course/picker_panel.png) + +### Table-backed `ui.picker` + +`ui.pickers` can pull directly from a table so they update automatically based on a column in the table. Modify your `ui.picker` to pass in a table instead, which is recommended for dynamic data. + +> [!NOTE] +> It’s important to filter your table down to the distinct values you want. The `ui.picker` does not do this for you. + +```py +species_table = iris.view("Species").select_distinct() + +@ui.component +def species_panel(): + species_picker = ui.picker(species_table) + + return species_picker + +species_picker_panel = species_panel() +``` + +![img](_assets/deephaven-ui-crash-course/species_picker_panel.png) + +### `ui.use_state` + +The [`ui.use_state`](hooks/use_state.md) hook is how you’ll enable interactivity. The hook takes a starting value and returns a tuple of the current value and a function to set the value. +Next, modify your picker to take the `species` and `set_species` from the hook you just defined using `on_change` and `selected_key`. `on_change` is called whenever an option is selected. `selected_key` is the currently selected option. Using these makes the component controlled, meaning that the `selected_key` is controlled outside of the `ui.picker` itself. This allows you to use the `selected_key` in other ways such as filtering a table. + +> [!WARNING] +> Hooks like `ui.use_state` are only available in components decorated with `ui.component`. +> Additionally, hooks should not be called conditionally within a component. Create a new component if conditional rendering is needed. + +```py +@ui.component +def species_panel(): + species, set_species = ui.use_state() + species_picker = ui.picker(species_table, on_change=set_species, selected_key=species, label="Current Species") + + return species_picker + +species_picker_panel = species_panel() +``` + +![img](_assets/deephaven-ui-crash-course/species_picker_panel_controlled.png) + +Now, your picker is controlled by the rendering engine and updates as you pick values, and you have an up-to-date value from the table to filter on. + +### Utilizing State + +Now, add a table filtered on this value and a chart that uses this filtered table. Return them with the picker. +You'll create a `dx.heatmap`, which shows the joint density of `SepalLength` and `SepalWidth` (the variables of interest) filtered by `Species`. + +```py +@ui.component +def species_panel(): + species, set_species = ui.use_state() + species_picker = ui.picker( + species_table, + on_change=set_species, + selected_key=species, + label="Current Species" + ) + + filtered_table = iris.where("Species = species") + + heatmap = dx.density_heatmap(filtered_table, x="SepalLength", y="SepalWidth") + + return ui.panel(ui.flex(species_picker, heatmap, direction="column"), title="Investigate Species") + +species_picker_panel = species_panel() +``` + +![img](_assets/deephaven-ui-crash-course/species_picker_panel_investigate.png) + +### `ui.illustrated_message` + +Currently, an empty table and chart appear if no species is selected. For a more user-friendly experience, add a [`ui.illustrated_message`](components/illustrated_message.md) component to display instead if no `species` is selected. + +```py +@ui.component +def species_panel(): + species, set_species = ui.use_state() + species_picker = ui.picker( + species_table, + on_change=set_species, + selected_key=species, + label="Current Species" + ) + + heatmap = ui.illustrated_message( + ui.icon("vsFilter"), + ui.heading("Species required"), + ui.content("Select a species to display filtered table and chart."), + width="100%", + ) + + if species: + filtered_table = iris.where("Species = species") + + heatmap = dx.density_heatmap(filtered_table, x="SepalLength", y="SepalWidth") + + return ui.panel(ui.flex(species_picker, heatmap, direction="column"), title="Investigate Species") + +species_picker_panel = species_panel() +``` + +![img](_assets/deephaven-ui-crash-course/species_picker_panel_illustrated.png) + + +### Utilizing custom components + +Next, embed your custom component in your dashboard. + +```python +iris_species_dashboard = ui.dashboard( + ui.column( + ui.row(about_panel, iris_agg_stack), ui.row(sepal_panel, species_picker_panel) + ) +) +``` +![img](_assets/deephaven-ui-crash-course/iris_species_dashboard.png) + +The top row contains lots of empty space. Resize the height of the top row to 1 and the bottom row to 2 for a ratio of 1:2, so the bottom row is twice the height of the top row. + +```py +iris_species_dashboard_resized = ui.dashboard(ui.column(ui.row(about_panel, iris_agg_stack, height=1), ui.row(sepal_panel, species_picker_panel, height=2))) +``` + +![img](_assets/deephaven-ui-crash-course/iris_species_dashboard_resized.png) + +### Dashboard state across panels + +Currently, the `ui.picker` selects `Species` within a panel. You can change state across panels as well. + +Recreate the `sepal_flex_tabs` panel within the `create_sepal_panel` function, which takes a `set_species` function as an argument. Now, we'll add a `ui.table` event -- these are useful if you see a row you want to investigate further. + +- Add a listener (`on_row_double_press`) to the table that is called when a row is double-clicked, returning the row data. This sets the `Species` value displayed in the `ui.picker` and `dx.density_heatmap`. +- Pull the `Species` value from the row data and set it with `set_species`. +- Then, create `sepal_panel` with `create_sepal_panel`, passing `set_species` to it. + +```python +def create_sepal_panel(set_species): + ui_iris = ui.table( + iris, + reverse=True, + front_columns=["Timestamp", "Species"], + hidden_columns=["PetalLength", "PetalWidth", "SpeciesID"], + density="compact", + on_row_double_press=lambda event: set_species(event["Species"]["value"]), + ) + + sepal_flex = ui.flex(ui_iris, scatter_by_species) + + sepal_tabs = ui.tabs( + ui.tab(sepal_flex, title="Sepal Length vs. Sepal Width"), + ui.tab(sepal_length_hist, title="Sepal Length Histogram"), + ui.tab(sepal_width_hist, title="Sepal Width Histogram"), + ) + + sepal_flex_tabs = ui.flex(sepal_text, sepal_tabs, direction="column") + + return ui.panel(sepal_flex_tabs, title="Sepal Panel") + + +@ui.component +def create_species_dashboard(): + species, set_species = ui.use_state() + species_picker = ui.picker( + species_table, + on_change=set_species, + selected_key=species, + label="Current Species", + ) + + heatmap = ui.illustrated_message( + ui.icon("vsFilter"), + ui.heading("Species required"), + ui.content("Select a species to display filtered table and chart."), + width="100%", + ) + + if species: + filtered_table = iris.where("Species = species") + + heatmap = dx.density_heatmap(filtered_table, x="SepalLength", y="SepalWidth") + + species_panel = ui.panel( + ui.flex(species_picker, heatmap, direction="column"), + title="Investigate Species", + ) + + sepal_panel = create_sepal_panel(set_species) + + return ui.column( + ui.row(about_panel, iris_agg_stack, height=1), + ui.row(sepal_panel, species_panel, height=2), + ) + + +iris_species_dashboard_state = ui.dashboard(create_species_dashboard()) +``` + +![img](_assets/deephaven-ui-crash-course/iris_species_dashboard_resized.png) + +### `ui.use_row_data` and `ui.badge` + +You've got a density heatmap that shows the density of `SepalLength` and `SepalWidth` by `Species`, but it's tricky to see the min, max, and average overall values for `SepalLength` and `SepalWidth` for the selected `Species`. They're in your table stack, but you can make them more prominent. + +`deephaven.ui` provides hooks to access table data. Each hook provides access to a specific part of the table and is updated when the table changes. +The hooks are: +- [`ui.use_cell_data`](hooks/use_cell_data.md) - Accesses a single cell value. +- [`ui.use_row_data`](hooks/use_row_data.md) - Accesses a row of data. +- [`ui.use_row_list`](hooks/use_row_list.md) - Accesses a row as a list. +- [`ui.use_column_data`](hooks/use_column_data.md) - Accesses a column of data. +- [`ui.use_table_data`](hooks/use_table_data.md) - Accesses the entire table. + +> [!WARNING] +> Filter your table to only the data you need the hook to pull out. The hooks do not filter the table for you. + +Create a custom component that pulls the min, max, and average values for `SepalLength` and `SepalWidth` for the selected `Species`. +To display the values, wrap them in [`ui.badge`](components/badge.md) components, which draw attention to specific values. + +```python +@ui.component +def summary_badges(species): + # Filter the tables to the selected species + species_min = iris_min.where("Species=species") + species_max = iris_max.where("Species=species") + species_avg = iris_avg.where("Species=species") + + # Pull the desired columns from the tables before using the hooks + sepal_length_min = ui.use_cell_data(species_min.view(["SepalLength"])) + sepal_width_min = ui.use_cell_data(species_min.view(["SepalWidth"])) + sepal_length_max = ui.use_cell_data(species_max.view(["SepalLength"])) + sepal_width_max = ui.use_cell_data(species_max.view(["SepalWidth"])) + sepal_length_avg = ui.use_cell_data(species_avg.view(["SepalLength"])) + sepal_width_avg = ui.use_cell_data(species_avg.view(["SepalWidth"])) + + # format the values to 3 decimal places + # set flex_grow to 0 to prevent the badges from growing + return ui.flex( + ui.badge(f"SepalLength Min: {sepal_length_min:.3f}", variant="info"), + ui.badge(f"SepalLength Max: {sepal_length_max:.3f}", variant="info"), + ui.badge(f"SepalLength Avg: {sepal_length_avg:.3f}", variant="info"), + ui.badge(f"SepalWidth Min: {sepal_width_min:.3f}", variant="info"), + ui.badge(f"SepalWidth Max: {sepal_width_max:.3f}", variant="info"), + ui.badge(f"SepalWidth Avg: {sepal_width_avg:.3f}", variant="info"), + flex_grow=0, + ) + + +@ui.component +def create_species_dashboard(): + species, set_species = ui.use_state() + species_picker = ui.picker( + species_table, + on_change=set_species, + selected_key=species, + label="Current Species", + ) + + heatmap = ui.illustrated_message( + ui.icon("vsFilter"), + ui.heading("Species required"), + ui.content("Select a species to display filtered table and chart."), + width="100%", + ) + + badges = None + + if species: + filtered_table = iris.where("Species = species") + + heatmap = dx.density_heatmap(filtered_table, x="SepalLength", y="SepalWidth") + + badges = summary_badges(species) + + species_panel = ui.panel( + ui.flex(species_picker, badges, heatmap, direction="column"), + title="Investigate Species", + ) + + sepal_panel = create_sepal_panel(set_species) + + return ui.column( + ui.row(about_panel, iris_agg_stack, height=1), + ui.row(sepal_panel, species_panel, height=2), + ) + + +iris_species_dashboard_badge = ui.dashboard(create_species_dashboard()) +``` + +![img](_assets/deephaven-ui-crash-course/iris_species_dashboard_final.png) + +### `ui.use_memo` + +`ui.use_memo` allows you to cache expensive calculations so they are only recalculated when needed. It takes a function and a list of dependencies. If the dependencies change, the function is recalculated. +Since you've added badges to the dashboard, the `dx.heatmap` is recreated every time any of the badges change, but only needs to be recreated when the `Species` changes. +Heatmap is a fairly expensive chart to create (it requires a filtered table in this case) and changes rarely, so pull the heatmap creation into a separate function and use `ui.use_memo` to cache the heatmap creation. + +```python +def create_heatmap(species): + heatmap = ui.illustrated_message( + ui.icon("vsFilter"), + ui.heading("Species required"), + ui.content("Select a species to display filtered table and chart."), + width="100%", + ) + + if species: + filtered_table = iris.where("Species = species") + heatmap = dx.density_heatmap(filtered_table, x="SepalLength", y="SepalWidth") + + return heatmap + + +@ui.component +def create_species_dashboard(): + species, set_species = ui.use_state() + species_picker = ui.picker( + species_table, + on_change=set_species, + selected_key=species, + label="Current Species", + ) + + heatmap = ui.use_memo(lambda: create_heatmap(species), [species]) + + badges = summary_badges(species) if species else None + + species_panel = ui.panel( + ui.flex(species_picker, badges, heatmap, direction="column"), + title="Investigate Species", + ) + + sepal_panel = create_sepal_panel(set_species) + + return ui.column( + ui.row(about_panel, iris_agg_stack, height=1), + ui.row(sepal_panel, species_panel, height=2), + ) + + +iris_species_dashboard_final = ui.dashboard(create_species_dashboard()) +``` + +![img](_assets/deephaven-ui-crash-course/iris_species_dashboard_final.png) + +
+Expand for final code + +```py skip-test +from deephaven import ui +import deephaven.plot.express as dx +from deephaven import agg + +iris = dx.data.iris() + +ui_iris = ui.table( + iris, + reverse=True, + front_columns=["Timestamp", "Species"], + hidden_columns=["PetalLength", "PetalWidth", "SpeciesID"], + density="compact", +) + +scatter_by_species = dx.scatter(iris, x="SepalLength", y="SepalWidth", by="Species") + +sepal_text = ui.text("SepalLength vs. SepalWidth By Species Panel") + +sepal_flex = ui.flex(ui_iris, scatter_by_species) + +sepal_flex_column = ui.flex(sepal_text, sepal_flex, direction="column") + +sepal_length_hist = dx.histogram(iris, x="SepalLength", by="Species") +sepal_width_hist = dx.histogram(iris, x="SepalWidth", by="Species") + +sepal_tabs = ui.tabs( + ui.tab(sepal_flex, title="Sepal Length vs. Sepal Width"), + ui.tab(sepal_length_hist, title="Sepal Length Histogram"), + ui.tab(sepal_width_hist, title="Sepal Width Histogram"), +) +sepal_flex_tabs = ui.flex(sepal_text, sepal_tabs, direction="column") + +about_markdown = ui.markdown(r""" +### Iris Dashboard + +Explore the Iris dataset with **deephaven.ui** + +- The data powering this dashboard is simulated Iris data +- Charts are from Deephaven Plotly Express +- Other components are from **deephaven.ui** + """) + +sepal_panel = ui.panel(sepal_flex_tabs, title="Sepal Panel") +about_panel = ui.panel(about_markdown, title="About") + +iris_avg = iris.agg_by([agg.avg(cols=["SepalLength", "SepalWidth", "PetalLength", "PetalWidth"])], by=["Species"]) +iris_max = iris.agg_by([agg.max_(cols=["SepalLength", "SepalWidth", "PetalLength", "PetalWidth"])], by=["Species"]) +iris_min = iris.agg_by([agg.min_(cols=["SepalLength", "SepalWidth", "PetalLength", "PetalWidth"])], by=["Species"]) + +ui_iris_avg = ui.panel(iris_avg, title="Average") +ui_iris_max = ui.panel(iris_max, title="Max") +ui_iris_min = ui.panel(iris_min, title="Min") + +iris_agg_stack = ui.stack(ui_iris_avg, ui_iris_max, ui_iris_min) + +species_table = iris.view("Species").select_distinct() + +def create_sepal_panel(set_species): + ui_iris = ui.table( + iris, + reverse=True, + front_columns=["Timestamp", "Species"], + hidden_columns=["PetalLength", "PetalWidth", "SpeciesID"], + density="compact", + on_row_double_press=lambda event: set_species(event["Species"]["value"]) + ) + + sepal_flex = ui.flex(ui_iris, scatter_by_species) + + sepal_tabs = ui.tabs( + ui.tab(sepal_flex, title="Sepal Length vs. Sepal Width"), + ui.tab(sepal_length_hist, title="Sepal Length Histogram"), + ui.tab(sepal_width_hist, title="Sepal Width Histogram"), + ) + + sepal_flex_tabs = ui.flex(sepal_text, sepal_tabs, direction="column") + + return ui.panel(sepal_flex_tabs, title="Sepal Panel") + +@ui.component +def summary_badges(species): + # Filter the tables to the selected species + species_min = iris_min.where("Species=species") + species_max = iris_max.where("Species=species") + species_avg = iris_avg.where("Species=species") + + # Pull the desired columns from the tables before using the hooks + sepal_length_min = ui.use_cell_data(species_min.view(["SepalLength"])) + sepal_width_min = ui.use_cell_data(species_min.view(["SepalWidth"])) + sepal_length_max = ui.use_cell_data(species_max.view(["SepalLength"])) + sepal_width_max = ui.use_cell_data(species_max.view(["SepalWidth"])) + sepal_length_avg = ui.use_cell_data(species_avg.view(["SepalLength"])) + sepal_width_avg = ui.use_cell_data(species_avg.view(["SepalWidth"])) + + # format the values to 3 decimal places + # set flex_grow to 0 to prevent the badges from growing + return ui.flex( + ui.badge(f"SepalLength Min: {sepal_length_min:.3f}", variant="info"), + ui.badge(f"SepalLength Max: {sepal_length_max:.3f}", variant="info"), + ui.badge(f"SepalLength Avg: {sepal_length_avg:.3f}", variant="info"), + ui.badge(f"SepalWidth Min: {sepal_width_min:.3f}", variant="info"), + ui.badge(f"SepalWidth Max: {sepal_width_max:.3f}", variant="info"), + ui.badge(f"SepalWidth Avg: {sepal_width_avg:.3f}", variant="info"), + flex_grow=0 + ) + +def create_heatmap(species): + heatmap = ui.illustrated_message( + ui.icon("vsFilter"), + ui.heading("Species required"), + ui.content("Select a species to display filtered table and chart."), + width="100%", + ) + + if species: + filtered_table = iris.where("Species = species") + heatmap = dx.density_heatmap(filtered_table, x="SepalLength", y="SepalWidth") + + return heatmap + +@ui.component +def create_species_dashboard(): + species, set_species = ui.use_state() + species_picker = ui.picker( + species_table, + on_change=set_species, + selected_key=species, + label="Current Species", + ) + + heatmap = ui.use_memo(lambda: create_heatmap(species), [species]) + + badges = summary_badges(species) if species else None + + species_panel = ui.panel( + ui.flex(species_picker, badges, heatmap, direction="column"), + title="Investigate Species", + ) + + sepal_panel = create_sepal_panel(set_species) + + return ui.column( + ui.row(about_panel, iris_agg_stack, height=1), + ui.row(sepal_panel, species_panel, height=2), + ) + +iris_species_dashboard_final = ui.dashboard(create_species_dashboard()) +``` + +
+ +You've now completed this dashboard crash course with your custom component and interactivity. + +## Wrapping up + +This wraps up the `deephaven.ui` dashboard crash course. In this course, you learned about the following components and concepts and created a dashboard with many of them: + +- [`ui.table`](components/table.md) +- [`ui.text`](components/text.md) +- [`ui.flex`](components/flex.md) +- [`ui.tabs`](components/tabs.md) +- [`ui.markdown`](components/markdown.md) +- [`ui.dashboard`](components/dashboard.md) +- [`ui.row`](components/row.md) +- [`ui.column`](components/column.md) +- [`ui.stack`](components/stack.md) +- [`ui.component`](describing/your_first_component.md) +- [`ui.picker`](components/picker.md) +- [`ui.use_state`](hooks/use_state.md) +- [`ui.illustrated_message`](components/illustrated_message.md) +- [`ui.badge`](components/illustrated_message.md) +- [`ui.use_cell_data`](hooks/use_cell_data.md) +- [`ui.use_row_data`](hooks/use_row_data.md) +- [`ui.use_row_list`](hooks/use_row_list.md) +- [`ui.use_column_data`](hooks/use_column_data.md) +- [`ui.use_table_data`](hooks/use_table_data.md) +- [`ui.use_memo`](hooks/use_memo.md) +- [`dx.scatter`](/core/plotly/docs/box) +- [`dx.histogram`](/core/plotly/docs/histogram) +- [`dx.density_heatmap`](/core/plotly/docs/density_heatmap) diff --git a/plugins/ui/src/deephaven/ui/components/__init__.py b/plugins/ui/src/deephaven/ui/components/__init__.py index ae82e443e..feb0b1360 100644 --- a/plugins/ui/src/deephaven/ui/components/__init__.py +++ b/plugins/ui/src/deephaven/ui/components/__init__.py @@ -6,6 +6,7 @@ component_element, ) from .badge import badge +from .breadcrumbs import breadcrumbs from .button import button from .button_group import button_group from .calendar import calendar @@ -22,6 +23,7 @@ from .date_range_picker import date_range_picker from .dialog import dialog from .dialog_trigger import dialog_trigger +from .divider import divider from .flex import flex from .form import form from .fragment import fragment @@ -33,6 +35,7 @@ from .inline_alert import inline_alert from .item import item from .item_table_source import item_table_source +from .labeled_value import labeled_value from .link import link from .list_action_group import list_action_group from .list_action_menu import list_action_menu @@ -64,6 +67,7 @@ from .tab import tab from .table import table, TableDatabar, TableFormat from .tabs import tabs +from .tag_group import tag_group from .text import text from .text_area import text_area from .text_field import text_field @@ -82,6 +86,7 @@ "avatar", "component_element", "badge", + "breadcrumbs", "button", "button_group", "calendar", @@ -99,6 +104,7 @@ "date_range_picker", "dialog", "dialog_trigger", + "divider", "flex", "form", "fragment", @@ -109,6 +115,7 @@ "item_table_source", "illustrated_message", "image", + "labeled_value", "inline_alert", "link", "list_view", @@ -143,6 +150,7 @@ "tab_panels", "tabs", "tab", + "tag_group", "text", "text_area", "text_field", diff --git a/plugins/ui/src/deephaven/ui/components/breadcrumbs.py b/plugins/ui/src/deephaven/ui/components/breadcrumbs.py new file mode 100644 index 000000000..f879cfe0d --- /dev/null +++ b/plugins/ui/src/deephaven/ui/components/breadcrumbs.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +from typing import Callable + +from .basic import component_element +from .section import Item +from ..elements import Element +from .types import ( + AlignSelf, + CSSProperties, + DimensionValue, + JustifySelf, + LayoutFlex, + Position, +) +from ..types import BreadcrumbsSize + + +def breadcrumbs( + *children: Item, + is_disabled: bool | None = None, + size: BreadcrumbsSize | None = None, + show_root: bool | None = None, + is_multiline: bool | None = None, + auto_focus_current: bool | None = None, + on_action: Callable[[str], None] | None = None, + flex: LayoutFlex | None = None, + flex_grow: float | None = None, + flex_shrink: float | None = None, + flex_basis: DimensionValue | None = None, + align_self: AlignSelf | None = None, + justify_self: JustifySelf | None = None, + order: int | None = None, + grid_area: str | None = None, + grid_column: str | None = None, + grid_row: str | None = None, + grid_column_start: str | None = None, + grid_column_end: str | None = None, + grid_row_start: str | None = None, + grid_row_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, + min_width: DimensionValue | None = None, + max_width: DimensionValue | None = None, + height: DimensionValue | None = None, + min_height: DimensionValue | None = None, + max_height: DimensionValue | None = None, + position: Position | None = None, + top: DimensionValue | None = None, + bottom: DimensionValue | None = None, + left: DimensionValue | None = None, + right: DimensionValue | None = None, + start: DimensionValue | None = None, + end: DimensionValue | None = None, + z_index: int | 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_details: str | None = None, + UNSAFE_class_name: str | None = None, + UNSAFE_style: CSSProperties | None = None, + key: str | None = None, +) -> Element: + """ + Breadcrumbs show hierarchy and navigational context for a user's location within an application. + + Args: + *children: The items to render within the breadcrumbs. + is_disabled: Whether the Breadcrumbs are disabled. + size: The size of the breadcrumbs inlcuding spacing and layout. + show_root: Whether to always show the root item if the items are collapsed. + is_multiline: Whether the last item will be placed below other items. + auto_focus_current: Whether to autoFocus the last item. + on_action: Handler that is called when an item is clicked. + 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_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. + key: A unique identifier used by React to render elements in a list. + + + Returns: + The rendered breadcrumbs element. + """ + return component_element( + "Breadcrumbs", + children=children, + is_disabled=is_disabled, + size=size, + show_root=show_root, + is_multiline=is_multiline, + auto_focus_current=auto_focus_current, + on_action=on_action, + flex=flex, + flex_grow=flex_grow, + flex_shrink=flex_shrink, + flex_basis=flex_basis, + align_self=align_self, + justify_self=justify_self, + order=order, + grid_area=grid_area, + grid_column=grid_column, + grid_row=grid_row, + grid_column_start=grid_column_start, + grid_column_end=grid_column_end, + grid_row_start=grid_row_start, + grid_row_end=grid_row_end, + margin=margin, + margin_top=margin_top, + margin_bottom=margin_bottom, + margin_start=margin_start, + margin_end=margin_end, + margin_x=margin_x, + margin_y=margin_y, + width=width, + min_width=min_width, + max_width=max_width, + height=height, + min_height=min_height, + max_height=max_height, + position=position, + top=top, + bottom=bottom, + left=left, + right=right, + start=start, + end=end, + z_index=z_index, + is_hidden=is_hidden, + id=id, + aria_label=aria_label, + aria_labelled_by=aria_labelledby, + aria_described_by=aria_describedby, + aria_details=aria_details, + UNSAFE_class_name=UNSAFE_class_name, + UNSAFE_style=UNSAFE_style, + key=key, + ) diff --git a/plugins/ui/src/deephaven/ui/components/checkbox_group.py b/plugins/ui/src/deephaven/ui/components/checkbox_group.py index 073328090..7996f14e4 100644 --- a/plugins/ui/src/deephaven/ui/components/checkbox_group.py +++ b/plugins/ui/src/deephaven/ui/components/checkbox_group.py @@ -4,6 +4,7 @@ from .types import ( Orientation, + Alignment, AlignSelf, CSSProperties, DimensionValue, @@ -12,6 +13,7 @@ Position, ValidationBehavior, FocusEventCallable, + LabelPosition, ) from .basic import component_element from ..elements import Element @@ -33,8 +35,8 @@ def checkbox_group( is_required: bool | None = None, is_invalid: bool | None = None, validation_behavior: ValidationBehavior | None = "aria", - label_position: str | None = None, - label_align: str | None = None, + label_position: LabelPosition | None = None, + label_align: Alignment | None = None, necessity_indicator: str | None = None, contextual_help: Any | None = None, show_error_icon: bool | None = None, diff --git a/plugins/ui/src/deephaven/ui/components/divider.py b/plugins/ui/src/deephaven/ui/components/divider.py new file mode 100644 index 000000000..ff13ea186 --- /dev/null +++ b/plugins/ui/src/deephaven/ui/components/divider.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from .types import ( + Orientation, + AlignSelf, + CSSProperties, + DimensionValue, + JustifySelf, + LayoutFlex, + Position, +) +from ..types import DividerSize +from .basic import component_element +from ..elements import Element + + +def divider( + size: DividerSize | None = "L", + orientation: Orientation = "horizontal", + flex: LayoutFlex | None = None, + flex_grow: float | None = None, + flex_shrink: float | None = None, + flex_basis: DimensionValue | None = None, + align_self: AlignSelf | None = None, + justify_self: JustifySelf | None = None, + order: int | 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: int | None = None, + is_hidden: bool | None = None, + id: str | None = None, + UNSAFE_class_name: str | None = None, + UNSAFE_style: CSSProperties | None = None, +) -> Element: + """ + Dividers bring clarity to a layout by grouping and dividing content in close proximity. + + Args: + size: How thick the Divider should be. + orientation: The axis the Divider should align with. + 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 the element will grow to fit the space available. + flex_shrink: When used in a flex layout, specifies how the element will shrink to fit the space available. + flex_basis: When used in a flex layout, specifies the initial main size of the element. + align_self: Overrides the alignItems property of a flex or grid container. + justify_self: Species 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: When used in a grid layout specifies, specifies the named grid area that the element should be placed in within the grid. + grid_row: When used in a grid layout, specifies the row the element should be placed in within the grid. + grid_column: When used in a grid layout, specifies the column the element should be placed in within the grid. + grid_row_start: When used in a grid layout, specifies the starting row to span within the grid. + grid_row_end: When used in a grid layout, specifies the ending row to span within the grid. + grid_column_start: When used in a grid layout, specifies the starting column to span within the grid. + grid_column_end: When used in a grid layout, specifies the ending column to span within the grid. + margin: The margin for all four sides of the element. + margin_top: The margin for the top side of the element. + margin_bottom: The margin for the bottom side of the element. + margin_start: The margin for the logical start side of the element, depending on layout direction. + margin_end: The margin for the logical end side of the element, depending on layout direction. + margin_x: The margin for the left and right sides of the element. + margin_y: The margin for the top and bottom sides of the element. + width: The width of the element. + min_width: The minimum width of the element. + max_width: The maximum width of the element. + height: The height of the element. + min_height: The minimum height of the element. + max_height: The maximum height of the element. + position: The position of the element. + top: The distance from the top of the containing element. + bottom: The distance from the bottom of the containing element. + left: The distance from the left of the containing element. + right: The distance from the right of the containing element. + start: The distance from the start of the containing element, depending on layout direction. + end: The distance from the end of the containing element, depending on layout direction. + z_index: The stack order of the element. + is_hidden: Whether the element is hidden. + id: The unique identifier of 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 rendered divider element. + """ + return component_element( + "Divider", + size=size, + orientation=orientation, + flex=flex, + flex_grow=flex_grow, + flex_shrink=flex_shrink, + flex_basis=flex_basis, + align_self=align_self, + justify_self=justify_self, + order=order, + grid_area=grid_area, + grid_row=grid_row, + grid_row_start=grid_row_start, + grid_row_end=grid_row_end, + grid_column=grid_column, + grid_column_start=grid_column_start, + grid_column_end=grid_column_end, + margin=margin, + margin_top=margin_top, + margin_bottom=margin_bottom, + margin_start=margin_start, + margin_end=margin_end, + margin_x=margin_x, + margin_y=margin_y, + width=width, + height=height, + min_width=min_width, + min_height=min_height, + max_width=max_width, + max_height=max_height, + position=position, + top=top, + bottom=bottom, + start=start, + end=end, + left=left, + right=right, + z_index=z_index, + is_hidden=is_hidden, + id=id, + UNSAFE_class_name=UNSAFE_class_name, + UNSAFE_style=UNSAFE_style, + ) diff --git a/plugins/ui/src/deephaven/ui/components/item.py b/plugins/ui/src/deephaven/ui/components/item.py index b12aa953a..87082baea 100644 --- a/plugins/ui/src/deephaven/ui/components/item.py +++ b/plugins/ui/src/deephaven/ui/components/item.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import Any, Union, List +from typing import Any, Union, List, Literal from ..elements import BaseElement from ..types import Stringable @@ -16,6 +16,8 @@ def item( title: str | None = None, text_value: str | None = None, aria_label: str | None = None, + href: str | None = None, + target: Literal["_self", "_blank", "_parent", "_top"] | str | None = None, key: str | None = None, **props: Any, ) -> ItemElement: @@ -27,6 +29,8 @@ def item( title: Rendered contents of the item if `children` contains child items. text_value: A string representation of the item's contents, used for features like typeahead. aria_label: An accessibility label for this item. + href: A URL to link to. + target: The target window for the link. key: A unique identifier used by React to render elements in a list. **props: Any other Item prop. """ diff --git a/plugins/ui/src/deephaven/ui/components/labeled_value.py b/plugins/ui/src/deephaven/ui/components/labeled_value.py new file mode 100644 index 000000000..2f3e36f3a --- /dev/null +++ b/plugins/ui/src/deephaven/ui/components/labeled_value.py @@ -0,0 +1,166 @@ +from __future__ import annotations +from typing import Any, List + +from .number_field import NumberFormatOptions +from .types import ( + Alignment, + AlignSelf, + CSSProperties, + DimensionValue, + JustifySelf, + LayoutFlex, + Position, + LabelPosition, +) +from .basic import component_element +from ..elements import Element + + +def labeled_value( + value: str | List[str] | int | None = None, + label: Element | None = None, + format_options: NumberFormatOptions | None = None, + label_position: LabelPosition | None = "top", + label_align: Alignment | None = None, + contextual_help: Any | None = None, + flex: LayoutFlex | None = None, + flex_grow: float | None = None, + flex_shrink: float | None = None, + flex_basis: DimensionValue | None = None, + align_self: AlignSelf | None = None, + justify_self: JustifySelf | None = None, + order: int | 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: int | None = None, + is_hidden: bool | None = None, + id: str | None = None, + UNSAFE_class_name: str | None = None, + UNSAFE_style: CSSProperties | None = None, +) -> Element: + """ + Labeled values displays non-editable values with a corresponding label + + Args: + value: The value to be displayed. + label: The content of the label. + format_options: Formatting options for the value displayed in the number 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. + contextual_help: A contextual help element to place next to the label. + 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 the element will grow to fit the space available. + flex_shrink: When used in a flex layout, specifies how the element will shrink to fit the space available. + flex_basis: When used in a flex layout, specifies the initial main size of the element. + align_self: Overrides the alignItems property of a flex or grid container. + justify_self: Species 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: When used in a grid layout specifies, specifies the named grid area that the element should be placed in within the grid. + grid_row: When used in a grid layout, specifies the row the element should be placed in within the grid. + grid_column: When used in a grid layout, specifies the column the element should be placed in within the grid. + grid_row_start: When used in a grid layout, specifies the starting row to span within the grid. + grid_row_end: When used in a grid layout, specifies the ending row to span within the grid. + grid_column_start: When used in a grid layout, specifies the starting column to span within the grid. + grid_column_end: When used in a grid layout, specifies the ending column to span within the grid. + margin: The margin for all four sides of the element. + margin_top: The margin for the top side of the element. + margin_bottom: The margin for the bottom side of the element. + margin_start: The margin for the logical start side of the element, depending on layout direction. + margin_end: The margin for the logical end side of the element, depending on layout direction. + margin_x: The margin for the left and right sides of the element. + margin_y: The margin for the top and bottom sides of the element. + width: The width of the element. + min_width: The minimum width of the element. + max_width: The maximum width of the element. + height: The height of the element. + min_height: The minimum height of the element. + max_height: The maximum height of the element. + position: The position of the element. + top: The distance from the top of the containing element. + bottom: The distance from the bottom of the containing element. + left: The distance from the left of the containing element. + right: The distance from the right of the containing element. + start: The distance from the start of the containing element, depending on layout direction. + end: The distance from the end of the containing element, depending on layout direction. + z_index: The stack order of the element. + is_hidden: Whether the element is hidden. + id: The unique identifier of 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 rendered labeled value element. + """ + return component_element( + "LabeledValue", + value=value, + label=label, + format_options=format_options, + label_position=label_position, + label_align=label_align, + contextual_help=contextual_help, + flex=flex, + flex_grow=flex_grow, + flex_shrink=flex_shrink, + flex_basis=flex_basis, + align_self=align_self, + justify_self=justify_self, + order=order, + grid_area=grid_area, + grid_row=grid_row, + grid_row_start=grid_row_start, + grid_row_end=grid_row_end, + grid_column=grid_column, + grid_column_start=grid_column_start, + grid_column_end=grid_column_end, + margin=margin, + margin_top=margin_top, + margin_bottom=margin_bottom, + margin_start=margin_start, + margin_end=margin_end, + margin_x=margin_x, + margin_y=margin_y, + width=width, + height=height, + min_width=min_width, + min_height=min_height, + max_width=max_width, + max_height=max_height, + position=position, + top=top, + bottom=bottom, + start=start, + end=end, + left=left, + right=right, + z_index=z_index, + is_hidden=is_hidden, + id=id, + UNSAFE_class_name=UNSAFE_class_name, + UNSAFE_style=UNSAFE_style, + ) diff --git a/plugins/ui/src/deephaven/ui/components/tag_group.py b/plugins/ui/src/deephaven/ui/components/tag_group.py new file mode 100644 index 000000000..ae1467e11 --- /dev/null +++ b/plugins/ui/src/deephaven/ui/components/tag_group.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +from typing import Callable, List + +from .basic import component_element +from .section import Item +from ..elements import Element, NodeType +from ..types import Key +from .types import ( + LabelPosition, + Alignment, + AlignSelf, + CSSProperties, + DimensionValue, + JustifySelf, + LayoutFlex, + Position, +) + + +def tag_group( + *children: Item, + action_label: str | None = None, + render_empty_state: Element | None = None, + max_rows: int | None = None, + error_message: NodeType = None, + label: NodeType = None, + description: NodeType = None, + label_position: LabelPosition = "top", + label_align: Alignment | None = "start", + contextual_help: NodeType = None, + is_invalid: bool | None = None, + on_action: Callable[[None], None] | None = None, + on_remove: Callable[[List[Key]], None] | None = None, + flex: LayoutFlex | None = None, + flex_grow: float | None = None, + flex_shrink: float | None = None, + flex_basis: DimensionValue | None = None, + align_self: AlignSelf | None = None, + justify_self: JustifySelf | None = None, + order: int | None = None, + grid_area: str | None = None, + grid_column: str | None = None, + grid_row: str | None = None, + grid_column_start: str | None = None, + grid_column_end: str | None = None, + grid_row_start: str | None = None, + grid_row_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, + min_width: DimensionValue | None = None, + max_width: DimensionValue | None = None, + height: DimensionValue | None = None, + min_height: DimensionValue | None = None, + max_height: DimensionValue | None = None, + position: Position | None = None, + top: DimensionValue | None = None, + bottom: DimensionValue | None = None, + left: DimensionValue | None = None, + right: DimensionValue | None = None, + start: DimensionValue | None = None, + end: DimensionValue | None = None, + z_index: int | 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_details: str | None = None, + UNSAFE_class_name: str | None = None, + UNSAFE_style: CSSProperties | None = None, + key: str | None = None, +) -> Element: + """ + A tag group displays a list of keywords to describe an item. + + Args: + *children: The tags to render within the tag_group. + action_label: The label for the action button. If provided, an action button will be displayed. + render_empty_state: The element that will be rendered when there is no content to display. + max_rows: The maximum amount of rows to show. If provided, will render a button that allows the user to expand the tag group. + error_message: An error message for the field. + label: The label for the tag group. + description: A description for the tag group. + label_position: The position of the label relative to the input. + label_align: The alignment of the label relative to the input. + contextual_help: A ContextualHelp element to place next to the label. + is_invalid: Whether the tag group is in an invalid state. + on_action: The handler that is called when action button is clicked. + on_remove: The handler that is called when remove button is clicked. If provided, a remove button will be displayed on each tag. + 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_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. + key: A unique identifier used by React to render elements in a list. + + + Returns: + The rendered tag group element. + """ + + return component_element( + "TagGroup", + *children, + action_label=action_label, + render_empty_state=render_empty_state, + max_rows=max_rows, + error_message=error_message, + label=label, + description=description, + label_position=label_position, + label_align=label_align, + contextual_help=contextual_help, + is_invalid=is_invalid, + on_action=on_action, + on_remove=on_remove, + flex=flex, + flex_grow=flex_grow, + flex_shrink=flex_shrink, + flex_basis=flex_basis, + align_self=align_self, + justify_self=justify_self, + order=order, + grid_area=grid_area, + grid_column=grid_column, + grid_row=grid_row, + grid_column_start=grid_column_start, + grid_column_end=grid_column_end, + grid_row_start=grid_row_start, + grid_row_end=grid_row_end, + margin=margin, + margin_top=margin_top, + margin_bottom=margin_bottom, + margin_start=margin_start, + margin_end=margin_end, + margin_x=margin_x, + margin_y=margin_y, + width=width, + min_width=min_width, + max_width=max_width, + height=height, + min_height=min_height, + max_height=max_height, + position=position, + top=top, + bottom=bottom, + left=left, + right=right, + start=start, + end=end, + z_index=z_index, + is_hidden=is_hidden, + id=id, + aria_label=aria_label, + aria_labelled_by=aria_labelledby, + aria_described_by=aria_describedby, + aria_details=aria_details, + UNSAFE_class_name=UNSAFE_class_name, + UNSAFE_style=UNSAFE_style, + key=key, + ) diff --git a/plugins/ui/src/deephaven/ui/types/types.py b/plugins/ui/src/deephaven/ui/types/types.py index 314dd5527..25599e75c 100644 --- a/plugins/ui/src/deephaven/ui/types/types.py +++ b/plugins/ui/src/deephaven/ui/types/types.py @@ -503,6 +503,8 @@ class SliderChange(TypedDict): ] Granularity = Literal["DAY", "HOUR", "MINUTE", "SECOND"] ListViewDensity = Literal["COMPACT", "NORMAL", "SPACIOUS"] +BreadcrumbsSize = Literal["S", "M", "L"] +DividerSize = Literal["S", "M", "L"] ListViewOverflowMode = Literal["truncate", "wrap"] ActionGroupDensity = Literal["compact", "regular"] TabDensity = Literal["compact", "regular"] diff --git a/plugins/ui/src/js/src/elements/LabeledValue.tsx b/plugins/ui/src/js/src/elements/LabeledValue.tsx new file mode 100644 index 000000000..dadf6920e --- /dev/null +++ b/plugins/ui/src/js/src/elements/LabeledValue.tsx @@ -0,0 +1,15 @@ +import { + LabeledValue as DHCLabeledValue, + LabeledValueProps as DHCLabeledValueProps, +} from '@deephaven/components'; + +export function LabeledValue( + props: DHCLabeledValueProps +): JSX.Element { + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + ); +} +LabeledValue.displayName = 'LabeledValue'; +export default LabeledValue; diff --git a/plugins/ui/src/js/src/elements/TagGroup.tsx b/plugins/ui/src/js/src/elements/TagGroup.tsx new file mode 100644 index 000000000..1d0dc07c9 --- /dev/null +++ b/plugins/ui/src/js/src/elements/TagGroup.tsx @@ -0,0 +1,31 @@ +import { + TagGroup as DHCTagGroup, + TagGroupProps as DHCTagGroupProps, +} from '@deephaven/components'; +import { useConditionalCallback } from './hooks'; + +export function TagGroup( + props: Omit, 'onRemove' | 'renderEmptyState'> & { + onRemove?: (keys: React.Key[]) => void; + renderEmptyState?: JSX.Element; + } +): JSX.Element { + const { onRemove: propOnRemove, renderEmptyState, ...otherProps } = props; + + const onRemove = useConditionalCallback( + propOnRemove != null, + (keys: Set) => propOnRemove?.(Array.from(keys)), + [propOnRemove] + ); + + return ( + renderEmptyState : undefined} + /* eslint-disable-next-line react/jsx-props-no-spreading */ + {...otherProps} + /> + ); +} + +export default TagGroup; diff --git a/plugins/ui/src/js/src/elements/index.ts b/plugins/ui/src/js/src/elements/index.ts index 79fb7f7ad..bcf54f718 100644 --- a/plugins/ui/src/js/src/elements/index.ts +++ b/plugins/ui/src/js/src/elements/index.ts @@ -17,6 +17,7 @@ export * from './HTMLElementView'; export * from './IconElementView'; export * from './IllustratedMessage'; export * from './Image'; +export * from './LabeledValue'; export * from './InlineAlert'; export * from './ListView'; export * from './LogicButton'; @@ -36,6 +37,7 @@ export * from './SearchField'; export * from './Slider'; export * from './Tabs'; export * from './TabPanels'; +export * from './TagGroup'; export * from './TextField'; export * from './TextArea'; export * from './TimeField'; diff --git a/plugins/ui/src/js/src/elements/model/ElementConstants.ts b/plugins/ui/src/js/src/elements/model/ElementConstants.ts index 61f4fb094..79cdbb8b3 100644 --- a/plugins/ui/src/js/src/elements/model/ElementConstants.ts +++ b/plugins/ui/src/js/src/elements/model/ElementConstants.ts @@ -28,6 +28,7 @@ export const ELEMENT_NAME = { actionMenu: uiComponentName('ActionMenu'), avatar: uiComponentName('Avatar'), badge: uiComponentName('Badge'), + breadcrumbs: uiComponentName('Breadcrumbs'), button: uiComponentName('Button'), buttonGroup: uiComponentName('ButtonGroup'), calendar: uiComponentName('Calendar'), @@ -42,6 +43,7 @@ export const ELEMENT_NAME = { dateRangePicker: uiComponentName('DateRangePicker'), dialog: uiComponentName('Dialog'), dialogTrigger: uiComponentName('DialogTrigger'), + divider: uiComponentName('Divider'), flex: uiComponentName('Flex'), form: uiComponentName('Form'), fragment: uiComponentName('Fragment'), @@ -51,6 +53,7 @@ export const ELEMENT_NAME = { image: uiComponentName('Image'), inlineAlert: uiComponentName('InlineAlert'), item: uiComponentName('Item'), + labeledValue: uiComponentName('LabeledValue'), listActionGroup: uiComponentName('ListActionGroup'), listActionMenu: uiComponentName('ListActionMenu'), link: uiComponentName('Link'), @@ -77,6 +80,7 @@ export const ELEMENT_NAME = { tabPanels: uiComponentName('TabPanels'), tabs: uiComponentName('Tabs'), tab: uiComponentName('Tab'), + tagGroup: uiComponentName('TagGroup'), text: uiComponentName('Text'), textArea: uiComponentName('TextArea'), textField: uiComponentName('TextField'), diff --git a/plugins/ui/src/js/src/layout/ReactPanel.tsx b/plugins/ui/src/js/src/layout/ReactPanel.tsx index d8ea4da0f..dc6201402 100644 --- a/plugins/ui/src/js/src/layout/ReactPanel.tsx +++ b/plugins/ui/src/js/src/layout/ReactPanel.tsx @@ -239,7 +239,7 @@ function ReactPanel({ * Don't render the children if there's an error with the widget. If there's an error with the widget, we can assume the children won't render properly, * but we still want the panels to appear so things don't disappear/jump around. */} - {renderedChildren} + {renderedChildren ?? null} diff --git a/plugins/ui/src/js/src/layout/ReactPanelErrorBoundary.test.tsx b/plugins/ui/src/js/src/layout/ReactPanelErrorBoundary.test.tsx new file mode 100644 index 000000000..184550590 --- /dev/null +++ b/plugins/ui/src/js/src/layout/ReactPanelErrorBoundary.test.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { TestUtils } from '@deephaven/test-utils'; +import { render, screen } from '@testing-library/react'; +import { ReactPanelErrorBoundary } from './ReactPanelErrorBoundary'; + +// Mock the WidgetErrorView component +jest.mock('../widget/WidgetErrorView', () => ({ + __esModule: true, + default: function MockWidgetErrorView({ error }: { error: Error }) { + return
{error.message}
; + }, +})); + +describe('ReactPanelErrorBoundary', () => { + // Suppress console.error for our intentional errors + beforeAll(() => { + TestUtils.disableConsoleOutput(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('renders children when there is no error', () => { + render( + +
Test Content
+
+ ); + + expect(screen.getByTestId('test-child')).toBeInTheDocument(); + expect(screen.getByText('Test Content')).toBeInTheDocument(); + expect(screen.queryByTestId('mock-error-view')).not.toBeInTheDocument(); + }); + + it('renders error view when child throws error', () => { + const ErrorComponent = () => { + throw new Error('Test error message'); + }; + + render( + + + + ); + + expect(screen.getByTestId('mock-error-view')).toBeInTheDocument(); + expect(screen.getByText('Test error message')).toBeInTheDocument(); + }); + + it('recovers when children are updated after error', () => { + const ErrorComponent = () => { + throw new Error('Test error message'); + }; + + const { rerender } = render( + + + + ); + + // Verify error state + expect(screen.getByTestId('mock-error-view')).toBeInTheDocument(); + expect(screen.getByText('Test error message')).toBeInTheDocument(); + + // Update with working component + rerender( + +
Working Content
+
+ ); + + // Verify recovery + expect(screen.getByTestId('working-component')).toBeInTheDocument(); + expect(screen.getByText('Working Content')).toBeInTheDocument(); + expect(screen.queryByTestId('mock-error-view')).not.toBeInTheDocument(); + }); + + it('maintains error state when props update does not include children change', () => { + const ErrorComponent = () => { + throw new Error('Test error message'); + }; + + const { rerender } = render( + + + + ); + + // Verify initial error state + expect(screen.getByTestId('mock-error-view')).toBeInTheDocument(); + + // Rerender with same children + rerender( + + + + ); + + // Error view should still be present + expect(screen.getByTestId('mock-error-view')).toBeInTheDocument(); + expect(screen.getByText('Test error message')).toBeInTheDocument(); + }); + + it('calls componentDidCatch when error occurs', () => { + const errorSpy = jest.spyOn( + ReactPanelErrorBoundary.prototype, + 'componentDidCatch' + ); + const ErrorComponent = () => { + throw new Error('Test error message'); + }; + + render( + + + + ); + + expect(errorSpy).toHaveBeenCalled(); + expect(errorSpy.mock.calls[0][0]).toBeInstanceOf(Error); + expect(errorSpy.mock.calls[0][0].message).toBe('Test error message'); + + errorSpy.mockRestore(); + }); + + it('does not throw an error when children are undefined', () => { + expect(() => + render({undefined}) + ).not.toThrow(); + }); +}); diff --git a/plugins/ui/src/js/src/layout/ReactPanelErrorBoundary.tsx b/plugins/ui/src/js/src/layout/ReactPanelErrorBoundary.tsx index ed7444b67..79a8e95c6 100644 --- a/plugins/ui/src/js/src/layout/ReactPanelErrorBoundary.tsx +++ b/plugins/ui/src/js/src/layout/ReactPanelErrorBoundary.tsx @@ -52,7 +52,9 @@ export class ReactPanelErrorBoundary extends Component< render(): ReactNode { const { children } = this.props; const { error } = this.state; - return error != null ? : children; + // We need to check for undefined children because React will throw an error if we return undefined from a render method + // Note this behaviour was changed in React 18: https://github.com/reactwg/react-18/discussions/75 + return error != null ? : children ?? null; } } diff --git a/plugins/ui/src/js/src/widget/WidgetUtils.tsx b/plugins/ui/src/js/src/widget/WidgetUtils.tsx index 192d984b7..fd816e5ee 100644 --- a/plugins/ui/src/js/src/widget/WidgetUtils.tsx +++ b/plugins/ui/src/js/src/widget/WidgetUtils.tsx @@ -7,12 +7,14 @@ import type { JSONRPCServerAndClient } from 'json-rpc-2.0'; import { ActionMenu, Avatar, + Breadcrumbs, ButtonGroup, SpectrumCheckbox as Checkbox, CheckboxGroup, Content, ContextualHelpTrigger, DialogTrigger, + Divider, Heading, Item, Link, @@ -67,6 +69,7 @@ import { Grid, IllustratedMessage, Image, + LabeledValue, InlineAlert, ListView, LogicButton, @@ -83,6 +86,7 @@ import { SearchField, Slider, TabPanels, + TagGroup, TextField, TextArea, TimeField, @@ -126,6 +130,7 @@ export const elementComponentMap: Record, unknown> = { [ELEMENT_NAME.actionMenu]: ActionMenu, [ELEMENT_NAME.avatar]: Avatar, [ELEMENT_NAME.badge]: Badge, + [ELEMENT_NAME.breadcrumbs]: Breadcrumbs, [ELEMENT_NAME.button]: Button, [ELEMENT_NAME.buttonGroup]: ButtonGroup, [ELEMENT_NAME.calendar]: Calendar, @@ -140,6 +145,7 @@ export const elementComponentMap: Record, unknown> = { [ELEMENT_NAME.dateRangePicker]: DateRangePicker, [ELEMENT_NAME.dialog]: Dialog, [ELEMENT_NAME.dialogTrigger]: DialogTrigger, + [ELEMENT_NAME.divider]: Divider, [ELEMENT_NAME.flex]: Flex, [ELEMENT_NAME.form]: Form, [ELEMENT_NAME.fragment]: React.Fragment, @@ -149,6 +155,7 @@ export const elementComponentMap: Record, unknown> = { [ELEMENT_NAME.image]: Image, [ELEMENT_NAME.inlineAlert]: InlineAlert, [ELEMENT_NAME.item]: Item, + [ELEMENT_NAME.labeledValue]: LabeledValue, [ELEMENT_NAME.link]: Link, [ELEMENT_NAME.listActionGroup]: ListActionGroup, [ELEMENT_NAME.listActionMenu]: ListActionMenu, @@ -175,6 +182,7 @@ export const elementComponentMap: Record, unknown> = { [ELEMENT_NAME.tabPanels]: TabPanels, [ELEMENT_NAME.tab]: Item, [ELEMENT_NAME.tabs]: Tabs, + [ELEMENT_NAME.tagGroup]: TagGroup, [ELEMENT_NAME.text]: Text, [ELEMENT_NAME.textArea]: TextArea, [ELEMENT_NAME.textField]: TextField, diff --git a/tests/app.d/ui_render_all.py b/tests/app.d/ui_render_all.py index 093ee8f42..819f394a6 100644 --- a/tests/app.d/ui_render_all.py +++ b/tests/app.d/ui_render_all.py @@ -51,6 +51,11 @@ def ui_components1(): ui.badge("Licensed", variant="positive"), ui.button_group(ui.button("One"), ui.button("Two")), ui.button("Button"), + ui.breadcrumbs( + ui.item("Deephaven", key="deephaven"), + ui.item("Products", key="products"), + ui.item("Community Core", key="community_core"), + ), ui.calendar(value="2021-01-01"), ui.checkbox("Checkbox"), ui.column("Column child A", "Column child B", "Column child C"), @@ -62,18 +67,19 @@ def ui_components1(): label="Date Range Picker", value={"start": "2021-01-01", "end": "2021-01-02"}, ), + ui.flex("Content before", ui.divider(orientation="vertical"), "Content after"), ui.flex("Flex default child A", "Flex default child B"), ui.flex("Flex column child A", "Flex column child B", direction="column"), ui.form("Form"), - ui.fragment("Fragment"), - ui.grid("Grid A", "Grid B"), - ui.heading("Heading"), ) @ui.component def ui_components2(): return ( + ui.fragment("Fragment"), + ui.grid("Grid A", "Grid B"), + ui.heading("Heading"), ui.icon("vsSymbolMisc"), ui.illustrated_message( ui.icon("vsWarning"), @@ -87,6 +93,7 @@ def ui_components2(): ), variant="positive", ), + ui.labeled_value(label="File name", value="Budget.xls"), ui.link("Learn more about Deephaven", href="https://deephaven.io/"), ui.list_view( _item_table_source_with_action_group, @@ -117,6 +124,12 @@ def ui_components2(): ui.range_calendar( default_value={"start": "2021-01-01", "end": "2021-01-02"}, ), + ) + + +@ui.component +def ui_components3(): + return ( ui.range_slider(default_value={"start": 10, "end": 99}, label="Range Slider"), ui.row("Row child A", "Row child B"), ui.slider( @@ -131,6 +144,11 @@ def ui_components2(): # ui.tab_list("Tab List"), # ui.tab_panels("Tab Panels"), # ui.tabs("Tabs"), + ui.tag_group( + ui.item("Tag 1", key="1"), + ui.item("Tag 2", key="2"), + ui.item("Tag 3", key="3"), + ), ui.text("Text"), ui.text_field( ui.icon("vsSymbolMisc"), default_value="Text Field", label="Text Field" @@ -161,6 +179,7 @@ def ui_html_elements(): _my_components1 = ui_components1() _my_components2 = ui_components2() +_my_components3 = ui_components3() _my_html_elements = ui_html_elements() ui_render_all1 = ui.dashboard( @@ -190,3 +209,16 @@ def ui_html_elements(): ), ) ) + +ui_render_all3 = ui.dashboard( + ui.stack( + ui.panel( + ui.grid( + _my_components3, + columns=["1fr", "1fr", "1fr"], + width="100%", + ), + title="Panel D", + ), + ) +) diff --git a/tests/ui.spec.ts b/tests/ui.spec.ts index 7e182bc11..975b11940 100644 --- a/tests/ui.spec.ts +++ b/tests/ui.spec.ts @@ -74,6 +74,12 @@ test('UI all components render 2', async ({ page }) => { await expect(page.locator(SELECTORS.REACT_PANEL_VISIBLE)).toHaveScreenshot(); }); +test('UI all components render 3', async ({ page }) => { + await gotoPage(page, ''); + await openPanel(page, 'ui_render_all3', SELECTORS.REACT_PANEL_VISIBLE); + await expect(page.locator(SELECTORS.REACT_PANEL_VISIBLE)).toHaveScreenshot(); +}); + // Tests flex components render as expected test.describe('UI flex components', () => { [ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-chromium-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-chromium-linux.png index 7150458c7..9f751f495 100644 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-chromium-linux.png and b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-chromium-linux.png differ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-firefox-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-firefox-linux.png index ed838b361..1f07bd64c 100644 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-firefox-linux.png and b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-firefox-linux.png differ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-webkit-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-webkit-linux.png index ba00b3cd4..4929dcd2e 100644 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-webkit-linux.png and b/tests/ui.spec.ts-snapshots/UI-all-components-render-1-1-webkit-linux.png differ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-chromium-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-chromium-linux.png index e6d46057b..ccf926bb3 100644 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-chromium-linux.png and b/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-chromium-linux.png differ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-firefox-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-firefox-linux.png index 594d143e4..ee99dc4d4 100644 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-firefox-linux.png and b/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-firefox-linux.png differ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-webkit-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-webkit-linux.png index e1d3c4e54..4ced905b5 100644 Binary files a/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-webkit-linux.png and b/tests/ui.spec.ts-snapshots/UI-all-components-render-2-1-webkit-linux.png differ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-3-1-chromium-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-3-1-chromium-linux.png new file mode 100644 index 000000000..c10c88a13 Binary files /dev/null and b/tests/ui.spec.ts-snapshots/UI-all-components-render-3-1-chromium-linux.png differ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-3-1-firefox-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-3-1-firefox-linux.png new file mode 100644 index 000000000..dc4ebf0f6 Binary files /dev/null and b/tests/ui.spec.ts-snapshots/UI-all-components-render-3-1-firefox-linux.png differ diff --git a/tests/ui.spec.ts-snapshots/UI-all-components-render-3-1-webkit-linux.png b/tests/ui.spec.ts-snapshots/UI-all-components-render-3-1-webkit-linux.png new file mode 100644 index 000000000..5ad149bc1 Binary files /dev/null and b/tests/ui.spec.ts-snapshots/UI-all-components-render-3-1-webkit-linux.png differ