Oracle APEX Interactive Grid: Custom Error Bubbles for Cell Validation
Show the error on the exact cell, while the user is typing, before they press Save.
Last week I was working on an editable Interactive Grid in Oracle APEX. The users could edit many rows at once, and I wanted them to see the mistake right inside the cell. I did not want a big error message after they click Save and then wait to find which row is wrong.
In this blog I will show you how I did it step by step. I will also share the things that failed for me, so you do not waste time on them.
What We Are Building
A reusable Interactive Grid validation that you can add to any editable grid. When a cell value is wrong, the cell turns red, a small error icon shows up, and the message appears in a bubble. The Save button also tells how many errors are left.
You can check each cell on its own, and you can also compare one cell with other cells in the same row. Here are the kinds of rules this setup can handle:
| Rule Type | Example |
|---|---|
| Required value | A dropdown must be selected |
| Text pattern and length | Only letters, between 3 and 10 characters |
| Number range | Greater than 0 and not more than a limit |
| Date check | Date is required and not in the future |
| Compare two columns | One value cannot be bigger than another |
| Depends on another column | A value is allowed only when another column has a certain value |
| Allowed list | Value must be one of a fixed set |
Step 1: Create the Page
- Open your application and click Create Page, then choose Interactive Grid.
- Give page number
22and a name like Validation IG. - Set the source as your table, turn on Editing Enabled and select your primary key column.
- Select all columns and create the page.
- Open the page in Page Designer, click the region and set the Static ID as
emp_ig.
emp_ig, so a small spelling change will break everything.I also changed JOB and DEPTNO to Select List with static values, and made the important columns Value Required. Then I ran the page once to confirm that the basic grid saves data.
Step 2: My First Try Did Not Work
Then I found that the grid has a client side model, and the model has a function called setValidity. This function draws the red cell and the error bubble for us. This approach finally worked for me.
Step 3: Add the Page JavaScript
Click the page name in Page Designer, go to JavaScript and open Function and Global Variable Declaration. Paste this code.
emp_ig: you can change it, just use the same value in every code block.function empVal(model, rec, field) {
var x = model.getValue(rec, field);
if (x && typeof x === "object") { x = x.v; }
return (x === null || x === undefined) ? "" : String(x).trim();
}
function empNum(s) {
return Number(String(s).replace(/,/g, ""));
}
function empDateToNumber(s) {
if (/^\d{8}/.test(s)) { return Number(s.substring(0, 8)); }
var dt = apex.date.parse(s, "DD-MON-YYYY");
if (!dt) { return NaN; }
return dt.getFullYear() * 10000 + (dt.getMonth() + 1) * 100 + dt.getDate();
}
function validateEmpRow(model, recordId) {
var rec = model.getRecord(recordId);
if (!rec) { return 0; }
var fields = ["EMPNO", "ENAME", "JOB", "MGR", "HIREDATE", "SAL", "COMM", "DEPTNO"];
var meta = model.getRecordMetadata(recordId);
if (meta && meta.deleted) {
fields.forEach(function (f) { model.setValidity("valid", recordId, f); });
return 0;
}
var v = {};
fields.forEach(function (f) { v[f] = empVal(model, rec, f); });
var errs = {};
if (v.EMPNO === "" || isNaN(empNum(v.EMPNO)) || empNum(v.EMPNO) < 1 || empNum(v.EMPNO) > 9999) {
errs.EMPNO = "Employee number must be between 1 and 9999.";
}
if (!/^[A-Za-z]{3,10}$/.test(v.ENAME)) {
errs.ENAME = "Name must be 3 to 10 letters (A-Z only).";
}
if (v.JOB === "") {
errs.JOB = "Please select a job.";
}
if (v.MGR !== "" && v.MGR === v.EMPNO) {
errs.MGR = "Employee cannot be their own manager.";
} else if (v.MGR === "" && v.JOB !== "" && v.JOB !== "PRESIDENT") {
errs.MGR = "Manager is required unless job is PRESIDENT.";
}
if (v.HIREDATE === "") {
errs.HIREDATE = "Hire date is required.";
} else {
var hd = empDateToNumber(v.HIREDATE);
var d = new Date();
var today = d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate();
if (isNaN(hd)) {
errs.HIREDATE = "Enter a valid date (DD-MON-YYYY).";
} else if (hd > today) {
errs.HIREDATE = "Hire date cannot be in the future.";
}
}
if (v.SAL === "" || isNaN(empNum(v.SAL)) || empNum(v.SAL) <= 0 || empNum(v.SAL) > 5000) {
errs.SAL = "Salary must be greater than 0 and not exceed 5000.";
}
if (v.COMM !== "") {
if (isNaN(empNum(v.COMM)) || empNum(v.COMM) < 0) {
errs.COMM = "Commission must be a number of 0 or more.";
} else if (v.JOB !== "SALESMAN") {
errs.COMM = "Commission is allowed only for SALESMAN.";
} else if (v.SAL !== "" && empNum(v.COMM) > empNum(v.SAL)) {
errs.COMM = "Commission cannot exceed salary.";
}
}
if (["10", "20", "30", "40"].indexOf(v.DEPTNO) === -1) {
errs.DEPTNO = "Department must be 10, 20, 30 or 40.";
}
var count = 0;
fields.forEach(function (f) {
if (errs[f]) {
model.setValidity("error", recordId, f, errs[f]);
count++;
} else {
model.setValidity("valid", recordId, f);
}
});
return count;
}
function validateEmpAll(model) {
var total = 0;
model.forEach(function (rec, index, id) {
total += validateEmpRow(model, id);
});
return total;
}
var empSaveTried = false;
function empUpdateSaveButton(errorCount, fromSave) {
if (fromSave) { empSaveTried = true; }
if (!empSaveTried) { return; }
var $btn = apex.jQuery('#emp_ig button[data-action="save"]');
$btn.removeClass("emp-has-errors emp-all-clear");
void ($btn[0] && $btn[0].offsetWidth);
if (errorCount > 0) {
$btn.addClass("emp-has-errors").attr("data-emp-count", errorCount);
} else {
$btn.addClass("emp-all-clear").removeAttr("data-emp-count");
apex.message.clearErrors();
}
}
apex.jQuery(function () {
apex.jQuery("#emp_ig").on("interactivegridviewmodelcreate", function (event, ui) {
var model = ui.model;
if (ui.viewId !== "grid") { return; }
model.subscribe({
onChange: function (type, change) {
if (type === "set") {
validateEmpRow(model, change.recordId);
empUpdateSaveButton(validateEmpAll(model), false);
}
}
});
});
});empDateToNumber function above fixes it, because it handles both formats.Step 4: Stop the Save Button When Errors Exist
Now click the grid region, find Attributes and open JavaScript Initialization Code. I placed the code here, not on the page. When I put it on the page first, nothing happened, because the grid was not ready at that time.
function (config) {
config.initActions = function (actions) {
var saveAction = actions.lookup("save");
if (!saveAction) { return; }
var originalSave = saveAction.action;
saveAction.action = function (event, focusElement) {
var model = apex.region("emp_ig").widget()
.interactiveGrid("getViews", "grid").model;
var errorCount = validateEmpAll(model);
empUpdateSaveButton(errorCount, true);
apex.message.clearErrors();
if (errorCount > 0) {
apex.message.showErrors([{
type: "error",
location: "page",
message: "Please fix " + errorCount + " invalid cell(s) before saving.",
unsafe: false
}]);
return;
}
originalSave.call(this, event, focusElement);
};
};
return config;
}Step 5: Add the Animated CSS
Click the page name, then CSS and Inline. This makes the bad cells shake and glow, and the Save button turns red with a number badge. When all errors are fixed, it turns green.
@keyframes emp-shake {
0%, 100% { transform: translateX(0); }
15% { transform: translateX(-6px); }
30% { transform: translateX(5px); }
45% { transform: translateX(-4px); }
60% { transform: translateX(3px); }
75% { transform: translateX(-2px); }
}
@keyframes emp-glow {
0%, 100% { box-shadow: inset 0 0 0 2px #d32f2f, 0 0 0 0 rgba(211, 47, 47, .45); }
50% { box-shadow: inset 0 0 0 2px #d32f2f, 0 0 10px 3px rgba(211, 47, 47, .35); }
}
@keyframes emp-icon-pop {
0% { transform: scale(0) rotate(-90deg); opacity: 0; }
70% { transform: scale(1.35) rotate(10deg); opacity: 1; }
100% { transform: scale(1) rotate(0); }
}
@keyframes emp-bubble-in {
from { opacity: 0; transform: translateY(-8px) scale(.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes emp-btn-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(46, 125, 50, .55); }
50% { box-shadow: 0 0 0 10px rgba(46, 125, 50, 0); }
}
@keyframes emp-badge-bounce {
0% { transform: scale(0); }
60% { transform: scale(1.3); }
100% { transform: scale(1); }
}
.a-GV-cell.is-error,
td.is-error {
background: linear-gradient(90deg, #ffebee, #fff5f5) !important;
animation: emp-shake .45s ease-in-out, emp-glow 1.8s ease-in-out .45s 3;
}
.a-GV-cell.is-error input,
.a-GV-cell.is-error select,
td.is-error input,
td.is-error select {
color: #b71c1c !important;
font-weight: 600;
}
.a-GV-cell.is-error .a-Icon,
td.is-error .a-Icon {
animation: emp-icon-pop .4s cubic-bezier(.34, 1.56, .64, 1) both;
}
.a-Tooltip, .ui-tooltip {
animation: emp-bubble-in .25s ease-out;
border-left: 4px solid #d32f2f;
border-radius: 6px;
box-shadow: 0 6px 18px rgba(0, 0, 0, .18);
}
tr:has(td.is-error) td:first-child {
box-shadow: inset 4px 0 0 #d32f2f;
}
#emp_ig button[data-action="save"] {
transition: background-color .3s ease, transform .15s ease;
position: relative;
}
#emp_ig button[data-action="save"]:active { transform: scale(.95); }
#emp_ig button[data-action="save"].emp-has-errors {
background-color: #c62828 !important;
color: #fff !important;
animation: emp-shake .45s ease-in-out;
}
#emp_ig button[data-action="save"].emp-has-errors::after {
content: attr(data-emp-count);
position: absolute;
top: -9px;
right: -9px;
min-width: 20px;
height: 20px;
padding: 0 5px;
border-radius: 10px;
background: #fff;
color: #c62828;
border: 2px solid #c62828;
font-size: 12px;
font-weight: 700;
line-height: 16px;
text-align: center;
animation: emp-badge-bounce .35s ease-out;
}
#emp_ig button[data-action="save"].emp-all-clear {
background-color: #2e7d32 !important;
color: #fff !important;
animation: emp-btn-pulse 1.2s ease-out 3;
}
.t-Alert--danger, .a-Notification {
animation: emp-bubble-in .35s ease-out;
}
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}is-error, so the selectors above worked.Step 6: Add Server Side Validation
Browser checks can be skipped by a smart user, so I repeated the rules on the server. Go to the Processing tab, create a Validation on the grid region, choose type PL/SQL Function Body (returning Error Text) and paste this.
declare
l_cnt number;
begin
if :APEX$ROW_STATUS = 'D' then
return null;
end if;
if :SAL is null or :SAL <= 0 or :SAL > 5000 then
return 'Salary must be greater than 0 and not exceed 5000.';
end if;
if not regexp_like(:ENAME, '^[A-Za-z]{3,10}$') then
return 'Name must be 3 to 10 letters (A-Z only).';
end if;
if :HIREDATE is null or to_date(:HIREDATE, 'DD-MON-YYYY') > trunc(sysdate) then
return 'Hire date cannot be in the future.';
end if;
if :MGR is not null and :MGR = :EMPNO then
return 'Employee cannot be their own manager.';
end if;
if :COMM is not null then
if :JOB <> 'SALESMAN' then
return 'Commission is allowed only for SALESMAN.';
elsif :COMM > :SAL then
return 'Commission cannot exceed salary.';
end if;
end if;
if :DEPTNO not in (10, 20, 30, 40) then
return 'Department must be 10, 20, 30 or 40.';
end if;
if :APEX$ROW_STATUS = 'C' then
select count(*) into l_cnt from your_table where your_key_column = :YOUR_KEY_COLUMN;
if l_cnt > 0 then
return 'Employee number ' || :EMPNO || ' already exists.';
end if;
end if;
return null;
end;Testing the Result
- Change ENAME to AB. The cell turns red and the bubble asks for 3 to 10 letters.
- Enter SAL as -5 or 9000. The salary cell shows the error.
- Give a commission to a CLERK. The COMM cell is flagged. Change the job to SALESMAN and the error goes away at once.
- Click Save with errors. The save stops, the button turns red and shows the error count.
- Fix every cell. The button turns green and Save works.
In my test grid I had 8 wrong cells in the beginning, and after I fixed the date logic it showed 9. It was a nice moment to see the number go down one by one.
Final Words
The main idea is to use the grid model and call setValidity on every change. Check the whole row each time, so related columns like JOB and COMM stay in sync. Keep the server validation too, because it protects your data in every case.
I hope this helps you. If you face any issue, try the demo or check the code from the buttons below.
Thanks for reading. Happy coding with Oracle APEX!
Comments
Post a Comment