Metadata-Version: 2.5
Name: sp-reflex-components
Version: 0.6.0
Summary: Reflex component wrappers for AG Grid (community + enterprise modules) and React Flow - self-host on core Reflex without reflex-enterprise.
Project-URL: Repository, https://github.com/MIM-SP/sp-reflex-components
Project-URL: Issues, https://github.com/MIM-SP/sp-reflex-components/issues
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: ag-grid,components,react-flow,reflex
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Software Development :: User Interfaces
Requires-Python: >=3.12
Requires-Dist: reflex<0.10,>=0.9.5
Description-Content-Type: text/markdown

# sp-reflex-components

Reflex component wrappers: **AG Grid** (ag-grid-react +
community + enterprise modules) and **React Flow** (@xyflow/react). These
replace `reflex-enterprise`'s component wrappers so apps self-host on core
Reflex (Apache-2.0) with **no Reflex tier, token, badge, or reflex.dev
dependency**.

Built and maintained by Solid Power for internal Reflex apps; published in
case others hit the same wall. Apache-2.0.

## Install

```bash
uv add sp-reflex-components
# or: pip install sp-reflex-components
```

## Use

```python
from sp_reflex_components import export_grid_csv, grid, flow

# AG Grid — drop-in for reflex_enterprise's ag_grid.root(...)
grid(
    id="my-grid",
    theme="quartz",  # or "alpine" (default); unknown values fall back to alpine
    row_data=State.rows,
    column_defs=[{"field": "name", "header_name": "Name", "editable": True}],
    on_selection_changed=State.on_select,  # (rows, source, type)
)

# Client-side CSV export of exactly the grid's own rows — no server surface.
# Requires the grid to carry an explicit unique id; safely no-ops until the
# grid is ready. (Replaces reflex-enterprise's AgGridAPI.export_data_as_csv.)
rx.button("Export CSV", on_click=export_grid_csv("my-grid"))

# React Flow — drop-in for reflex_enterprise's flow(...)
flow(
    flow.controls(),
    flow.background(variant="dots", gap=18, size=1),
    nodes=State.nodes,
    edges=State.edges,
    fit_view=True,
)
```

Column defs, default_col_def, detail_cell_renderer_params, and side_bar accept
snake_case keys when passed as plain dicts — `AgGrid.create` camelizes them (`header_name` → `headerName`);
`row_data` keys are never touched. Var values (formatters, `get_detail_row_data`)
pass through untouched.

Master/detail (0.4.0): pass `master_detail=True` plus
`detail_cell_renderer_params={"detail_grid_options": {...}, "get_detail_row_data": Var(...)}`
(and optionally `detail_row_auto_height=True`).

Mixed master/detail (0.5.0): `is_row_master=Var("(data) => bool")` limits the
expander to matching rows; pass `detail_cell_renderer_params` as a whole-prop
Var callback (`(params) => ({detailGridOptions, getDetailRowData})`) to select
a different detail schema per row.

Row grouping + side bar (0.6.0): set `enable_row_group` / `enable_value` in
column defs and pass a SideBarDef as `side_bar`, e.g. a Columns tool panel
with `tool_panel_params={"suppress_pivots": True, "suppress_pivot_mode": True}`.
Tool panels take `width` / `min_width` / `max_width` — compute the width from
your column labels in Python. Column state (order, visibility, widths,
grouping, sort, aggregation) round-trips through `on_state_updated` and
`initial_state`; see [Column-state round trip](#column-state-round-trip).

AG Grid Enterprise features (clipboard, cell selection, column/context menus,
Excel export, master/detail, row grouping + aggregation, side bar / columns
tool panel) need `AG_GRID_LICENSE_KEY` in the environment **at compile
time** — that is AG Grid's own per-developer license, unrelated to Reflex.
Without a key, **every** grid loads in trial mode: a watermark on the grid
plus a "License Key Not Found" console notice at load. The enterprise
modules are registered globally, so this applies to all grids rendered by
this package, not only the ones exercising enterprise features
(browser-verified 2026-08-11 on a plain grid and a menus/Excel grid;
re-verified 2026-08-27 with the 0.4.0 module set including MasterDetailModule
— watermark + console notice on a plain grid, registered set confirmed in the
compiled frontend; re-verified 2026-10-06 with the 0.6.0 set adding
RowGroupingModule, SideBarModule and ColumnsToolPanelModule — same result on
a plain grid and a grouping/side-bar grid).

## Scope doctrine

- A wrapper exposes **exactly** the prop/event surface consuming apps use —
  no speculative props. Need a new prop? Add it here **with a test**, in the
  same PR that uses it; every app inherits it.
- npm versions are **pinned exactly** in this package. A bump is a deliberate
  release with a changelog read (AG Grid majors change behavior: v35 added
  no-matching-rows/exporting overlays, v36 overhauled the DOM containers).
- Legacy CSS theming (`ag-theme-alpine` + `theme: "legacy"`) is deprecated
  upstream; some future AG Grid major will force a Theming API migration —
  that lands here once, for everyone.
- Each consuming app should keep an adapter/wrapper **prop-equality test**
  (both directions, derived from a real render) — see PRISM's
  `tests/unit/reflex_runtime/components/test_vendor_ag_grid.py` for the
  pattern.

## Column-state round trip

`on_state_updated` delivers the grid's column state (order, visibility,
widths, row groups, sort, aggregation — nothing else) and `initial_state`
restores it. AG Grid reads `initial_state` once, at grid creation, so the
pattern is: save per table, remount per table.

