diff --git a/.github/workflows/test-python-package.yml b/.github/workflows/test-python-package.yml index 4e43d46c6..166e2233a 100644 --- a/.github/workflows/test-python-package.yml +++ b/.github/workflows/test-python-package.yml @@ -27,5 +27,5 @@ jobs: working-directory: 'plugins/${{ inputs.package }}' - name: Run tox - run: tox -e py + run: tox -e py${{ matrix.python }} working-directory: 'plugins/${{ inputs.package }}' diff --git a/README.md b/README.md index 2d2eb0252..ac0235460 100644 --- a/README.md +++ b/README.md @@ -73,14 +73,30 @@ You should be able to pass arguments to these commands as if you were running Pl It is highly recommended to use `npm run e2e:docker` (instead of `npm run e2e`) as CI also uses the same environment. You can also use `npm run e2e:update-snapshots` to regenerate snapshots in said environment. Run Playwright in [UI Mode](https://playwright.dev/docs/test-ui-mode) with `npm run e2e:ui` when creating new tests or debugging, as this will allow you to run each test individually, see the browser as it runs it, inspect the console, evaluate locators, etc. ### Running Python tests - -The above steps will also set up `tox` to run tests for the python plugins that support it. +The [venv setup](#pre-commit-hookspython-formatting) steps will also set up `tox` to run tests for the python plugins that support it. +Note that `tox` sets up an isolated environment for running tests. +Be default, `tox` will run against Python 3.8, which will need to be installed on your system before running tests. You can run tests with the following command from the `plugins/` directory: - ```shell tox -e py ``` +> [!IMPORTANT] +> Linux, and possibly other setups such as MacOS depending on method, may require additional packages to be installed to run Python 3.8. +> ```shell +> sudo apt install python3.8 python3.8-distutils libpython3.8 +> # or just full install although it will include more packages than necessary +> sudo apt install python3.8-full +> ``` + +You can also run tests against a specific version of python by appending the version to `py` +This assumes that the version of Python you're targeting is installed on your system. +For example, to run tests against Python 3.12, run: +```shell +tox -e py3.12 +``` + + ### Running plugin against deephaven-core #### Building Python plugins for development diff --git a/package-lock.json b/package-lock.json index 322705379..43dda2e20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11351,6 +11351,12 @@ "@types/unist": "^2" } }, + "node_modules/@types/memoizee": { + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/@types/memoizee/-/memoizee-0.4.11.tgz", + "integrity": "sha512-2gyorIBZu8GoDr9pYjROkxWWcFtHCquF7TVbN2I+/OvgZhnIGQS0vX5KJz4lXNKb8XOSfxFOSG5OLru1ESqLUg==", + "dev": true + }, "node_modules/@types/minimatch": { "version": "3.0.5", "dev": true, @@ -32209,6 +32215,7 @@ "react-redux": "^7.x" }, "devDependencies": { + "@types/memoizee": "^0.4.5", "@types/react": "^17.0.2", "react": "^17.0.2", "react-dom": "^17.0.2", @@ -35843,6 +35850,7 @@ "@deephaven/utils": "^0.95.0", "@fortawesome/react-fontawesome": "^0.2.0", "@internationalized/date": "^3.5.5", + "@types/memoizee": "^0.4.5", "@types/react": "^17.0.2", "classnames": "^2.5.1", "json-rpc-2.0": "^1.6.0", @@ -41736,6 +41744,12 @@ "@types/unist": "^2" } }, + "@types/memoizee": { + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/@types/memoizee/-/memoizee-0.4.11.tgz", + "integrity": "sha512-2gyorIBZu8GoDr9pYjROkxWWcFtHCquF7TVbN2I+/OvgZhnIGQS0vX5KJz4lXNKb8XOSfxFOSG5OLru1ESqLUg==", + "dev": true + }, "@types/minimatch": { "version": "3.0.5", "dev": true diff --git a/plugins/plotly-express/tox.ini b/plugins/plotly-express/tox.ini index b214f738a..edcbe5445 100644 --- a/plugins/plotly-express/tox.ini +++ b/plugins/plotly-express/tox.ini @@ -5,4 +5,22 @@ isolated_build = True deps = deephaven-server commands = - python -m unittest discover \ No newline at end of file + python -m unittest discover +basepython = python3.8 + +[testenv:py3.8] +basepython = python3.8 + +[testenv:py3.9] +basepython = python3.9 + +[testenv:py3.10] +basepython = python3.10 + +[testenv:py3.11] +basepython = python3.11 + +[testenv:py3.12] +basepython = python3.12 + + diff --git a/plugins/ui/DESIGN.md b/plugins/ui/DESIGN.md index e0eb7227e..aa412eb9e 100644 --- a/plugins/ui/DESIGN.md +++ b/plugins/ui/DESIGN.md @@ -3542,112 +3542,6 @@ With callbacks, there will be a delay between when the user makes changes in the The above examples are all in Python, and particularly take some advantage of language constructs in python (such as positional arguments and kwargs). We should consider how it would work in Groovy/Java as well, and how we can build one on top of the other. -#### Architecture - -##### Rendering - -When you call a function decorated by `@ui.component`, it will return an `Element` object that has a reference to the function it is decorated; that is to say, the function does _not_ get run immediately. The function is only run when the `Element` is rendered by the client, and the result is sent back to the client. This allows the `@ui.component` decorator to execute the function with the appropriate rendering context. The client must also set the initial state before rendering, allowing the client to persist the state and re-render in the future. - -Let's say we execute the following, where a table is filtered based on the value of a text input: - -```python -from deephaven import ui - - -@ui.component -def text_filter_table(source, column, initial_value=""): - value, set_value = ui.use_state(initial_value) - ti = ui.text_field(value=value, on_change=set_value) - tt = source.where(f"{column}=`{value}`") - return [ti, tt] - - -# This will render two panels, one filtering the table by Sym, and the other by Exchange -@ui.component -def double_text_filter_table(source): - tft1 = text_filter_table(source, "Sym") - tft2 = text_filter_table(source, "Exchange") - return ui.panel(tft1, title="Sym"), ui.panel(tft2, title="Exchange") - - -import deephaven.plot.express as dx - -_stocks = dx.data.stocks() - -tft = double_text_filter_table(_stocks) -``` - -Which should result in a UI like this: - -![Double Text Filter Tables](docs/_assets/double-tft.png) - -How does that look when the notebook is executed? When does each code block execute? - -```mermaid -sequenceDiagram - participant U as User - participant W as Web UI - participant UIP as UI Plugin - participant C as Core - participant SP as Server Plugin - - U->>W: Run notebook - W->>C: Execute code - C->>SP: is_type(object) - SP-->>C: Matching plugin - C-->>W: VariableChanges(added=[t, tft]) - - W->>UIP: Open tft - UIP->>C: Export tft - C-->>UIP: tft (Element) - - Note over UIP: UI knows about object tft
double_text_filter_table not executed yet - - UIP->>SP: Render tft (initialState) - SP->>SP: Run double_text_filter_table - Note over SP: double_text_filter_table executes, running text_filter_table twice - SP-->>UIP: Result (document=[panel(tft1), pane(tft2)], exported_objects=[tft1, tft2]) - UIP-->>W: Display Result - - U->>UIP: Change text input 1 - UIP->>SP: Change state - SP->>SP: Run double_text_filter_table - Note over SP: double_text_filter_table executes, text_filter_table only
runs once for the one changed input
only exports the new table, as client already has previous tables - SP-->>UIP: Result (document=[panel(tft1'), panel(tft2)], state={}, exported_objects=[tft1']) - UIP-->>W: Display Result -``` - -##### Communication/Callbacks - -When the document is first rendered, it will pass the entire document to the client. When the client makes a callback, it needs to send a message to the server indicating which callback it wants to trigger, and with which parameters. For this, we use [JSON-RPC](https://www.jsonrpc.org/specification). When the client opens the message stream to the server, the communication looks like: - -```mermaid -sequenceDiagram - participant UIP as UI Plugin - participant SP as Server Plugin - - Note over UIP, SP: Uses JSON-RPC - UIP->>SP: setState(initialState) - SP-->>UIP: documentUpdated(Document, State) - - loop Callback - UIP->>SP: foo(params) - SP-->>UIP: foo result - SP->>UIP: documentUpdated(Document, State) - Note over UIP: Client can store State to restore the same state later - end -``` - -##### Communication Layers - -A component that is created on the server side runs through a few steps before it is rendered on the client side: - -1. [Element](./src/deephaven/ui/elements/Element.py) - The basis for all UI components. Generally a [FunctionElement](./src/deephaven/ui/elements/FunctionElement.py) created by a script using the [@ui.component](./src/deephaven/ui/components/make_component.py) decorator, and does not run the function until it is rendered. The result can change depending on the context that it is rendered in (e.g. what "state" is set). -2. [ElementMessageStream](./src/deephaven/ui/object_types/ElementMessageStream.py) - The `ElementMessageStream` is responsible for rendering one instance of an element in a specific rendering context and handling the server-client communication. The element is rendered to create a [RenderedNode](./src/deephaven/ui/renderer/RenderedNode.py), which is an immutable representation of a rendered document. The `RenderedNode` is then encoded into JSON using [NodeEncoder](./src/deephaven/ui/renderer/NodeEncoder.py), which pulls out all the non-serializable objects (such as Tables) and maps them to exported objects, and all the callables to be mapped to commands that can be accepted by JSON-RPC. This is the final representation of the document that is sent to the client, and ultimately handled by the `WidgetHandler`. -3. [DashboardPlugin](./src/js/src/DashboardPlugin.tsx) - Client side `DashboardPlugin` that listens for when a widget of type `Element` is opened, and manage the `WidgetHandler` instances that are created for each widget. -4. [WidgetHandler](./src/js/src/WidgetHandler.tsx) - Uses JSON-RPC communication with an `ElementMessageStream` instance to load the initial rendered document and associated exported objects. Listens for any changes and updates the document accordingly. -5. [DocumentHandler](./src/js/src/DocumentHandler.tsx) - Handles the root of a rendered document, laying out the appropriate panels or dashboard specified. - #### Other Decisions While mocking this up, there are a few decisions regarding the syntax we should be thinking about/address prior to getting too far along with implementation. diff --git a/plugins/ui/conf.py b/plugins/ui/conf.py index e20cfdb4b..e111513dc 100644 --- a/plugins/ui/conf.py +++ b/plugins/ui/conf.py @@ -39,6 +39,8 @@ # options for sphinx_autodoc_typehints always_use_bars_union = True +strip_signature_backslash = True + from deephaven_server import Server # need a server instance to pull types from the autodocs diff --git a/plugins/ui/docs/README.md b/plugins/ui/docs/README.md index 4a960fbcc..7863b74c5 100644 --- a/plugins/ui/docs/README.md +++ b/plugins/ui/docs/README.md @@ -58,7 +58,7 @@ my_button = ui.button("Click Me!", on_press=lambda e: print(f"Button was clicked ## Creating components -Use the `@ui.component` decorator to create your own custom components. This decorator wraps the function provided as a Deephaven component. For more details on the architecture, see [TODO: Link to architecture](). +Use the `@ui.component` decorator to create your own custom components. This decorator wraps the function provided as a Deephaven component. For more details on the architecture, see [Architecture documentation](./architecture.md). We can display a heading above a button as our custom component: @@ -893,7 +893,7 @@ def ui_range_table(source, column): my_range_table = ui_range_table(stocks, "Size") ``` -![Table with a slider for selecting the range.](_assets/range_table.png) +![Table with a slider for selecting the range.](./_assets/range_table.png) ## Table with required filters diff --git a/plugins/ui/docs/architecture.md b/plugins/ui/docs/architecture.md new file mode 100644 index 000000000..1da0c0fb3 --- /dev/null +++ b/plugins/ui/docs/architecture.md @@ -0,0 +1,149 @@ +# Architecture + +deephaven.ui is a flexible and extensible [React-like](https://react.dev/learn/thinking-in-react) UI framework that can create complex UIs in Python. You can create UIs using only the components provided by deephaven.ui, or you can create your own components using the `@ui.component` decorator. + +## Components + +Components are reusable pieces of UI that can be combined to create complex UIs. Each component defines its own logic and appearance. Components can be simple, like a button, or complex, like a table with controls for filtering and sorting. Components can also be composed of other components, allowing for complex UIs to be built up from simpler pieces. + +Components are created using the `@ui.component` decorator. This decorator takes a function that returns a list of components, and returns a new function that can be called to render the component. The function returned by the decorator is called a "component function". Calling the function and assigning it to a variable will create an "element" that can be rendered by the client. + +```python +from deephaven import ui + + +@ui.component +def my_button(): + return ui.button("Click me!") + + +btn = my_button() +``` + +Once you have declared a component, you can nest it into another component. + +```python +@ui.component +def my_app(): + return ui.flex(ui.text("Hello, world!"), my_button(), direction="column") + + +app = my_app() +``` + +## Rendering + +When you call a function decorated by `@ui.component`, it will return an `Element` object that references the function it is decorated by; that is to say, the function does _not_ run immediately. The function runs when the `Element` is rendered by the client, and the result is sent back to the client. This allows the `@ui.component` decorator to execute the function with the appropriate rendering context. The client must also set the initial state before rendering, allowing the client to persist the state and re-render in the future. + +Let's say we execute the following, where a table is filtered based on the value of a text input: + +```python +from deephaven import ui +import deephaven.plot.express as dx + + +@ui.component +def text_filter_table(source, column, initial_value=""): + value, set_value = ui.use_state(initial_value) + ti = ui.text_field(value=value, on_change=set_value) + tt = source.where(f"{column}=`{value}`") + return [ti, tt] + + +# This will render two panels, one filtering the table by Sym, and the other by Exchange +@ui.component +def double_text_filter_table(source): + tft1 = text_filter_table(source, "Sym", "CAT") + tft2 = text_filter_table(source, "Exchange", "PETX") + return ui.panel(tft1, title="Sym"), ui.panel(tft2, title="Exchange") + + +_stocks = dx.data.stocks() + +tft = double_text_filter_table(_stocks) +``` + +This should result in a UI like: + +![Double Text Filter Tables](_assets/double-tft.png) + +How does that look when the notebook is executed? When does each code block execute? + +```mermaid +sequenceDiagram + participant U as User + participant W as Web UI + participant UIP as UI Plugin + participant C as Core + participant SP as Server Plugin + + U->>W: Run notebook + W->>C: Execute code + C->>SP: is_type(object) + SP-->>C: Matching plugin + C-->>W: VariableChanges(added=[t, tft]) + + W->>UIP: Open tft + activate UIP + UIP->>C: Fetch tft + C-->>UIP: Export tft (Element) + + Note over UIP: UI knows about object tft
double_text_filter_table not executed yet + + UIP->>SP: Render tft (initialState) + SP->>SP: Run double_text_filter_table + Note over SP: double_text_filter_table executes, running text_filter_table twice + SP-->>UIP: Result (document=[panel(tft1), pane(tft2)], exported_objects=[tft1, tft2]) + UIP-->>W: Display Result + deactivate UIP + + U->>UIP: Change text input 1 + activate UIP + UIP->>SP: Change state + SP->>SP: Run double_text_filter_table + Note over SP: double_text_filter_table executes, text_filter_table only
runs once for the one changed input
only exports the new table, as client already has previous tables + SP-->>UIP: Result (document=[panel(tft1'), panel(tft2)],
state={}, exported_objects=[tft1']) + UIP-->>W: Display Result + deactivate UIP +``` + +### Threads and rendering + +When a component is rendered, the render task is [submitted to the Deephaven server as a "concurrent" task](https://deephaven.io/core/pydoc/code/deephaven.server.executors.html#deephaven.server.executors.submit_task). This ensures that rendering one component does not block another component from rendering. A lock is then held on that component instance to ensure it can only be rendered by one thread at a time. After the lock is acquired, a root [render context](#render-context) is set in the thread-local data, and the component is rendered. + +### Render context + +Each component renders in its own render context, which helps keep track of state and side effects. While rendering components, "hooks" are used to manage state and other side effects. The magic part of hooks is they work based on the order they are called within a component. When a component is rendered, a new context is set, replacing the existing context. When the component is done rendering, the context is reset to the previous context. This allows for nested components to have their own state and side effects, and for the parent component to manage the state of the child components, re-using the same context when re-rendering a child component. + +## Communication/Callbacks + +When the document is first rendered, it will pass the entire document to the client. When the client makes a callback, it needs to send a message to the server indicating which callback it wants to trigger, and with which parameters. For this, we use [JSON-RPC](https://www.jsonrpc.org/specification). When the client opens the message stream to the server, the communication looks like: + +```mermaid +sequenceDiagram + participant UIP as UI Plugin + participant SP as Server Plugin + + Note over UIP, SP: Uses JSON-RPC + UIP->>SP: setState(initialState) + SP-->>UIP: documentUpdated(Document, State) + + loop Callback + UIP->>SP: foo(params) + SP-->>UIP: foo result + opt Update sent if callback modified state + SP->>UIP: documentUpdated(Document, State) + end + Note over UIP: Client can store State to restore the same state later + end +``` + +## Communication Layers + +A component that is created on the server side runs through a few steps before it is rendered on the client side: + +1. [Element](https://github.com/deephaven/deephaven-plugins/blob/main/plugins/ui/src/deephaven/ui/elements/Element.py) - The basis for all UI components. Generally, a [FunctionElement](https://github.com/deephaven/deephaven-plugins/blob/main/plugins/ui/src/deephaven/ui/elements/FunctionElement.py) created by a script using the [@ui.component](https://github.com/deephaven/deephaven-plugins/blob/main/plugins/ui/src/deephaven/ui/components/make_component.py) decorator that does not run the function until it is rendered. The result can change depending on the context that it is rendered in (e.g., what "state" is set). +2. [ElementMessageStream](https://github.com/deephaven/deephaven-plugins/blob/main/plugins/ui/src/deephaven/ui/object_types/ElementMessageStream.py) - The `ElementMessageStream` is responsible for rendering one instance of an element in a specific rendering context and handling the server-client communication. The element is rendered to create a [RenderedNode](https://github.com/deephaven/deephaven-plugins/blob/main/plugins/ui/src/deephaven/ui/renderer/RenderedNode.py), which is an immutable representation of a rendered document. The `RenderedNode` is then encoded into JSON using [NodeEncoder](https://github.com/deephaven/deephaven-plugins/blob/main/plugins/ui/src/deephaven/ui/renderer/NodeEncoder.py), which pulls out all the non-serializable objects (such as Tables) and maps them to exported objects, and all the callables to be mapped to commands that JSON-RPC can accept. This is the final representation of the document sent to the client and ultimately handled by the `WidgetHandler`. +3. [DashboardPlugin](https://github.com/deephaven/deephaven-plugins/blob/main/plugins/ui/src/js/src/DashboardPlugin.tsx) - Client-side `DashboardPlugin` that listens for when a widget of type `Element` is opened and manages the `WidgetHandler` instances that are created for each widget. +4. [WidgetHandler](https://github.com/deephaven/deephaven-plugins/blob/main/plugins/ui/src/js/src/WidgetHandler.tsx) - Uses JSON-RPC communication with an `ElementMessageStream` instance to set the initial state, then load the initial rendered document and associated exported objects. Listens for any changes and updates the document accordingly. +5. [DocumentHandler](https://github.com/deephaven/deephaven-plugins/blob/main/plugins/ui/src/js/src/DocumentHandler.tsx) - Handles the root of a rendered document, laying out the appropriate panels or dashboard specified. diff --git a/plugins/ui/docs/components/contextual_help.md b/plugins/ui/docs/components/contextual_help.md new file mode 100644 index 000000000..4538c7491 --- /dev/null +++ b/plugins/ui/docs/components/contextual_help.md @@ -0,0 +1,117 @@ +# Contextual Help + +Contextual help can be used to show extra information about the state of a component. + +## Example + +For the contextual help component, both the `heading` and `content` props are required. + +```python +from deephaven import ui + + +my_contextual_help_basic = ui.contextual_help( + heading="Need Help", + content="If you are having issues accessing your account, contact our customer support team for help.", + variant="info", +) +``` + + +## Placement + +The contextual help component supports different placement options for when the popover's positioning needs to be customized. + +```python +from deephaven import ui + + +@ui.component +def ui_contextual_help_placement_examples(): + return [ + ui.contextual_help( + heading="Need Help", + content="If you are having issues accessing your account, contact our customer support team for help.", + variant="info", + ), + ui.contextual_help( + heading="Need Help", + content="If you are having issues accessing your account, contact our customer support team for help.", + variant="info", + placement="top start", + ), + ui.contextual_help( + heading="Need Help", + content="If you are having issues accessing your account, contact our customer support team for help.", + variant="info", + placement="end", + ), + ] + + +my_contextual_help_placement_examples = ui_contextual_help_placement_examples() +``` + + +## Events + +The `on_open_change` prop is triggered when the popover opens or closes. + +```python +from deephaven import ui + + +@ui.component +def ui_contextual_help_events_example(): + is_open, set_is_open = ui.use_state(False) + return [ + ui.flex( + ui.contextual_help( + heading="Permission required", + content="Your admin must grant you permission before you can create a segment.", + variant="info", + on_open_change={set_is_open}, + ), + align_items="center", + ) + ] + + +my_contextual_help_events_example = ui_contextual_help_events_example() +``` + + +## Visual Options + +The `variant` prop can be set to either "info" or "help", depending on how the contextual help component is meant to help the user. + +```python +from deephaven import ui + + +@ui.component +def ui_contextual_help_variant_examples(): + return [ + ui.contextual_help( + heading="Permission required", + content="Your admin must grant you permission before you can create a segment.", + variant="info", + ), + ui.contextual_help( + heading="What is a segment?", + content="Segments identify who your visitors are, what devices and services they use, where they navigated from, and much more.", + variant="help", + ), + ] + + +my_contextual_help_variant_examples = ui_contextual_help_variant_examples() +``` + + +## API reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.contextual_help +``` + diff --git a/plugins/ui/docs/components/flex.md b/plugins/ui/docs/components/flex.md new file mode 100644 index 000000000..e61099943 --- /dev/null +++ b/plugins/ui/docs/components/flex.md @@ -0,0 +1,297 @@ +# Flex +A [flexbox](https://developer.mozilla.org/en-US/docs/Learn/CSS/CSS_layout/Flexbox)-based layout container that utilizes dimension values and supports the gap property for consistent spacing between items. + +## Example + +```python +from deephaven import ui + + +@ui.component +def ui_flex(): + return ui.flex( + ui.view(1, background_color="red", height="size-800", width="size-800"), + ui.view(2, background_color="green", height="size-800", width="size-800"), + ui.view(3, background_color="blue", height="size-800", width="size-800"), + ) + + +my_flex = ui_flex() +``` + +## Direction + +The `direction` prop determines the direction in which the flex items are laid out. + +Options: +- `row` (default): the flex items are arranged horizontally from left to right. +- `column`: the flex items are arranged vertically from top to bottom. +- `row-reverse`: the flex items are arranged horizontally from right to left. +- `column-reverse`: the flex items are arranged vertically from bottom to top. + +```python +from deephaven import ui + + +@ui.component +def ui_flex_direction(): + return [ + 'direction="row"', + ui.flex( + ui.view(1, background_color="red", height="size-800", width="size-800"), + ui.view(2, background_color="green", height="size-800", width="size-800"), + ui.view(3, background_color="blue", height="size-800", width="size-800"), + ), + 'direction="row-reverse"', + ui.flex( + ui.view(1, background_color="red", height="size-800", width="size-800"), + ui.view(2, background_color="green", height="size-800", width="size-800"), + ui.view(3, background_color="blue", height="size-800", width="size-800"), + direction="row-reverse", + ), + 'direction="column"', + ui.flex( + ui.view(1, background_color="red", height="size-800", width="size-800"), + ui.view(2, background_color="green", height="size-800", width="size-800"), + ui.view(3, background_color="blue", height="size-800", width="size-800"), + direction="column", + ), + 'direction="column-reverse"', + ui.flex( + ui.view(1, background_color="red", height="size-800", width="size-800"), + ui.view(2, background_color="green", height="size-800", width="size-800"), + ui.view(3, background_color="blue", height="size-800", width="size-800"), + direction="column-reverse", + ), + ] + + +my_flex_direction = ui_flex_direction() +``` + +## Nesting + +Flexboxes can be nested to create more complicated layouts. By using the `flex` prop on the children, the flexbox can expand to fill the remaining space. + +```python +from deephaven import ui + + +@ui.component +def ui_flex_nesting(): + return [ + ui.flex( + ui.view(1, background_color="red", height="size-800"), + ui.flex( + ui.view( + 2, background_color="green", height="size-800", width="size-800" + ), + ui.view( + 3, background_color="blue", height="size-800", width="size-800" + ), + ), + direction="column", + ), + ] + + +my_flex_nesting = ui_flex_nesting() +``` + + +## Wrapping + +When enabled, items that overflow wrap into the next row. Resize your browser window to see the items reflow. + +```python +from deephaven import ui + + +@ui.component +def ui_flex_wrap(): + return ui.flex( + ui.view(1, background_color="red", height="size-800", width="size-800"), + ui.view(2, background_color="green", height="size-800", width="size-800"), + ui.view(3, background_color="yellow", height="size-800", width="size-800"), + ui.view(4, background_color="blue", height="size-800", width="size-800"), + ui.view(5, background_color="orange", height="size-800", width="size-800"), + wrap=True, + width="200px", + align_content="start", + ) + + +my_flex_wrap = ui_flex_wrap() +``` + + +## Justification + +The `justify_content` prop is used to align items along the main axis. When the direction is set to "column", it controls the vertical alignment, and when the direction is set to "row", it controls the horizontal alignment. + +Options: +- `stretch` (default): the flex items are stretched to fill the container along the cross-axis. +- `start`: the flex items are aligned at the start of the cross-axis. +- `end`: the flex items are aligned at the end of the cross-axis. +- `center`: the flex items are centered along the cross-axis. +- `left`: the flex items are packed toward the left edge of the container. +- `right`: the flex items are packed toward the right edge of the container. +- `space-between`: the flex items are evenly distributed with the first item at the start and the last item at the end. +- `space-around`: the flex items are evenly distributed with equal space around them. +- `space-evenly`: the flex items are evenly distributed with equal space between them. +- `baseline`: the flex items are aligned based on their baselines. +- `first baseline`: the flex items are aligned based on the first baseline of the container. +- `last baseline`: the flex items are aligned based on the last baseline of the container. +- `safe center`: the flex items are centered along the cross-axis, ensuring they remain within the safe area. +- `unsafe center`: the flex items are centered along the cross-axis, without considering the safe area. + +```python +from deephaven import ui + + +@ui.component +def ui_flex_justify(): + start = ui.flex( + ui.view(1, background_color="red", height="size-800", width="size-400"), + ui.view(2, background_color="green", height="size-800", width="size-800"), + ui.view(3, background_color="blue", height="size-800", width="size-200"), + justify_content="start", + ) + center = ui.flex( + ui.view(1, background_color="red", height="size-800", width="size-400"), + ui.view(2, background_color="green", height="size-800", width="size-800"), + ui.view(3, background_color="blue", height="size-800", width="size-200"), + justify_content="center", + ) + end = ui.flex( + ui.view(1, background_color="red", height="size-800", width="size-400"), + ui.view(2, background_color="green", height="size-800", width="size-800"), + ui.view(3, background_color="blue", height="size-800", width="size-200"), + justify_content="end", + ) + space_between = ui.flex( + ui.view(1, background_color="red", height="size-800", width="size-400"), + ui.view(2, background_color="green", height="size-800", width="size-800"), + ui.view(3, background_color="blue", height="size-800", width="size-200"), + justify_content="space-between", + ) + space_around = ui.flex( + ui.view(1, background_color="red", height="size-800", width="size-400"), + ui.view(2, background_color="green", height="size-800", width="size-800"), + ui.view(3, background_color="blue", height="size-800", width="size-200"), + justify_content="space-around", + ) + + return ui.flex( + 'justify_content="start"', + start, + 'justify_content="center"', + center, + 'justify_content="end"', + end, + 'justify_content="space-between"', + space_between, + 'justify_content="space-around"', + space_around, + direction="column", + ) + + +my_flex_justify = ui_flex_justify() +``` + + +## Alignment + +The `align_items` prop aligns items along the cross-axis. When the direction is set to "column", it controls horizontal alignment, and when it is set to "row", it controls vertical alignment. + +Options: +- `stretch` (default): the flex items are stretched to fill the container along the cross-axis. +- `start`: the flex items are aligned at the start of the cross-axis. +- `end`: the flex items are aligned at the end of the cross-axis. +- `center`: the flex items are centered along the cross-axis. +- `self-start`: the flex items are aligned at the start of their container. +- `self-end`: the flex items are aligned at the end of their container. +- `baseline`: the flex items are aligned based on their baselines. +- `first baseline`: the flex items are aligned based on the first baseline of the container. +- `last baseline`: the flex items are aligned based on the last baseline of the container. +- `safe center`: the flex items are centered along the cross-axis, ensuring they remain within the safe area. +- `unsafe center`: the flex items are centered along the cross-axis, without considering the safe area. + +```python +from deephaven import ui + + +@ui.component +def ui_flex_align_vertical(): + vertical = ui.flex( + ui.flex( + ui.view(1, background_color="red", height="size-800", width="size-400"), + ui.view(2, background_color="green", height="size-800", width="size-800"), + ui.view(3, background_color="blue", height="size-800", width="size-200"), + direction="column", + align_items="start", + ), + ui.flex( + ui.view(1, background_color="red", height="size-800", width="size-400"), + ui.view(2, background_color="green", height="size-800", width="size-800"), + ui.view(3, background_color="blue", height="size-800", width="size-200"), + direction="column", + align_items="center", + ), + ui.flex( + ui.view(1, background_color="red", height="size-800", width="size-400"), + ui.view(2, background_color="green", height="size-800", width="size-800"), + ui.view(3, background_color="blue", height="size-800", width="size-200"), + direction="column", + align_items="end", + ), + ) + + return ui.flex(vertical) + + +my_flex_align_vertical = ui_flex_align_vertical() +``` + + +```python +from deephaven import ui + + +@ui.component +def ui_flex_align_horizontal(): + horizontal = ui.flex( + ui.flex( + ui.view(1, background_color="red", height="size-800", width="size-400"), + ui.view(2, background_color="green", height="size-800", width="size-800"), + ui.view(3, background_color="blue", height="size-800", width="size-200"), + align_items="start", + ), + ui.flex( + ui.view(1, background_color="red", height="size-800", width="size-400"), + ui.view(2, background_color="green", height="size-800", width="size-800"), + ui.view(3, background_color="blue", height="size-800", width="size-200"), + align_items="center", + ), + ui.flex( + ui.view(1, background_color="red", height="size-800", width="size-400"), + ui.view(2, background_color="green", height="size-800", width="size-800"), + ui.view(3, background_color="blue", height="size-800", width="size-200"), + align_items="end", + ), + direction="column", + ) + + return ui.flex(horizontal) + + +my_flex_align_horizontal = ui_flex_align_horizontal() +``` + + +## API reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.flex +``` \ No newline at end of file diff --git a/plugins/ui/docs/components/form.md b/plugins/ui/docs/components/form.md new file mode 100644 index 000000000..ee8c67da0 --- /dev/null +++ b/plugins/ui/docs/components/form.md @@ -0,0 +1,261 @@ +# Form + +Forms allow users to enter data that can be submitted while providing alignment and styling for form fields. + +## Example + +```python +from deephaven import ui + + +@ui.component +def ui_form(): + return ui.form( + ui.text_field(name="name", label="Enter name"), + ui.button("Submit", type="submit"), + ) + + +my_form = ui_form() +``` + +## Events + +Forms can handle three events: + +1. **Submit**: Triggered when the form is submitted. Users can define a callback function to handle the form data upon submission. +2. **Reset**: Triggered when the form is reset. This event can be used to clear the form fields or reset them to their initial values. +3. **Invalid**: Triggered when the form validation fails. This event allows users to handle validation errors and provide feedback. + + +### Submit + +```python +from deephaven import ui + + +@ui.component +def ui_form_submit(): + return ui.form( + ui.text_field(name="name", label="Enter name"), + ui.number_field(name="age", label="Enter age"), + ui.button("Submit", type="submit"), + on_submit=lambda e: print(f"Form submitted: {e}"), + ) + + +my_form_submit = ui_form_submit() +``` + +### Reset + +```python +from deephaven import ui + + +@ui.component +def ui_form_submit(): + return ui.form( + ui.text_field(name="name", label="Enter name"), + ui.number_field(name="age", label="Enter age"), + ui.button("Reset", type="reset"), + on_reset=lambda e: print(f"Form reset"), + ) + + +my_form_submit = ui_form_submit() +``` + +### Invalid + +```python +from deephaven import ui + + +@ui.component +def ui_form_invalid(): + value, set_value = ui.use_state("") + return ui.form( + ui.text_field( + name="name", + label="Enter name", + value=value, + on_change=set_value, + is_required=True, + ), + ui.number_field(name="age", label="Enter age"), + ui.button("Submit", type="submit"), + on_invalid=lambda e: print(f"Form invalid"), + validation_behavior="native", + ) + + +my_form_invalid = ui_form_invalid() +``` + +## Action + +The `action` prop enables forms to be sent to a URL. The example below communicates with a 3rd party server and displays the content received from the form. + +```python +from deephaven import ui + + +@ui.component +def ui_form_action(): + return ui.form( + ui.text_field(name="first_name", default_value="Mickey", label="First Name"), + ui.text_field(name="last_name", default_value="Mouse", label="Last Name"), + ui.button("Submit", type="submit"), + action="https://postman-echo.com/get", + method="get", + target="_blank", + ) + + +my_form_action = ui_form_action() +``` + +## Validation Behavior + +By default, validation errors will be displayed in real-time as the fields are edited, but form submission is not blocked. To enable this and native HTML form validation, set `validation_behavior` to "native". + +```python +from deephaven import ui + + +@ui.component +def ui_form_validation_behavior(): + return ui.form( + ui.text_field(name="email", label="Email", type="email", is_required=True), + ui.button("Submit", type="submit"), + validation_behavior="native", + ) + + +my_form_validation_behavior = ui_form_validation_behavior() +``` + + +## Quiet + +The `is_quiet` prop makes form fields "quiet". This can be useful when its corresponding styling should not distract users from surrounding content. + +```python +from deephaven import ui + + +@ui.component +def ui_form_quiet(): + + return ui.form( + ui.text_field(name="name", label="Enter name"), + is_quiet=True, + ) + + +my_form_quiet = ui_form_quiet() +``` + +## Emphasized + +The `is_emphasized` prop adds visual prominence to the form fields. This can be useful when its corresponding styling should catch the user's attention. + +```python +from deephaven import ui + + +@ui.component +def ui_form_emphasized(): + + return ui.form( + ui.text_field(name="name", label="Enter name"), + ui.radio_group( + ui.radio("Video games", value="games"), + ui.radio("Reading", value="reading"), + ui.radio("Sports", value="sports"), + label="Favorite hobby", + default_value="games", + ), + is_emphasized=True, + ) + + +my_form = ui_form_emphasized() +``` + +## Disabled + +The `is_disabled` prop disables form fields to prevent user interaction. This is useful when the fields should be visible but not available for input. + +```python +from deephaven import ui + + +@ui.component +def ui_form_disabled(): + return ui.form( + ui.text_field(name="name", label="Enter name"), + is_disabled=True, + ) + + +my_form_disabled = ui_form_disabled() +``` + +## Necessity Indicator + +The `necessity_indicator` prop dictates whether form labels will use an icon or a label to outline which form fields are required. The default is "icon". + +```python +from deephaven import ui + + +@ui.component +def ui_form_indicator(): + def icon_indicator(): + return ui.form( + ui.text_field(name="name", label="Name", is_required=True), + ui.text_field(name="age", label="Age"), + is_required=True, + ) + + def label_indicator(): + return ui.form( + ui.text_field(name="name", label="Name", is_required=True), + ui.text_field(name="age", label="Age"), + is_required=True, + necessity_indicator="label", + ) + + return [icon_indicator(), label_indicator()] + + +my_form_required = ui_form_indicator() +``` + +## Read only + +The `is_read_only` prop makes form fields read-only to prevent user interaction. This is different than setting the `is_disabled` prop since the fields remains focusable, and the contents of the fields remain visible. + +```python +from deephaven import ui + + +@ui.component +def ui_form_read_only(): + return ui.form( + ui.text_field(name="name", label="Name"), + is_read_only=True, + ) + + +my_form_read_only = ui_form_read_only() +``` + + +## API Reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.form +``` diff --git a/plugins/ui/docs/components/fragment.md b/plugins/ui/docs/components/fragment.md new file mode 100644 index 000000000..e3533dc9f --- /dev/null +++ b/plugins/ui/docs/components/fragment.md @@ -0,0 +1,45 @@ +# Fragment + +The `fragment` component allows you to group multiple elements without adding extra nodes to the DOM. This is especially useful when you need to return several elements but want to avoid wrapping them in an additional element. By using `fragment`, you can maintain a clean DOM tree and prevent unnecessary nesting. + +## Example + +```python +from deephaven import ui + +my_fragment = ui.fragment(ui.text("Child 1"), ui.text("Child 2")) +``` + +## Rendering a List + +When rendering multiple elements in a loop, ensure each fragment has a unique key. This is crucial if array items might be inserted, deleted, or reordered. + +```python +from deephaven import ui + + +@ui.component +def ui_post_list(items): + posts = ( + ui.fragment(ui.heading(p["title"]), ui.text(p["body"]), key=p["id"]) + for p in items + ) + return ui.flex( + *posts, + direction="column", + ) + + +my_post_list = ui_post_list( + [ + {"id": 1, "title": "About me", "body": "I am a developer"}, + {"id": 2, "title": "Contact", "body": "I want to hear from you!"}, + ] +) +``` + +## API Reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.fragment +``` \ No newline at end of file diff --git a/plugins/ui/docs/components/link.md b/plugins/ui/docs/components/link.md new file mode 100644 index 000000000..9bbc22eb2 --- /dev/null +++ b/plugins/ui/docs/components/link.md @@ -0,0 +1,95 @@ +# Link + +Links allow users to navigate to a specified location. + +## Example + +```python +from deephaven import ui + +my_link_basic = ui.link("Learn more about Deephaven", href="https://deephaven.io/") +``` + + +## Content + +The link component accepts other components, such as `text` and `icon`, as children. + +```python +from deephaven import ui + + +@ui.component +def ui_link_content_examples(): + return [ + ui.link(ui.icon("github"), href="https://github.com/deephaven"), + ui.link("Deephaven Website", href="https://deephaven.io/"), + ] + + +my_link_content_examples = ui_link_content_examples() +``` + + +## Variants + +Links can have different styles to indicate their purpose. + +```python +from deephaven import ui + + +@ui.component +def ui_link_variant_examples(): + return [ + ui.link("Deephaven", href="https://deephaven.io/", variant="primary"), + ui.link( + "Contact the team", + href="https://deephaven.io/contact", + variant="secondary", + ), + ] + + +my_link_variant_examples = ui_link_variant_examples() +``` + +## Over background + +Links can be placed over a background to add a visual prominence to the link. + +```python +from deephaven import ui + + +my_link_over_background_example = ui.view( + ui.link( + "Learn more about pandas here!", + href="https://en.wikipedia.org/wiki/Giant_panda", + variant="overBackground", + ), + background_color="green-500", + padding="size-300", +) +``` + +## Quiet State + +The `is_quiet` prop makes the link "quiet". This can be useful when the link and its corresponding styling should not distract users from surrounding content. + +```python +from deephaven import ui + + +my_link_is_quiet_example = ui.text( + "You can ", ui.link("use quiet", is_quiet=True), " links inline." +) +``` + + +## API Reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.link +``` + diff --git a/plugins/ui/docs/components/panel.md b/plugins/ui/docs/components/panel.md new file mode 100644 index 000000000..41936a601 --- /dev/null +++ b/plugins/ui/docs/components/panel.md @@ -0,0 +1,40 @@ +# Panel + +The `panel` component is a versatile [flex](./flex.md) container designed to group and organize elements within a layout. Panels are presented as individual tabs that can be moved to different positions by dragging the tabs around. By default, the top-level return of a `@ui.component` is automatically wrapped in a panel, so you only need to define a panel explicitly if you want to customize it. Customizations can include setting a custom tab title, background color or customizing the flex layout. + +## Example + +```python +from deephaven import ui + + +@ui.component +def ui_panel(): + text = ui.text_field() + + return ui.panel(text, title="Text Field") + + +my_panel = ui_panel() +``` + +## Nesting + +Panels can only be nested within [ui.row](./dashboard.md#row-api-reference), [ui.column](./dashboard.md#column-api-reference), [ui.stack](./dashboard.md#stack-api-reference) or [ui.dashboard](#./dashboard.md). + +```python +from deephaven import ui + +my_nested_panel = ui.dashboard([ui.panel("A"), ui.panel("B")]) +``` + + +## API Reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.panel +``` + + + + diff --git a/plugins/ui/docs/components/table.md b/plugins/ui/docs/components/table.md index 42a298b99..1d80c95e2 100644 --- a/plugins/ui/docs/components/table.md +++ b/plugins/ui/docs/components/table.md @@ -11,12 +11,143 @@ _t = empty_table(10).update("X=i") t = ui.table(_t) ``` -## UI Recommendations +## UI recommendations 1. It is not necessary to use a UI table if you do not need any of its properties. You can just use the Deephaven table directly. 2. Use a UI table to show properties like filters as if the user had created them in the UI. Users can change the default values provided by the UI table, such as filters. 3. UI tables handle ticking tables automatically, so you can pass any Deephaven table to a UI table. +## Formatting + +You can format the table using the `format_` prop. This prop takes a `ui.TableFormmat` object or list of `ui.TableFormat` objects. `ui.TableFormat` is a dataclass that encapsulates the formatting options for a table. The full list of formatting options can be found in the [API Reference](#tableformat). + +### Formatting rows and columns + +Every formatting rule may optionally specify `cols` and `if_` properties. The `cols` property is a column name or list of column names to which the formatting rule applies. If `cols` is omitted, then the rule will be applied to the entire row. The `if_` property is a Deephaven formula that indicates a formatting rule should be applied conditionally. The `if_` property _must_ evaluate to a boolean. If `if_` is omitted, then the rule will be applied to every row. These may be combined to apply formatting to specific columns only when a condition is met. + +> [!NOTE] +> The `if_` property is a Deephaven formula evaluated in the engine. You can think of it like adding a new boolean column using [`update_view`](https://deephaven.io/core/docs/reference/table-operations/select/update-view/). + +The following example shows how to format the `Sym` and `Exchange` columns with a red background and white text when the `Sym` is `DOG`. + +```python +from deephaven import ui +import deephaven.plot.express as dx + +t = ui.table( + dx.data.stocks(), + format_=[ + ui.TableFormat( + cols=["Sym", "Exchange"], + if_="Sym = `DOG`", + background_color="red", + color="white", + ) + ], +) +``` + +### Formatting rule priority + +The last matching formatting rule for each property will be applied. This means the lowest priority rules should be first in the list with higher priority rules at the end. + +In the following example, the `Sym` column will have a red background with white text, and the rest of the table will have a blue background with white text. + +```python +from deephaven import ui +import deephaven.plot.express as dx + +t = ui.table( + dx.data.stocks(), + format_=[ + ui.TableFormat(background_color="blue", color="white"), + ui.TableFormat(cols="Sym", background_color="red"), + ], +) +``` + +### Formatting color + +Formatting rules for colors support Deephaven theme colors, hex colors, or any valid CSS color (e.g., `red`, `#ff0000`, `rgb(255, 0, 0)`). It is **recommended to use Deephaven theme colors** when possible to maintain a consistent look and feel across the UI. Theme colors will also automatically update if the user changes the theme. + +#### Formatting text color + +The `color` property sets the text color of the cell. If a cell has a `background_color`, but no `color` set, the text color will be set to black or white depending on which contrasts better with the background color. Setting the `color` property will override this behavior. + +The following example will make all text the foreground color except the `Sym` column, which will be white. In dark mode, the foreground color is white, and in light mode, it is black. In light mode, the `Sym` column will be nearly invisible because it is not a theme color. + +```py +from deephaven import ui +import deephaven.plot.express as dx + +t = ui.table( + dx.data.stocks(), + format_=[ + ui.TableFormat(color="fg"), + ui.TableFormat(cols="Sym", color="white"), + ], +) +``` + +#### Formatting background color + +The `background_color` property sets the background color of the cell. Setting the `background_color` without setting `color` will result in the text color automatically being set to black or white based on the contrast with the `background_color`. + +The following example will make all the background color what is usually the foreground color. This means the table will have a white background with black text in dark theme and a black background with white text in light theme. The `Sym` column text will be the accent color in both themes. + +```py +from deephaven import ui +import deephaven.plot.express as dx + +t = ui.table( + dx.data.stocks(), + format_=[ + ui.TableFormat(background_color="fg"), + ui.TableFormat(cols="Sym", color="accent"), + ], +) +``` + +### Formatting numeric values + +> [!WARNING] +> Datetime values are considered numeric. If you provide a default format for numeric values, it will also apply to datetime values. It is recommended to specify `cols` when applying value formats. + +Numeric values can be formatted using the `value` property. The `value` property is a string that follows [the GWT Java NumberFormat syntax](https://www.gwtproject.org/javadoc/latest/com/google/gwt/i18n/client/NumberFormat.html). If a numeric format is applied to a non-numeric column, it will be ignored. + +This example will format the `Price` and `Dollars` columns with the dollar sign, a comma separator for every 3 digits, 2 decimal places, and a minimum of 1 digit to the left of the decimal point. The `Random` column will be formatted with 3 decimal places and will drop the leading zero if the absolute value is less than 1. + +```py +from deephaven import ui +import deephaven.plot.express as dx + +t = ui.table( + dx.data.stocks(), + format_=[ + ui.TableFormat(cols=["Price", "Dollars"], value="$#,##0.00"), + ui.TableFormat(cols="Random", value="#.000") + ], +) +``` + +### Formatting datetime and timestamp values + +Datetime and timestamp values can be formatted using the `value` property. The `value` property is a string that follows [the GWT Java DateTimeFormat syntax](https://www.gwtproject.org/javadoc/latest/com/google/gwt/i18n/client/DateTimeFormat.html) with additional support for nanoseconds. You may provide up to 9 `S` characters after the decimal to represent partial seconds down to nanoseconds. + +The following example formats the `Timestamp` column to show the short date of the week, day of the month, short month name, full year, hours, minutes, seconds, and microseconds with the user selected timezone. + +```py +from deephaven import ui +import deephaven.plot.express as dx + +t = ui.table( + dx.data.stocks(), + format_=[ + ui.TableFormat(cols="Timestamp", value="E, dd MMM yyyy HH:mm:ss.SSSSSS z"), + ], +) +``` + ## Events You can listen for different user events on a `ui.table`. There is both a `press` and `double_press` event for `row`, `cell`, and `column`. These events typically correspond to a click or double click on the table. The event payloads include table data related to the event. For `row` and `column` events, the corresponding data within the viewport will be sent to the event handler. The viewport is typically the visible area ± a window equal to the visible area (e.g., if rows 5-10 are visible, rows 0-15 will be in the viewport). @@ -42,7 +173,7 @@ t = ui.table( ) ``` -## Context Menu +## Context menu Items can be added to the bottom of the `ui.table` context menu (right-click menu) by using the `context_menu` or `context_header_menu` props. The `context_menu` prop adds items to the cell context menu, while the `context_header_menu` prop adds items to the column header context menu. You can pass either a single dictionary for a single item or a list of dictionaries for multiple items. @@ -106,7 +237,7 @@ t = ui.table( ) ``` -### Dynamic Menu Items +### Dynamic menu items Menu items can be dynamically created by passing a function as the context item. The function will be called with the data of the cell that was clicked when the menu was opened, and must return the menu items or None if you do not want to add context menu items based on the cell info. @@ -130,7 +261,7 @@ t = ui.table( ) ``` -## Column Order and Visibility +## Column order and visibility You can freeze columns to the front of the table using the `frozen_columns` prop. Frozen columns will always be visible on the left side of the table, even when the user scrolls horizontally. The `frozen_columns` prop takes a list of column names to freeze. @@ -153,7 +284,7 @@ t = ui.table( ![Example of column order and visibility](../_assets/table_column_order.png) -## Grouping Columns +## Grouping columns Columns can be grouped visually using the `column_groups` prop. Columns in a column group are moved so they are next to each other, and a header spanning all columns in the group is added. Columns can be rearranged within a group, but they cannot be moved outside of the group without using the table sidebar menu. @@ -195,7 +326,7 @@ t = ui.table( ![Example of column groups](../_assets/table_column_groups.png) -## Always Fetching Some Columns +## Always fetching some columns Deephaven only fetches data for visible rows and columns within a window around the viewport (typically the viewport plus 1 page in all directions). This reduces the amount of data transferred between the server and client and allows displaying tables with billions of rows. Sometimes you may need to always fetch columns, such as a key column for a row press event. You can use the `always_fetch_columns` prop to specify columns that should always be fetched regardless of their visibility. @@ -218,7 +349,7 @@ t = ui.table( ) ``` -## Quick Filters +## Quick filters Quick filters are an easy way to filter the table while also showing the user what filters are currently applied. These filters are applied on the server via request from the client, so users may change the filters without affecting other users. Unlike a `where` statement to filter a table on the server, quick filters can be easily changed by the user. @@ -265,6 +396,14 @@ t = ui.table( ## API Reference +### Table + ```{eval-rst} .. dhautofunction:: deephaven.ui.table ``` + +### TableFormat + +```{eval-rst} +.. dhautofunction:: deephaven.ui.TableFormat +``` diff --git a/plugins/ui/docs/components/tabs.md b/plugins/ui/docs/components/tabs.md new file mode 100644 index 000000000..ff9b97bd3 --- /dev/null +++ b/plugins/ui/docs/components/tabs.md @@ -0,0 +1,368 @@ +# Tabs + +Tabs organize related content into sections within panels, allowing users to navigate between them. + +## Example + +```python +from deephaven import ui, empty_table + +my_tabs_basic = ui.tabs( + ui.tab("Hello World!", title="Tab 1"), + ui.tab( + ui.flex( + "Hello World with table!", + empty_table(10).update("I=i"), + ), + title="Tab 2", + ), +) +``` + +## UI Recommendations + +1. Use tabs to organize sections of equal importance. Avoid using tabs for content with varying levels of importance. +2. Use a vertical tabs layout when displaying shortcuts to sections of content on a single page. +3. Avoid nesting tabs more than two levels deep, as it can become overly complicated. + + +## Content + +Tabs can be created using `ui.tab`, or using `ui.tab_list` and `ui.tab_panels`, but not the two options combined. + +If you want a default tab layout with minimal customization for tab appearance, tabs should be created by passing in `ui.tab` to `ui.tabs`. + +Note that the `ui.tab` component can only be used within `ui.tabs`. + +```python +from deephaven import ui + + +my_tabs_tab_content_example = ui.tabs( + ui.tab("Arma virumque cano, Troiae qui primus ab oris.", title="Founding of Rome"), + ui.tab("Senatus Populusque Romanus.", title="Monarchy and Republic"), + ui.tab("Alea jacta est.", title="Empire"), +) +``` + +For more control over the layout, types, and styling of the tabs, create them with `ui.tab_list` and `ui.tab_panels` with `ui.tabs`. + +The `ui.tab_list` specifies the titles of the tabs, while the `ui.tab_panels` specify the content within each of the tab panels. + +When specifying tabs using `ui.tab_list` and `ui.tab_panels`, keys must be provided that match each of the respective tabs. + +```python +from deephaven import ui + + +my_tabs_list_panels_content_example = ui.tabs( + ui.tab_list(ui.item("Tab 1", key="Key 1"), ui.item("Tab 2", key="Key 2")), + ui.tab_panels( + ui.item( + ui.calendar( + aria_label="Calendar (uncontrolled)", + default_value="2020-02-03", + ), + key="Key 1", + ), + ui.item( + ui.radio_group( + ui.radio("Yes", value="Yes"), + ui.radio("No", value="No"), + label="Is vanilla the best flavor of ice cream?", + ), + key="Key 2", + ), + flex_grow=1, + position="relative", + ), + flex_grow=1, + margin_bottom="size-400", +) +``` + +Note that both the `ui.tab_list` and `ui.tab_panels` components can also only be used within `ui.tabs`. + + +## Selection + +With tabs, the `default_selected_key` or `selected_key` props can be set to have a selected tab. + +```python +from deephaven import ui + + +@ui.component +def ui_tabs_selected_key_examples(): + selected_tab, set_selected_tab = ui.use_state("Tab 1") + return [ + "Pick a tab (uncontrolled)", + ui.tabs( + ui.tab( + "There is no prior chat history with John Doe.", + title="John Doe", + key="Tab 1", + ), + ui.tab( + "There is no prior chat history with Jane Doe.", + title="Jane Doe", + key="Tab 2", + ), + ui.tab( + "There is no prior chat history with Joe Bloggs.", + title="Joe Bloggs", + key="Tab 3", + ), + default_selected_key="Tab 2", + ), + f"Pick a tab (controlled), selected tab: {selected_tab}", + ui.tabs( + ui.tab( + "There is no prior chat history with John Doe.", + title="John Doe", + key="Tab 1", + ), + ui.tab( + "There is no prior chat history with Jane Doe.", + title="Jane Doe", + key="Tab 2", + ), + ui.tab( + "There is no prior chat history with Joe Bloggs.", + title="Joe Bloggs", + key="Tab 3", + ), + selected_key=selected_tab, + on_selection_change=set_selected_tab, + ), + ] + + +my_tabs_selected_key_examples = ui_tabs_selected_key_examples() +``` + + +## Events + +The `on_change` property is triggered whenever the currently selected tab changes. + + +```python +from deephaven import ui + + +@ui.component +def ui_tabs_on_change_example(): + selected_tab, set_selected_tab = ui.use_state("Tab 1") + + def get_background_color(tab): + if tab == "Tab 1": + return "celery-500" + elif tab == "Tab 2": + return "fuchsia-500" + elif tab == "Tab 3": + return "blue-500" + else: + return "gray-200" + + return [ + ui.view( + ui.tabs( + ui.tab( + "There is no prior chat history with John Doe.", + title="John Doe", + key="Tab 1", + ), + ui.tab( + "There is no prior chat history with Jane Doe.", + title="Jane Doe", + key="Tab 2", + ), + ui.tab( + "There is no prior chat history with Joe Bloggs.", + title="Joe Bloggs", + key="Tab 3", + ), + selected_key=selected_tab, + on_selection_change=set_selected_tab, + ), + background_color=get_background_color(selected_tab), + flex="auto", + width="100%", + ), + ui.text(f"You have selected: {selected_tab}"), + ] + + +my_tabs_on_change_example = ui_tabs_on_change_example() +``` + + +## Keyboard activation + +By default, pressing the arrow keys while currently focused on a tab will automatically switch selection to the adjacent tab in that key's direction. + +To prevent this automatic selection change, the `keyboard_activation` prop can be set to "manual". + +```python +from deephaven import ui + + +my_tabs_keyboard_activation_example = ui.tabs( + ui.tab("Arma virumque cano, Troiae qui primus ab oris.", title="Founding of Rome"), + ui.tab("Senatus Populusque Romanus.", title="Monarchy and Republic"), + ui.tab("Alea jacta est.", title="Empire"), + keyboard_activation="manual", +) +``` + + +## Density + +By default, the density of the tab list is "compact". To change this, the `density` prop can be set to "regular". + +```python +from deephaven import ui + + +@ui.component +def ui_tabs_density_examples(): + return [ + ui.tabs( + ui.tab("There is no prior chat history with John Doe.", title="John Doe"), + ui.tab("There is no prior chat history with Jane Doe.", title="Jane Doe"), + ui.tab( + "There is no prior chat history with Joe Bloggs.", title="Joe Bloggs" + ), + density="regular", + ), + ] + + +my_tabs_density_examples = ui_tabs_density_examples() +``` + + +## Quiet State + +The `is_quiet` prop makes tabs "quiet" by removing the line separating the tab titles and panel contents. This can be useful when the tabs should not distract users from surrounding content. + +```python +from deephaven import ui + + +my_tabs_is_quiet_example = ui.tabs( + ui.tab("There is no prior chat history with John Doe.", title="John Doe"), + ui.tab("There is no prior chat history with Jane Doe.", title="Jane Doe"), + ui.tab("There is no prior chat history with Joe Bloggs.", title="Joe Bloggs"), + is_quiet=True, +) +``` + + +## Disabled state + +The `is_disabled` prop disables the tabs component to prevent user interaction. This is useful when tabs should be visible but not available for selection. + +```python +from deephaven import ui + + +my_tabs_is_disabled_example = ui.tabs( + ui.tab("There is no prior chat history with John Doe.", title="John Doe"), + ui.tab("There is no prior chat history with Jane Doe.", title="Jane Doe"), + ui.tab("There is no prior chat history with Joe Bloggs.", title="Joe Bloggs"), + is_disabled=True, +) +``` + +## Orientation + +By default, tabs are horizontally oriented. To change the tabs' orientation, set the `orientation` prop to "vertical". + +```python +from deephaven import ui + + +@ui.component +def ui_tabs_orientation_examples(): + return [ + ui.tabs( + ui.tab("There is no prior chat history with John Doe.", title="John Doe"), + ui.tab("There is no prior chat history with Jane Doe.", title="Jane Doe"), + ui.tab( + "There is no prior chat history with Joe Bloggs.", title="Joe Bloggs" + ), + orientation="vertical", + ), + ui.tabs( + ui.tab("There is no prior chat history with John Doe.", title="John Doe"), + ui.tab("There is no prior chat history with Jane Doe.", title="Jane Doe"), + ui.tab( + "There is no prior chat history with Joe Bloggs.", title="Joe Bloggs" + ), + ), + ] + + +my_tabs_orientation_examples = ui_tabs_orientation_examples() +``` + + +## Overflow behaviour + +If there isn't enough horizontal space to render all tabs on a single line, the component will automatically collapse all tabs into a Picker. + +Note that this only occurs when tabs are horizontally oriented; when tabs are vertically oriented, the list continues to extend downwards. + +```python +from deephaven import ui + + +@ui.component +def ui_tabs_overflow_example(): + return [ + ui.view( + ui.tabs( + ui.tab( + "There is no prior chat history with John Doe.", title="John Doe" + ), + ui.tab( + "There is no prior chat history with Jane Doe.", title="Jane Doe" + ), + ui.tab( + "There is no prior chat history with Joe Bloggs.", + title="Joe Bloggs", + ), + ), + width="80px", + ) + ] + + +my_tabs_overflow_example = ui_tabs_overflow_example() +``` + + +## Emphasized + +The `is_emphasized` prop makes the line underneath the selected tab the user's accent color, adding a visual prominence to the selection. + +```python +from deephaven import ui + + +my_tabs_is_emphasized_example = ui.tabs( + ui.tab("There is no prior chat history with John Doe.", title="John Doe"), + ui.tab("There is no prior chat history with Jane Doe.", title="Jane Doe"), + ui.tab("There is no prior chat history with Joe Bloggs.", title="Joe Bloggs"), + is_emphasized=True, +) +``` + + +## API Reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.tabs +``` \ No newline at end of file diff --git a/plugins/ui/docs/sidebar.json b/plugins/ui/docs/sidebar.json index 7fb174b40..f08e959fa 100644 --- a/plugins/ui/docs/sidebar.json +++ b/plugins/ui/docs/sidebar.json @@ -18,6 +18,10 @@ "label": "Installation", "path": "installation.md" }, + { + "label": "Architecture", + "path": "architecture.md" + }, { "label": "Components", "items": [ @@ -53,6 +57,10 @@ "label": "combo_box", "path": "components/combo_box.md" }, + { + "label": "contextual_help", + "path": "components/contextual_help.md" + }, { "label": "date_picker", "path": "components/date_picker.md" @@ -69,6 +77,10 @@ "label": "dialog_trigger", "path": "components/dialog_trigger.md" }, + { + "label": "fragment", + "path": "components/fragment.md" + }, { "label": "heading", "path": "components/heading.md" @@ -81,6 +93,10 @@ "label": "image", "path": "components/image.md" }, + { + "label": "link", + "path": "components/link.md" + }, { "label": "picker", "path": "components/picker.md" @@ -113,6 +129,10 @@ "label": "Table", "path": "components/table.md" }, + { + "label": "tabs", + "path": "components/tabs.md" + }, { "label": "text", "path": "components/text.md" diff --git a/plugins/ui/src/deephaven/ui/components/__init__.py b/plugins/ui/src/deephaven/ui/components/__init__.py index e302448f8..9c7e07f36 100644 --- a/plugins/ui/src/deephaven/ui/components/__init__.py +++ b/plugins/ui/src/deephaven/ui/components/__init__.py @@ -30,6 +30,7 @@ from .image import image from .item import item from .item_table_source import item_table_source +from .link import link from .list_action_group import list_action_group from .list_action_menu import list_action_menu from .list_view import list_view @@ -51,7 +52,7 @@ from .tab_list import tab_list from .tab_panels import tab_panels from .tab import tab -from .table import table +from .table import table, TableDatabar, TableFormat from .tabs import tabs from .text import text from .text_area import text_area @@ -95,6 +96,7 @@ "item_table_source", "illustrated_message", "image", + "link", "list_view", "list_action_group", "list_action_menu", @@ -114,6 +116,8 @@ "stack", "switch", "table", + "TableDatabar", + "TableFormat", "tab_list", "tab_panels", "tabs", diff --git a/plugins/ui/src/deephaven/ui/components/contextual_help.py b/plugins/ui/src/deephaven/ui/components/contextual_help.py index d9669d7b6..19c988995 100644 --- a/plugins/ui/src/deephaven/ui/components/contextual_help.py +++ b/plugins/ui/src/deephaven/ui/components/contextual_help.py @@ -77,6 +77,7 @@ def contextual_help( ) -> Element: """ A contextual help is a quiet action button that triggers an informational popover. + Args: heading: The heading of the popover. content: The content of the popover. @@ -127,13 +128,17 @@ def contextual_help( z_index: The stacking order for the element is_hidden: Hides the element. id: The unique identifier of the element. - aria-label: Defines a string value that labels the current element. - aria-labelledby: Identifies the element (or elements) that labels the current element. - aria-describedby: Identifies the element (or elements) that describes the object. - aria-details: Identifies the element (or elements) that provide a detailed, extended description for the object. + aria_label: Defines a string value that labels the current element. + aria_labelledby: Identifies the element (or elements) that labels the current element. + aria_describedby: Identifies the element (or elements) that describes the object. + aria_details: Identifies the element (or elements) that provide a detailed, extended description for the object. UNSAFE_class_name: Set the CSS className for the element. Only use as a last resort. Use style props instead. UNSAFE_style: Set the inline style for the element. Only use as a last resort. Use style props instead. key: A unique identifier used by React to render elements in a list. + + Returns: + The rendered contextual help component. + """ return component_element( "ContextualHelp", diff --git a/plugins/ui/src/deephaven/ui/components/form.py b/plugins/ui/src/deephaven/ui/components/form.py index 57c79facd..bca349bb6 100644 --- a/plugins/ui/src/deephaven/ui/components/form.py +++ b/plugins/ui/src/deephaven/ui/components/form.py @@ -160,6 +160,9 @@ def form( 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 form element. """ return component_element( "Form", diff --git a/plugins/ui/src/deephaven/ui/components/fragment.py b/plugins/ui/src/deephaven/ui/components/fragment.py index 1086407a0..f604c6d11 100644 --- a/plugins/ui/src/deephaven/ui/components/fragment.py +++ b/plugins/ui/src/deephaven/ui/components/fragment.py @@ -2,9 +2,10 @@ from typing import Any from .basic import component_element +from ..elements import Element -def fragment(*children: Any, key: str | None = None): +def fragment(*children: Any, key: str | None = None) -> Element: """ A React.Fragment: https://react.dev/reference/react/Fragment. Used to group elements together without a wrapper node. @@ -12,5 +13,8 @@ def fragment(*children: Any, key: str | None = None): Args: *children: The children in the fragment. key: A unique identifier used by React to render elements in a list. + + Returns: + The rendered fragment element. """ return component_element("Fragment", children=children, key=key) diff --git a/plugins/ui/src/deephaven/ui/components/link.py b/plugins/ui/src/deephaven/ui/components/link.py new file mode 100644 index 000000000..a4c4cef26 --- /dev/null +++ b/plugins/ui/src/deephaven/ui/components/link.py @@ -0,0 +1,222 @@ +from __future__ import annotations +from typing import Any, Callable +from .types import ( + Target, + FocusEventCallable, + KeyboardEventCallable, + PressEventCallable, + AlignSelf, + CSSProperties, + DimensionValue, + JustifySelf, + LayoutFlex, + Position, +) +from .basic import component_element +from ..elements import Element +from ..types import LinkVariant + + +def link( + *children: Any, + variant: LinkVariant | None = "primary", + is_quiet: bool | None = None, + auto_focus: bool | None = None, + href: str | None = None, + target: Target | None = None, + rel: str | None = None, + ping: str | None = None, + download: str | None = None, + href_lang: str | None = None, + referrer_policy: str | None = None, + on_press: PressEventCallable | None = None, + on_press_start: PressEventCallable | None = None, + on_press_end: PressEventCallable | None = None, + on_press_up: PressEventCallable | None = None, + on_press_change: Callable[[bool], None] | None = None, + on_focus: FocusEventCallable | None = None, + on_blur: FocusEventCallable | None = None, + on_focus_change: Callable[[bool], None] | None = None, + on_key_down: KeyboardEventCallable | None = None, + on_key_up: KeyboardEventCallable | 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_column: str | None = None, + grid_row_start: str | None = None, + grid_row_end: 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, + 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, + 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, +) -> Element: + """ + A link is used for navigating between locations. + + Args: + *children: The content to display in the link. + variant: The background color of the link. + is_quiet: Whether the link should be displayed with a quiet style. + auto_focus: Whether the element should receive focus on render. + href: A URL to link to. + target: The target window for the link. + rel: The relationship between the linked resource and the current page. + ping: A space-separated list of URLs to ping when the link is followed. + download: Causes the browser to download the linked URL. + href_lang: Hints at the human language of the linked URL. + referrer_policy: How much of the referrer to send when following the link. + on_press: Function called when the link is pressed. + on_press_start: Function called when the link is pressed and held. + on_press_end: Function called when the link is released after being pressed. + on_press_up: Function called when the link is released. + on_press_change: Function called when the pressed state changes. + on_focus: Function called when the link receives focus. + on_blur: Function called when the link loses focus. + on_focus_change: Function called when the focus state changes. + on_key_down: Function called when a key is pressed down. + on_key_up: Function called when a key is released. + 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. + 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 position. + top: The top position of the element. + bottom: The bottom position of the element. + left: The left position of the element. + right: The right position of the element. + start: The logical start position of the element, depending on layout direction. + end: The logical end position of the element, depending on layout direction. + z_index: The stacking order for the element + is_hidden: Hides the element. + aria_label: Defines a string value that labels the current element. + aria_labelledby: Identifies the element (or elements) that labels the current element. + aria_describedby: Identifies the element (or elements) that describes the object. + aria_details: Identifies the element (or elements) that provide a detailed, extended description for the object. + UNSAFE_class_name: Set the CSS className for the element. Only use as a last resort. Use style props instead. + UNSAFE_style: Set the inline style for the element. Only use as a last resort. Use style props instead. + + Returns: + The rendered link element. + + """ + return component_element( + "Link", + *children, + variant=variant, + is_quiet=is_quiet, + auto_focus=auto_focus, + href=href, + target=target, + rel=rel, + ping=ping, + download=download, + href_lang=href_lang, + referrer_policy=referrer_policy, + on_press=on_press, + on_press_start=on_press_start, + on_press_end=on_press_end, + on_press_up=on_press_up, + on_press_change=on_press_change, + on_focus=on_focus, + on_blur=on_blur, + on_focus_change=on_focus_change, + on_key_down=on_key_down, + on_key_up=on_key_up, + 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_column=grid_column, + grid_row_start=grid_row_start, + grid_row_end=grid_row_end, + 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, + left=left, + right=right, + start=start, + end=end, + z_index=z_index, + is_hidden=is_hidden, + aria_label=aria_label, + aria_labelledby=aria_labelledby, + aria_describedby=aria_describedby, + aria_details=aria_details, + UNSAFE_class_name=UNSAFE_class_name, + UNSAFE_style=UNSAFE_style, + ) diff --git a/plugins/ui/src/deephaven/ui/components/panel.py b/plugins/ui/src/deephaven/ui/components/panel.py index 9e9f12429..dca1e4af4 100644 --- a/plugins/ui/src/deephaven/ui/components/panel.py +++ b/plugins/ui/src/deephaven/ui/components/panel.py @@ -11,8 +11,10 @@ AlignItems, DimensionValue, Overflow, + CSSProperties, ) from ..elements import Element +from ..types import Color def panel( @@ -34,8 +36,10 @@ def panel( padding_end: DimensionValue | None = None, padding_x: DimensionValue | None = None, padding_y: DimensionValue | None = None, + background_color: Color | None = None, + UNSAFE_class_name: str | None = None, + UNSAFE_style: CSSProperties | None = None, key: str | None = None, - **props: Any, ) -> Element: """ A panel is a container that can be used to group elements. @@ -51,6 +55,7 @@ def panel( gap: The space to display between both rows and columns of children. column_gap: The space to display between columns of children. row_gap: The space to display between rows of children. + overflow: Specifies what to do when the elment's content is too long to fit its size. padding: The padding to apply around the element. padding_top: The padding to apply above the element. padding_bottom: The padding to apply below the element. @@ -58,6 +63,9 @@ def panel( padding_end: The padding to apply after the element. padding_x: The padding to apply to the left and right of the element. padding_y: The padding to apply to the top and bottom of the element. + background_color: The background color of 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: diff --git a/plugins/ui/src/deephaven/ui/components/table.py b/plugins/ui/src/deephaven/ui/components/table.py index 9f9558614..37f1a04a2 100644 --- a/plugins/ui/src/deephaven/ui/components/table.py +++ b/plugins/ui/src/deephaven/ui/components/table.py @@ -1,91 +1,100 @@ from __future__ import annotations -from typing import Literal - +from dataclasses import dataclass +from typing import Literal, Any, Optional +import logging from deephaven.table import Table -from ..elements import UITable +from ..elements import Element from .types import AlignSelf, DimensionValue, JustifySelf, LayoutFlex, Position from ..types import ( CellPressCallback, + Color, ColumnGroup, ColumnName, ColumnPressCallback, - DatabarConfig, QuickFilterExpression, RowPressCallback, ResolvableContextMenuItem, ) +from .._internal import dict_to_react_props, RenderContext + +logger = logging.getLogger(__name__) + + +@dataclass +class TableFormat: + """ + A formatting rule for a table. + + Args: + cols: The columns to format. If None, the format will apply to the entire row. + if_: Deephaven expression to filter which rows should be formatted. Must resolve to a boolean. + color: The font color. + background_color: The cell background color. + alignment: The cell text alignment. + value: Format string for the cell value. + E.g. "0.00%" to format as a percentage with two decimal places. + mode: The cell rendering mode. + Currently only databar is supported as an alternate rendering mode. + Returns: + The TableFormat. + """ + cols: ColumnName | list[ColumnName] | None = None + if_: str | None = None + color: Color | None = None + background_color: Color | None = None + alignment: Literal["left", "center", "right"] | None = None + value: str | None = None + mode: TableDatabar | None = None -def table( - table: Table, - *, - on_row_press: RowPressCallback | None = None, - on_row_double_press: RowPressCallback | None = None, - on_cell_press: CellPressCallback | None = None, - on_cell_double_press: CellPressCallback | None = None, - on_column_press: ColumnPressCallback | None = None, - on_column_double_press: ColumnPressCallback | None = None, - always_fetch_columns: ColumnName | list[ColumnName] | bool | None = None, - quick_filters: dict[ColumnName, QuickFilterExpression] | None = None, - show_quick_filters: bool = False, - show_grouping_column: bool = True, - show_search: bool = False, - reverse: bool = False, - front_columns: list[ColumnName] | None = None, - back_columns: list[ColumnName] | None = None, - frozen_columns: list[ColumnName] | None = None, - hidden_columns: list[ColumnName] | None = None, - column_groups: list[ColumnGroup] | None = None, - density: Literal["compact", "regular", "spacious"] | None = None, - context_menu: ( - ResolvableContextMenuItem | list[ResolvableContextMenuItem] | None - ) = None, - context_header_menu: ( - ResolvableContextMenuItem | list[ResolvableContextMenuItem] | None - ) = None, - databars: list[DatabarConfig] | None = None, - key: str | 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, -) -> UITable: + +@dataclass +class TableDatabar: + """ + A databar configuration for a table. + + Args: + column: Name of the column to display as a databar. + value_column: Name of the column to use as the value for the databar. + If not provided, the databar will use the column value. + + This can be useful if you want to display a databar with + a log scale, but display the actual value in the cell. + In this case, the value_column would be the log of the actual value. + min: Minimum value for the databar. Defaults to the minimum value in the column. + + If a column name is provided, the minimum value will be the value in that column. + If a constant is providded, the minimum value will be that constant. + max: Maximum value for the databar. Defaults to the maximum value in the column. + + If a column name is provided, the maximum value will be the value in that column. + If a constant is providded, the maximum value will be that constant. + axis: Whether the databar 0 value should be proportional to the min and max values, + in the middle of the cell, or on one side of the databar based on direction. + direction: The direction of the databar. + value_placement: Placement of the value relative to the databar. + color: The color of the databar. + opacity: The opacity of the databar. + """ + + column: ColumnName + value_column: ColumnName | None = None + min: ColumnName | float | None = None + max: ColumnName | float | None = None + axis: Literal["proportional", "middle", "directional"] | None = None + direction: Literal["LTR", "RTL"] | None = None + value_placement: Literal["beside", "overlap", "hide"] | None = None + color: Color | None = None + opacity: float | None = None + + +class table(Element): """ Customization to how a table is displayed, how it behaves, and listen to UI events. Args: table: The table to wrap + format_: A formatting rule or list of formatting rules for the table. on_row_press: The callback function to run when a row is clicked. The callback is invoked with the visible row data provided in a dictionary where the column names are the keys. @@ -165,6 +174,92 @@ def table( Returns: The rendered Table. """ - props = locals() - del props["table"] - return UITable(table, **props) + + _props: dict[str, Any] + """ + The props that are passed to the frontend + """ + + def __init__( + self, + table: Table, + *, + format_: TableFormat | list[TableFormat] | None = None, + on_row_press: RowPressCallback | None = None, + on_row_double_press: RowPressCallback | None = None, + on_cell_press: CellPressCallback | None = None, + on_cell_double_press: CellPressCallback | None = None, + on_column_press: ColumnPressCallback | None = None, + on_column_double_press: ColumnPressCallback | None = None, + always_fetch_columns: ColumnName | list[ColumnName] | bool | None = None, + quick_filters: dict[ColumnName, QuickFilterExpression] | None = None, + show_quick_filters: bool = False, + show_grouping_column: bool = True, + show_search: bool = False, + reverse: bool = False, + front_columns: list[ColumnName] | None = None, + back_columns: list[ColumnName] | None = None, + frozen_columns: list[ColumnName] | None = None, + hidden_columns: list[ColumnName] | None = None, + column_groups: list[ColumnGroup] | None = None, + density: Literal["compact", "regular", "spacious"] | None = None, + context_menu: ( + ResolvableContextMenuItem | list[ResolvableContextMenuItem] | None + ) = None, + context_header_menu: ( + ResolvableContextMenuItem | list[ResolvableContextMenuItem] | None + ) = None, + databars: list[TableDatabar] | None = None, + key: str | 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, + ) -> None: + props = locals() + del props["self"] + self._props = props + self._key = props.get("key") + + @property + def name(self): + return "deephaven.ui.elements.UITable" + + @property + def key(self) -> str | None: + return self._key + + def render(self, context: RenderContext) -> dict[str, Any]: + logger.debug("Returning props %s", self._props) + return dict_to_react_props(self._props) diff --git a/plugins/ui/src/deephaven/ui/components/tabs.py b/plugins/ui/src/deephaven/ui/components/tabs.py index 29832f75e..ebed99672 100644 --- a/plugins/ui/src/deephaven/ui/components/tabs.py +++ b/plugins/ui/src/deephaven/ui/components/tabs.py @@ -15,6 +15,9 @@ ) from ..types import Key, TabDensity +from ..elements import BaseElement + +TabElement = BaseElement def tabs( @@ -75,7 +78,7 @@ def tabs( UNSAFE_class_name: str | None = None, UNSAFE_style: CSSProperties | None = None, key: str | None = None, -): +) -> TabElement: """ Python implementation for the Adobe React Spectrum Tabs component. https://react-spectrum.adobe.com/react-spectrum/Tabs.html @@ -118,6 +121,12 @@ def tabs( 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. + 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 position. top: The top position of the element. bottom: The bottom position of the element. @@ -135,6 +144,10 @@ def tabs( UNSAFE_class_name: Set the CSS className for the element. Only use as a last resort. Use style props instead. UNSAFE_style: Set the inline style for the element. Only use as a last resort. Use style props instead. key: A unique identifier used by React to render elements in a list. + + Returns: + The rendered tabs component. + """ if not children: raise ValueError("Tabs must have at least one child.") diff --git a/plugins/ui/src/deephaven/ui/elements/UITable.py b/plugins/ui/src/deephaven/ui/elements/UITable.py deleted file mode 100644 index 09e228d60..000000000 --- a/plugins/ui/src/deephaven/ui/elements/UITable.py +++ /dev/null @@ -1,478 +0,0 @@ -from __future__ import annotations - -import logging -import sys -from typing import Callable, Literal, Sequence, Any, cast -from warnings import warn - -if sys.version_info < (3, 11): - from typing_extensions import TypedDict, NotRequired -else: - from typing import TypedDict, NotRequired - -from deephaven.table import Table -from deephaven import SortDirection -from .Element import Element -from ..types import ( - ColumnName, - AggregationOperation, - QuickFilterExpression, - Color, - CellPressCallback, - ColumnPressCallback, - DataBarAxis, - DataBarValuePlacement, - DataBarDirection, - SelectionMode, - TableSortDirection, - RowPressCallback, - StringSortDirection, -) -from .._internal import dict_to_react_props, RenderContext - -logger = logging.getLogger(__name__) - - -def remap_sort_direction(direction: TableSortDirection | str) -> Literal["ASC", "DESC"]: - """ - Remap the sort direction to the grid sort direction - - Args: - direction: The deephaven sort direction or grid sort direction to remap - - Returns: - The grid sort direction - """ - if direction == SortDirection.ASCENDING: - return "ASC" - elif direction == SortDirection.DESCENDING: - return "DESC" - elif direction in {"ASC", "DESC"}: - return cast(StringSortDirection, direction) - raise ValueError(f"Invalid table sort direction: {direction}") - - -class UITableProps(TypedDict): - on_row_press: NotRequired[RowPressCallback] - """ - Callback function to run when a row is clicked. - The first parameter is the row index, and the second is the visible row data provided in a dictionary where the - column names are the keys. - """ - - on_row_double_press: NotRequired[RowPressCallback] - """ - The callback function to run when a row is double clicked. - The first parameter is the row index, and the second is the visible row data provided in a dictionary where the - column names are the keys. - """ - - on_cell_press: NotRequired[CellPressCallback] - """ - The callback function to run when a cell is clicked. - The first parameter is the cell index, and the second is the cell data provided in a dictionary where the - column names are the keys. - """ - - on_cell_double_press: NotRequired[CellPressCallback] - """ - The callback function to run when a cell is double clicked. - The first parameter is the cell index, and the second is the cell data provided in a dictionary where the - column names are the keys. - """ - - on_column_press: NotRequired[ColumnPressCallback] - """ - The callback function to run when a column is clicked. - The first parameter is the column name. - """ - - on_column_double_press: NotRequired[ColumnPressCallback] - """ - The callback function to run when a column is double clicked. - The first parameter is the column name. - """ - - table: Table - """ - The table to wrap - """ - - -class UITable(Element): - """ - Wrap a Table with some extra props for giving hints to displaying a table - """ - - _props: UITableProps - """ - The props that are passed to the frontend - """ - - def __init__( - self, - table: Table, - **props: Any, - ): - """ - Create a UITable from the passed in table. UITable provides an [immutable fluent interface](https://en.wikipedia.org/wiki/Fluent_interface#Immutability) for adding UI hints to a table. - - Args: - table: The table to wrap - props: UITableProps props to pass to the frontend. - """ - - # Store all the props that were passed in - self._props = UITableProps(**props, table=table) - self._key = props.get("key") - - @property - def name(self): - return "deephaven.ui.elements.UITable" - - @property - def key(self) -> str | None: - return self._key - - def _with_prop(self, key: str, value: Any) -> "UITable": - """ - Create a new UITable with the passed in prop added to the existing props - - Args: - key: The key to add to the props - value: The value to add with the associated key - - Returns: - A new UITable with the passed in prop added to the existing props - """ - logger.debug("_with_prop(%s, %s)", key, value) - return UITable(**{**self._props, key: value}) - - def _with_appendable_prop(self, key: str, value: Any) -> "UITable": - """ - Create a new UITable with the passed in prop added to the existing prop - list (if it exists) or a new list with the passed in value - - Args: - key: The key to add to the props - value: The value to add with the associated key - - Returns: - A new UITable with the passed in prop added to the existing props - """ - logger.debug("_with_appendable_prop(%s, %s)", key, value) - existing = self._props.get(key, []) - - if not isinstance(existing, list): - raise ValueError(f"Expected {key} to be a list") - - value = value if isinstance(value, list) else [value] - - return UITable(**{**self._props, key: existing + value}) - - def _with_dict_prop(self, key: str, value: dict[str, Any]) -> "UITable": - """ - Create a new UITable with the passed in prop in a dictionary. - This will override any existing prop with the same key within - the dict stored at prop_name. - - - Args: - prop_name: The key to add to the props - value: The value to add with the associated key - - Returns: - A new UITable with the passed in prop added to the existing props - """ - logger.debug("_with_dict_prop(%s, %s)", key, value) - existing = ( - self._props.get(key) or {} - ) # Turn missing or explicit None into empty dict - return UITable(**{**self._props, key: {**existing, **value}}) # type: ignore - - def render(self, context: RenderContext) -> dict[str, Any]: - logger.debug("Returning props %s", self._props) - return dict_to_react_props({**self._props}) - - def aggregations( - self, - operations: dict[ColumnName, list[AggregationOperation]], - operation_order: list[AggregationOperation] | None = None, - default_operation: AggregationOperation | None = None, - group_by: list[ColumnName] | None = None, - show_on_top: bool = False, - ) -> "UITable": - """ - Set the totals table to display below the main table. - - Args: - operations: The operations to apply to the columns of the table. - operation_order: The order in which to display the operations. - default_operation: The default operation to apply to columns that do not have an operation specified. - group_by: The columns to group by. - show_on_top: Whether to show the totals table above the main table. - - Returns: - A new UITable - """ - raise NotImplementedError() - - def always_fetch_columns(self, columns: str | list[str]) -> "UITable": - """ - Set the columns to always fetch from the server. - These will not be affected by the users current viewport/horizontal scrolling. - Useful if you have a column with key value data that you want to always include - in the data sent for row click operations. - - Args: - columns: The columns to always fetch from the server. - May be a single column name. - - Returns: - A new UITable - """ - return self._with_appendable_prop("always_fetch_columns", columns) - - def back_columns(self, columns: str | list[str]) -> "UITable": - """ - Set the columns to show at the back of the table. - These will not be moveable in the UI. - - Args: - columns: The columns to show at the back of the table. - May be a single column name. - - Returns: - A new UITable - """ - raise NotImplementedError() - - def column_group( - self, name: str, children: list[str], color: str | None - ) -> "UITable": - """ - Create a group for columns in the table. - - Args: - name: The group name. Must be a valid column name and not a duplicate of another column or group. - children: The children in the group. May contain column names or other group names. - Each item may only be specified as a child once. - color: The hex color string or Deephaven color name. - - Returns: - A new UITable - """ - raise NotImplementedError() - - def color_column( - self, - column: ColumnName, - where: QuickFilterExpression | None = None, - color: Color | None = None, - background_color: Color | None = None, - ) -> "UITable": - """ - Applies color formatting to a column of the table. - - Args: - column: The column name - where: The filter to apply to the expression. - Uses quick filter format (e.g. `>10`). - color: The text color. Accepts hex color strings or Deephaven color names. - background_color: The background color. Accepts hex color strings or Deephaven color names. - - Returns: - A new UITable - """ - raise NotImplementedError() - - def color_row( - self, - column: ColumnName, - where: QuickFilterExpression | None = None, - color: Color | None = None, - background_color: Color | None = None, - ) -> "UITable": - """ - Applies color formatting to rows of the table conditionally based on the value of a column. - - Args: - column: The column name - where: The filter to apply to the expression. - Uses quick filter format (e.g. `>10`). - color: The text color. Accepts hex color strings or Deephaven color names. - background_color: The background color. Accepts hex color strings or Deephaven color names. - - Returns: - A new UITable - """ - raise NotImplementedError() - - def data_bar( - self, - col: str, - value_col: str | None = None, - min: float | str | None = None, - max: float | str | None = None, - axis: DataBarAxis | None = None, - positive_color: Color | list[Color] | None = None, - negative_color: Color | list[Color] | None = None, - value_placement: DataBarValuePlacement | None = None, - direction: DataBarDirection | None = None, - opacity: float | None = None, - marker_col: str | None = None, - marker_color: Color | None = None, - ) -> "UITable": - """ - Applies data bar formatting to the specified column. - - Args: - col: Column to generate data bars in - value_col: Column containing the values to generate data bars from - min: Minimum value for data bar scaling or column to get value from - max: Maximum value for data bar scaling or column to get value from - axis: Orientation of data bar relative to cell - positive_color: Color for positive bars. Use list of colors to form a gradient - negative_color: Color for negative bars. Use list of colors to form a gradient - value_placement: Orientation of values relative to data bar - direction: Orientation of data bar relative to horizontal axis - opacity: Opacity of data bar. Accepts values from 0 to 1 - marker_col: Column containing the values to generate markers from - marker_color: Color for markers - - Returns: - A new UITable - """ - raise NotImplementedError() - - def format(self, column: ColumnName, format: str) -> "UITable": - """ - Specify the formatting to display a column in. - - Args: - column: The column name - format: The format to display the column in. Valid format depends on column type - - Returns: - A new UITable - """ - raise NotImplementedError() - - def freeze_columns(self, columns: str | list[str]) -> "UITable": - """ - Set the columns to freeze to the front of the table. - These will always be visible and not affected by horizontal scrolling. - - Args: - columns: The columns to freeze to the front of the table. - - Returns: - A new UITable - """ - raise NotImplementedError() - - def front_columns(self, columns: str | list[str]) -> "UITable": - """ - Set the columns to show at the front of the table. These will not be moveable in the UI. - - Args: - columns: The columns to show at the front of the table. - - Returns: - A new UITable - """ - raise NotImplementedError() - - def hide_columns(self, columns: str | list[str]) -> "UITable": - """ - Set the columns to hide by default in the table. The user can still resize the columns to view them. - - Args: - columns: The columns to hide from the table. May be a single column name. - - Returns: - A new UITable - """ - raise NotImplementedError() - - def on_row_double_press(self, callback: RowPressCallback) -> "UITable": - """ - Add a callback for when a row is double clicked. - *Deprecated: Use the on_row_double_press keyword arg instead. - - Args: - callback: The callback function to run when a row is double clicked. - The first parameter is the row index, and the second is the row data provided in a dictionary where the - column names are the keys. - - Returns: - A new UITable - """ - warn( - "on_row_double_press function is deprecated. Use the on_row_double_press keyword arg instead.", - DeprecationWarning, - stacklevel=2, - ) - return self._with_prop("on_row_double_press", callback) - - def selection_mode(self, mode: SelectionMode) -> "UITable": - """ - Set the selection mode for the table. - - Args: - mode: The selection mode to use. Must be one of `"ROW"`, `"COLUMN"`, or `"CELL"` - `"ROW"` selects the entire row of the cell you click on. - `"COLUMN"` selects the entire column of the cell you click on. - `"CELL"` selects only the cells you click on. - - Returns: - A new UITable - """ - raise NotImplementedError() - - def sort( - self, - by: str | Sequence[str], - direction: TableSortDirection | Sequence[TableSortDirection] | None = None, - ) -> "UITable": - """ - Provide the default sort that will be used by the UI. - - Args: - by: The column(s) to sort by. May be a single column name, or a list of column names. - direction: The sort direction(s) to use. If provided, that must match up with the columns provided. - May be a single sort direction, or a list of sort directions. The possible sort directions are - `"ASC"` `"DESC"`, `SortDirection.ASCENDING`, and `SortDirection.DESCENDING`. - Defaults to "ASC". - - Returns: - A new UITable - """ - direction_list: Sequence[TableSortDirection] = [] - if direction: - direction_list_unmapped = ( - direction if isinstance(direction, Sequence) else [direction] - ) - - # map deephaven sort direction to frontend sort direction - direction_list = [ - remap_sort_direction(direction) for direction in direction_list_unmapped - ] - - by_list = [by] if isinstance(by, str) else by - - if direction and len(direction_list) != len(by_list): - raise ValueError("by and direction must be the same length") - - if direction: - sorts = [ - {"column": column, "direction": direction, "is_abs": False} - for column, direction in zip(by_list, direction_list) - ] - else: - sorts = [ - {"column": column, "direction": "ASC", "is_abs": False} - for column in by_list - ] - - return self._with_prop("sorts", sorts) diff --git a/plugins/ui/src/deephaven/ui/elements/__init__.py b/plugins/ui/src/deephaven/ui/elements/__init__.py index 01aa1c109..21143a2f9 100644 --- a/plugins/ui/src/deephaven/ui/elements/__init__.py +++ b/plugins/ui/src/deephaven/ui/elements/__init__.py @@ -2,7 +2,6 @@ from .BaseElement import BaseElement from .DashboardElement import DashboardElement from .FunctionElement import FunctionElement -from .UITable import UITable __all__ = [ "BaseElement", @@ -10,5 +9,4 @@ "Element", "FunctionElement", "PropsType", - "UITable", ] diff --git a/plugins/ui/src/deephaven/ui/renderer/Renderer.py b/plugins/ui/src/deephaven/ui/renderer/Renderer.py index ab834aa9b..5f7161adb 100644 --- a/plugins/ui/src/deephaven/ui/renderer/Renderer.py +++ b/plugins/ui/src/deephaven/ui/renderer/Renderer.py @@ -2,7 +2,8 @@ from dataclasses import asdict as dataclass_asdict, is_dataclass import logging from typing import Any, Union -from .._internal import RenderContext + +from .._internal import RenderContext, remove_empty_keys from ..elements import Element, PropsType from .RenderedNode import RenderedNode @@ -32,7 +33,8 @@ def _render_child_item(item: Any, parent_context: RenderContext, index_key: str) # If the item is an instance of a dataclass if is_dataclass(item) and not isinstance(item, type): return _render_dict( - dataclass_asdict(item), parent_context.get_child_context(index_key) + remove_empty_keys(dataclass_asdict(item)), + parent_context.get_child_context(index_key), ) if isinstance(item, Element): diff --git a/plugins/ui/src/deephaven/ui/types/types.py b/plugins/ui/src/deephaven/ui/types/types.py index 28b41c70d..1b8b9bd15 100644 --- a/plugins/ui/src/deephaven/ui/types/types.py +++ b/plugins/ui/src/deephaven/ui/types/types.py @@ -506,6 +506,7 @@ class SliderChange(TypedDict): ListViewOverflowMode = Literal["truncate", "wrap"] ActionGroupDensity = Literal["compact", "regular"] TabDensity = Literal["compact", "regular"] +LinkVariant = Literal["primary", "secondary", "over_background"] BadgeVariant = Literal[ "neutral", "info", @@ -557,71 +558,3 @@ class DateRange(TypedDict): """ End value for the date range. """ - - -DataBarAxis = Literal["PROPORTIONAL", "MIDDLE", "DIRECTIONAL"] -DataBarDirection = Literal["LTR", "RTL"] -DataBarValuePlacement = Literal["BESIDE", "OVERLAP", "HIDE"] - - -class DatabarConfig(TypedDict): - """ - Configuration for displaying a databar. - """ - - column: ColumnName - """ - Name of the column to display as a databar. - """ - - value_column: NotRequired[ColumnName] - """ - Name of the column to use as the value for the databar. - If not provided, the databar will use the column value. - - This can be useful if you want to display a databar with - a log scale, but display the actual value in the cell. - In this case, the value_column would be the log of the actual value. - """ - - min: NotRequired[Union[ColumnName, float]] - """ - Minimum value for the databar. Defaults to the minimum value in the column. - - If a column name is provided, the minimum value will be the value in that column. - If a constant is providded, the minimum value will be that constant. - """ - - max: NotRequired[Union[ColumnName, float]] - """ - Maximum value for the databar. Defaults to the maximum value in the column. - - If a column name is provided, the maximum value will be the value in that column. - If a constant is providded, the maximum value will be that constant. - """ - - axis: NotRequired[DataBarAxis] - """ - Whether the databar 0 value should be proportional to the min and max values, - in the middle of the cell, or on one side of the databar based on direction. - """ - - direction: NotRequired[DataBarDirection] - """ - Direction of the databar. - """ - - value_placement: NotRequired[DataBarValuePlacement] - """ - Placement of the value relative to the databar. - """ - - color: NotRequired[Color] - """ - Color of the databar. - """ - - opacity: NotRequired[float] - """ - Opacity of the databar fill. - """ diff --git a/plugins/ui/src/js/package.json b/plugins/ui/src/js/package.json index b6983ac88..fc0cb8581 100644 --- a/plugins/ui/src/js/package.json +++ b/plugins/ui/src/js/package.json @@ -29,6 +29,7 @@ "update-dh-packages": "node ../../../../tools/update-dh-packages.mjs" }, "devDependencies": { + "@types/memoizee": "^0.4.5", "@types/react": "^17.0.2", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/plugins/ui/src/js/src/elements/Dialog.tsx b/plugins/ui/src/js/src/elements/Dialog.tsx index 515facf1d..d8ae596d9 100644 --- a/plugins/ui/src/js/src/elements/Dialog.tsx +++ b/plugins/ui/src/js/src/elements/Dialog.tsx @@ -1,16 +1,17 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import { Dialog as DHCDialog, DialogProps as DHCDialogProps, } from '@deephaven/components'; +import useConditionalCallback from './hooks/useConditionalCallback'; export function Dialog(props: DHCDialogProps): JSX.Element { const { onDismiss: onDismissProp, ...otherProps } = props; - const onDismissCallback = useCallback( + const onDismiss = useConditionalCallback( + onDismissProp != null, () => onDismissProp?.(), [onDismissProp] ); - const onDismiss = onDismissProp != null ? onDismissCallback : undefined; // eslint-disable-next-line react/jsx-props-no-spreading return ; } diff --git a/plugins/ui/src/js/src/elements/UITable/JsTableProxy.ts b/plugins/ui/src/js/src/elements/UITable/JsTableProxy.ts index 8bc2c3126..ebae4cd5e 100644 --- a/plugins/ui/src/js/src/elements/UITable/JsTableProxy.ts +++ b/plugins/ui/src/js/src/elements/UITable/JsTableProxy.ts @@ -21,7 +21,11 @@ interface JsTableProxy extends dh.Table {} // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore class JsTableProxy implements dh.Table { - static HIDDEN_COLUMN_SUFFIXES = ['__DATABAR_Min', '__DATABAR_Max']; + static HIDDEN_COLUMN_SUFFIXES = [ + '__DATABAR_Min', + '__DATABAR_Max', + '__FORMAT', + ]; private table: dh.Table; diff --git a/plugins/ui/src/js/src/elements/UITable/UITable.tsx b/plugins/ui/src/js/src/elements/UITable/UITable.tsx index 6ebb6ea9f..65e1f6834 100644 --- a/plugins/ui/src/js/src/elements/UITable/UITable.tsx +++ b/plugins/ui/src/js/src/elements/UITable/UITable.tsx @@ -45,6 +45,7 @@ function useStableArray(array: T[]): T[] { } export function UITable({ + format_: formatProp, onCellPress, onCellDoublePress, onColumnPress, @@ -127,9 +128,13 @@ export function UITable({ hiddenColumns, columnGroups, }); + + // TODO: #982 respond to prop changes here + const [format] = useState(formatProp != null ? ensureArray(formatProp) : []); + // TODO: #981 move databars to format and rewire for databar support const [databars] = useState(databarsProp ?? []); - const databarColorMap = useMemo(() => { + const colorMap = useMemo(() => { log.debug('Theme changed, updating databar color map', theme); const colorSet = new Set(); databars?.forEach(databar => { @@ -155,21 +160,31 @@ export function UITable({ }); }); + format.forEach(rule => { + const { color, background_color: backgroundColor } = rule; + if (color != null) { + colorSet.add(color); + } + if (backgroundColor != null) { + colorSet.add(backgroundColor); + } + }); + const colorRecord: Record = {}; colorSet.forEach(c => { colorRecord[c] = colorValueStyle(c); }); const resolvedColors = resolveCssVariablesInRecord(colorRecord); - const colorMap = new Map(); + const newColorMap = new Map(); Object.entries(resolvedColors).forEach(([key, value]) => { - colorMap.set(key, value); + newColorMap.set(key, value); }); - return colorMap; - }, [databars, theme]); + return newColorMap; + }, [databars, format, theme]); if (model) { - model.setDatabarColorMap(databarColorMap); + model.setColorMap(colorMap); } const hydratedSorts = useMemo(() => { @@ -214,7 +229,8 @@ export function UITable({ dh, table, databars, - layoutHints + layoutHints, + format ); if (!isCancelled) { setError(null); @@ -235,7 +251,7 @@ export function UITable({ return () => { isCancelled = true; }; - }, [databars, dh, exportedTable, layoutHints]); + }, [databars, dh, exportedTable, layoutHints, format]); const modelColumns = model?.columns ?? EMPTY_ARRAY; diff --git a/plugins/ui/src/js/src/elements/UITable/UITableModel.test.ts b/plugins/ui/src/js/src/elements/UITable/UITableModel.test.ts new file mode 100644 index 000000000..0c7df094e --- /dev/null +++ b/plugins/ui/src/js/src/elements/UITable/UITableModel.test.ts @@ -0,0 +1,214 @@ +import { + IrisGridThemeType, + type IrisGridTableModel, +} from '@deephaven/iris-grid'; +import { GridRenderer } from '@deephaven/grid'; +import { type dh } from '@deephaven/jsapi-types'; +import { TestUtils } from '@deephaven/test-utils'; +import UITableModel from './UITableModel'; + +const MOCK_DH = TestUtils.createMockProxy(); + +const MOCK_BASE_MODEL = TestUtils.createMockProxy({ + columns: [ + { + name: 'column0', + type: 'string', + }, + { + name: 'column1', + type: 'int', + }, + ] as dh.Column[], +}); + +const MOCK_TABLE = TestUtils.createMockProxy(); + +describe('Formatting', () => { + describe('getFormatOptionForCell', () => { + test('applies last rule for an option', () => { + const model = new UITableModel({ + dh: MOCK_DH, + model: MOCK_BASE_MODEL, + table: MOCK_TABLE, + databars: [], + format: [{ color: 'red' }, { color: 'blue' }], + }); + expect(model.getFormatOptionForCell(0, 0, 'color')).toBe('blue'); + expect(model.getFormatOptionForCell(1, 1, 'color')).toBe('blue'); + }); + + test('only applies rules matching the column', () => { + const model = new UITableModel({ + dh: MOCK_DH, + model: MOCK_BASE_MODEL, + table: MOCK_TABLE, + databars: [], + format: [ + { cols: 'column0', color: 'red' }, + { cols: 'column1', color: 'blue' }, + ], + }); + expect(model.getFormatOptionForCell(0, 0, 'color')).toBe('red'); + expect(model.getFormatOptionForCell(1, 1, 'color')).toBe('blue'); + }); + + test('only applies rules matching the condition', () => { + (MOCK_BASE_MODEL.row as jest.Mock).mockImplementation(r => ({ + data: { + get: () => ({ + value: r % 2 === 0, // Even rows are true + }), + }, + })); + + const model = new UITableModel({ + dh: MOCK_DH, + model: MOCK_BASE_MODEL, + table: MOCK_TABLE, + databars: [], + format: [ + { color: 'red', if_: 'even' }, + { cols: 'column1', color: 'blue', if_: 'even' }, + ], + }); + expect(model.getFormatOptionForCell(0, 0, 'color')).toBe('red'); + expect(model.getFormatOptionForCell(0, 1, 'color')).toBeUndefined(); + expect(model.getFormatOptionForCell(1, 0, 'color')).toBe('blue'); + expect(model.getFormatOptionForCell(1, 1, 'color')).toBeUndefined(); + (MOCK_BASE_MODEL.row as jest.Mock).mockClear(); + }); + + test('returns undefined if no matching rule', () => { + const model = new UITableModel({ + dh: MOCK_DH, + model: MOCK_BASE_MODEL, + table: MOCK_TABLE, + databars: [], + format: [{ cols: 'column0', color: 'red' }], + }); + expect(model.getFormatOptionForCell(1, 1, 'color')).toBeUndefined(); + expect( + model.getFormatOptionForCell(1, 1, 'background_color') + ).toBeUndefined(); + }); + + test('returns undefined if condition data has not been fetched', () => { + (MOCK_BASE_MODEL.row as jest.Mock).mockImplementation(r => ({ + data: null, + })); + + const model = new UITableModel({ + dh: MOCK_DH, + model: MOCK_BASE_MODEL, + table: MOCK_TABLE, + databars: [], + format: [{ color: 'red', if_: 'even' }], + }); + expect(model.getFormatOptionForCell(0, 0, 'color')).toBeUndefined(); + expect(model.getFormatOptionForCell(0, 1, 'color')).toBeUndefined(); + (MOCK_BASE_MODEL.row as jest.Mock).mockClear(); + }); + }); + + describe('colorForCell', () => { + test('returns the color for a cell', () => { + const model = new UITableModel({ + dh: MOCK_DH, + model: MOCK_BASE_MODEL, + table: MOCK_TABLE, + databars: [], + format: [{ color: 'red' }], + }); + expect(model.colorForCell(0, 0, {} as IrisGridThemeType)).toBe('red'); + }); + + test('returns undefined if no color for a cell', () => { + const model = new UITableModel({ + dh: MOCK_DH, + model: MOCK_BASE_MODEL, + table: MOCK_TABLE, + databars: [], + format: [], + }); + expect(model.colorForCell(0, 0, {} as IrisGridThemeType)).toBeUndefined(); + expect(MOCK_BASE_MODEL.colorForCell).toHaveBeenCalledTimes(1); + }); + + test('returns grid theme white if no color and background color dark', () => { + jest + .spyOn(GridRenderer, 'getCachedColorIsDark') + .mockImplementation(() => true); + const model = new UITableModel({ + dh: MOCK_DH, + model: MOCK_BASE_MODEL, + table: MOCK_TABLE, + databars: [], + format: [{ background_color: 'black' }], + }); + expect( + model.colorForCell(0, 0, { white: 'white' } as IrisGridThemeType) + ).toBe('white'); + jest.restoreAllMocks(); + }); + + test('returns grid theme black if no color and background color light', () => { + jest + .spyOn(GridRenderer, 'getCachedColorIsDark') + .mockImplementation(() => false); + const model = new UITableModel({ + dh: MOCK_DH, + model: MOCK_BASE_MODEL, + table: MOCK_TABLE, + databars: [], + format: [{ background_color: 'white' }], + }); + expect( + model.colorForCell(0, 0, { black: 'black' } as IrisGridThemeType) + ).toBe('black'); + jest.restoreAllMocks(); + }); + + test('returns theme colors from color map', () => { + const model = new UITableModel({ + dh: MOCK_DH, + model: MOCK_BASE_MODEL, + table: MOCK_TABLE, + databars: [], + format: [{ color: 'foo' }], + }); + model.setColorMap(new Map([['foo', 'bar']])); + expect(model.colorForCell(0, 0, {} as IrisGridThemeType)).toBe('bar'); + }); + }); + + describe('backgroundColorForCell', () => { + test('returns undefined if no background_color for a cell', () => { + const model = new UITableModel({ + dh: MOCK_DH, + model: MOCK_BASE_MODEL, + table: MOCK_TABLE, + databars: [], + format: [], + }); + expect( + model.backgroundColorForCell(0, 0, {} as IrisGridThemeType) + ).toBeUndefined(); + expect(MOCK_BASE_MODEL.backgroundColorForCell).toHaveBeenCalledTimes(1); + }); + + test('returns theme colors from color map', () => { + const model = new UITableModel({ + dh: MOCK_DH, + model: MOCK_BASE_MODEL, + table: MOCK_TABLE, + databars: [], + format: [{ background_color: 'foo' }], + }); + model.setColorMap(new Map([['foo', 'bar']])); + expect(model.backgroundColorForCell(0, 0, {} as IrisGridThemeType)).toBe( + 'bar' + ); + }); + }); +}); diff --git a/plugins/ui/src/js/src/elements/UITable/UITableModel.ts b/plugins/ui/src/js/src/elements/UITable/UITableModel.ts index 1c5e2af5b..4fc494f47 100644 --- a/plugins/ui/src/js/src/elements/UITable/UITableModel.ts +++ b/plugins/ui/src/js/src/elements/UITable/UITableModel.ts @@ -3,24 +3,31 @@ import { DataBarOptions, CellRenderType, ModelIndex, + GridColor, + NullableGridColor, + memoizeClear, + GridRenderer, } from '@deephaven/grid'; import { ColumnName, IrisGridModel, IrisGridModelFactory, + type IrisGridThemeType, isIrisGridTableModelTemplate, UIRow, } from '@deephaven/iris-grid'; +import { ensureArray } from '@deephaven/utils'; import { TableUtils } from '@deephaven/jsapi-utils'; import { type dh as DhType } from '@deephaven/jsapi-types'; -import { ColorGradient, DatabarConfig } from './UITableUtils'; +import { ColorGradient, DatabarConfig, FormattingRule } from './UITableUtils'; import JsTableProxy, { UITableLayoutHints } from './JsTableProxy'; export async function makeUiTableModel( dh: typeof DhType, table: DhType.Table, databars: DatabarConfig[], - layoutHints: UITableLayoutHints + layoutHints: UITableLayoutHints, + format: FormattingRule[] ): Promise { const joinColumns: string[] = []; const totalsOperationMap: Record = {}; @@ -56,6 +63,31 @@ export async function makeUiTableModel( let baseTable = table; + const customColumns: string[] = []; + format.forEach((rule, i) => { + const { if_ } = rule; + if (if_ != null) { + customColumns.push(`${getFormatCustomColumnName(i)}=${if_}`); + } + }); + + if (customColumns.length > 0) { + await new TableUtils(dh).applyCustomColumns(baseTable, customColumns); + format.forEach((rule, i) => { + const { if_ } = rule; + if (if_ != null) { + const columnType = baseTable.findColumn( + getFormatCustomColumnName(i) + ).type; + if (!TableUtils.isBooleanType(columnType)) { + throw new Error( + `ui.TableFormat if_ must be a boolean column. "${if_}" is a ${columnType} column` + ); + } + } + }); + } + if (joinColumns.length > 0) { const totalsTable = await table.getTotalsTable({ operationMap: totalsOperationMap, @@ -80,9 +112,19 @@ export async function makeUiTableModel( model: baseModel, table: uiTableProxy, databars, + format, }); } +/** + * Gets the name of the custom column that stores the where clause for a formatting rule + * @param i The index of the formatting rule + * @returns The name of the custom column that stores the where clause for the formatting rule + */ +function getFormatCustomColumnName(i: number): string { + return `_${i}__FORMAT`; +} + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -103,18 +145,25 @@ class UITableModel extends IrisGridModel { private databars: Map; - private databarColorMap: Map = new Map(); + /** + * Map of theme color keys to hex color values + */ + private colorMap: Map = new Map(); + + private format: FormattingRule[]; constructor({ dh, model, table, databars, + format, }: { dh: typeof DhType; model: IrisGridModel; table: DhType.Table; databars: DatabarConfig[]; + format: FormattingRule[]; }) { super(dh); @@ -126,6 +175,8 @@ class UITableModel extends IrisGridModel { this.databars.set(databar.column, databar); }); + this.format = format; + // eslint-disable-next-line no-constructor-return return new Proxy(this, { // We want to use any properties on the proxy model if defined @@ -169,8 +220,8 @@ class UITableModel extends IrisGridModel { }); } - setDatabarColorMap(colorMap: Map): void { - this.databarColorMap = colorMap; + setColorMap(colorMap: Map): void { + this.colorMap = colorMap; } // eslint-disable-next-line class-methods-use-this @@ -285,7 +336,7 @@ class UITableModel extends IrisGridModel { typeof markerValue === 'string' ? this.getDatabarValueFromRow(row, markerValue, 'marker') : markerValue, - color: this.databarColorMap.get(markerColor) ?? markerColor, + color: this.colorMap.get(markerColor) ?? markerColor, }; }); @@ -304,18 +355,18 @@ class UITableModel extends IrisGridModel { if (Array.isArray(positiveColor)) { positiveColor = positiveColor.map( - color => this.databarColorMap.get(color) ?? color + color => this.colorMap.get(color) ?? color ); } else { - positiveColor = this.databarColorMap.get(positiveColor) ?? positiveColor; + positiveColor = this.colorMap.get(positiveColor) ?? positiveColor; } if (Array.isArray(negativeColor)) { negativeColor = negativeColor.map( - color => this.databarColorMap.get(color) ?? color + color => this.colorMap.get(color) ?? color ); } else { - negativeColor = this.databarColorMap.get(negativeColor) ?? negativeColor; + negativeColor = this.colorMap.get(negativeColor) ?? negativeColor; } return { @@ -330,6 +381,139 @@ class UITableModel extends IrisGridModel { value, }; } + + formatColumnMatch = memoizeClear( + (columns: string[], column: string): boolean => + columns.some(c => c === column), + { primitive: true, max: 10000 } + ); + + /** + * Gets the first matching format option for a cell. + * Checks if there is a column match and if there is a where clause match if needed. + * If there is no value for the key that matches in any rule, returns undefined. + * Stops on first match. + * + * @param column The model column index + * @param row The model row index + * @param formatKey The key to get the format option for + * @returns The format option if set or undefined + */ + getFormatOptionForCell( + column: ModelIndex, + row: ModelIndex, + formatKey: K + ): FormattingRule[K] | undefined { + if (!isIrisGridTableModelTemplate(this.model)) { + return undefined; + } + const columnName = this.columns[column].name; + + // Iterate in reverse so that the last rule that matches is used + for (let i = this.format.length - 1; i >= 0; i -= 1) { + const rule = this.format[i]; + const { cols, if_, [formatKey]: formatValue } = rule; + if (formatValue == null) { + // eslint-disable-next-line no-continue + continue; + } + if ( + cols == null || + this.formatColumnMatch(ensureArray(cols), columnName) + ) { + if (if_ == null) { + return formatValue; + } + const rowValues = this.model.row(row)?.data; + if (rowValues == null) { + return undefined; + } + const whereValue = rowValues.get(getFormatCustomColumnName(i))?.value; + if (whereValue === true) { + return formatValue; + } + } + } + return undefined; + } + + getCachedFormatForCell = memoizeClear( + ( + format: DhType.Format | undefined, + formatString: string | null | undefined + ): DhType.Format | undefined => ({ + ...format, + formatString, + }), + { max: 10000 } + ); + + override formatForCell( + column: ModelIndex, + row: ModelIndex + ): DhType.Format | undefined { + const format = this.model.formatForCell(column, row); + return this.getCachedFormatForCell( + format, + this.getFormatOptionForCell(column, row, 'value') ?? format?.formatString + ); + } + + override colorForCell( + column: ModelIndex, + row: ModelIndex, + theme: IrisGridThemeType + ): GridColor { + const color = this.getFormatOptionForCell(column, row, 'color'); + const { colorMap } = this; + + // If a color is explicitly set, use it + if (color != null) { + return colorMap.get(color) ?? color; + } + + // If there is a background color, use white or black depending on the background color + const backgroundColor = this.getFormatOptionForCell( + column, + row, + 'background_color' + ); + + if (backgroundColor != null) { + const isDarkBackground = GridRenderer.getCachedColorIsDark( + colorMap.get(backgroundColor) ?? backgroundColor + ); + return isDarkBackground ? theme.white : theme.black; + } + + return this.model.colorForCell(column, row, theme); + } + + override textAlignForCell( + column: ModelIndex, + row: ModelIndex + ): CanvasTextAlign { + return ( + this.getFormatOptionForCell(column, row, 'alignment') ?? + this.model.textAlignForCell(column, row) + ); + } + + override backgroundColorForCell( + column: ModelIndex, + row: ModelIndex, + theme: IrisGridThemeType + ): NullableGridColor { + const backgroundColor = this.getFormatOptionForCell( + column, + row, + 'background_color' + ); + if (backgroundColor != null) { + return this.colorMap.get(backgroundColor) ?? backgroundColor; + } + return this.model.backgroundColorForCell(column, row, theme); + } } export default UITableModel; diff --git a/plugins/ui/src/js/src/elements/UITable/UITableUtils.tsx b/plugins/ui/src/js/src/elements/UITable/UITableUtils.tsx index bbaa7f8cc..597ec0cd5 100644 --- a/plugins/ui/src/js/src/elements/UITable/UITableUtils.tsx +++ b/plugins/ui/src/js/src/elements/UITable/UITableUtils.tsx @@ -38,8 +38,19 @@ export type DatabarConfig = { markers?: { value: number | string; color?: string }[]; }; +export type FormattingRule = { + cols?: ColumnName | ColumnName[]; + if_?: string; + color?: string; + background_color?: string; + alignment?: 'left' | 'center' | 'right'; + value?: string; + mode?: DatabarConfig; +}; + export type UITableProps = StyleProps & { table: dh.WidgetExportedObject; + format_?: FormattingRule | FormattingRule[]; onCellPress?: (data: CellData) => void; onCellDoublePress?: (data: CellData) => void; onRowPress?: (rowData: RowDataMap) => void; diff --git a/plugins/ui/src/js/src/elements/hooks/useConditionalCallback.test.ts b/plugins/ui/src/js/src/elements/hooks/useConditionalCallback.test.ts new file mode 100644 index 000000000..af0bbc206 --- /dev/null +++ b/plugins/ui/src/js/src/elements/hooks/useConditionalCallback.test.ts @@ -0,0 +1,47 @@ +import { renderHook } from '@testing-library/react-hooks'; +import useConditionalCallback from './useConditionalCallback'; + +// Write unit tests for useConditionalCallback +describe('useConditionalCallback', () => { + // Test that the function returns the callback if the condition is met + it('returns the callback if the condition is met', () => { + const callback = jest.fn(); + const { result } = renderHook(() => + useConditionalCallback(true, callback, []) + ); + expect(result.current).toBe(callback); + }); + + // Test that the function returns undefined if the condition is not met + it('returns undefined if the condition is not met', () => { + const callback = jest.fn(); + const { result } = renderHook(() => + useConditionalCallback(false, callback, []) + ); + expect(result.current).toBeUndefined(); + }); + + // Test that the callback is recreated when the dependencies change + it('recreates the callback when the dependencies change', () => { + const callback = jest.fn(); + const { result, rerender } = renderHook( + ({ condition, cb, dep }) => useConditionalCallback(condition, cb, [dep]), + { initialProps: { cb: callback, condition: true, dep: 'A' } } + ); + + // useCallback will return a wrapped version of the callback + const lastCallback = result.current; + + // The callback should not be recreated if the dependencies are the same + rerender({ cb: jest.fn(), condition: true, dep: 'A' }); + expect(result.current).toBe(lastCallback); + + // The callback should be recreated if the dependencies change + rerender({ cb: jest.fn(), condition: true, dep: 'B' }); + expect(result.current).not.toBe(lastCallback); + + // The callback should return undefined if the condition is not met + rerender({ cb: jest.fn(), condition: false, dep: 'B' }); + expect(result.current).toBeUndefined(); + }); +}); diff --git a/plugins/ui/src/js/src/elements/hooks/useConditionalCallback.ts b/plugins/ui/src/js/src/elements/hooks/useConditionalCallback.ts new file mode 100644 index 000000000..649a3dfb2 --- /dev/null +++ b/plugins/ui/src/js/src/elements/hooks/useConditionalCallback.ts @@ -0,0 +1,21 @@ +import React, { useCallback } from 'react'; + +/** + * A hook that takes a condition, a callback, and a dependencies array, then returns the callback if the condition is met, or undefined otherwise. + * @param condition The condition to check. If it's false, the `undefined` will be return + * @param callback The callback to use if the condition is met + * @param deps The dependencies array to use with the callback + * @returns The callback if the condition is met, or `undefined` otherwise + */ +export function useConditionalCallback unknown>( + condition: boolean, + callback: T, + deps: React.DependencyList +): T | undefined { + // We don't want to include the callback in the dependencies array, as that would cause the callback to be recreated every time the passed in callback changes which defeats the purpose of using useCallback + // eslint-disable-next-line react-hooks/exhaustive-deps + const optionalCallback = useCallback(callback, deps); + return condition ? optionalCallback : undefined; +} + +export default useConditionalCallback; diff --git a/plugins/ui/src/js/src/elements/hooks/useFocusEventCallback.ts b/plugins/ui/src/js/src/elements/hooks/useFocusEventCallback.ts index 648408a7a..b61049752 100644 --- a/plugins/ui/src/js/src/elements/hooks/useFocusEventCallback.ts +++ b/plugins/ui/src/js/src/elements/hooks/useFocusEventCallback.ts @@ -1,5 +1,6 @@ -import { FocusEvent, useCallback } from 'react'; +import { FocusEvent } from 'react'; import { getTargetName } from '../utils'; +import useConditionalCallback from './useConditionalCallback'; export function serializeFocusEvent(event: FocusEvent): SerializedFocusEvent { const { relatedTarget, target, type } = event; @@ -36,11 +37,11 @@ export type DeserializedFocusEventCallback = (e: FocusEvent) => void; export function useFocusEventCallback( callback?: SerializedFocusEventCallback ): DeserializedFocusEventCallback | undefined { - const focusCallBack = useCallback( + return useConditionalCallback( + callback != null, (e: FocusEvent) => { callback?.(serializeFocusEvent(e)); }, [callback] ); - return callback != null ? focusCallBack : undefined; } diff --git a/plugins/ui/src/js/src/elements/hooks/useFormEventCallback.ts b/plugins/ui/src/js/src/elements/hooks/useFormEventCallback.ts index 95ce7feb7..ad16defee 100644 --- a/plugins/ui/src/js/src/elements/hooks/useFormEventCallback.ts +++ b/plugins/ui/src/js/src/elements/hooks/useFormEventCallback.ts @@ -1,4 +1,5 @@ -import React, { useCallback } from 'react'; +import React from 'react'; +import useConditionalCallback from './useConditionalCallback'; export type SerializedFormEvent = { [key: string]: FormDataEntryValue; @@ -9,7 +10,8 @@ export type SerializedFormEventCallback = (event: SerializedFormEvent) => void; export function useFormEventCallback( callback?: SerializedFormEventCallback ): ((e: React.FormEvent) => void) | undefined { - const formCallback = useCallback( + return useConditionalCallback( + callback != null, (e: React.FormEvent) => { // We never want the page to refresh, prevent submitting the form e.preventDefault(); @@ -20,6 +22,4 @@ export function useFormEventCallback( }, [callback] ); - - return callback ? formCallback : undefined; } diff --git a/plugins/ui/src/js/src/elements/hooks/useKeyboardEventCallback.ts b/plugins/ui/src/js/src/elements/hooks/useKeyboardEventCallback.ts index f772db21e..3318c892b 100644 --- a/plugins/ui/src/js/src/elements/hooks/useKeyboardEventCallback.ts +++ b/plugins/ui/src/js/src/elements/hooks/useKeyboardEventCallback.ts @@ -1,4 +1,5 @@ -import { KeyboardEvent, useCallback } from 'react'; +import { KeyboardEvent } from 'react'; +import useConditionalCallback from './useConditionalCallback'; export function serializeKeyboardEvent( event: KeyboardEvent @@ -36,11 +37,11 @@ export type DeserializedKeyboardEventCallback = (e: KeyboardEvent) => void; export function useKeyboardEventCallback( callback?: SerializedKeyboardEventCallback ): DeserializedKeyboardEventCallback | undefined { - const keyboardCallback = useCallback( + return useConditionalCallback( + callback != null, (e: KeyboardEvent) => { callback?.(serializeKeyboardEvent(e)); }, [callback] ); - return callback != null ? keyboardCallback : undefined; } diff --git a/plugins/ui/src/js/src/elements/hooks/usePressEventCallback.ts b/plugins/ui/src/js/src/elements/hooks/usePressEventCallback.ts index 6b4fab19f..a54ecd645 100644 --- a/plugins/ui/src/js/src/elements/hooks/usePressEventCallback.ts +++ b/plugins/ui/src/js/src/elements/hooks/usePressEventCallback.ts @@ -1,6 +1,6 @@ -import { useCallback } from 'react'; import { PressEvent } from '@deephaven/components'; import { getTargetName } from '../utils'; +import useConditionalCallback from './useConditionalCallback'; export function serializePressEvent(event: PressEvent): SerializedPressEvent { const { target, type, pointerType, shiftKey, ctrlKey, metaKey, altKey } = @@ -37,11 +37,11 @@ export type SerializedPressEventCallback = ( export function usePressEventCallback( callback?: SerializedPressEventCallback ): ((e: PressEvent) => void) | undefined { - const pressCallback = useCallback( + return useConditionalCallback( + callback != null, (e: PressEvent) => { callback?.(serializePressEvent(e)); }, [callback] ); - return callback != null ? pressCallback : undefined; } diff --git a/plugins/ui/src/js/src/elements/model/ElementConstants.ts b/plugins/ui/src/js/src/elements/model/ElementConstants.ts index eceddec24..e3630a5b4 100644 --- a/plugins/ui/src/js/src/elements/model/ElementConstants.ts +++ b/plugins/ui/src/js/src/elements/model/ElementConstants.ts @@ -50,6 +50,7 @@ export const ELEMENT_NAME = { item: uiComponentName('Item'), listActionGroup: uiComponentName('ListActionGroup'), listActionMenu: uiComponentName('ListActionMenu'), + link: uiComponentName('Link'), listView: uiComponentName('ListView'), numberField: uiComponentName('NumberField'), picker: uiComponentName('Picker'), diff --git a/plugins/ui/src/js/src/styles.scss b/plugins/ui/src/js/src/styles.scss index 4fa048b52..840d7991d 100644 --- a/plugins/ui/src/js/src/styles.scss +++ b/plugins/ui/src/js/src/styles.scss @@ -90,3 +90,11 @@ overflow: hidden; width: 100%; } + +.ui-widget-error-contextual-help { + section[class*='spectrum-ContextualHelp-dialog'] { + // Our error messages can be quite large. The default size of the contextual help is only 250px and is too small. + // Just set a size automatically based on the content. + width: fit-content; + } +} diff --git a/plugins/ui/src/js/src/widget/WidgetErrorView.tsx b/plugins/ui/src/js/src/widget/WidgetErrorView.tsx index 2a1634612..b043c52aa 100644 --- a/plugins/ui/src/js/src/widget/WidgetErrorView.tsx +++ b/plugins/ui/src/js/src/widget/WidgetErrorView.tsx @@ -42,7 +42,10 @@ export function WidgetErrorView({ {shortMessage} - + {name}{' '} { + [ + 't_alignment', + 't_background_color', + 't_color', + 't_priority', + 't_value_format', + ].forEach(name => { + test(name, async ({ page }) => { + await gotoPage(page, ''); + await openPanel(page, name, REACT_PANEL_VISIBLE); + + await expect(page.locator(REACT_PANEL_VISIBLE)).toHaveScreenshot(); + }); + }); +}); diff --git a/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-alignment-1-chromium-linux.png b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-alignment-1-chromium-linux.png new file mode 100644 index 000000000..52600623b Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-alignment-1-chromium-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-alignment-1-firefox-linux.png b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-alignment-1-firefox-linux.png new file mode 100644 index 000000000..16c9ad784 Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-alignment-1-firefox-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-alignment-1-webkit-linux.png b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-alignment-1-webkit-linux.png new file mode 100644 index 000000000..46416046c Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-alignment-1-webkit-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-background-color-1-chromium-linux.png b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-background-color-1-chromium-linux.png new file mode 100644 index 000000000..1fedc79ac Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-background-color-1-chromium-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-background-color-1-firefox-linux.png b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-background-color-1-firefox-linux.png new file mode 100644 index 000000000..abf839d97 Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-background-color-1-firefox-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-background-color-1-webkit-linux.png b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-background-color-1-webkit-linux.png new file mode 100644 index 000000000..7c32b6c79 Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-background-color-1-webkit-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-color-1-chromium-linux.png b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-color-1-chromium-linux.png new file mode 100644 index 000000000..4e059cbe4 Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-color-1-chromium-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-color-1-firefox-linux.png b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-color-1-firefox-linux.png new file mode 100644 index 000000000..a8c2aee1c Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-color-1-firefox-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-color-1-webkit-linux.png b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-color-1-webkit-linux.png new file mode 100644 index 000000000..4fde883da Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-color-1-webkit-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-priority-1-chromium-linux.png b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-priority-1-chromium-linux.png new file mode 100644 index 000000000..20507dd83 Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-priority-1-chromium-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-priority-1-firefox-linux.png b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-priority-1-firefox-linux.png new file mode 100644 index 000000000..2666bfcec Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-priority-1-firefox-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-priority-1-webkit-linux.png b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-priority-1-webkit-linux.png new file mode 100644 index 000000000..dabd360b3 Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-priority-1-webkit-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-value-format-1-chromium-linux.png b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-value-format-1-chromium-linux.png new file mode 100644 index 000000000..355577044 Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-value-format-1-chromium-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-value-format-1-firefox-linux.png b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-value-format-1-firefox-linux.png new file mode 100644 index 000000000..ffb5f7492 Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-value-format-1-firefox-linux.png differ diff --git a/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-value-format-1-webkit-linux.png b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-value-format-1-webkit-linux.png new file mode 100644 index 000000000..68ae6e070 Binary files /dev/null and b/tests/ui_table.spec.ts-snapshots/UI-flex-components-t-value-format-1-webkit-linux.png differ