How to Expand a Report Row to Show Its Details in Oracle APEX

Add an expand button to each report row, load the child rows on demand with an Ajax Callback, escape them safely, and test sorting, empty and invalid requests.

A report often lists parent records whose details live in a child table. Opening a separate page for each record breaks the flow. An expandable row keeps the user on the report: a click on a small arrow opens the child rows right below the record, and a second click closes them again.

Oracle APEX has no single switch for this in a Classic Report, but a few declarative parts do the job. This guide adds an expand button column, an Ajax Callback process that returns the detail rows as JSON, and one dynamic action that loads them on demand, escapes them and toggles the row.

How to Build an Orders and Order Lines Master-Detail Page in Oracle APEX shows the details in a second region that changes with the selected master row. Here the details open inline, inside the report itself, and only for the rows the user clicks.

The example uses orders and their order lines. The same steps work for any parent and child table.

Step 1: Create the Tables

Two tables hold the data:

  • DEPOT_ORDERS has one row per order, with a unique order number, the customer name, the order date and a status.
  • DEPOT_ORDER_LINES has one row per order line: the line number, item, quantity and unit price. LINE_AMOUNT is a virtual column.

Create the tables with five orders and eight lines. One item name contains HTML tags on purpose, to test the escaping later:

Example:

create table depot_orders (
  order_id        number generated by default on null as identity,
  order_no        varchar2(20)   not null,
  customer_name   varchar2(100)  not null,
  order_date      date           default on null sysdate not null,
  status          varchar2(15)   default on null 'NEW' not null,
  constraint depot_orders_pk primary key (order_id),
  constraint depot_orders_no_uk unique (order_no),
  constraint depot_orders_status_ck check (status in ('NEW', 'SHIPPED', 'CANCELLED'))
);

create table depot_order_lines (
  line_id         number generated by default on null as identity,
  order_id        number         not null,
  line_no         number(3)      not null,
  item_name       varchar2(100)  not null,
  quantity        number(6)      not null,
  unit_price      number(10,2)   not null,
  line_amount     number(12,2)   generated always as (quantity * unit_price) virtual,
  constraint depot_order_lines_pk primary key (line_id),
  constraint depot_order_lines_order_fk foreign key (order_id) references depot_orders (order_id) on delete cascade,
  constraint depot_order_lines_uk unique (order_id, line_no),
  constraint depot_order_lines_qty_ck check (quantity > 0),
  constraint depot_order_lines_price_ck check (unit_price >= 0)
);

create index depot_order_lines_order_ix on depot_order_lines (order_id);

insert into depot_orders (order_no, customer_name, order_date, status)
select order_no, customer_name, order_date, status
from (values
  ('DO-3001', 'Northwind Traders',   date '2026-10-02', 'SHIPPED'),
  ('DO-3002', 'Blue Lake Stores',    date '2026-10-05', 'NEW'),
  ('DO-3003', 'Cedar Office Supply', date '2026-10-08', 'NEW'),
  ('DO-3004', 'Harbor Retail',       date '2026-10-12', 'CANCELLED'),
  ('DO-3005', 'Summit Books',        date '2026-10-15', 'NEW')
) v (order_no, customer_name, order_date, status);

insert into depot_order_lines (order_id, line_no, item_name, quantity, unit_price)
select o.order_id, v.line_no, v.item_name, v.quantity, v.unit_price
from (values
  ('DO-3001', 1, 'A4 Copy Paper (box)',    10, 24.50),
  ('DO-3001', 2, 'Stapler',                 4,  8.75),
  ('DO-3001', 3, 'Whiteboard Markers',     12,  2.40),
  ('DO-3002', 1, 'Desk Lamp',               2, 31.00),
  ('DO-3002', 2, '<b>Bold</b> Gel Pens',   20,  1.25),
  ('DO-3003', 1, 'Filing Cabinet',          1, 189.00),
  ('DO-3004', 1, 'Paper Shredder',          1, 145.00),
  ('DO-3004', 2, 'Shredder Oil',            2,  6.50)
) v (order_no, line_no, item_name, quantity, unit_price)
join depot_orders o on o.order_no = v.order_no;