```python
class GridState(rx.State):
    table: str = ""
    _saved: dict[str, dict] = {}  # backend-only: a save sends no delta
    mount_state: dict = {}  # changes only together with the table (key)
    column_defs: list[dict] = []  # state vars keep a stable JS reference
    side_bar: dict = {}

    @rx.event
    def pick(self, table: str):
        self.table = table
        self.mount_state = self._saved.get(table, {})
        # ...set column_defs / side_bar for the new table here, too

    @rx.event
    def save(self, table: str, state: dict):
        self._saved = {**self._saved, table: state}


spc.grid(
    id="grid",
    key=GridState.table,  # new table = new grid instance
    column_defs=GridState.column_defs,
    side_bar=GridState.side_bar,
    initial_state=GridState.mount_state,
    on_state_updated=lambda s: GridState.save(GridState.table, s),
)
```

Rules — each one prevents a real reset:

- Bind a plain handler; never `.debounce(...)` it. Reflex debounces by
  handler name across every grid, so one table's events would cancel
  another's pending save. The wrapper already coalesces per grid, drops
  scroll/focus/selection noise, and sends the final state when a grid is
  torn down.
- Save into a backend-only (`_`-prefixed) var and set `mount_state` only when
  the table changes. If `initial_state` tracks the dict every save writes,
  each save re-renders the grid.
- Pass `column_defs`, `default_col_def` and `side_bar` as state vars (already
  camelCase — `create` only camelizes plain dicts). An inline literal reaches
  AG Grid as a new object on every re-render; AG then re-applies the column
  definitions and rebuilds the side bar.
- Use `initialSort` / `initialWidth` / `initialHide` for column defaults,
  not `sort` / `width` / `hide` (camelCase — these live in the state-var
  `column_defs`). AG re-applies the latter whenever the column definitions
  change, wiping what the user did.
- Mount the keyed grid with `column_defs` already populated — saved state is
  applied when the columns first load.

## AG Grid pitfall: grid geometry via props, not CSS

The new AG Grid DOM (the v36 container overhaul noted above) positions the
row container using the grid's **internal** header height — the
`headerHeight` grid option plus border (e.g. `40` + 1px = 41px offset) —
not the header element's rendered height. If CSS forces the header smaller:

```css
.ag-header { height: 34px !important; }
```

the header shrinks but rows still start at the internal offset, leaving a
visible gap between the header and the first row.

Set grid geometry (`header_height`, `row_height`) via grid props; use CSS
only for paint (colors, fonts, borders). Real case: PRISM hit exactly this
(commit `e49ce07`) — fixed by setting `header_height: 34` on the grid and
deleting the CSS element-height `!important` overrides.

## Known issues

- React Flow renders nodes but **edge rendering has an unresolved defect**
  (edges reach the ReactFlow component as props but no edge elements render;
  reproduces identically under reflex-enterprise's own wrapper on
  @xyflow/react 12.8.4 and 12.11.2, so it is not specific to this package).
  Root cause TBD; issues/PRs welcome.

## Dev

```bash
uv sync
uv run pytest -q
uv run ruff check src tests && uv run ruff format --check .
```
