Dynamic Interactive Grid Toolbar Controller for Oracle APEX
Software Engineer at Optimizer
TL;DR
Use a single, declarative JavaScript controller to manage which Interactive Grid toolbar buttons are visible and enabled, based on selection count, page item values, other grid selections, and custom expressions. Configure rules in one place, plug a small controller into your IG initialization, and call refresh() from a few Dynamic Actions so the toolbar reliably reflects the current state without scattered, repetitive Dynamic Actions.
Overview
The Interactive Grid Toolbar Controller is a lightweight, declarative JavaScript controller for Oracle APEX that enables dynamic control of Interactive Grid toolbar button visibility and enabled states based on runtime conditions. Instead of scattering logic across Dynamic Actions, you declare rules in one place, and the controller applies them whenever state changes.
Under the hood it talks only to the Interactive Grid actions API – interactiveGrid('getActions') with show(), hide(), enable(), and disable(). It never manipulates the DOM directly. All rule evaluation happens on the client side: the controller controls what toolbar actions are visible and enabled, while your existing server-side processes remain responsible for enforcing business and security rules. Tested with APEX 24.2.13.
The Problem
Oracle APEX Interactive Grids provide powerful toolbar customization capabilities, but managing button states dynamically presents several challenges:
- No built-in conditions for toolbar actions: IG toolbar actions don’t behave like standard APEX buttons – there’s no low-code way to attach conditions in the builder; everything lives in JavaScript.
- Repetitive Dynamic Actions: The same condition logic often has to be repeated across multiple Dynamic Actions (different events or triggers).
- Complex JavaScript: Custom conditions often involve verbose, error-prone code.
- Maintenance Burden: Changes to button behavior require updates across multiple Dynamic Actions.
- Performance: Multiple Dynamic Actions can impact page performance.
- Readability: Business logic becomes scattered and difficult to understand.
The Solution
The Toolbar Controller provides a centralized, declarative configuration system where you define rules for each toolbar action. The framework automatically evaluates those rules on the client and uses the IG actions API to keep toolbar state in sync. It handles:
- ✅ Button visibility based on conditions
- ✅ Enabled/disabled state management
- ✅ Selection count requirements
- ✅ Page item value comparisons
- ✅ Cross-grid selection dependencies
- ✅ Custom JavaScript expressions
- ✅ Complex AND/OR condition logic
Key Features
Condition Types
The controller supports five powerful condition types:
1. Selection Count
Enable or show buttons based on the number of selected rows:
{
action: "delete-btn",
enabled: { type: "selectionCount", minSelected: 1 }
}
2. Page Item Comparison
Control buttons based on page item values:
{
action: "admin-btn",
visible: {
type: "pageItem",
item: "P80_PERMISSION",
operator: "equals",
value: "ADMIN"
}
}
Supported Operators:
equals,notEqualscontains,notContains>,>=,<,<=isNull,isNotNullinList,notInList– for these,valuemay be a comma- or colon-separated string (e.g."A,B,C") or an array (["A","B","C"])
You can also compare one page item to another using valueFromItem instead of a literal value, for example:
{
action: "admin-btn",
visible: {
type: "pageItem",
item: "P80_PERMISSION",
operator: "equals",
valueFromItem: "P80_REQUIRED_PERMISSION"
}
}
3. Other IG Selection
Make buttons dependent on another Interactive Grid's selection:
{
action: "copy-btn",
enabled: {
type: "otherIGSelection",
regionId: "source_grid",
count: 1,
operator: ">="
}
}
Supported Operators: >=, >, ==, !=, <, <=
4. JavaScript Expression
Execute custom logic with full context access:
{
action: "manager-action",
enabled: {
type: "javascriptExpression",
condition: function(ctx) {
if (ctx.selectedCount !== 1) return false;
const jobValue = ctx.model.getValue(ctx.selectedRecords[0], "JOB");
return jobValue === "MANAGER";
}
}
}
Context Object (ctx) Properties:
regionId- The Interactive Grid static IDregion- APEX region objectwidget- Interactive Grid widgetgridView- Grid view instancemodel- Grid model (for accessing row data)selectedRecords- Array of selected record IDsselectedCount- Number of selected rows
5. Logical Operators (AND/OR)
Combine multiple conditions with nested logic:
{
action: "complex-btn",
visible: {
type: "and",
conditions: [
{ type: "pageItem", item: "P80_MODE", operator: "equals", value: "EDIT" },
{ type: "selectionCount", minSelected: 1 }
]
}
}
Installation
1. Add the controller to a workspace JavaScript file
- Create a Workspace Static File (e.g.
workspace_utils.jsortoolbar-controller.js). - Paste the Toolbar Controller code into that file (see the Appendix for the full code).
2. Reference the file in User Interface Attributes
- Go to Shared Components → User Interface Attributes → JavaScript.
- In File URLs, add (use the filename you chose in step 1):
#WORKSPACE_FILES#workspace_utils#MIN#.js
This way, the controller is automatically loaded and can be used on all pages.
3. Configure Your Interactive Grid
Give your Interactive Grid a Static ID in the region attributes (e.g. my_ig_region) so the controller can reliably find it. Define the customizeIgToolbar function (see the Appendix for a full example) somewhere accessible to the page – for example in Function and Global Variable Declaration, since the IG typically exists on one page only. You can put it elsewhere if it remains accessible.
Add the following to your Interactive Grid's JavaScript Initialization Code:
function(config) {
return customizeIgToolbar(config);
}
4. Trigger Refresh Events
Create Dynamic Actions to refresh the toolbar controller when conditions change. Use these triggers:
- Page Load – Run on initial load.
- Selection Change – On the IG region (see note below about false positives).
- Page item changes – When your rules depend on page item values, create a DA with Event = Change, Selection Type = Item(s), and select the relevant item(s) (e.g.
P80_PERMISSION,P80_MODE). In the True Action, execute the refresh code below.
In each case, the JavaScript is the same:
- Action: Execute JavaScript Code
if (window.myIgController) {
window.myIgController.refresh();
}
Selection Change – and why “false positives” matter
Oracle APEX fires the Selection Change event more often than you might expect: not only when the selection truly changes, but sometimes on focus changes or internal refreshes. If you call window.myIgController.refresh() on every such event, you can end up re-running your rules unnecessarily and possibly causing flicker on the toolbar or performance issues.
To avoid this, use a selection fingerprint: compute a fingerprint of the selected primary keys and only run your Dynamic Action’s “True” branch when that fingerprint actually changed. In that true branch, you then call refresh().
For a full working version of the fingerprint pattern (including example code), see Preventing false positive selection change events in Interactive Grids. Pairing that approach with the Toolbar Controller lets you keep your toolbar logic declarative while only refreshing when the selection really changed.
Multiple Interactive Grids on one page
If your page has multiple Interactive Grids, each needs its own controller. Use distinct variable names (e.g. window.ordersController, window.productsController) and call refresh() on the controller(s) whose conditions may have changed.
API Reference
ToolbarController Constructor
new Demo.ToolbarController(regionId, options)
Parameters:
regionId(string, required): Interactive Grid static IDoptions(object, optional):rules(array): Array of rule objects
By default the controller attaches the constructor to a simple global namespace on window:
window.Demo = window.Demo || {};
window.Demo.ToolbarController = ToolbarController;
If you want to change the namespace, you can edit your copy of the controller and wire it to your own global instead of Demo, for example:
// In your version of toolbar-controller.js
window.MyApp = window.MyApp || {};
window.MyApp.ToolbarController = ToolbarController;
// Usage elsewhere
window.myIgController = new window.MyApp.ToolbarController("my_ig_region", { rules: [...] });
Or, if you prefer a single global symbol without a namespace object:
// In your version of toolbar-controller.js
window.ToolbarController = ToolbarController;
// Usage elsewhere
window.myIgController = new window.ToolbarController("my_ig_region", { rules: [...] });
Rule Object Structure
{
action: "action-name", // Required: must exactly match the action name in initActions
visible: <condition>, // Optional: when omitted, always visible
enabled: <condition> // Optional: when omitted, follows visible state
}
The action value must exactly match the action name registered in your IG's initActions callback.
Condition Object Structure
Simple Conditions:
{ type: "selectionCount", minSelected: number }
{ type: "pageItem", item: "P1_ITEM", operator: "equals", value: "VALUE" }
{ type: "otherIGSelection", regionId: "grid_id", count: number, operator: ">=" }
{ type: "javascriptExpression", condition: function(ctx) { return boolean; } }
Compound Conditions:
{ type: "and", conditions: [<condition>, <condition>, ...] }
{ type: "or", conditions: [<condition>, <condition>, ...] }
Instance Methods
refresh()
Re-evaluates all rules and updates toolbar button states.
window.myIgController.refresh();
When to call:
- Selection changes in the Interactive Grid
- Page item values change
- Selection changes in other Interactive Grids
- After any state change affecting your conditions
Appendix
Here is the full implementation of the controller:
(function(apex) {
"use strict";
function getActions(regionId) {
const region = apex.region(regionId);
if (!region) return null;
const widget = region.widget();
if (!widget || !widget.interactiveGrid) return null;
return widget.interactiveGrid("getActions") || null;
}
function getSelectedCount(regionId) {
const region = apex.region(regionId);
if (!region) return 0;
const widget = region.widget();
if (!widget || !widget.interactiveGrid) return 0;
const grid = widget.interactiveGrid("getViews", "grid");
if (!grid) return 0;
const sel = grid.getSelectedRecords();
return sel ? sel.length : 0;
}
/**
* Builds a context object for custom conditions. Avoids re-digging into the IG in each rule.
* @param {string} regionId
* @returns {{ regionId: string, region: object|null, widget: object|null, gridView: object|null, model: object|null, selectedRecords: array, selectedCount: number }}
*/
function getContext(regionId) {
const region = apex.region(regionId);
if (!region) {
return { regionId, region: null, widget: null, gridView: null, model: null, selectedRecords: [], selectedCount: 0 };
}
const widget = region.widget();
if (!widget || !widget.interactiveGrid) {
return { regionId, region, widget: null, gridView: null, model: null, selectedRecords: [], selectedCount: 0 };
}
const gridView = widget.interactiveGrid("getViews", "grid");
if (!gridView) {
return { regionId, region, widget, gridView: null, model: null, selectedRecords: [], selectedCount: 0 };
}
const model = gridView.model || null;
const selectedRecords = gridView.getSelectedRecords ? (gridView.getSelectedRecords() || []) : [];
return {
regionId,
region,
widget,
gridView,
model,
selectedRecords,
selectedCount: selectedRecords.length
};
}
/**
* Compare item value to expected value. Supports APEX-style client-side condition operators.
* @param {*} actual - Item value (may be null/undefined)
* @param {*} expected - Expected value (optional for isNull/isNotNull)
* @param {string} operator - equals|notEquals|>|>=|<|<=|contains|notContains|isNull|isNotNull|inList|notInList
*/
function compareValue(actual, expected, operator) {
const empty = actual === null || actual === undefined || actual === "";
if (operator === "isNull") return empty;
if (operator === "isNotNull") return !empty;
const a = actual === null || actual === undefined ? "" : String(actual);
const b = expected === null || expected === undefined ? "" : String(expected);
if (operator === "equals") return a === b;
if (operator === "notEquals") return a !== b;
if (operator === "contains") return a.indexOf(b) !== -1;
if (operator === "notContains") return a.indexOf(b) === -1;
if (operator === "inList" || operator === "notInList") {
const list = Array.isArray(expected)
? expected.map(function (x) { return String(x).trim(); })
: String(expected).split(/[,:]/).map(function (s) { return s.trim(); });
const inList = list.indexOf(a.trim()) !== -1;
return operator === "inList" ? inList : !inList;
}
const numA = Number(actual);
const numB = Number(expected);
const bothNumeric = (actual !== "" && expected !== "" && Number.isFinite(numA) && Number.isFinite(numB));
if (bothNumeric) {
if (operator === ">") return numA > numB;
if (operator === ">=") return numA >= numB;
if (operator === "<") return numA < numB;
if (operator === "<=") return numA <= numB;
} else {
if (operator === ">") return a > b;
if (operator === ">=") return a >= b;
if (operator === "<") return a < b;
if (operator === "<=") return a <= b;
}
return false;
}
function compareCount(actual, expected, operator) {
const a = Number(actual);
const b = Number(expected);
if (operator === ">=") return a >= b;
if (operator === ">") return a > b;
if (operator === "==") return a === b;
if (operator === "!=") return a !== b;
if (operator === "<") return a < b;
if (operator === "<=") return a <= b;
return false;
}
function applyActionStateSplit(actions, actionName, visible, enabled) {
if (!visible) {
actions.hide(actionName);
return;
}
actions.show(actionName);
if (enabled) {
actions.enable(actionName);
} else {
actions.disable(actionName);
}
}
function evaluateCondition(cond, context) {
if (!cond || !cond.type) return false;
if (cond.type === "and") {
const conditions = cond.conditions;
if (!Array.isArray(conditions)) return false;
for (let i = 0; i < conditions.length; i++) {
if (!evaluateCondition(conditions[i], context)) return false;
}
return true;
}
if (cond.type === "or") {
const conditions = cond.conditions;
if (!Array.isArray(conditions)) return false;
for (let i = 0; i < conditions.length; i++) {
if (evaluateCondition(conditions[i], context)) return true;
}
return false;
}
if (cond.type === "selectionCount") {
const count = getSelectedCount(context.regionId);
const min = cond.minSelected;
return typeof min === "number" && count >= min;
}
if (cond.type === "pageItem") {
const itemName = cond.item;
const op = cond.operator || "equals";
if (!itemName || !apex.item) return false;
const item = apex.item(itemName);
const val = item ? item.getValue() : undefined;
const expected = cond.valueFromItem != null
? (apex.item(cond.valueFromItem) ? apex.item(cond.valueFromItem).getValue() : undefined)
: cond.value;
return compareValue(val, expected, op);
}
if (cond.type === "otherIGSelection") {
const otherRegionId = cond.regionId;
const count = typeof cond.count === "number" ? cond.count : 0;
const op = cond.operator || ">=";
if (!otherRegionId) return false;
const actualCount = getSelectedCount(otherRegionId);
return compareCount(actualCount, count, op);
}
if (cond.type === "javascriptExpression") {
const fn = cond.condition;
if (typeof fn !== "function") return false;
try {
return Boolean(fn(context));
} catch (e) {
if (apex && apex.debug && typeof apex.debug.error === "function") {
apex.debug.error("ToolbarController: custom condition threw (regionId: " + context.regionId + ").", e);
}
return false;
}
}
return false;
}
function applyRule(actions, rule, context) {
const actionName = rule.action;
if (!actionName) return;
const visible = rule.visible == null
? true
: evaluateCondition(rule.visible, context);
const enabled = rule.enabled == null
? visible
: visible && evaluateCondition(rule.enabled, context);
applyActionStateSplit(actions, actionName, visible, enabled);
}
/**
* @param {string} regionId - IG region static ID
* @param {Object} [options] - options.rules (array of rule objects)
*/
function ToolbarController(regionId, options) {
if (!regionId) throw new Error("ToolbarController: regionId is required");
this.regionId = regionId;
this.rules = (options && options.rules) ? options.rules : [];
}
/**
* Re-applies all rules. Call on page load, selection change, or any DA.
*/
ToolbarController.prototype.refresh = function() {
const actions = getActions(this.regionId);
if (!actions) return;
const context = getContext(this.regionId);
Object.freeze(context.selectedRecords);
Object.freeze(context);
for (let i = 0; i < this.rules.length; i++) {
applyRule(actions, this.rules[i], context);
}
};
window.Demo = window.Demo || {};
window.Demo.ToolbarController = ToolbarController;
})(typeof apex !== "undefined" ? apex : {});
Here's a full working example demonstrating all features wired into customizeIgToolbar. The example references page items P80_PERMISSION, P80_MODE, P80_STATUS, and P80_CUSTOM_CHECK – ensure these exist on your page or replace them with your own item names.
/**
* Configures a custom toolbar for an Interactive Grid
* @param {object} config - The IG configuration object
*/
function customizeIgToolbar(config) {
const $ = apex.jQuery;
// Derive the Static ID from the config object
const staticId = config.regionStaticId;
const toolbarData = $.apex.interactiveGrid.copyDefaultToolbar();
const actionsGroup = toolbarData.toolbarFind('actions3');
const demoButtons = [
{ action: 'btn-01', label: 'Button 1' },
{ action: 'btn-02', label: 'Button 2' },
{ action: 'btn-03', label: 'Button 3' },
{ action: 'btn-04', label: 'Button 4' },
{ action: 'btn-05', label: 'Button 5' },
{ action: 'btn-06', label: 'Button 6' },
{ action: 'btn-07', label: 'Button 7' },
{ action: 'btn-08', label: 'Button 8' },
{ action: 'btn-09', label: 'Button 9' },
{ action: 'btn-10', label: 'Button 10' },
{ action: 'btn-11', label: 'Button 11' }
];
demoButtons.forEach(function (btn) {
actionsGroup.controls.push({
type: 'BUTTON',
hot: false,
action: btn.action
});
});
config.toolbarData = toolbarData;
const existingInitActions = config.initActions;
config.initActions = function (actions) {
if (typeof existingInitActions === 'function') {
existingInitActions(actions);
}
demoButtons.forEach(function (btn) {
actions.add({
name: btn.action,
label: btn.label,
action: function (event, focusElement, args) {
apex.message.showPageSuccess('Action: ' + btn.label + ' for region: ' + staticId);
return true;
}
});
});
};
// Defensive behavior: if another IG (otherIGSelection) is not on the page, its selection count is 0.
// If a custom condition throws, it is caught and the condition is false (action disabled/hidden as per rule).
window.myIgController = new window.Demo.ToolbarController(staticId, {
rules: [
{ action: "btn-01", enabled: { type: "selectionCount", minSelected: 3 } },
{
action: "btn-02",
visible: { type: "pageItem", item: "P80_PERMISSION", operator: "equals", value: "ADMIN" },
enabled: { type: "selectionCount", minSelected: 1 }
},
{
action: "btn-03",
visible: { type: "pageItem", item: "P80_PERMISSION", operator: "equals", value: "ADMIN" }
},
{
action: "btn-04",
visible: { type: "selectionCount", minSelected: 1 }
},
// otherIGSelection – enabled when another IG has at least 2 rows selected (set regionId to your other IG static ID)
{
action: "btn-05",
enabled: { type: "otherIGSelection", regionId: "other_ig_region", count: 2, operator: ">=" }
},
// JavaScript expression – ctx is provided by the controller (model, selectedRecords, selectedCount, etc.).
// Enabled when exactly one row is selected AND that row's column value matches (e.g. JOB = 'MANAGER').
{
action: "btn-06",
enabled: {
type: "javascriptExpression",
condition: function (ctx) {
if (!ctx.model || ctx.selectedCount !== 1) return false;
const value = ctx.model.getValue(ctx.selectedRecords[0], "JOB");
return value === "MANAGER";
}
}
},
// No visible/enabled → always visible, always enabled
{ action: "btn-07" },
// pageItem with notEquals – visible when permission is not ADMIN
{
action: "btn-08",
visible: { type: "pageItem", item: "P80_PERMISSION", operator: "notEquals", value: "ADMIN" }
},
// otherIGSelection with different operator – enabled when other IG has fewer than 2 selected (or missing → count 0)
{
action: "btn-09",
enabled: { type: "otherIGSelection", regionId: "other_ig_region", count: 2, operator: "<" }
},
// AND/OR: visible when P80_PERMISSION = ADMIN AND at least 1 row selected
{
action: "btn-10",
visible: {
type: "and",
conditions: [
{ type: "pageItem", item: "P80_PERMISSION", operator: "equals", value: "ADMIN" },
{ type: "selectionCount", minSelected: 1 }
]
}
},
// btn-11: complex expression – visible and enabled via nested AND/OR using all page items + other IG
{
action: "btn-11",
visible: {
type: "or",
conditions: [
{
type: "and",
conditions: [
{ type: "pageItem", item: "P80_MODE", operator: "equals", value: "EDIT" },
{ type: "pageItem", item: "P80_PERMISSION", operator: "equals", value: "ADMIN" },
{ type: "pageItem", item: "P80_STATUS", operator: "equals", value: "ACTIVE" }
]
},
{
type: "and",
conditions: [
{ type: "pageItem", item: "P80_MODE", operator: "equals", value: "VIEW" },
{ type: "pageItem", item: "P80_CUSTOM_CHECK", operator: "equals", value: "Y" },
{ type: "otherIGSelection", regionId: "other_ig_region", count: 1, operator: ">=" }
]
},
{
type: "and",
conditions: [
{ type: "pageItem", item: "P80_PERMISSION", operator: "equals", value: "ADMIN" },
{ type: "pageItem", item: "P80_STATUS", operator: "equals", value: "IN_PROGRESS" }
]
}
]
},
enabled: {
type: "and",
conditions: [
{ type: "selectionCount", minSelected: 1 },
{
type: "or",
conditions: [
{
type: "and",
conditions: [
{ type: "pageItem", item: "P80_MODE", operator: "equals", value: "EDIT" },
{ type: "pageItem", item: "P80_CUSTOM_CHECK", operator: "equals", value: "Y" }
]
},
{
type: "and",
conditions: [
{ type: "otherIGSelection", regionId: "other_ig_region", count: 2, operator: ">=" },
{ type: "pageItem", item: "P80_STATUS", operator: "equals", value: "ACTIVE" }
]
},
{
type: "and",
conditions: [
{ type: "pageItem", item: "P80_PERMISSION", operator: "equals", value: "ADMIN" },
{ type: "pageItem", item: "P80_STATUS", operator: "equals", value: "IN_PROGRESS" },
{ type: "pageItem", item: "P80_CUSTOM_CHECK", operator: "equals", value: "Y" }
]
}
]
}
]
}
}
]
});
return config;
}