commit;

select o.order_no, o.customer_name, count(l.line_id) line_count, nvl(sum(l.line_amount), 0) order_total
from depot_orders o
left join depot_order_lines l on l.order_id = o.order_id
group by o.order_no, o.customer_name
order by o.order_no;

Output:

Table DEPOT_ORDERS created.

Table DEPOT_ORDER_LINES created.

Index DEPOT_ORDER_LINES_ORDER_IX created.

5 rows inserted.

8 rows inserted.

Commit complete.

ORDER_NO    CUSTOMER_NAME             LINE_COUNT    ORDER_TOTAL
___________ ______________________ _____________ ______________
DO-3001     Northwind Traders                  3          308.8
DO-3002     Blue Lake Stores                   2             87
DO-3003     Cedar Office Supply                1            189
DO-3004     Harbor Retail                      2            158
DO-3005     Summit Books                       0              0
  • DEPOT_ORDER_LINES_ORDER_FK links each line to its order and deletes the lines when the order is deleted. DEPOT_ORDER_LINES_ORDER_IX indexes that foreign key, which the detail lookup uses.
  • DEPOT_ORDER_LINES_UK allows each line number only once per order.
  • LINE_AMOUNT is calculated by the database, so no trigger or process has to keep it up to date.
  • ORDER_DATE and STATUS use DEFAULT ON NULL, so a new order gets today's date and NEW without a trigger.

Order DO-3005 has no lines. It shows how the page handles an empty detail list.

Step 2: Create the Classic Report Page

The report lists the orders. Its query has two extra columns: ORDER_ID, which the button needs, and DETAILS, an empty column that will hold the button:

Example:

select o.order_id,
       null as details,
       o.order_no,
       o.customer_name,
       o.order_date,
       o.status,
       (select count(*) from depot_order_lines l where l.order_id = o.order_id) as line_count
from depot_orders o

In Page Designer, click Create (the plus icon) and choose Page. Select the Classic Report tile:

Create a Page wizard with the Classic Report tile marked with a red box
Choose the Classic Report page type.

On the Create Classic Report screen, enter Page Number 820 and Name Order Details. Leave Page Mode Normal and Include Form Page off:

Create Classic Report screen with Page Number 820 and Name Order Details marked with a red box
Page number and name.

Under Data Source, keep Local Database, set Source Type to SQL Query and paste the query above into the editor:

Data Source section with Source Type SQL Query and the report query marked with a red box
The report source is a SQL query.

Under Navigation, keep the defaults: a breadcrumb and a new navigation menu entry with the icon fa-table. Click Create Page:

Navigation section of the Create Classic Report wizard with the default breadcrumb and navigation menu settings
Keep the navigation defaults and create the page.

Step 3: Set Up the Report Columns

Page Designer opens page 820. In the Rendering tree, expand the Order Details region and its Columns. Select ORDER_ID and set Type to Hidden:

ORDER_ID column with Type set to Hidden marked with a red box
The ID stays in the report but is not shown.

A hidden column is not displayed, but other columns can still use its value as #ORDER_ID#.

In this test, the wizard gave DETAILS, ORDER_NO, CUSTOMER_NAME and STATUS a Default Sequence of 1 under Sorting. Keep it only on the column the report should start with. Select ORDER_NO and check Default Sequence 1 and Direction Ascending:

ORDER_NO column with Sorting Default Sequence 1 and Direction Ascending marked with a red box
The report starts sorted by order number.

Select CUSTOMER_NAME and set Default Sequence to - Select -. Do the same for STATUS. Both columns stay sortable by their headers:

CUSTOMER_NAME column with Sorting Default Sequence cleared marked with a red box
Clear the extra default sort columns.

Select ORDER_DATE and set Format Mask to DD-MON-YYYY under Appearance:

ORDER_DATE column with Format Mask DD-MON-YYYY marked with a red box
Show the date without the time.

Step 4: Turn the Details Column into an Expand Button

Select the DETAILS column. Under Column Formatting, enter this HTML Expression:

Example:

