Building and Using an APEX Item Property Matrix

I’ve recently been involved in some APEX development.

One of the application form had a bunch of input items whose visibility and mandatory aspects depend on the record type, and the business role of the current user.

This situation is typically handled with dynamic actions (DAs), and custom classes on items to minimize the number of DAs.

However, for this task, I chose to build an external item property matrix (in the form of an Excel file) to further minimize the amount of declarative settings in APEX.

Here’s a very simplified example of such a matrix :

There are three record types (Type 1, Type 2, Type 3), two user roles (Role A, Role B) and ten items.

For instance, P1_ITEM01 is visible when the record type is ‘Type 1’ or ‘Type 3’, and is required when the current user possesses either ‘Role A’ or ‘Role B’.

The idea is now to transform that into a readily-usable format so that a single DA will suffice to set all items properties depending on the record type and user role.

Since I’ll be using JavaScript, I naturally chose a JSON format :

{
  "T1": {
    "hidden": ["P1_ITEM04","P1_ITEM07","P1_ITEM08","P1_ITEM09","P1_ITEM10"],
	"required": {
	  "ROLE_A": ["P1_ITEM01","P1_ITEM02","P1_ITEM03","P1_ITEM05"],
	  "ROLE_B": ["P1_ITEM01","P1_ITEM02","P1_ITEM03","P1_ITEM06"]
	}
  },
  "T2": {
    "hidden": ["P1_ITEM01","P1_ITEM04","P1_ITEM05","P1_ITEM06","P1_ITEM07"],
	"required": {
	  "ROLE_A": ["P1_ITEM02","P1_ITEM08","P1_ITEM09","P1_ITEM10"],
	  "ROLE_B": ["P1_ITEM02","P1_ITEM03","P1_ITEM08"]
	}
  },
  "T3": {
    "hidden": [],
	"required": {
	  "ROLE_A": ["P1_ITEM01","P1_ITEM02","P1_ITEM03","P1_ITEM04","P1_ITEM05","P1_ITEM07","P1_ITEM08","P1_ITEM09","P1_ITEM10"],
	  "ROLE_B": ["P1_ITEM01","P1_ITEM02","P1_ITEM03","P1_ITEM04","P1_ITEM05","P1_ITEM06","P1_ITEM07","P1_ITEM08","P1_ITEM09","P1_ITEM10"]
	}
  }
}

1- Generating the JSON File

The transformation is carried out using a single SQL query over the Excel file :

with sheetdata as (
    select *
    from ExcelTable.getRows(
           p_file      => to_blob(bfilename('TEST_DIR', 'role-item-matrix.xlsx'))
         , p_sheet     => 'sheet1'
         , p_cols      => q'{
                              "ITEM"           varchar2(30) column 'A'
                            , "T1_VISIBLE"     varchar2(1)  column 'B'
                            , "T1_REQUIRED_A"  varchar2(1)  column 'C'
                            , "T1_REQUIRED_B"  varchar2(1)  column 'D'
                            , "T2_VISIBLE"     varchar2(1)  column 'E'
                            , "T2_REQUIRED_A"  varchar2(1)  column 'F'
                            , "T2_REQUIRED_B"  varchar2(1)  column 'G'
                            , "T3_VISIBLE"     varchar2(1)  column 'H'
                            , "T3_REQUIRED_A"  varchar2(1)  column 'I'
                            , "T3_REQUIRED_B"  varchar2(1)  column 'J'
                          }'
         , p_range     => 'A4'
        )
),
unpivoted as (
    select * 
    from sheetdata
    unpivot include nulls (
      (visible, required_a, required_b) 
      for record_type in (
        (t1_visible, t1_required_a, t1_required_b) as 'T1'
      , (t2_visible, t2_required_a, t2_required_b) as 'T2'
      , (t3_visible, t3_required_a, t3_required_b) as 'T3'
      )
    )
)
select json_serialize(
         json_objectagg(
           record_type value json_object(
                               'hidden'   value json_arrayagg(case when visible is null then item end order by item)
                             , 'required' value json_object(
                                                  'ROLE_A' value json_arrayagg(case when required_a = 'X' then item end order by item)
                                                , 'ROLE_B' value json_arrayagg(case when required_b = 'X' then item end order by item)
                                                )
                             )
           returning clob
         ) 
         returning clob pretty
       ) as json_content
