@deephaven/iris-grid
v1.27.0
Published
Deephaven Iris Grid
Keywords
Readme
@deephaven/iris-grid
This is a library of Deephaven Iris Grid component. Display a grid with enhanced functionality with a Deephaven table.
Usage
Add the package to your "dependencies":
npm install --save @deephaven/iris-gridThen, import and use the component from the package:
import { useApi } from '@deephaven/jsapi-bootstrap';
import { IrisGrid, IrisGridModelFactory } from '@deephaven/iris-grid';
// In your initialization, create the model async
const dh = useApi();
const model = await IrisGridModelFactory.makeModel(dh, table);
// In your render function
<IrisGrid dh={dh} model={model} />Customizing the Table Options menu
The Table Options sidebar (the gear menu on the right edge of the grid) is
extensible. Plugin authors can hide built-in items, relabel or reorder
them, and add their own items that open a custom configuration page —
without forking IrisGrid.
There is a single entry point: the transformTableOptions prop on
<IrisGrid>. It is opt-in — it lives on the iris-grid-specific
IrisGridTableOptionsWidgetProps, not on the generic
WidgetComponentProps / WidgetPanelProps, so widgets that don't care
about the Table Options menu never see it.
- Own the render site? Pass
transformTableOptionsstraight to<IrisGrid>. - A
WidgetMiddlewarePluginthat doesn't render<IrisGrid>yourself? Thread the prop down the middleware chain via theComponentyou wrap, composing your own transform on top of the one you received (see Publishing from middleware). The panel hosts that ship with Deephaven (IrisGridPanel,GridWidgetPlugin) accepttransformTableOptionsas a prop and forward it to<IrisGrid>.
Writing a transform
transformTableOptions(defaults) is a pure function that receives the
built-in items (already filtered by what the current model supports) and
returns the items to actually render. Use it to add, hide, relabel,
reorder, or replace entries.
import { OptionType, type OptionItem } from '@deephaven/iris-grid';
const transformTableOptions = (defaults: readonly OptionItem[]) => [
// hide a built-in
...defaults.filter(o => o.type !== OptionType.SELECT_DISTINCT),
// add a plugin item with its own page
{
type: 'plugin:my-plugin:column-inspector',
title: 'Column Inspector',
configPage: ColumnInspectorPage,
},
];Rules:
- The transform should be referentially stable and side-effect-free
(it's called inside memoization). Memoize it with
useMemo/useCallbackrather than rebuilding per render. - A throwing transform is logged once and treated as identity for that render, so the menu degrades gracefully.
- Items without a
configPageMUST have atypematching an existingOptionTypeenum value — those are rendered by the built-in page switch. - Items with a
configPageSHOULD use a namespaced key, conventionallyplugin:<name>:<id>, to avoid colliding with built-ins or other plugins.
Implementing a configPage
A configPage is a regular React component that receives
IrisGridTableOptionsPageProps:
import { type IrisGridTableOptionsPageProps } from '@deephaven/iris-grid';
export function ColumnInspectorPage({
model,
viewState,
onBack,
}: IrisGridTableOptionsPageProps): JSX.Element {
const hidden = new Set(viewState.hiddenColumns);
return (
<div>
<button type="button" onClick={onBack}>Back</button>
<pre>
{model.columns
.filter(c => !hidden.has(c.name))
.map(c => c.name)
.join('\n')}
</pre>
</div>
);
}IrisGrid wraps each configPage render in PluginTableOptionsErrorBoundary,
so a throw inside your page shows a small inline fallback instead of
unmounting the whole grid.
The viewState prop is an IrisGridViewState — a curated, read-only snapshot
of the grid's current view configuration (e.g. viewState.hiddenColumns). It's
a small, stable subset of the grid's internal state; new fields are added
additively as plugins need them.
Publishing from middleware
When you're a WidgetMiddlewarePlugin and don't render <IrisGrid>
yourself, you receive transformTableOptions as a prop and pass a
composed transform down to the Component (or panel) you wrap. Run the
upstream transform first so contributions compose, then layer your own
changes on top.
A panelComponent middleware should be built with createPanelMiddleware
from @deephaven/plugin: you supply a body hook that may inject props onto
the wrapped component and/or wrap the child in a wrapper element (both
optional), and the factory owns the React.forwardRef ceremony and ref
forwarding for you. That ref matters —
golden-layout binds a ref to the registered panel to persist class-panel
state (sorts, filters, column moves, etc.) into its componentState, and a
middleware that swallowed it would silently break that persistence for every
panel below it; the factory guarantees it can't be dropped. For the non-panel
component path use createWidgetMiddleware, which is otherwise identical but
takes no ref.
import { useMemo } from 'react';
import { createPanelMiddleware, type WidgetPanelProps } from '@deephaven/plugin';
import {
type IrisGridTableOptionsWidgetProps,
type TableOptionsTransform,
} from '@deephaven/iris-grid';
function makeMyTransform(
upstream: TableOptionsTransform | undefined
): TableOptionsTransform {
return defaults => {
const base = upstream != null ? upstream(defaults) : defaults;
return [...base, myPluginItem];
};
}
const MyMiddleware = createPanelMiddleware<
unknown,
WidgetPanelProps & IrisGridTableOptionsWidgetProps
>(({ transformTableOptions }) => {
const composedTransform = useMemo(
() => makeMyTransform(transformTableOptions),
[transformTableOptions]
);
return { inject: { transformTableOptions: composedTransform } };
}, 'MyMiddleware');The body hook receives the incoming props (minus Component) and returns an
optional { inject?, wrap? }. Every incoming prop is forwarded to the wrapped
component automatically; inject only adds or overrides the few props you
actually change (here transformTableOptions), and wrap optionally nests the
child (e.g. in a context provider). Both fields are optional — a pass-through
middleware can return {}. The factory adds the ref plumbing on top.
Composition rule: each middleware layer reads the transformTableOptions
it was handed, runs that transform first, then layers its own changes on
top — last writer wins for any given OptionItem.type.
Model-aware menus
The transform must stay pure — it only sees defaults, never the
IrisGridModel. To make a menu react to model state (e.g. relabel an
item once a pivot is active), take a snapshot of the value you care
about from model events and fold it into the transform's identity:
const [isPivot, setIsPivot] = useState(model.isPivot);
useEffect(() => {
const handler = () => setIsPivot(model.isPivot);
model.addEventListener(SOME_MODEL_EVENT, handler);
return () => model.removeEventListener(SOME_MODEL_EVENT, handler);
}, [model]);
const composedTransform = useMemo(
() => makeMyTransform(transformTableOptions, isPivot),
[transformTableOptions, isPivot]
);Because composedTransform's identity changes when the snapshot
changes, IrisGrid re-runs it (its menu cache is keyed on
[defaults, transform]). Keeping the snapshot in the dependency array —
rather than reading model.isPivot inside the transform — is what keeps
that memoization honest.
To obtain the model when the host builds it for you, pass an
onModelChanged callback to IrisGridPanel (called once the panel's
model is ready).
Full example
See the @deephaven/js-plugin-pivot-builder
plugin for a working WidgetMiddlewarePlugin that replaces the default
widget renderer and adds a configPage-backed "Rollup, Aggregate and Pivot"
item to the Table Options sidebar.
Why the transform doesn't take the model
The transform signature is (defaults) => items — it deliberately does
not receive the IrisGridModel or grid state. State-aware menus
(e.g. "relabel an item once a pivot is active", "show Reset filters
only when filters exist") are implemented in the middleware: subscribe
to model events, snapshot the value you need, and fold that snapshot
into the transform's identity (see
Model-aware menus). The transform itself stays
pure.
This isn't just about keeping the public surface small — it's also
what keeps menu memoization honest. IrisGrid caches the computed
item list on [defaults, transform] (see getCachedTransformedOptionItems),
both of which are stable values/refs. Adding a live model argument
would break that: IrisGridModel is a long-lived mutable handle whose
identity does not change when isExpandable, filter, sorts, or
isRollup flip, so any plugin that read those fields would silently
return stale items until something unrelated invalidated the cache.
By passing a curated snapshot of values through the transform's
closure instead, the memo key changes exactly when those values change
and re-runs are driven by actual dependencies. Passing the model itself,
or the full IrisGrid instance, is intentionally off the table: the
surface is too large, too volatile, and (in the model's case)
memoization-hostile.
Transforming the model
Some plugins need more than a custom menu — they need to change the
model the grid renders (e.g. wrap it in a proxy that can swap its
inner model in response to a config page). For that there is a second,
symmetric opt-in seam: the transformModel prop.
Like transformTableOptions, it lives on an iris-grid-specific
interface (IrisGridModelWidgetProps), not on the generic
WidgetComponentProps / WidgetPanelProps, and is threaded down the
middleware chain by the hosts that build the model for you
(IrisGridPanel, GridWidgetPlugin / useIrisGridModel).
import { type IrisGridModelTransform } from '@deephaven/iris-grid';
// (model: IrisGridModel) => IrisGridModel | Promise<IrisGridModel>
const transformModel: IrisGridModelTransform = model =>
wrapInMyProxy(model);The host builds the model from fetch as usual, then applies
transformModel to whatever it built before handing it to
<IrisGrid>. The returned model must be a drop-in for the input — the
host owns its lifecycle and will close() whatever you return, so wrap
rather than discard the model you were given. The transform may be async
if it needs to await dependencies first.
Rules:
transformModelmust be referentially stable. It is applied when the model is (re)built; an unstable transform would rebuild the model. Memoize it withuseMemo/useCallback.- It runs once per model build, not per render, so it is the right place for one-time wrapping — not for per-render state.
- This is why model construction stays in the host: the host keeps
ownership of
fetch, error/loading state, andclose(), while the plugin only augments the result. A middleware usingtransformModelcan therefore render the wrappedComponentand stay a chained layer instead of taking over model construction and becoming terminal.
Publishing transformModel from middleware
A middleware that needs both seams composes them the same way — run any
upstream transform first, then layer your own — and returns both from its
body hook's inject. Props you don't touch (here transformTableOptions)
are forwarded automatically, so you only inject what you change:
import { useMemo } from 'react';
import { createPanelMiddleware, type WidgetPanelProps } from '@deephaven/plugin';
import {
type IrisGridModelTransform,
type IrisGridModelWidgetProps,
type IrisGridTableOptionsWidgetProps,
} from '@deephaven/iris-grid';
const MyMiddleware = createPanelMiddleware<
unknown,
WidgetPanelProps & IrisGridModelWidgetProps & IrisGridTableOptionsWidgetProps
>(({ transformModel }) => {
const composedModel = useMemo<IrisGridModelTransform>(
() => async model => {
const base = transformModel != null ? await transformModel(model) : model;
return wrapInMyProxy(base);
},
[transformModel]
);
return { inject: { transformModel: composedModel } };
}, 'MyMiddleware');