Metadata-Version: 2.5
Name: sp-reflex-components
Version: 0.7.1
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.11,>=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, side_bar, and each
type inside column_types 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).

Column types (0.7.0): a static `column_types` map holds per-cell JavaScript
(renderers, tooltips, formatters) so state-var column defs stay JSON and pick
it up by `type` name; see [Column types](#column-types).

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 (or when the [reload guard](#reload-and-navigation)
  remounts a stale grid). 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. (`column_types` is the exception: it
  carries functions, so pass a plain dict — see [Column types](#column-types).)
- 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.

### Reload and navigation

Without a guard, a grid that mounts outside a table switch — a page reload, or
navigating away and back — starts from `mount_state`, the state saved at the
**last table switch**. Worse, it writes that state back: its saves (at the
latest, the final save it sends when torn down) overwrite the newer
`_saved[table]`, so edits made since the last switch are lost, not just
unrestored. The guard remounts a stale grid from the latest save and drops the
stale grid's saves by mount id:

```python
class GridState(rx.State):
    # ...the fields and pick() above, plus:
    mount_id: int = 0  # part of the grid key

    @rx.event
    def save(self, table: str, mount_id: int, state: dict):
        if mount_id == self.mount_id:  # else: sent by a grid mounted stale
            self._saved = {**self._saved, table: state}

    @rx.event
    def remount_if_stale(self):
        latest = self._saved.get(self.table, {})
        if latest != self.mount_state:  # the grid just mounted from older state
            self.mount_state = latest
            self.mount_id += 1  # remount it from the latest save


rx.box(  # the sized box that holds the grid
    spc.grid(
        # ...the props above, with:
        key=GridState.table + "#" + GridState.mount_id.to_string(),
        on_state_updated=lambda s: GridState.save(GridState.table, GridState.mount_id, s),
    ),
    on_mount=GridState.remount_if_stale,
    height="600px",
)
```

- Run the check from `on_mount` on the box that holds and sizes the grid
  (`grid` fills its parent's height), not from `on_load`: Reflex
  re-runs `on_load` on every socket reconnect, so the grid would remount each
  time the connection blips. `on_mount` runs when the page mounts (load or
  navigation), not on a reconnect.
- `pick` leaves `mount_id` alone: the outgoing grid's final save carries the
  current id and has to land.
- Keep both halves. The remount restores the latest save; the id check drops
  the final save of the stale grid it replaces, which reaches the server first
  whenever the `on_mount` round trip is slower than the grid's first state
  event.
- A store that outlives the tab (`rx.LocalStorage`, a database) needs the same
  guard — the stale grid writes there too.

## Column types

State is JSON, so per-cell JavaScript (renderers, tooltips, formatters) can't
ride inside state-var `column_defs`. Define it once in a static `column_types`
map in component code; the state column defs reference those types by name.

```python
from reflex.vars.function import FunctionStringVar

# Self-contained function source: no globals, nothing to load first.
RETEST_RENDERER = FunctionStringVar.create("""((params) => {
  const base = params.colDef.cellRendererParams?.baseColName;
  if (params.data?.retest_flags?.[base] === true) return "[!] Multiple";
  return String(params.valueFormatted ?? params.value ?? "");
})""")
RETEST_TOOLTIP = FunctionStringVar.create("""((params) => {
  const base = params.colDef.cellRendererParams?.baseColName;
  return params.data?.retest_flags?.[base] === true ? "Multiple results" : null;
})""")
GROUP_AGG_3DP = FunctionStringVar.create("""((params) => {
  const v = params.value, n = Number(v);
  const agg = params.node?.group || params.node?.footer;
  return agg && v !== null && v !== "" && Number.isFinite(n) ? n.toFixed(3) : v;
})""")

COLUMN_TYPES = {  # module-level constant
    "retestFlagged": {
        "cell_renderer": RETEST_RENDERER,
        "tooltip_value_getter": RETEST_TOOLTIP,
    },
    "groupAggThreeDecimal": {"value_formatter": GROUP_AGG_3DP},
}


class GridState(rx.State):
    # JSON only: type names + the params the functions read.
    column_defs: list[dict] = [
        {
            "field": "d50",
            "type": ["retestFlagged", "groupAggThreeDecimal"],
            "cellRendererParams": {"baseColName": "d50"},
        },
    ]


spc.grid(column_defs=GridState.column_defs, column_types=COLUMN_TYPES, row_data=GridState.rows)
```

Rules:

- Pass `column_types` as a plain dict. Type names (the top-level keys) are
  kept exactly as written — column defs match them by string. Each type's own
  keys are camelized like column defs (`cell_renderer` → `cellRenderer`).
- The wrapper hoists the map to a module-level constant in the compiled page,
  so AG Grid sees the same object on every re-render. An inline map (e.g.
  through `custom_attrs`) is a new object per render and AG Grid rebuilds every
  column (`gridOptionsChanged`). Wrapping the dict in `rx.Var.create(...)`
  skips the hoist — don't.
- The map holds literals and `FunctionStringVar`s only: no state vars,
  `rx.foreach` items, or f-strings of Vars (`TypeError` at create). A JSON-only
  map that changes at runtime can be a state var instead.
- Keep the function source self-contained, as above. Don't call functions an
  asset script defines on `window`: `rx.script` is injected through Helmet
  after hydration, so cells can render first — one `TypeError` in a
  formatter and the grid never renders its rows, even once the script
  arrives (smoke-tested). If you must reference a global anyway, look it up
  inside the function body (`(params) => window.fns.f(params)`), never as the
  value itself (`window.fns.f`): the hoisted map is evaluated when the page
  module loads.
- Don't reuse AG Grid's built-in type names (`numericColumn`,
  `rightAligned`), and a column type can't itself carry `type`.

## 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 .
```