<button type="button" class="t-Button t-Button--noLabel t-Button--icon t-Button--small js-expand" data-id="#ORDER_ID#" aria-expanded="false" title="Show lines" aria-label="Show lines"><span class="fa fa-chevron-right" aria-hidden="true"></span></button>

Under Sorting, set Default Sequence to - Select - and turn Sortable off, because sorting by a button makes no sense:

DETAILS column with the button HTML Expression and Sortable off marked with red boxes
Each row gets a small arrow button.
  • The class js-expand is what the dynamic action listens to.
  • data-id carries the order ID of the row, taken from the hidden ORDER_ID column.
  • aria-expanded tells screen readers whether the details are open. The script below keeps it up to date.
  • type="button" stops the button from submitting the page.

Step 5: Give the Region an HTML DOM ID

The dynamic action and the JavaScript need a fixed ID for the report. Select the Order Details region. Under Advanced, set HTML DOM ID to order-details:

Region Advanced properties with HTML DOM ID order-details marked with a red box
The report gets a fixed DOM ID.

Step 6: Add the Ajax Callback That Returns the Lines

The lines are not loaded with the page. They come from the server only when a user opens a row. Open the Processing tab, right-click Ajax Callback and choose Create Process.

Set Name to GET_ORDER_LINES and Type to Execute Code. Keep Language PL/SQL and enter this PL/SQL Code:

Example:

declare
    l_order_id depot_orders.order_id%type;
begin
    l_order_id := to_number(apex_application.g_x01);

    apex_json.open_object;
    apex_json.open_array('lines');
    for r in (select line_no, item_name, quantity, unit_price, line_amount
              from   depot_order_lines
              where  order_id = l_order_id
              order  by line_no)
    loop
        apex_json.open_object;
        apex_json.write('lineNo', r.line_no);
        apex_json.write('item',   r.item_name);
        apex_json.write('qty',    r.quantity);
        apex_json.write('price',  to_char(r.unit_price,  'FM999G990D00'));
        apex_json.write('amount', to_char(r.line_amount, 'FM999G990D00'));
        apex_json.close_object;
    end loop;
    apex_json.close_array;
    apex_json.close_object;
exception
    when value_error then
        apex_json.open_object;
        apex_json.write('error', 'Invalid order ID.');
        apex_json.close_object;
end;

Under Execution, check that Point is Ajax Callback:

Process GET_ORDER_LINES with Name, Type, Language, PL/SQL Code and Point Ajax Callback marked with red boxes
The process runs only when JavaScript calls it.
  • apex_application.g_x01 holds the order ID that the JavaScript sends.
  • to_number raises VALUE_ERROR for anything that is not a number. The handler then returns a JSON object with an error key.
  • apex_json writes the lines as a JSON array. The prices are formatted on the server, so the browser only displays them.
  • An order without lines returns an empty array.

Step 7: Add the Dynamic Action That Opens and Closes the Row

Open the Dynamic Actions tab, right-click Events and choose Create Dynamic Action. Set these properties:

  • Name: Toggle Order Lines
  • Event Scope: Dynamic, with Static Container (jQuery Selector) #order-details
  • Event: Click, Selection Type: jQuery Selector, jQuery Selector: .js-expand
Dynamic action Toggle Order Lines with Name, Event Scope Dynamic, Static Container, Event Click and jQuery Selector .js-expand marked with red boxes
One dynamic action handles the buttons of all rows.

Event Scope Dynamic matters. Sorting or paging a Classic Report replaces its rows, and with them the buttons. A dynamic event keeps working for the new buttons inside the static container.

In a test with Event Scope Static, the buttons stopped working after the region was refreshed. With Dynamic, they kept working.

Select the True action, set Action to Execute JavaScript Code and enter this Code:

Example:

var btn  = this.triggeringElement,
    row  = btn.closest("tr"),
    next = row.nextElementSibling,
    icon = btn.querySelector(".fa");

