diff --git a/docs/app/reflex_docs/pages/docs/__init__.py b/docs/app/reflex_docs/pages/docs/__init__.py index 6db40cc7ea1..ee2cf79a947 100644 --- a/docs/app/reflex_docs/pages/docs/__init__.py +++ b/docs/app/reflex_docs/pages/docs/__init__.py @@ -175,6 +175,8 @@ def get_previews_from_frontmatter(filepath: str) -> dict[str, str]: "docs/enterprise/ag_grid/model-wrapper.md": "AG Grid with a Pandas DataFrame in Python", "docs/enterprise/ag_grid/value-transformers.md": "AG Grid Value Transformers in Python", "docs/enterprise/ag_grid/aligned-grids.md": "AG Grid Aligned Grids in Python", + "docs/enterprise/ag_grid/tree-data.md": "AG Grid Tree Data in Python", + "docs/enterprise/ag_grid/master-detail.md": "AG Grid Master Detail in Python", } diff --git a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/enterprise.py b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/enterprise.py index 31caec00c4c..35e491954f7 100644 --- a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/enterprise.py +++ b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/enterprise.py @@ -103,6 +103,14 @@ def get_sidebar_items_enterprise_components(): names="Pivot Mode", link=enterprise.ag_grid.pivot_mode.path, ), + SideBarItem( + names="Tree Data", + link=enterprise.ag_grid.tree_data.path, + ), + SideBarItem( + names="Master Detail", + link=enterprise.ag_grid.master_detail.path, + ), SideBarItem( names="Theme", link=enterprise.ag_grid.theme.path, diff --git a/docs/enterprise/ag_grid/cell-selection.md b/docs/enterprise/ag_grid/cell-selection.md index 1316d23d0f1..324ec251d1d 100644 --- a/docs/enterprise/ag_grid/cell-selection.md +++ b/docs/enterprise/ag_grid/cell-selection.md @@ -138,12 +138,27 @@ To enable the fill handle, configure the `cell_selection` prop with a dictionary ```python cell_selection = { + "mode": "multiCell", # or "singleCell" to restrict selection to one cell "handle": { "mode": "fill", # Enable fill handle - } + "direction": "xy", # "x" (horizontal), "y" (vertical), or "xy" (both) + }, } ``` +The fill handle is configured entirely through `cell_selection` — props like `enable_fill_handle`, `fill_handle`, or `grid_options={"enableFillHandle": True}` do not exist. Similarly, range selection is enabled with `cell_selection=True`, not `enable_range_selection`. + +To exclude specific columns from fill operations (typically text columns where an incremental series makes no sense), set `suppress_fill_handle: True` on the column definition: + +```python +column_defs = [ + {"field": "athlete", "suppress_fill_handle": True}, + {"field": "age", "editable": True, "type": "numericColumn"}, +] +``` + +Fill behavior depends on the data type: numbers extend as an incremental series, while text and dates are copied to the filled cells. + ### Fill Handle Events When using the fill handle, it will trigger `on_cell_value_changed` for each cell receiving a fill value. This allows your backend to handle the data changes appropriately. diff --git a/docs/enterprise/ag_grid/index.md b/docs/enterprise/ag_grid/index.md index 84377cb2890..55d7b7612df 100644 --- a/docs/enterprise/ag_grid/index.md +++ b/docs/enterprise/ag_grid/index.md @@ -170,6 +170,29 @@ def ag_grid_column_filter_types(): 📊 **Dataset source:** [GanttChart-updated.csv](https://raw.githubusercontent.com/plotly/datasets/master/GanttChart-updated.csv) +To show an inline filter input below the column headers, set `floating_filter: True` in the column definition (or in `default_col_def` to apply it to every column). + +### Multi-Column Filter (Enterprise) + +The enterprise `agMultiColumnFilter` combines several filter types on a single column: + +```python +column_defs = [ + { + "field": "athlete", + "filter": "agMultiColumnFilter", + "filter_params": { + "filters": [ + {"filter": "agTextColumnFilter"}, + {"filter": "agSetColumnFilter"}, + ], + }, + }, +] +``` + +Enterprise filters may require loading their modules explicitly via the `enterprise_modules` prop — for example `SetFilterModule` for `agSetColumnFilter`, `MultiFilterModule` for `agMultiColumnFilter`, and `FiltersToolPanelModule` for the filter tool panel (shown with `side_bar=True`). See [Functionality you need is not available/working in Reflex](#functionality-you-need-is-not-availableworking-in-reflex) below. + ## Row Sorting By default, the rows can be sorted by any column by clicking on the column header. You can disable sorting of the rows for a column by setting the `sortable` key to `False` in the column definition. @@ -235,6 +258,34 @@ def ag_grid_simple_row_selection(): 📊 **Dataset source:** [gapminder2007.csv](https://raw.githubusercontent.com/plotly/datasets/master/gapminder2007.csv) +### Handling Selection Changes + +Use the `on_selection_changed` event trigger to react to selection changes. The event handler receives the selected rows directly as a `list[dict]` — not an event object, so don't try to read the rows from `event["rows"]` or through the grid API: + +```python +class GridSelectionState(rx.State): + selected_rows: list[dict] = [] + + @rx.event + def handle_selection_changed(self, selected_rows: list[dict]): + self.selected_rows = selected_rows + return rx.toast(f"Selected {len(selected_rows)} rows") + + +def grid_with_selection(): + return rxe.ag_grid( + id="selection_grid", + row_data=df.to_dict("records"), + column_defs=column_defs, + row_selection={"mode": "multiRow"}, + on_selection_changed=GridSelectionState.handle_selection_changed, + width="100%", + height="40vh", + ) +``` + +An event handler annotated as `def handle(self, event: dict)` raises an `EventHandlerArgTypeMismatchError`, since the trigger passes a `list[dict]`. + ## Editing Enable Editing by setting the `editable` attribute to `True`. The cell editor is inferred from the cell data type. Set the cell editor type using the `cell_editor` attribute. diff --git a/docs/enterprise/ag_grid/master-detail.md b/docs/enterprise/ag_grid/master-detail.md new file mode 100644 index 00000000000..edb705f0554 --- /dev/null +++ b/docs/enterprise/ag_grid/master-detail.md @@ -0,0 +1,119 @@ +--- +meta_description: "Enable master-detail rows in AG Grid with Reflex. Expand rows to reveal a nested detail grid backed by per-row data, configured entirely in Python." +title: Master Detail +--- + +# Master Detail + +Master-detail lets rows expand to show detailed information in a nested grid. Each master row carries its detail rows as a nested list, and an expandable column reveals them. + +Three pieces are required: + +1. `master_detail=True` on the grid. +2. A column with `"cell_renderer": "agGroupCellRenderer"`, which renders the expand/collapse arrows. +3. `detail_cell_renderer_params` describing the detail grid's columns and how to extract the detail rows from the master row. + +```python +import reflex as rx +import reflex_enterprise as rxe + + +class MasterDetailState(rx.State): + master_data: list[dict] = [ + { + "id": 1, + "name": "Product A", + "category": "Electronics", + "price": 299.99, + "counts": [ # Detail rows for this master row + {"count": 10, "value": "Stock Level"}, + {"count": 5, "value": "Orders Today"}, + {"count": 25, "value": "Total Sales"}, + ], + }, + { + "id": 2, + "name": "Product B", + "category": "Clothing", + "price": 49.99, + "counts": [ + {"count": 50, "value": "Stock Level"}, + {"count": 12, "value": "Orders Today"}, + {"count": 78, "value": "Total Sales"}, + ], + }, + ] + + +column_defs = [ + { + "field": "id", + "header_name": "ID", + "width": 80, + "cell_renderer": "agGroupCellRenderer", # Required for expand/collapse + }, + {"field": "name", "header_name": "Product Name", "width": 150}, + {"field": "category", "header_name": "Category", "width": 120}, + { + "field": "price", + "header_name": "Price", + "width": 100, + "value_formatter": "params.value ? '$' + params.value.toFixed(2) : ''", + }, +] + +detail_cell_renderer_params = { + "detail_grid_options": { + "column_defs": [ + {"field": "count", "header_name": "Count"}, + {"field": "value", "header_name": "Description"}, + ] + }, + "get_detail_row_data": lambda params: rx.vars.function.FunctionStringVar( + "params.successCallback" + ).call(params.data.counts), +} + + +def master_detail_grid(): + return rxe.ag_grid( + id="master_detail_grid", + row_data=MasterDetailState.master_data, + column_defs=column_defs, + master_detail=True, + detail_cell_renderer_params=detail_cell_renderer_params, + width="100%", + height="500px", + ) +``` + +## How Detail Rows Are Provided + +`get_detail_row_data` follows AG Grid's asynchronous convention: the grid passes a `params` object containing the master row's `data` and a `successCallback` to invoke with the detail rows. The lambda above calls `params.successCallback` with the nested `counts` list of the expanded row. + +The detail grid is a full AG Grid instance with its own `column_defs`, independent from the master grid's columns. + +## Static vs Stateful Configuration + +`row_data` and `column_defs` are plain serializable data, so they can live in state and change at runtime: + +```python +class MasterDetailState(rx.State): + master_data: list[dict] = [] # fetch/replace at runtime + column_defs: list[dict] = [] +``` + +`detail_cell_renderer_params` is different because it holds a callback (`get_detail_row_data`). A callable cannot be stored in a state var: Reflex serializes state to the client as JSON, and a Python lambda (or `FunctionStringVar`) has no JSON representation, so syncing that state raises a serialization error at runtime. + +Keep the renderer params as a module-level object and pass it to the grid directly — it is compiled into the app once and does not need to change per request: + +```python +DETAIL_PARAMS = { + "detail_grid_options": {"column_defs": [{"field": "count"}]}, + "get_detail_row_data": lambda params: rx.vars.function.FunctionStringVar( + "params.successCallback" + ).call(params.data.counts), +} +``` + +Reserve state vars for the serializable pieces (row data, column defs) and leave the callback-bearing renderer params at module level. diff --git a/docs/enterprise/ag_grid/model-wrapper.md b/docs/enterprise/ag_grid/model-wrapper.md index f65c091461a..7ad07c419f2 100644 --- a/docs/enterprise/ag_grid/model-wrapper.md +++ b/docs/enterprise/ag_grid/model-wrapper.md @@ -12,14 +12,25 @@ A model wrapper is an utility used to wrap a database model and provide a consis You can use the basic functionality of the model wrapper by using the `rxe.model_wrapper` function. This function takes a database model and returns a wrapper object that can be used to interact with the model. ```python +import reflex as rx import reflex_enterprise as rxe def index_page(): - return rxe.model_wrapper(class_model=MyModel) + return rx.box( + rxe.model_wrapper(model_class=MyModel, width="100%"), + height="80vh", + ) ``` -By default the model_wrapper use the infinite rows model from AgGrid. +By default the model_wrapper use the infinite rows model from AgGrid. As the user scrolls, the wrapper automatically loads windows of rows from the database instead of loading the whole table into memory. The cache size can be tuned with the `max_blocks_in_cache` and `cache_block_size` props. + +```md alert warning +# Always place the model wrapper in a container with a calculable height. +If the containing element has no fixed height, the grid will not render. Setting e.g. `height="80vh"` on the parent box is enough. +``` + +The model passed as `model_class` must exactly match the schema of the database table backing it, or querying will fail. In particular, don't invent a primary key that the actual table doesn't have. ## Custom Model Wrapper @@ -41,17 +52,75 @@ In the custom model wrapper, you can override the following methods: to modify how the model wrapper will behave. +A custom wrapper is rendered with its `create` classmethod: + +```python +def index_page(): + return rx.box( + MyCustomWrapper.create(model_class=MyModel, width="100%"), + height="80vh", + ) +``` + +### Authorization + +By default there is no authentication checking for any database operation performed through the grid — inserts, updates, and deletes are open to any user who can reach the page. To restrict operations, override the `_is_authorized` method in a `ModelWrapper` subclass: + +```python +from typing import Sequence + +import reflex_enterprise as rxe +from reflex_enterprise.components.ag_grid.wrapper import ModelWrapperActionType + + +class UserModelWrapper(rxe.ModelWrapper[User]): + async def _is_authorized( + self, + action: ModelWrapperActionType, + action_data: Sequence[User] | dict | None, + ) -> bool: + """Check if the user is authorized to perform the action. + + For SELECT, action_data is None. + For INSERT, action_data is a dict of the new row data. + For UPDATE, action_data is a dict of updated row data. + For DELETE, action_data is a list of model objects to delete. + """ + auth_state = await self.get_state(AuthState) + return auth_state.user_is_admin +``` + +### Customizing Columns and Toolbar + +Override `_get_column_defs` to adjust the generated column definitions — for example to disable filtering or sorting on a specific field: + +```python +class UserModelWrapper(rxe.ModelWrapper[User]): + def _get_column_defs(self): + cols = super()._get_column_defs() + for col in cols: + if col.field == "internal_notes": + col.filter = None + col.sortable = False + return cols +``` + +The toolbar UI can be customized as well: override the `_top_toolbar`, `_delete_button`, and `_add_dialog` classmethods to replace the default add/delete controls with your own components. ## SSRM Model Wrapper The SSRM model wrapper, used with `rxe.model_wrapper_ssrm`, is a version of the model wrapper that allows you to use the ServerSideRowModel of AgGrid. ```python +import reflex as rx import reflex_enterprise as rxe def index_page(): - return rxe.model_wrapper_ssrm(class_model=MyModel) + return rx.box( + rxe.model_wrapper_ssrm(model_class=MyModel, width="100%"), + height="80vh", + ) ``` ## SSRM Custom Model Wrapper diff --git a/docs/enterprise/ag_grid/tree-data.md b/docs/enterprise/ag_grid/tree-data.md new file mode 100644 index 00000000000..c527fb7997c --- /dev/null +++ b/docs/enterprise/ag_grid/tree-data.md @@ -0,0 +1,157 @@ +--- +meta_description: "Display hierarchical data in AG Grid with Reflex. Build tree views like file explorers with expandable rows, group aggregation, and custom data paths in pure Python." +title: Tree Data +--- + +# Tree Data + +Tree data displays hierarchical data — like a file system — with expandable and collapsible nodes. Each row provides a *path* (an array like `["Documents", "Projects", "file.txt"]`) that determines its position in the tree. + +To enable it, set `tree_data=True` and tell the grid where to find the path with either `data_path_key` or `get_data_path` (specify exactly one, not both). + +## Simple Case: `data_path_key` + +When a field in the row data already contains the path array, pass its name as a string via `data_path_key`: + +```python +import reflex as rx +import reflex_enterprise as rxe + + +class TreeDataState(rx.State): + data: list[dict] = [ + { + "path": ["Documents", "Projects", "file1.txt"], + "size": 1024, + "created": "2023-10-01", + }, + { + "path": ["Documents", "Projects", "subfolder", "file2.py"], + "size": 2048, + "created": "2023-10-02", + }, + { + "path": ["Downloads", "image.jpg"], + "size": 512000, + "created": "2023-10-03", + }, + ] + + +def tree_grid_simple(): + return rxe.ag_grid( + id="tree_grid_simple", + row_data=TreeDataState.data, + tree_data=True, + data_path_key="path", + auto_group_column_def={ + "headerName": "File Path", + "minWidth": 280, + "cellRendererParams": {"suppressCount": True}, + }, + column_defs=[ + {"field": "size", "aggFunc": "sum"}, + {"field": "created"}, + ], + group_default_expanded=0, # 0 = collapsed, -1 = all expanded + width="100%", + height="500px", + ) +``` + +Key configuration: + +- `auto_group_column_def` configures the tree column (the one with the expand/collapse arrows). +- `group_default_expanded` controls the initial expansion: `0` for collapsed, `-1` for fully expanded, or a positive number for the depth to expand to. +- `aggFunc` on value columns aggregates values at the group level (e.g. total size of a folder). + +## Custom Paths: `get_data_path` + +For anything beyond reading a single field, pass a JavaScript function as `get_data_path`. It must be built with `rx.vars.function.ArgsFunctionOperation` and cast to `rx.vars.FunctionVar`: + +```python +def tree_grid_custom(): + return rxe.ag_grid( + id="tree_grid_custom", + row_data=TreeDataState.data, + tree_data=True, + get_data_path=rx.vars.function.ArgsFunctionOperation.create( + ["data"], + rx.Var("data.path"), + ).to(rx.vars.FunctionVar), + auto_group_column_def={"headerName": "File Path", "minWidth": 280}, + column_defs=[ + {"field": "size", "aggFunc": "sum"}, + {"field": "created"}, + ], + group_default_expanded=0, + width="100%", + height="500px", + ) +``` + +The function receives the row `data` and must return the array representing the row's position in the hierarchy. The JavaScript expression can build the array on the fly, e.g. `rx.Var("[data.host, ...data.path]")` to prefix each path with a host name. + +```md alert warning +# Pass `get_data_path` directly as a grid prop — never define it as a state var or computed var. +Returning the function from an `@rx.var` (or storing it in state) raises `Invalid var passed for prop WrappedAgGrid.get_data_path`. Build the `ArgsFunctionOperation` inline in the `rxe.ag_grid(...)` call. +``` + +### Switching Paths Dynamically + +`get_data_path` can be selected at render time with `rx.cond`. Give the grid a `key` derived from the condition so it re-initializes when the path logic changes: + +```python +class TreeDisplayState(rx.State): + combine_hosts: bool = True + data: list[dict] = [ + {"host": "server1", "path": ["Documents", "file1.txt"], "size": 1024}, + {"host": "server2", "path": ["Downloads", "file2.pdf"], "size": 2048}, + ] + + +def tree_grid_conditional(): + return rxe.ag_grid( + id="tree_grid_conditional", + row_data=TreeDisplayState.data, + tree_data=True, + get_data_path=rx.cond( + TreeDisplayState.combine_hosts, + rx.vars.function.ArgsFunctionOperation.create( + ["data"], + rx.Var("data.path"), + ), + rx.vars.function.ArgsFunctionOperation.create( + ["data"], + rx.Var("[data.host, ...data.path]"), + ), + ).to(rx.vars.FunctionVar), + key=f"grid_{TreeDisplayState.combine_hosts}", + auto_group_column_def={"headerName": "File Explorer", "minWidth": 280}, + column_defs=[{"field": "size", "aggFunc": "sum"}], + width="100%", + height="500px", + ) +``` + +## Formatting Aggregated Values + +Value formatters work on tree columns too. For example, human-readable file sizes: + +```python +human_size = rx.vars.function.ArgsFunctionOperation.create( + ["params"], + rx.Var("""{ + const sizeInKb = params.value / 1024; + if (sizeInKb > 1024) { + return `${+(sizeInKb / 1024).toFixed(2)} MB`; + } else { + return `${+sizeInKb.toFixed(2)} KB`; + } + }"""), +) + +column_defs = [ + {"field": "size", "aggFunc": "sum", "value_formatter": human_size}, +] +``` diff --git a/docs/enterprise/ag_grid/value-transformers.md b/docs/enterprise/ag_grid/value-transformers.md index 31ef23e516c..44266b97116 100644 --- a/docs/enterprise/ag_grid/value-transformers.md +++ b/docs/enterprise/ag_grid/value-transformers.md @@ -15,6 +15,8 @@ AgGrid allow you to apply transformers based on the column of your grid. This al TOC: - [Value Getter](#value-getter) - [Value Formatter](#value-formatter) +- [Formatter Patterns](#formatter-patterns) +- [Cell Renderer](#cell-renderer) ## Value Getter @@ -94,4 +96,93 @@ def ag_grid_value_formatter(): ) ``` +## Formatter Patterns +Formatters and getters can be written in a few different styles. Simple inline JavaScript expressions are the most reliable and should be preferred: + +```python +column_defs = [ + {"field": "name", "value_formatter": "params.value.toUpperCase()"}, + {"field": "price", "value_formatter": "'$' + params.value.toFixed(2)"}, + {"field": "percent", "value_formatter": "(params.value * 100).toFixed(1) + '%'"}, + {"field": "date", "value_formatter": "new Date(params.value).toLocaleDateString()"}, + # Conditional logic works inline with a ternary + {"field": "score", "value_formatter": "params.value > 100 ? 'High' : 'Low'"}, +] +``` + +Python lambdas are useful for basic type conversions — the lambda receives a `params` var and operates on it symbolically: + +```python +column_defs = [ + { + "field": "number", + "value_formatter": lambda params: round(params.value.to(float), 2), + }, + {"field": "status", "value_formatter": lambda params: params.value.to(str).title()}, +] +``` + +Short arrow functions can also be passed via `rx.vars.FunctionStringVar`: + +```python +CURRENCY_FORMATTER = rx.vars.FunctionStringVar.create( + "(params) => '$' + params.value.toFixed(2)" +) +column_defs = [{"field": "price", "value_formatter": CURRENCY_FORMATTER}] +``` + +Keep JavaScript formatters short: multi-line function bodies with complex conditionals often fail to render. If the logic doesn't fit a simple expression, compute the value on the backend instead, or use a [cell renderer](#cell-renderer). + +Formatters and getters are always passed directly in the column definition — they are not registered as AG Grid "components". + +## Cell Renderer + +While formatters change the displayed text, `cell_renderer` replaces the cell contents with a Reflex component. The renderer is a lambda that receives the cell `params` and returns a component: + +```python +column_defs = [ + { + "field": "number", + "cell_renderer": lambda params: rx.text( + params.value, + font_family="monospace", + color="rebeccapurple", + ), + }, + # params.valueFormatted holds the output of the column's value_formatter + { + "field": "total", + "cell_renderer": lambda params: rx.tooltip( + rx.text(params.valueFormatted, line_height="inherit", width="fit-content"), + content=f"{params.data.number} * {params.data.percent}", + side="left", + ), + }, +] +``` + +### Interactive Cell Renderers + +Renderers that use state or event handlers must be defined as an `@rx.memo` component. Pass it to `cell_renderer` through a lambda, with all arguments given by keyword — never pass the memoized function itself directly: + +```python +@rx.memo +def row_action_button(rowid: str) -> rx.Component: + return rx.flex( + rx.button( + RowClickCounterState.row_clicks.get(rowid, 0), + on_click=RowClickCounterState.handle_click(rowid), + ), + height="100%", + align="center", + ) + + +column_defs = [ + { + "field": "actions", + "cell_renderer": lambda params: row_action_button(rowid=params.node.id), + }, +] +``` diff --git a/docs/enterprise/map/index.md b/docs/enterprise/map/index.md index 01ffa584757..6393c3fdab4 100644 --- a/docs/enterprise/map/index.md +++ b/docs/enterprise/map/index.md @@ -126,6 +126,24 @@ def markers_example(): ) ``` +#### Custom Marker Icons + +To customize a marker's icon, pass a plain dictionary of [Leaflet icon options](https://leafletjs.com/reference.html#icon) to the marker's `icon` prop. There is no `rxe.map.icon` component or `icon` helper in the types module — the prop takes a dict directly: + +```python +rxe.map.marker( + position=rxe.map.latlng(lat=51.505, lng=-0.09), + icon={ + "iconUrl": "https://example.com/custom-marker.png", + "iconSize": [25, 41], + "iconAnchor": [12, 41], + "popupAnchor": [1, -34], + "shadowUrl": "https://example.com/marker-shadow.png", + "shadowSize": [41, 41], + }, +) +``` + ### Vector Layers Draw shapes and areas on the map: @@ -223,6 +241,17 @@ def interactive_example(): ) ``` +Zoom and move events fire rapidly while the user interacts with the map. Debounce them to avoid flooding the backend with state updates: + +```python +rxe.map( + ..., + on_zoom=InteractiveMapState.handle_zoom_change.debounce(100), +) +``` + +When storing the zoom level in state, annotate it as `float` (e.g. `zoom: float = 13.0`) — Leaflet reports fractional zoom values. + ### Map Controls Add UI controls for enhanced user interaction: @@ -275,6 +304,20 @@ To access the Map API, you need to get a reference to your map using its ID: map_api = rxe.map.api("my-map-id") ``` +`rxe.map.api()` is a function that returns an API object — it is not a class. There is no `rxe.map.API` and no importable `MapApi` type to use in annotations. Call it inline wherever you need it (in a component function or inside an event handler); never store the returned object in a state var, which fails with a `TypeError` about un-annotated parameters. + +API methods accept **positional arguments only**, with `callback` as the one keyword exception: + +```python +# Correct - positional arguments +map_api.fly_to(coordinates, 15.0) +map_api.locate(rxe.map.locate_options(set_view=True)) +map_api.fly_to(coordinates, 15.0, callback=my_callback) + +# Wrong - keyword arguments raise TypeError +map_api.fly_to(coordinates, zoom=15.0) +``` + ### Interactive Demo Here are some commonly used API methods demonstrated in action: diff --git a/docs/enterprise/react_flow/components.md b/docs/enterprise/react_flow/components.md index 04ba0536166..8dd7f99c614 100644 --- a/docs/enterprise/react_flow/components.md +++ b/docs/enterprise/react_flow/components.md @@ -19,6 +19,31 @@ The `FlowProvider` component is a context provider that makes it possible to acc - `node_origin`: `NodeOrigin` - The origin of the node to use when placing it in the flow or looking up its x and y position. - `node_extent`: `CoordinateExtent` - The boundary a node can be moved in. +`rxe.flow.provider` does not accept any event handlers. All event handlers (`on_nodes_change`, `on_edges_change`, `on_connect`, etc.) must be set on the inner `rxe.flow` component. + +**Example:** + +```python +rx.box( + rxe.flow.provider( + rxe.flow( + rxe.flow.background(), + nodes=FlowState.nodes, + edges=FlowState.edges, + on_nodes_change=lambda changes: FlowState.set_nodes( + rxe.flow.util.apply_node_changes(FlowState.nodes, changes) + ), + on_edges_change=lambda changes: FlowState.set_edges( + rxe.flow.util.apply_edge_changes(FlowState.edges, changes) + ), + fit_view=True, + ) + ), + height="100vh", + width="100vw", +) +``` + ## rxe.flow The `Flow` component is the main component that renders the flow. It takes in nodes and edges, and provides event handlers for user interactions. diff --git a/docs/enterprise/react_flow/hooks.md b/docs/enterprise/react_flow/hooks.md index e9d12d95369..5427650e241 100644 --- a/docs/enterprise/react_flow/hooks.md +++ b/docs/enterprise/react_flow/hooks.md @@ -2,6 +2,8 @@ The `rxe.flow.api` module provides hooks to interact with the Flow instance. These hooks are wrappers around the `useReactFlow` hook from React Flow. +These hooks rely on the flow being wrapped in `rxe.flow.provider`. + ## Node Hooks - `get_nodes()`: Returns an array of all nodes in the flow. @@ -25,6 +27,23 @@ The `rxe.flow.api` module provides hooks to interact with the Flow instance. The - `screen_to_flow_position(x, y, snap_to_grid=False)`: Translates a screen pixel position to a flow position. - `flow_to_screen_position(x, y)`: Translates a position inside the flow’s canvas to a screen pixel position. +Use `screen_to_flow_position` to convert pointer coordinates from an event into canvas coordinates — for example, to place a node where the user dropped an unfinished connection: + +```python +rxe.flow( + ..., + on_connect_end=lambda connection_status, event: FlowState.handle_connect_end( + connection_status, + rxe.flow.api.screen_to_flow_position( + x=event.client_x, + y=event.client_y, + ), + ), +) +``` + +The conversion happens on the client, and the resulting `XYPosition` is passed to the state event handler as an argument. See the "Add Node on Edge Drop" example on the Examples page for a complete implementation. + ## Other Hooks - `to_object()`: Converts the React Flow state to a JSON object. diff --git a/docs/enterprise/react_flow/interactivity.md b/docs/enterprise/react_flow/interactivity.md index 18af2460dcb..e32f0813d8b 100644 --- a/docs/enterprise/react_flow/interactivity.md +++ b/docs/enterprise/react_flow/interactivity.md @@ -58,6 +58,7 @@ def set_edges(self, edges: list[Edge]): - set_edges updates edges when they are modified or deleted. +Note that these are plain setters — they receive the already-updated list. The change-application logic lives in the component wiring, shown next. ## Render the Interactive Flow @@ -78,6 +79,9 @@ def interactive_flow(): on_edges_change=lambda edge_changes: FlowState.set_edges( rxe.flow.util.apply_edge_changes(FlowState.edges, edge_changes) ), + on_connect=lambda connection: FlowState.set_edges( + rxe.flow.util.add_edge(connection, FlowState.edges) + ), fit_view=True, attribution_position="bottom-right", ), @@ -85,3 +89,30 @@ def interactive_flow(): width="100vw", ) ``` + +- `on_nodes_change` fires when nodes are dragged, selected, resized, or removed. +- `on_edges_change` fires when edges are selected or removed. +- `on_connect` fires when the user drags a connection between two handles; `rxe.flow.util.add_edge` builds the new edge list from the connection. + +Make sure to set the `height` and `width` of the container around the flow — the flow fills its parent, so without explicit dimensions it will not be visible. + +## Controlled vs. Uncontrolled Flows + +Passing `nodes` and `edges` makes the flow **controlled**: your state is the source of truth, and the canvas renders exactly what the state contains. A controlled flow must wire `on_nodes_change` and `on_edges_change` as shown above, otherwise user interactions (dragging, selecting, deleting) are discarded. + +Passing only `default_nodes` and `default_edges` makes the flow **uncontrolled**: the component manages changes internally, but your state never learns about them. + +## Where Changes Get Applied + +`rxe.flow.util.apply_node_changes` and `rxe.flow.util.apply_edge_changes` must be used inside of component code — in the lambda wired to `on_nodes_change` / `on_edges_change` — and never inside of state code. They evaluate on the client and produce the updated list, which is then passed to a plain setter event handler: + +```python +rxe.flow( + ..., + on_nodes_change=lambda changes: FlowState.set_nodes( + rxe.flow.util.apply_node_changes(FlowState.nodes, changes) + ), +) +``` + +Do not call these utilities from within an `@rx.event` handler; the state side should only receive and store the resulting list.