from unpivoted
group by record_type
;

Let’s break down the query into smaller parts.

a- Reading the Excel file (“sheetdata” subquery)

I chose to use ExcelTable for this task :

select *
from ExcelTable.getRows(
       p_file      => to_blob(bfilename('TEST_DIR', 'role-item-matrix.xlsx'))
     , p_sheet     => 'sheet1'
     , p_cols      => q'{
                          "ITEM"           varchar2(30) column 'A'
                        , "T1_VISIBLE"     varchar2(1)  column 'B'
                        , "T1_REQUIRED_A"  varchar2(1)  column 'C'
                        , "T1_REQUIRED_B"  varchar2(1)  column 'D'
                        , "T2_VISIBLE"     varchar2(1)  column 'E'
                        , "T2_REQUIRED_A"  varchar2(1)  column 'F'
                        , "T2_REQUIRED_B"  varchar2(1)  column 'G'
                        , "T3_VISIBLE"     varchar2(1)  column 'H'
                        , "T3_REQUIRED_A"  varchar2(1)  column 'I'
                        , "T3_REQUIRED_B"  varchar2(1)  column 'J'
                      }'
     , p_range     => 'A4'
    )
;
 
ITEM                           T1_VISIBLE T1_REQUIRED_A T1_REQUIRED_B T2_VISIBLE T2_REQUIRED_A T2_REQUIRED_B T3_VISIBLE T3_REQUIRED_A T3_REQUIRED_B
------------------------------ ---------- ------------- ------------- ---------- ------------- ------------- ---------- ------------- -------------
P1_ITEM01                      X          X             X                                                    X          X             X
P1_ITEM02                      X          X             X             X          X             X             X          X             X
P1_ITEM03                      X          X             X             X                        X             X          X             X
P1_ITEM04                                                                                                    X          X             X
P1_ITEM05                      X          X                                                                  X          X             X
P1_ITEM06                      X                        X                                                    X                        X
P1_ITEM07                                                                                                    X          X             X
P1_ITEM08                                                             X          X             X             X          X             X
P1_ITEM09                                                             X          X                           X          X             X
P1_ITEM10                                                             X          X                           X          X             X
 

Built-in API APEX_DATA_PARSER.PARSE would be another option here :

select col001 as ITEM
     , col002 as T1_VISIBLE
     , col003 as T1_REQUIRED_A
     , col004 as T1_REQUIRED_B
     , col005 as T2_VISIBLE
     , col006 as T2_REQUIRED_A
     , col007 as T2_REQUIRED_B
     , col008 as T3_VISIBLE
     , col009 as T3_REQUIRED_A
     , col010 as T3_REQUIRED_B
from apex_data_parser.parse(
       p_content => to_blob(bfilename('TEST_DIR', 'role-item-matrix.xlsx'))
     , p_file_type => 1 -- c_file_type_xlsx
     , p_skip_rows => 3
     )
;

b- Unpivoting per record type (“unpivoted” subquery)

Here, each column triplet (VISIBLE, REQUIRED_A, REQUIRED_B) is unpivoted per record type :