if (next && next.classList.contains("detail-row")) {
    next.remove();
    btn.setAttribute("aria-expanded", "false");
    icon.classList.replace("fa-chevron-down", "fa-chevron-right");
} else {
    apex.server.process("GET_ORDER_LINES", { x01: btn.dataset.id }, { dataType: "json" })
        .then(function (data) {
            var esc = apex.util.escapeHTML,
                tr  = document.createElement("tr"),
                html;

            if (data.lines.length === 0) {
                html = "<p>This order has no lines.</p>";
            } else {
                html = '<table class="detail-lines"><thead><tr><th>Line</th><th>Item</th>' +
                       '<th class="num">Qty</th><th class="num">Unit Price</th><th class="num">Amount</th></tr></thead><tbody>' +
                       data.lines.map(function (l) {
                           return "<tr><td>" + esc(String(l.lineNo)) + "</td><td>" + esc(l.item) + "</td>" +
                                  '<td class="num">' + esc(String(l.qty)) + '</td><td class="num">' + esc(l.price) + "</td>" +
                                  '<td class="num">' + esc(l.amount) + "</td></tr>";
                       }).join("") +
                       "</tbody></table>";
            }
            tr.className = "detail-row";
            tr.innerHTML = '<td colspan="' + row.cells.length + '">' + html + "</td>";
            row.after(tr);
            btn.setAttribute("aria-expanded", "true");
            icon.classList.replace("fa-chevron-right", "fa-chevron-down");
        });
}
True action with Action Execute JavaScript Code and the Code editor marked with red boxes
The JavaScript loads and toggles the detail row.
  • this.triggeringElement is the clicked button. Its row is the closest tr element.
  • If the next row is a detail row, the click closes it. Otherwise the script calls GET_ORDER_LINES with the order ID in x01.
  • apex.util.escapeHTML escapes every value before it goes into the HTML, so data cannot inject markup or scripts.
  • The new row has one cell that spans all columns of the report.
  • The arrow icon and aria-expanded change with each click.

Under Execution, make sure Fire on Initialization is off:

Execution properties of the action with Fire on Initialization off marked with a red box
The action must run only on a click.

In a first version of this page the switch was on. The action then ran once for every button when the page loaded, and all five orders opened their lines at once.

Step 8: Style the Detail Rows

Select the page (Page 820: Order Details) at the top of the tree. Under CSS, enter this Inline CSS:

Example:

.detail-row > td { background-color: #f6f8fa; padding: 8px 16px 12px 56px; }
.detail-lines { border-collapse: collapse; }
.detail-lines th, .detail-lines td { padding: 4px 16px 4px 0; text-align: left; }
.detail-lines .num { text-align: right; }
Page CSS Inline property with the detail row styles marked with a red box
A light background separates the details from the orders.

Click Save.

Step 9: Test the Expandable Rows

Before the test, list the lines the first two orders should show:

Example:

select o.order_no, l.line_no, l.item_name, l.quantity, l.unit_price, l.line_amount
from depot_orders o
join depot_order_lines l on l.order_id = o.order_id
where o.order_no in ('DO-3001', 'DO-3002')
order by o.order_no, l.line_no;

Output:

ORDER_NO       LINE_NO ITEM_NAME                  QUANTITY    UNIT_PRICE    LINE_AMOUNT
___________ __________ _______________________ ___________ _____________ ______________
DO-3001              1 A4 Copy Paper (box)              10          24.5            245
DO-3001              2 Stapler                           4          8.75             35
DO-3001              3 Whiteboard Markers               12           2.4           28.8
DO-3002              1 Desk Lamp                         2            31             62
DO-3002              2 <b>Bold</b> Gel Pens             20          1.25             25

Run the page. This test used SALES_USER, a user with the Reader role. The report lists five orders, each with an arrow button:

Order Details report with an arrow button in the Details column of each row marked with a red box
All rows start closed.

Click the arrow of DO-3001. A detail row opens below it with the three lines from the query above, and the arrow points down:

Order DO-3001 expanded with its three order lines marked with a red box
The lines load only when the row is opened.

Click the arrow of DO-3002 as well. Both orders stay open. The second line shows the item name with its HTML tags as plain text:

Order DO-3002 expanded with the item name <b>Bold</b> Gel Pens shown as text marked with a red box
The tags are displayed, not executed.

The page had no b element inside the detail rows. apex.util.escapeHTML turned the tags into text, as it should.

Click the arrow of DO-3005. It has no lines, so the detail row shows a short message:

Order DO-3005 expanded with the message This order has no lines marked with a red box
An empty order gets a message instead of an empty table.

Click the arrow of DO-3001 again. Its detail row closes and the arrow points right again. The other open rows stay open:

Order DO-3001 collapsed again while DO-3002 and DO-3005 stay expanded, DO-3001 row marked with a red box
A second click closes the row.

Now click the Customer Name heading to sort the report. The report reloads its rows, so all open details close. Click the arrow of DO-3004 in the new order. Its lines open as before:

Report sorted by Customer Name with order DO-3004 expanded, the heading and the detail row marked with red boxes
The buttons still work after sorting.

APEX remembered the sort for the user. In this test it was saved as a user preference named FSP400_P820_R..._SORT, and the report opened sorted by customer name on the next visit.

Step 10: Test Invalid Requests

Any user who can open the page can call the Ajax Callback with any value, not only through the buttons. Open the browser console on page 820 and run three calls: a valid order ID, an order ID that does not exist, and a value that is not a number:

Example:

apex.server.process("GET_ORDER_LINES", { x01: "2" }, { dataType: "json" })
    .then(function (data) { console.log(JSON.stringify(data)); });

apex.server.process("GET_ORDER_LINES", { x01: "999999" }, { dataType: "json" })
    .then(function (data) { console.log(JSON.stringify(data)); });

apex.server.process("GET_ORDER_LINES", { x01: "abc" }, { dataType: "json" })
    .fail(function (jqXHR, textStatus, errorThrown) { console.log("failed: " + textStatus + " - " + errorThrown); });

Output:

failed: APEX - Invalid order ID.
{"lines":[]}
{"lines":[{"lineNo":1,"item":"Desk Lamp","qty":2,"price":"31.00","amount":"62.00"},{"lineNo":2,"item":"<b>Bold</b> Gel Pens","qty":20,"price":"1.25","amount":"25.00"}]}

The three calls run at the same time, so the answers can arrive in any order:

  • Order ID 2 (DO-3002) returned its two lines. The tags arrived as data, which is why the escaping in the browser matters.
  • The unknown ID returned an empty list.
  • The value abc did not cause a server error. The exception handler returned the error key.

apex.server.process treats a JSON reply with an error key as a failed call. The success function did not run, and the fail function received the message.

To see what a user gets, change the data-id of the first button to abc in the browser and click it. APEX showed the message as a page error and added no detail row:

Page error notification Invalid order ID. after a forged request, marked with a red box
APEX displays the error from the callback.

The dynamic action needs no error handling code of its own for this case.

Things to Know

  • The detail rows are added in the browser. They are not part of the report, so sorting, paging or a region refresh removes them.
  • Use Event Scope Dynamic with the region as the static container. With Static, the buttons stopped working after a refresh.
  • Turn off Fire on Initialization on the JavaScript action, or every row opens when the page loads.
  • Escape every value from the server with apex.util.escapeHTML before you build HTML with it.
  • An Ajax Callback can be called with any x01 value. Check the input, and if the report shows only some records to a user, apply the same filter in the callback.
  • A JSON reply with an error key makes apex.server.process fail, and APEX shows the message as a page error.
  • Each opening of a row is one request to the server. Closing a row needs no request.

Related Guides

Conclusion

An expandable row shows the details of a record without leaving the report in Oracle APEX. A button column, an Ajax Callback that returns JSON, and one dynamic action with a short script are all it takes. The details load only for the rows a user opens.

In this example, the rows opened and closed correctly, survived sorting, escaped HTML in the data and handled empty and invalid requests. Keep the event scope dynamic, the initialization off and every value escaped, and the same pattern works for any parent and child table.

Get more Vinish.dev tutorials on Google

See my articles more often when you search Google.

Vinish Kapoor
Vinish Kapoor

An Oracle ACE, author of four books on Oracle APEX, SQL and PL/SQL, and Oracle Forms, and a software developer building Oracle database applications since 2001.

guest

0 Comments
Oldest
Newest Most Voted