Skip to main content

IG Validation with Error Bubbles


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.

Oracle APEXInteractive GridJavaScriptValidation

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 TypeExample
Required valueA dropdown must be selected
Text pattern and lengthOnly letters, between 3 and 10 characters
Number rangeGreater than 0 and not more than a limit
Date checkDate is required and not in the future
Compare two columnsOne value cannot be bigger than another
Depends on another columnA value is allowed only when another column has a certain value
Allowed listValue must be one of a fixed set

Step 1: Create the Page

  1. Open your application and click Create Page, then choose Interactive Grid.
  2. Give page number 22 and a name like Validation IG.
  3. Set the source as your table, turn on Editing Enabled and select your primary key column.
  4. Select all columns and create the page.
  5. Open the page in Page Designer, click the region and set the Static ID as emp_ig.
Keep the Static ID in lower case. All my JavaScript uses 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

I tried to use only the built in column validations and a dynamic action on Change. It worked for one column, but it could not check two columns together, like COMM and SAL. The messages also only came after saving.

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.

The column names in the code (like SAL or DEPTNO) are from my demo. Replace them with your own column names and rules, and the rest works the same way. The same goes for the Static ID emp_ig: you can change it, just use the same value in every code block.
Page JavaScript
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);
                }
            }
        });
    });
});
One issue I faced: my future hire date, 13-JAN-2027, was not flagged. The grid gave me the date as text like 13-JAN-2027, but my first code expected YYYYMMDD. The 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.

JavaScript Initialization Code
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.

Inline CSS
@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; }
}
Another issue I faced: the CSS did nothing in the beginning. The class names are different in some APEX versions. I right clicked on a red cell, chose Inspect, and checked the class on the td. In my app it was 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.

PL/SQL Validation
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

  1. Change ENAME to AB. The cell turns red and the bubble asks for 3 to 10 letters.
  2. Enter SAL as -5 or 9000. The salary cell shows the error.
  3. Give a commission to a CLERK. The COMM cell is flagged. Change the job to SALESMAN and the error goes away at once.
  4. Click Save with errors. The save stops, the button turns red and shows the error count.
  5. 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

Popular posts from this blog

Face Detection in Oracle Apex

Face Detection Blog - Embedded JS Jefith Shalin Oracle APEX Developer · May 2025 Live Demo GitHub Oracle APEX · AI Integration A Developer's Real Story Building a Face Detection Attendance System in Oracle APEX A real story of trial, error, and finally making face-api.js actually work inside an APEX app. No fluff — just what happened and what finally worked. Try Live Demo View on GitHub 0.22.2 face-api.js Version 2 APEX Pages Built 4 AJAX Processes face-api.js Oracle APEX JavaScript AI / ML Face Recognition CDN Attendance System PL/SQL The Beginning So here is how it all started I was tasked with building an attendance system for our off...

Screen Recorder in Oracle APEX (Single Page)

Oracle APEX Tutorial Screen Recorder in Oracle APEX : Single Page Build a fully functional browser-based screen recorder inside Oracle APEX using just one Static Content region and native JavaScript. No plugins, no external libraries, no server uploads required. ✍️ Why I Built This I was working on a client project where the support team needed to record screen issues and share them directly from the APEX application, without switching to any external tool. Installing third-party software was not an option on their machines, and every screen recorder extension required IT approval. That is when I thought: the browser already has everything we need. Why not build it right inside APEX? That idea turned into this. &#127916; Start / Stop Recording &#128065; Instant Preview ⬇️ One-click Download &#128266; Audio + Video ...

Sticky Notes Widget Inside Oracle APEX

Oracle APEX Project Building a Sticky Notes Widget in Oracle APEX How I built a fully draggable, color-coded, per-user sticky notes board using jQuery UI, APEX Ajax callbacks, and a bit of patience. Live Demo GitHub Repo Oracle APEX jQuery UI PL/SQL Ajax Callbacks JavaScript CSS Introduction Why I Built This I have been building internal tools on Oracle APEX for a while now, and one thing I always felt was missing was a place where users could quickly jot down thoughts without leaving the page. Think of it like a personal scratchpad that lives right inside the app. I had seen sticky note UIs in some Google products and I thought, how hard can this be in APEX? It turned out to be more interesting than I expected. There were a few wrong tu...