select *
from sheetdata
unpivot include nulls (
  (visible, required_a, required_b)
  for record_type in (
    (t1_visible, t1_required_a, t1_required_b) as 'T1'
  , (t2_visible, t2_required_a, t2_required_b) as 'T2'
  , (t3_visible, t3_required_a, t3_required_b) as 'T3'
  )
)

 
ITEM                           RECORD_TYPE VISIBLE REQUIRED_A REQUIRED_B
------------------------------ ----------- ------- ---------- ----------
P1_ITEM01                      T1          X       X          X
P1_ITEM01                      T2                             
P1_ITEM01                      T3          X       X          X
P1_ITEM02                      T1          X       X          X
P1_ITEM02                      T2          X       X          X
P1_ITEM02                      T3          X       X          X
P1_ITEM03                      T1          X       X          X
P1_ITEM03                      T2          X                  X
P1_ITEM03                      T3          X       X          X
P1_ITEM04                      T1                             
P1_ITEM04                      T2                             
P1_ITEM04                      T3          X       X          X
P1_ITEM05                      T1          X       X          
P1_ITEM05                      T2                             
P1_ITEM05                      T3          X       X          X
P1_ITEM06                      T1          X                  X
P1_ITEM06                      T2                             
P1_ITEM06                      T3          X                  X
P1_ITEM07                      T1                             
P1_ITEM07                      T2                             
P1_ITEM07                      T3          X       X          X
P1_ITEM08                      T1                             
P1_ITEM08                      T2          X       X          X
P1_ITEM08                      T3          X       X          X
P1_ITEM09                      T1                             
P1_ITEM09                      T2          X       X          
P1_ITEM09                      T3          X       X          X
P1_ITEM10                      T1                             
P1_ITEM10                      T2          X       X          
P1_ITEM10                      T3          X       X          X
 

c- Main query

We’re almost there. The last step consists in generating the JSON file by aggregating lists of items by role and visibility (using JSON_ARRAYAGG), and then by record type (using JSON_OBJECTAGG) :

select json_serialize(
         json_objectagg(
           record_type value json_object(
                               'hidden'   value json_arrayagg(case when visible is null then item end order by item)
                             , 'required' value json_object(
                                                  'ROLE_A' value json_arrayagg(case when required_a = 'X' then item end order by item)
                                                , 'ROLE_B' value json_arrayagg(case when required_b = 'X' then item end order by item)
                                                )
                             )
           returning clob
         ) 
         returning clob pretty
       ) as json_content
from unpivoted
group by record_type

NB: I used JSON_SERIALIZE with pretty-print option for debugging purpose. It should not be used in a production environment.

2- Using the File in APEX

Here’s what the form region looks like without the rules applied :

In my actual use case, “User Role” is an hidden application item. I’ve included it here with a selector for testing purpose.

“Record Type” item is a select list holding the aforementioned three display values : ‘Type 1’, ‘Type 2’, ‘Type 3’ (and their respective return values ‘T1’, ‘T2’ and ‘T3’).

In order to reference the matrix from APEX, the JSON file is uploaded as an application static file named “item-property-matrix.json”.

Next, we declare a global variable in the page “Function and Global Variable Declaration” section. This variable will hold the JSON content throughout the life of the page :

var itemPropertyMatrix;

Last step is to define an on-change DA on the “Record List” item (P1_RECORD_TYPE), with action “Execute JavaScript code” :

function setRequired(item, state) {
    item.prop("required", state);
    if (state) {
        item.closest(".t-Form-fieldContainer").addClass("is-required");
    } else {
        item.closest(".t-Form-fieldContainer").removeClass("is-required");
    }
    
}

function applyRules() {

    const recordType = $v("P1_RECORD_TYPE");
    const userRole = $v("P1_USER_ROLE");

    //show currently hidden items
    $(".recordtype-hidden").each(function(){
        apex.item(this.id).show();
        $(this).removeClass("recordtype-hidden");
    });

    itemPropertyMatrix[recordType].hidden.forEach(e => {
        var item = apex.item(e);
        item.hide();
        item.node.classList.add("recordtype-hidden");
    });

    //remove required property from items
    $(".recordtype-required").each(function(){
        setRequired($(this), false);
        $(this).removeClass("recordtype-required");
    });    

    itemPropertyMatrix[recordType].required[userRole].forEach(e => {
        var item = $("#" + e);
        setRequired(item, true);
        item.addClass("recordtype-required");
    });

}

if (itemPropertyMatrix === undefined) {
    fetch("#APP_IMAGES#item-property-matrix.json", {cache: "force-cache"})
        .then(res => res.json())
        .then(json => {
            //console.log(json);
            itemPropertyMatrix = json;
            applyRules();
        })
    ;
} else {
    applyRules();
}

And here it is in live action :