Skip to main content

Cross-widget actions

Widgets on a view can drive one another. A source widget emits a payload on a trigger; the runtime routes it to every wired target widget's inbox, keyed by the action's effect. Targets read their inbox and react.

Source: src/widgets/actions/

The wiring model

Action wiring is stored on the source widget instance as WidgetInstance.actions (see src/workspaces/types.ts):

interface WidgetAction {
id: string;
trigger: WidgetActionTrigger; // "map:extent-change" | "map:layer-click" | "chat:command" | "list:item-click" | "table:row-select"
effect: WidgetActionEffect; // "filter" | "show-feature" | "map-command" | "link-chart-command" | "zoom" | "pan" | "flash"
targetWidgetIds: string[];
enabled: boolean;
}
TriggerEmitted byTypical effect
map:extent-changeMap widget when the view settles after pan/zoomfilter a Table or List
map:layer-clickMap widget when a feature is clickedshow-feature in a Details widget
chat:commandAI Chat widget when an assistant turn carries commandsmap-command on a Map or 3D Scene, or link-chart-command on a Link Chart
list:item-clickList widget when an item is clickedzoom or pan a Map or 3D Scene to the item's feature
table:row-selectTable widget when a row is selectedzoom, pan, or flash a Map or 3D Scene for the row's feature

Runtime flow

ActionsProvider wraps a view and holds the inbox:

interface ActionsProviderProps {
widgets: WidgetInstance[]; // the current view's widgets (holds the wiring)
children: React.ReactNode;
}

The inbox is shaped targetWidgetId -> effect -> latest payload. The provider also prunes inbox entries whose target/effect is no longer wired (widget removed or action disabled), so stale payloads never keep filtering a target.

Hooks

useActionEmitter()

Source widgets push payloads. Returns a stable emitter; it is a no-op when used outside an ActionsProvider.

const emit = useActionEmitter();
// emit(sourceWidgetId, trigger, payload)
emit(widgetId, "map:extent-change", { extent, center });

useActionInbox(widgetId, effect)

Target widgets read the latest payload addressed to them for a given effect, or null if none.

const filter = useActionInbox(widgetId, "filter") as MapExtentPayload | null;

Payload types

Payloads are plain JSON, decoupled from live ArcGIS instances (src/widgets/actions/types.ts):

interface MapExtentPayload {
extent: ExtentJson; // xmin/ymin/xmax/ymax (+ spatialReference)
center: CenterJson; // x/y (+ longitude/latitude when available)
}

interface LayerClickPayload {
layerId: string;
layerTitle?: string;
attributes: Record<string, unknown>;
}

The list:item-click and table:row-select triggers carry a FeatureNavPayload: the feature's geometry reduced for cross-widget use, plus a nonce that is unique per click/selection so each consumer applies its effect exactly once (the inbox only keeps the latest payload):

interface FeatureNavPayload {
nonce: string;
point?: { x; y; spatialReference? }; // point features
extent?: ExtentJson; // line / polygon / multipoint bounding box (zoom/pan)
shape?: GeometryJson; // full geometry JSON for high-fidelity flash outlines
flash?: FlashStyle; // highlight color/size/count/speed for the flash effect
}

A single item click or row selection emits once; the runtime fans the same payload out to whichever effects are wired, so a List or Table can zoom, pan, and/or flash a map from the one interaction. The Map/Scene consumer reads useActionInbox(widgetId, 'zoom' | 'pan' | 'flash'). For navigation it calls view.goTo: zoom frames the feature (a point uses a fixed padding scale so it never snaps to the deepest level), while pan recenters without changing the scale; when both are wired to the same map, zoom wins for that click. flash briefly pulses a highlight on the feature: a solid core on the feature itself under a soft, borderless pulse that radiates off it (a point flashes from its center; a polygon or line's pulse extends off its edge). It traces the true geometry when shape is present, otherwise a point marker or bounding box, respects the OS reduced-motion setting, and cancels any in-flight flash on the same view. The highlight's color, size, pulse count, and per-pulse speed come from the source widget's configured FlashStyle carried in payload.flash (the Table exposes these under Flash appearance once a flash target is wired; count is 1–5 pulses and speed is slow, normal, or fast, so the total animation time is count × per-flash duration). Navigating the live view also re-fires the map's own map:extent-change, so any extent-wired filter targets update for free.

The chat:command trigger carries an array of ChatCommand objects (the commands from a single assistant turn), validated from the backend's ui.command events (src/widgets/actions/chat-commands.ts):

type ChatCommand =
| {
id: string;
type: "zoom-to-location";
args: { latitude; longitude; scale? };
}
| {
id: string;
type: "zoom-to-extent";
args: { xmin; ymin; xmax; ymax; spatialReference? };
}
| {
id: string;
type: "apply-cypher";
// openCypher to re-seed a connected Link Chart, plus an optional
// label map (entity/relationship type -> property to display).
args: { query: string; labels?: Record<string, string> };
};

Each command's id is unique, so a target applies it exactly once even though the inbox only keeps the latest payload and the array grows as commands stream in. The Map/Scene consumer tracks applied ids and runs each new command via view.goTo; because it navigates the live view, the map's own map:extent-change re-fires once it settles, so any extent-wired filter targets update for free.

When the AI Chat is connected to a Link Chart (the link-chart-command effect), the backend runs the knowledge_graph workflow: it translates the question into a subgraph openCypher query (entities + relationships) and, for count/statistic questions, a separate aggregate query for an exact figure. It summarizes the results in chat and emits an apply-cypher command carrying the subgraph query. The Link Chart consumer reads it via useActionInbox(widgetId, 'link-chart-command') and re-seeds its base-cypher-query, regenerating the chart from the matching subgraph. When the graph's authoring metadata names a display property for a type, the command also carries a labels map (type → property) and the chart labels those nodes and relationships by that property. These merge over the chart's own labelFields starting labels (configured under Data) — the command's per-type values win, while configured types it omits keep their label. (The aggregate query never drives the chart — it would render nothing; and Lucene searches are summarized in chat only, since only openCypher maps to the chart's base query.)

The tile-search widget runs its AOI summary inline rather than emitting a cross-widget action. After the user selects tiles, it posts the array of TileResult objects to the backend area_summary workflow and opens the report in a modal (src/widgets/widgets/tile-search/summary-api.ts):

interface TileResult {
tile_key: string;
extent: number[]; // [xmin, ymin, xmax, ymax]
service_url: string; // imagery service the chip is exported from
wkid: number;
size_px: number;
score?: number | null; // CLIP similarity (query / combined modes)
}

Configuring wiring in the UI

The config drawer's Actions section renders a type-specific panel from src/widgets/actions/panels.tsx:

export function getWidgetActionsPanel(
type: string,
): WidgetActionsPanel | undefined;

Widgets flagged hideActions in the registry (such as Details) do not show this section.