Improving User Experience in Oracle APEX: Adding a Smart Click-to-Copy Feature in Interactive Grid

 

🧾 Introduction 

In modern enterprise applications, improving usability plays a key role in increasing productivity. Oracle APEX Interactive Grids are widely used for displaying and managing structured data, but small enhancements can make a big difference in user interaction.

One such enhancement is enabling users to quickly copy column values with a single click. Instead of manually selecting text, this feature allows seamless copying directly from the grid, saving time and improving efficiency.

In this article, we will explore how to implement a smart click-to-copy functionality within an Interactive Grid in Oracle APEX using simple JavaScript and minimal configuration.


🎯 Objective

The goal of this enhancement is to:

  • Simplify data copying from Interactive Grid cells

  • Reduce manual effort for end users

  • Improve overall user experience

  • Provide instant visual feedback after copying

⚙️ Implementation Overview

To achieve this functionality, we will:

  • Identify the Interactive Grid column

  • Add a custom CSS class

  • Use JavaScript to detect click events

  • Copy the selected value to clipboard

  • Display a confirmation tooltip/message

🧱 Step-by-Step Approach


Step 1: Environment Setup (SQL & Components)

For the JavaScript script to identify click targets, we must prepare the data source and the Interactive Grid columns accordingly.

A. SQL Query Adjustment

First, add two "placeholder" columns to your SQL query. These will serve as anchors for the copy buttons:




B. APEX Column Configuration

After adding the columns to the SQL, configure them in the Page Designer using the following specifications:

Column: COPY_COL (Cell Copy):

Type: Link

Target -> Type: URL

Target -> URL: javascript:void(0);

Link Text: <span class="fa fa-copy ig-copy-ico" aria-hidden="true"></span>

Link Attributes: class="js-ig-copy-cell" data-copy-col="COLUMN_NAME"

Note:  Replace COLUMN_NAME with the actual database column name/alias (e.g., ID, PROJECT) that you want this button to capture.

Column COPY_ROW (Row Copy):

Type: Link

Target -> Type: URL

Target -> URL: javascript:void(0);

Link Text: <span class="fa fa-copy ig-copy-ico" aria-hidden="true"></span>

Link Attributes: class="js-ig-copy-row"




Step 2: Assign Static ID to Interactive Grid

Assign a Static ID to your Interactive Grid region.

emp_ig


Step 3: Add JavaScript Code

Go to:

Page → Execute When Page Loads




JS Code:

(function ($) {
  "use strict";

  const IG_STATIC_ID = "emp_ig";
  const RESET_MS = 2000;
  const SWAP_MS  = 120;

  /* ===========================
   * UI (Icon Animation)
   * =========================== */
  function animateIcon($btn) {
    const $ico = $btn.find(".ig-copy-ico").first();
    if (!$ico.length) return;

    const prev = $btn.data("igCopyIconTimer");
    if (prev) clearTimeout(prev);

    $ico.addClass("is-swap");

    setTimeout(() => {
      $ico.removeClass("fa-copy")
          .addClass("fa-check is-success")
          .removeClass("is-swap");
    }, SWAP_MS);

    const timer = setTimeout(() => {
      $ico.addClass("is-swap");
      setTimeout(() => {
        $ico.removeClass("fa-check is-success")
            .addClass("fa-copy")
            .removeClass("is-swap");
      }, SWAP_MS);
    }, RESET_MS);

    $btn.data("igCopyIconTimer", timer);
  }

  function showCopyError(msg) {
    apex.message.clearErrors();
    apex.message.showErrors([{
      type: "error",
      location: "page",
      message: msg,
      unsafe: false
    }]);
  }

  /* ===========================
   * Clipboard (Focus + Fallback)
   * =========================== */
  function legacyCopySync(text) {
    const ta = document.createElement("textarea");
    ta.value = text;
    ta.setAttribute("readonly", "");
    ta.style.position = "fixed";
    ta.style.left = "-9999px";
    ta.style.top = "0";
    ta.style.opacity = "0";
    document.body.appendChild(ta);
    ta.focus();
    ta.select();

    let ok = false;
    try { ok = document.execCommand("copy"); }
    catch (e) { ok = false; }
    finally { document.body.removeChild(ta); }

    return ok;
  }

  function copyFromClick(text) {
    const t = (text ?? "").toString();

    try { window.focus(); } catch (e) {}
    try { document.body?.focus?.(); } catch (e) {}

    if (navigator.clipboard && window.isSecureContext) {
      return navigator.clipboard.writeText(t)
        .then(() => true)
        .catch(() => legacyCopySync(t));
    }
    return Promise.resolve(legacyCopySync(t));
  }

  /* ===========================
   * Value Normalization (LOV + NULL)
   * - If null/undefined/"" => returns "EMPTY"
   * - If object (LOV) => tries display (d/display/label/text) then return (v)
   * =========================== */
  function getDisplayOrValue(v) {
    if (v === null || v === undefined || v === "") return "EMPTY";

    if (Array.isArray(v)) {
      const parts = v.map(getDisplayOrValue);
      return parts.join(", ");
    }

    if (typeof v === "object") {
      // Common formats for LOV/complex values
      if (v.d !== undefined) return getDisplayOrValue(v.d);
      if (v.display !== undefined) return getDisplayOrValue(v.display);
      if (v.label !== undefined) return getDisplayOrValue(v.label);
      if (v.text !== undefined) return getDisplayOrValue(v.text);

      // Fallback: return value
      if (v.v !== undefined) return getDisplayOrValue(v.v);

      // Final fallback: avoid [object Object]
      try { return JSON.stringify(v); } catch (e) { return "EMPTY"; }
    }

    return String(v);
  }

  /* ===========================
   * IG Helpers
   * =========================== */
  function getGrid() {
    const ig$ = apex.region(IG_STATIC_ID).widget();
    return ig$.interactiveGrid("getViews", "grid");
  }

  function getRecordFromButton(grid, $btn) {
    const model = grid.model;

    // Preferred: TR[data-id]
    const rid = $btn.closest("tr[data-id]").attr("data-id");
    if (rid) {
      const rec = model.getRecord(rid);
      if (rec) return rec;
    }

    // Fallback: Context Record
    const ctx = grid.getContextRecord($btn);
    if (typeof ctx === "string") {
      const rec2 = model.getRecord(ctx);
      if (rec2) return rec2;
    }
    if (ctx) return ctx;

    return null;
  }

  function getValueByField(model, rec, fieldName) {
    const fields = model.getOption("fields") || {};
    const keys = Object.keys(fields);

    const fn = keys.find(k => k.toUpperCase() === String(fieldName).toUpperCase());
    if (!fn) return { ok: false, reason: "field_not_found" };

    // 1) Preferred: getValue(fieldKey)
    if (typeof model.getFieldKey === "function" && typeof model.getValue === "function") {
      try {
        const fk = model.getFieldKey(fn);
        const v = model.getValue(rec, fk);
        if (v !== undefined) return { ok: true, value: v, field: fn };
      } catch (e) {}
    }

    // 2) Fallback: Array + Index
    const idx = fields[fn] && typeof fields[fn].index === "number" ? fields[fn].index : null;
    if (idx !== null && Array.isArray(rec)) {
      return { ok: true, value: rec[idx], field: fn };
    }

    // 3) Final Fallback: Direct name (if supported)
    if (typeof model.getValue === "function") {
      try {
        return { ok: true, value: model.getValue(rec, fn), field: fn };
      } catch (e) {}
    }

    return { ok: false, reason: "no_strategy_worked", field: fn };
  }

  /* ===========================
   * Visual Order (Reorder-safe)
   * - Maps Header C... -> fieldName via fieldsMap[field].id
   * =========================== */
  function getVisibleFieldsInHeaderOrder(grid, $btn) {
    const model = grid.model;
    const fieldsMap = model.getOption("fields") || {};

    // colId (C...) -> fieldName
    const colIdToField = {};
    Object.keys(fieldsMap).forEach(fn => {
      const meta = fieldsMap[fn];
      if (meta && meta.id) {
        colIdToField[String(meta.id)] = fn;
      }
    });

    const $ig = $btn.closest(".a-IG");

    // Header TH elements
    const $ths = $ig.find("th.a-GV-header").filter(function () {
      const $th = $(this);
      if ($th.is(":hidden")) return false;

      // Must have a header label ID ending in _HDR
      const $lbl = $th.find(".a-GV-headerLabel[id$='_HDR']");
      return $lbl.length > 0;
    });

    const fieldsInOrder = [];

    $ths.each(function () {
      const $th = $(this);
      const labelId = $th.find(".a-GV-headerLabel[id$='_HDR']").attr("id"); // C..._HDR
      if (!labelId) return;

      const colId = labelId.replace(/_HDR$/, ""); // C...
      const fieldName = colIdToField[colId];
      if (!fieldName) return;

      // Filter technical columns or buttons
      if (["APEX$ROW_ACTION", "ROWID", "COPY_COL", "COPY_ROW", "_meta"].includes(fieldName)) return;

      fieldsInOrder.push(fieldName);
    });

    // Fallback if DOM scanning fails
    if (!fieldsInOrder.length) {
      return Object.keys(fieldsMap).filter(f =>
        !["APEX$ROW_ACTION", "ROWID", "COPY_COL", "COPY_ROW", "_meta"].includes(f)
      );
    }

    return fieldsInOrder;
  }

  function escapeForSemicolonCSV(v) {
    // Normalizes LOV and NULL to "EMPTY"
    let s = getDisplayOrValue(v);

    // Excel/CSV with ; (escapes when necessary)
    if (/[;"\r\n]/.test(s)) s = `"${s.replace(/"/g, '""')}"`;
    return s;
  }

  /* ===========================
   * Actions
   * =========================== */
  function copyCell($btn) {
    const field = String($btn.data("copy-col") || "").trim();
    const grid = getGrid();
    const model = grid.model;
    const rec = getRecordFromButton(grid, $btn);

    if (!rec || !field) {
      showCopyError("It was not possible to identify the row/field.");
      return;
    }

    const r = getValueByField(model, rec, field);
    if (!r.ok) {
      showCopyError(`Could not read field "${field}" from model. Reason: ${r.reason || "unknown"}`);
      return;
    }

    // Converts LOV object to display text and null to "EMPTY"
    copyFromClick(getDisplayOrValue(r.value)).then(ok => {
      if (ok) animateIcon($btn);
      else showCopyError("Failed to copy to clipboard.");
    });
  }

  function copyRowAsSemicolon($btn) {
    const grid = getGrid();
    const model = grid.model;
    const rec = getRecordFromButton(grid, $btn);

    if (!rec) {
      showCopyError("It was not possible to identify the row (record).");
      return;
    }

    // User's VISUAL ORDER (reorder-safe)
    const fieldNames = getVisibleFieldsInHeaderOrder(grid, $btn);

    const values = fieldNames.map(f => {
      const rr = getValueByField(model, rec, f);
      // rr.value can be an object (LOV) or null -> normalization
      return escapeForSemicolonCSV(rr.ok ? rr.value : null);
    });

    copyFromClick(values.join(";")).then(ok => {
      if (ok) animateIcon($btn);
      else showCopyError("Failed to copy to clipboard.");
    });
  }

  /* ===========================
   * Handlers (NO inline onclick)
   * =========================== */
  const SEL_CELL = `#${IG_STATIC_ID} .js-ig-copy-cell`;
  const SEL_ROW  = `#${IG_STATIC_ID} .js-ig-copy-row`;

  // Prevents duplicate handlers on refresh/reload
  $(document).off("click.igCopyCell", SEL_CELL);
  $(document).off("click.igCopyRow",  SEL_ROW);

  $(document).on("click.igCopyCell", SEL_CELL, function (e) {
    e.preventDefault();
    e.stopPropagation();
    copyCell($(this));
  });

  $(document).on("click.igCopyRow", SEL_ROW, function (e) {
    e.preventDefault();
    e.stopPropagation();
    copyRowAsSemicolon($(this));
  });

})(apex.jQuery);


Step 4: Add CSS Class to Target Column




CSS Code :

/* smooth transition */
#emp_ig .ig-copy-ico{
  display: inline-block;
  transition: transform .14s ease, opacity .14s ease, color .14s ease;
  transform: scale(1);
  opacity: 1;
}

/* vanish effect */
#emp_ig .ig-copy-ico.is-swap {
  transform: scale(0.70);
  opacity: 0;
}

/* success */
#emp_ig .ig-copy-ico.is-success {
  color: #22c55e;
  transform: scale(1.10);
  opacity: 1;
}

Step 5: Clipboard Copy Logic

Now your IG is ready in frontend for columns and rows copy purpose.

Use JavaScript to copy values dynamically when user clicks the cell.

This ensures:

✔ No page refresh
✔ Instant copy
✔ Smooth UX





🌟 Benefits of This Approach

  • Enhances usability of Interactive Grid

  • Saves user time

  • Reduces manual errors

  • Improves productivity

  • Adds modern UX behavior to APEX apps


🏁 Conclusion

By implementing a simple click-to-copy feature, we can significantly improve how users interact with data in Oracle APEX Interactive Grids. Small UX enhancements like this bring enterprise applications closer to modern usability standards.

This approach is lightweight, easy to implement, and can be reused across multiple applications.



Comments

Popular posts from this blog

Building Secure RESTful Services in Oracle APEX 24.2 Using ORDS, OAuth2 Client Credentials, and PL/SQL

Building a Portfolio Generator in Oracle APEX: A Step-by-Step Guide to Generate Downloadable Portfolio Documents

Implementing WhatsApp OTP Verification in Oracle APEX Using UltraMsg API