Multilevel Story Approvals

Oracle APEX lets low-coders easily automate human approvals. With a bit of coordination logic, you can manage multi-step ones, too. Read on to learn how a company gets stories for their corporate website approved by all the required stakeholders before publishing. You’ll find the link to download and try the sample app at the end.

Who Writes & Approves Stories?

To make the sample app more realistic and interesting, we imagine a small widget-making company with a corporate web site. Each new story for the site needs a multi-step approval before publishing. The figure below shows the company’s eight colleagues. KIM is the founder and runs the company. CHAZ leads Marketing and is RORY‘s manager. GINA directs Editorial and mentors TINA. Last but not least, PAT oversees Product Management (PM) and manages BO and JANE.

Hierarchy of users involved in writing and approving stories

Typically product managers BO and JANE author stories for the website. These first get approved by their manager, then by someone in the Editorial and Marketing departments, and finally by KIM the CEO before the story can go live.

Adding Workplace Info for APEX Users

Oracle APEX user accounts have a user_name and email , but no built-in department name or manager field. Since the multi-step process depends on approvals by manager and by multiple different departments, I added tables to the application’s data model to track department names, which department each user belongs to, and who their manager is. The figure below shows the page in the sample app for managing departments. The MARKETING, EDITORIAL, and PM (product management) ones are predefined.

Defining workplace department names to associate with APEX user accounts

The page for managing users is below. The initial users’ departments and managers are also setup by the app’s Supporting Objects installation scripts.

Defining a management hierarchy and department for APEX user accounts

Overview of the App Data Model

The two pages above manage rows in the eba_demo_story_department and eba_demo_story_users table shown in the data model below. Stories bound for the corporate website live in the eba_demo_story table. The eba_demo_story_appr_step_def table contains the “template” approval steps that every story needs to follow. Finally, the eba_demo_story_appr_steps tracks the progress of each approval step for each story requiring approval.

Five-table data model for the Story Approval sample

Defining the Approval Steps

The page to manage the approval step “template” is below. The four steps you see come pre-installed. They illustrate the different ways the sample lets you define the approver for a step. It can be a specific username like KIM, a first-to-fourth level manager of the story author (e.g. #MANAGER1#), or all the members of a particular department (e.g. #EDITORIAL#). Each step also provides a descriptive title.

Defining the approval steps for a story

Submitting a Story for Approval

As shown below, from the Stories page in the sample, Jane clicks (Create) to author a story about a new version of the company’s widget.

Jane creates a new story for the website that requires approval before publishing

After Jane creates the story, she can revisit the Story page to edit it. As shown below, when she’s ready to submit it for approval, she clicks on the (Submit for Approval) button. It uses an Invoke API page process to call the submit_for_approval() procedure in the eba_demo_story_approvals package explained in the next section.

Jane submits her story for approval

Once she’s submitted her story for approval, the approval status appears in the Stories page as shown below. Its status is Submitted for Approval and indicates it’s waiting on PAT in step 1 of 4 to give the first level manager approval.

Users see the current status of Jane’s story on the Stories page

Any user viewing the story after it’s been submitted for approval sees the Approvals tab shown below with the current progress of the approval chain. It’s a classic report region showing the rows in the approval steps table, for the given story id and iteration number of the approval chain for that story.

Viewing the progress of a story’s approval chain

The Approval Coordination Package

The eba_demo_story_approvals package coordinates the multi-step process. When the Story page invokes submit_for_approval(), passing the story id, it inserts rows in the approval steps table (eba_demo_story_appr_steps). These come directly from the “template” steps in the approval steps definition table (eba_demo_story_appr_step_def) as shown below. In the process it determines what iteration number of the approval process is about to start, and includes the story_id and iteration number in the table as well.

Approvals coordinator package uses step definitions to create a story’s approval steps

The coordinator package initiates the single Story Approval task definition (with static id STORY_APPROVAL) once for each step of the approval process. It does this by calling the create_task() function in the apex_approval package. The function returns the task ID of the new task instance, which the coordinator package stores in the task_id column of the approval steps tracking table for the current step.

-- Create new STORY_APPROVAL task instance for approval step
l_new_task_id :=
apex_approval.create_task(
p_task_def_static_id => 'STORY_APPROVAL',
p_subject => apex_string.format(
p_approval_step.title
||' for "%s"',
l_story.name),
p_initiator => l_story.created_by,
p_parameters => approval_params(
p_approval_step,
p_total_steps),
p_detail_pk => p_approval_step.story_id);

When creating the task instance, it passes in key information using task parameters. For example, it provides the number of the current approval step (p_approval_step), the total number of approval steps (p_total_steps), and a comma-separated list of one or more approver usernames (p_potential_owners) for the current step in the approval “chain”. It also sets the subject of the current approval task based on the current approval step’s title, the initiator to be the story’s creator, and the Detail Primary Key to be the story ID. This information lets a single task definition handle all levels.

Story Approval Task Definition

As the figure below depicts, the STORY_APPROVAL task definition has actions for approval and rejection. Each is a one-line PL/SQL call to the handle_approval() or handle_rejection() procedure in the coordinator package.

Diagram of how the approvals coordinator package and the task definition cooperate

For example, the Complete (Approved) action’s PL/SQL code is the following line, passing in the task ID using the built-in :APEX$TASK_ID bind variable.

eba_demo_story_approvals.handle_approval(:APEX$TASK_ID);

As conveyed by the arrow on the left side of the handle_approval() procedure in the figure, if another approval step exists with a null status it repeats the process described above to call create_task() again for the next step. Using the current iteration number, it determines the next step (if any) by selecting the first row from the approval steps table with the current story’s ID where status is null ordered ascending by the step column.

Finally, notice in the figure above that the STORY_APPROVAL task definition contains several other actions that send notification emails:

  • On creation, it notifies the one or more potential approvers
  • On request information, it alerts the story creator that more info is needed
  • On submit info, it informs the approver that info has been provided
  • On approval, if it’s the final approval step, it emails the story creator

Enhancing the Task Details Page

After Jane’s first-level manager Pat receives the email that a story awaits her approval, Pat logs in and sees Jane’s story in her Approvals page as shown below. Notice how the title of approval step 1 and the story title were incorporated into the subject of the task.

Pat sees Jane’s story approval in her inbox

Clicking on the subject of the approval, Pat opens the Story Review page below. I created this page by clicking on the (Create Task Details Page) button on the STORY_APPROVAL task definition edit page in the APEX builder. By default it has all of the built-in APEX approvals functionality. I then simplified it in Page Designer by hiding two regions and enhanced it to add the story text to review. Of course Pat can approve or reject the task, but she can also add comments, request more information from Jane, review the approval audit history, or delegate the approval to a colleague. Since Pat also happens to be the business administrator for all approvals in the company, she’s allowed to perform additional actions like invite an additional approver or change the due date.

Story Approval Review page based on the generated Task Details page

In short, the enhancements I made were:

  • Hid Overview & Details regions by setting Server-Side Condition to Never
  • Added region to display read-only story text
  • Searched page for Task approved & Task rejected and changed TaskStory

I updated similar generic-sounding Task approved and Task rejected messages on the Approvals Unified Task List page, too. These changes along with other small tweaks to No Data Found messages around the app make a big difference for end users so are well worth the time. Now when a user approves a story, they aren’t scratching their head wondering, “What’s a task?” or “What’s a row?” The figure below shows the app-specific “Story approved” success message and the “No stories need your approval at the moment.” No Data Found text on the Approvals page.

Fine-tuning the approvals and story review pages with Story-focused messages

After Pat approves the story, the task definition’s approval action invokes the coordinator package’s handle_approval() as mentioned above. It determines there is another step to perform and creates a new task instance for that new step. Pat sees in the Stories page below that either Gina or Tina in the editorial department is next in line.

Coordinator package created a second task instance for step 2 of 4 in the approval chain

Letting Coordinator Pass In Approvers

A task definition can specify its approvers (called “potential owners”) using a static value, a SQL query, or using PL/SQL. In the SQL Query case, it needs to return a one-column result containing one username per row. In the PL/SQL case, it needs to return a comma-separated list of case-sensitive usernames. Did I mention the usernames ARE case SeNsItIvE? This is the most common problem developers encounter using task definitions. Since APEX user accounts are always in UPPERCASE, keep that in mind when using approval features.

That said, in the multi-step use case we need the approval step coordinator logic to pass in the list of approvers for the current task instance at create time. It gets the list of approvers for the current step from the approver column of the approval steps table. We enable this by defining the P_POTENTIAL_OWNERS parameter in the STORY_APPROVAL task definition, and setting its Potential Owner to the PL/SQL expression consisting simply of the task parameter bind variable :P_POTENTIAL_OWNERS. Accordingly the coordinator package passes in a comma-separated list of case-sensitive usernames for the value of the P_POTENTIAL_OWNERS parameter.

Expanding Approver Substitutions

We saw above that step 2 in the approval chain was assigned to GINA or TINA. When multiple usernames appear in the potential owners list, the approval task shows in the Unified Task List inbox page for all of them. When one claims the task to work on, approves, or rejects it, the others no longer see it.

The coordinator’s submit_for_approval() procedure starts by calling copy_story_approval_steps_for(). It “clones” the approval step definition template rows for the new multi-step story approval process being created. As it loops through the approval step definition rows, it calls the expand_substitution() function for any approver value like #%# to replace it by the one or more usernames that the substitution token represents.

Step 1 in the story approval steps definition uses the substition token #MANAGER1# to require a first-level manager’s approval. Step 2 specifies #EDITORIAL# to represent a list of anyone in the Editorial department. The expand_substitution() is straightforward. If the substitution token matches the regular expression ^#MANAGER[1-4]#$ then it’s replaced by the corresponding Nth-level manager if it exists. To compute the values of up to four levels of managers in one row, the code queries the eba_demo_story_users table using the story creator’s username, and outer joins with the same table four times:

select mgr1.username manager1_username,
mgr2.username manager2_username,
mgr3.username manager3_username,
mgr4.username manager4_username
from eba_demo_story_users usr
left outer join eba_demo_story_users mgr1
on mgr1.id = usr.manager_id
left outer join eba_demo_story_users mgr2
on mgr2.id = mgr1.manager_id
left outer join eba_demo_story_users mgr3
on mgr3.id = mgr2.manager_id
left outer join eba_demo_story_users mgr4
on mgr4.id = mgr3.manager_id
where usr.username = upper(p_story_creator)

If instead the substitution token is a name surrounded by pound signs, it trims them off both sides and uses the department name to query the eba_demo_story_department table joined with the users table. Using listagg() it retrieves a comma-separated list of department users like this:

l_department_name := trim(both '#' from p_substitution_text);
select listagg(u.username,',')
into l_result
from eba_demo_story_users u
left join eba_demo_story_department d
on d.id = u.department_id
where d.name = l_department_name;

Understanding Actions Source Query

After someone in the Editorial and Marketing departments approves the story, as shown below it’s ready for CEO Kim’s approval.

Almost done! Step 4 of 4: the story awaits its final CEO approval

Once Kim approves, the STORY_APPROVAL task definition will finally email Jane the story’s creator to let her know the good news. It’s important to understand the grand declarative power of the task definition’s Actions Source Query. Recall that every task definition has an associated system of record primary key called its Detail Primary Key. In this app, the coordinator package passes in the story ID to play this role. It’s available inside the task definition using the :APEX$TASK_PK bind variable. Other frequently-used bind variables include :APEX$TASK_ID, :APEX$TASK_INITIATOR, and :APEX$TASK_OWNER. Refer to the Substitution Strings for Tasks in the APEX doc for the complete list.

The Actions Source Query lets you easily retrieve any additional information your task definition requires to get its job done. This includes anything action code, emails, push notificiations, or their server-side conditions may require. The SQL query will typically reference the Detail Primary Key value and possibly the task ID to pull in all supplementary data required. Any of its SELECT list column names is available to reference throughout the task definition using either bind variable or substitution string syntax. The task parameter names can also be used in the same way.

The query below is the STORY_APPROVAL task definition’s Actions Source Query. It includes a CASE statement to derive the Y or N value for an IS_FINAL_APPROVAL column depending on whether the P_APPROVAL_STEP parameter equals the P_TOTAL_STEPS parameter. It includes multiple joins to the apex_workspace_apex_users table to pickup the email addresses of the story creator, potential approvers, and the task owner. It references the apex_task_participants view to access the task’s potential owners (i.e. approvers). Its SELECT list column values are referenced by name as appropriate in the task definition actions that send emails and by their server-side conditions to make the actions conditional. The admittedly odd-looking coalesce(…) expressions let you override the email address for all notifications for demo purposes as explained in a later section.

select stp.approver as approvers,
case
when :P_APPROVAL_STEP = :P_TOTAL_STEPS
then 'Y' else 'N'
end as is_final_approval,
sty.name as story_title,
sty.created_by as story_author,
coalesce(:P12_OVERRIDE_EMAIL_ADDRESS,
styusr.email) as story_author_email,
(select listagg(coalesce(:P12_OVERRIDE_EMAIL_ADDRESS,
aprusr.email),',')
from apex_task_participants apr
left outer join apex_workspace_apex_users aprusr
on aprusr.user_name = apr.participant
where apr.participant_type = 'POTENTIAL_OWNER'
and apr.task_id = :APEX$TASK_ID
and aprusr.email is not null) as approvers_email,
coalesce(:P12_OVERRIDE_EMAIL_ADDRESS,
ownusr.email) as task_owner_email
from eba_demo_story sty
left join eba_demo_story_appr_steps stp
on stp.story_id = sty.id
left outer join apex_workspace_apex_users styusr
on styusr.user_name = sty.created_by
left outer join apex_workspace_apex_users ownusr
on ownusr.user_name = :APEX$TASK_OWNER
where stp.story_id = :APEX$TASK_PK
and stp.step = :P_APPROVAL_STEP

Unifying Task Comments & History

Stories don’t always emerge from the approval chain unscathed. At any step of the process, the current approver can add comments on the Story Approval Review page, request additional information from the story creator, or even outright reject the story. The sample app lets you explore these outcomes as well, and anticipates them in two key ways:

  1. It unifies comments and history across task instances in the chain, and
  2. It lets the story approval chain undergo multiple iterations

In this section we’ll explore the comments and history, and the following one explains the iterations. For example, the screenshot below shows the feedback Bo got from Pat, her first level manager. Pat has requested more information before approving it.

Bo gets feedback on a story from her manager Pat before getting her approval

Once Bo updates the story and visits the My Story Approvals page to indicate she’s submitted the information Pat requested, Pat gets an email letting her know the story is ready for another look.

Pat receive email notification that Bo’s story is ready for another look

After Pat approves, as shown in the figure below, when Gina in Editorial reviews Bo’s story next she sees the discussion from previous approval steps, too. She also requests more information from Bo, asking her to remove the use of passive voice.

Story Approval Review page shows comments across all task instances in the approval chain

By default, the generated Task Details page the APEX builder creates only shows the comments for a single task instance. To unify the display of all comments from all tasks in the approval chain, I simply modified the WHERE clause of the Comments region on that page to what appears below. It shows the comments for all the task IDs from the story approval steps table related to the current task’s story ID and iteration number. Had it been desired, the iteration could have been removed from the criteria to show all comments across all iterations of a story’s approval process.

select ... etc. ...
from apex_task_comments
where task_id in (
select task_id
from eba_demo_story_appr_steps
where (story_id,iteration)
in (select story_id, iteration
from eba_demo_story_appr_steps
where task_id = :P3_TASK_ID)
)

Along a similar vector, I modified the query of the page’s History region to display the task history for the same set of task IDs using the query below:

select x.event_type,
x.event_timestamp,
x.event_creator_lower,
x.display_msg
from eba_demo_story_appr_steps steps,
table ( apex_approval.get_task_history (
p_task_id => steps.task_id,
p_include_all => :P3_ALL_HISTORY ) ) x
where steps.task_id in (
select task_id
from eba_demo_story_appr_steps
where (story_id,iteration)
in (select story_id, iteration
from eba_demo_story_appr_steps
where task_id = :P3_TASK_ID))

Rejections and Iterations

When a story gets rejected by any approver, the handle_rejection() procedure in the coordinator package sets the status of the approval step and the story to Rejected. But that’s not the end of the line for the author. As shown below, Chaz in Marketing rejected Bo’s customer profile for a reason out of her control.

Bo reviews the comments on her rejected customer profile story

She can rework the story based on constructive feedback received and submit it again for another iteration through the approval chain. After Bo spoke with Chaz, she learned she could save her story by using different people’s names for the testimonials, so Bo made the changes and resubmitted for approval.

The submit_for_approval() procedure calls the package’s get_latest_iteration() function to determine the maximum value of the iteration column for a given story ID in the approval steps table. If it returns null, then the logic defaults the iteration number to 1. Otherwise, it increments the latest iteration number by one. As shown below, if a story going through the multi-step approval process for a second or subsequent time, the Story page’s Approvals tab conditionally displays the “Second Iteration” heading.

Conditional “Second Iteration” header identifies Bo’s second iteration of the approval chain

One interesting technique I learned while implementing this part of the sample was how easy it wasy to format the number value of the iteration into ordinal words like “Second”, “Third”, or “Fourth.” The expression below is used on the Story page in a pre-rendering computation for the hidden P10_ITERATION_TITLE page item.

initcap(to_char(to_date(:P10_LATEST_APPROVAL_ITERATION,'j'),'jspth'))

Soft Deleting and Undeleting Stories

Since approval task instances are historical records that reference the Detail Primary Key of a row in a system of record table, we don’t want to actually remove a story when an author decides one is a lost cause and deletes it. This would “orphan” the task instance related to that story and potentially cause problems. So, we let the end-users think they are deleting a story, but we really “soft delete” it. This happens by changing the deleted flag column on the eba_demo_story row in question from 0 to 1. Accordingly the Stories page contains a WHERE clause to show stories where deleted = 0.

To implement this soft delete, on the Story page I duplicated the Process form Story page process of type Form – Automatic Row Processing (DML) and renamed the original one to Process form Story (Create,Update) and the copy to Process form Story (Soft Delete). As shown below, I configured a Server-side Condition on the (Create,Update) one to execute only if the Request is contained in Value CREATE,SAVE.

Limiting one of the DML page processes to only CREATE and SAVE requests

For the (Soft Delete) one, as shown below, I set the Target Type to PL/SQL Code and entered the simple update statement to set the current story’s deleted flag column to the value 1. By also adding a Server-side Condition I confined this special-case handling to only happen for story delete requests. Thanks goes to my colleague Carsten for the tip on handle this soft-delete in a low-code way.

Implementing “soft delete” for DELETE requests using PL/SQL code to update a flag instead

With the deleted flag in place, and the soft-delete working. It was easy to add a Recently Deleted Stories page to give authors a 10-day period in which to undelete any story that “deleted” by mistake. Even after 10 days we don’t actually delete the story, but to avoid accumulating a long list of deleted stories over the months and years, we set a 10-day “grace period” which felt right. The page is shown below. It’s essentially a copy of the Stories page, but with a where deleted = 1 clause instead. Its Interactive Report region’s Link Column is configured to use an fa-undo icon instead of the usual edit icon:

<span role="img" aria-label="Undelete"><span class="fa fa-undo" aria-hidden="true" title="Undelete"></span></span>

As shown below, its link Target redirects to the Stories page (9) setting the P9_STORY_ID_TO_UNDELETE page item to the value of the clicked-on story ID to undelete and signalling special processing is required by setting the Request to UNDELETE.

Undelete link target passes story ID to undelete and Request = UNDELETE to Stories page

Over in the Stories page, a conditional Execute Code page process configured to only run if Request = UNDELETE performs the simple update statement to revert the deleted flag back to 0 for the story id passed in the parameter.

Conditionally undeleting a story by ID when Request = UNDELETE

Overriding Email Address for Notifications

For a sample app, it is convenient to have all the email notifications come to your own email address. You can do that by typing in the email you want to use on the Email page and clicking the button to save the setting for the duration of the current session.

Configuring an override email address for the session to receive all notifications in the demo

If the setting is not provided, the sample tries to retrieve email addresses from the APEX use account for the story creator and approvers. If neither email address resolves to a valid value, no email gets sent. This P12_OVERRIDE_EMAIL_ADDRESS page item with its Storage property set to Per Session (Persistent) made an appearance above in the Story Approval task definition’s Actions Source Query. Using the coalesce() expression in multiple places in reference to the value of :P12_OVERRIDE_EMAIL_ADDRESS that query allowed its value to take precedence over the email address values it tried to query from the apex_workspace_apex_users view.

Easy Demo-Purpose-Only Logins

The last aspect of the sample to explore involves the techique I’ve used to simplify switching between different users to experiment with the story approval process. For a production application, you should ABSOLUTELY use Oracle APEX accounts, sigle sign on, or some other supported authentication scheme to ensure that only expected users with valid credentials can access your app. However, for a sample aimed at helping developers learn about approvals, I’m trying out a new idea using a custom authentication scheme that considers any username that appears in the eba_demo_story_users table to be a valid user. The sample app’s login page shows the users in a management hierarchy, and allows you to easily login as any user in the tree by selecting a user node and clicking the (Login as USER) button that appears. When you first run the application, you won’t be logged in so you can select any user and click the button to login as shown below.

Choosing a user from the management hierarchy and logging in

Once you are logged in, you can easily switch to wear the “hat” of another user in the company by visiting the Switch User page shown below. It confirms who you are currently logged-in as, and lets you select any different user in the tree and login by clicking the button. In both situations, you’re seeing the Demo Purposes Only Login page. However, using an appropriate condition on the Navigation Menu items labeled Login and Switch User we help guide the user to perform the task at hand.

Easily switching to a different user to experiment with the multi-step approvals

This approach, again recommended only for demo purposes, also saves developers the trouble of having to create eight additional APEX user accounts before they can try out the sample. If any APEX account does exist with a username matching a sample user in the eba_demo_story_users table, then the sample will pickup their email address (unless it’s been overridden for the session using the facility described above).

Downloading the Sample

You can download the sample from here. Try creating some stories and running them through the approval chain. Try adjusting the approval step definitions using multiple levels of management to see what happens when a story author doesn’t have a third-level manager, for example. Try changing the departments, the users, or how users are assigned to departments or report to each other. Hope the sample proves useful and you find some exciting ways to use APEX approvals in your upcoming apps.

Parent/Child Validation Made Easy

When working with parent/child data, an app often needs to validate an aggregate condition about all of the child rows related to a parent row. For example, say each teammate assigned to an action item can have a role of “Lead” or “Member”. Some typical parent/child validations for an action item’s team members might require the team to…

  • have a Lead
  • only have one Lead, and
  • not contain any duplicate members.

Ideally, the app will refuse to save anything unless the pending changes to parent and child rows satisfy all 3 business rules. The app should show the user a message so they know what to fix before trying to save again.

Starting with APEX 23.1, aggregate validations are easy to implement using simple SQL statements. This article first explains how to do them, and then why a new feature in 23.1 makes them possible. It goes on to highlight a few other techniques I learned while building the sample app. At the end, I provide a download link to try it out for yourself.

Sample App Overview

The sample app uses the three tables shown below. One stores the Action Items, another the Staff Members, and the third holds the action item team list relating an action to the staff members collaborating to complete it.

Three tables involved in the “One Lead on a Team” sample application

The sample includes a page to maintain Staff Members and, as shown below, another page to maintain Action Items.

Page to maintain action items

You can see the page used to create, edit, or delete an Action Item below. It includes a “parent” Form region for the Action Item row and an editable Interactive Grid “child” region for the list of action team members.

Page to edit an action item and its related action team members

Ensuring Child Rows Get a Parent Id

When building an editable page including parent and child data, I first ensure any child row gets its parent id foreign key set correctly. My preferred technique is adding an Execute Code page process after the Process form Action one that saves the parent row and before the Save Interactive Grid Data one that stores the grid’s child rows. This approach works for both create and edit use cases.

This new Execute Code page process assigns the parent foreign key column with the one line of PL/SQL below. The :ACTION_ID bind variable references the ACTION_ID foreign key column in each grid row being created or modified, and :P5_ID is the ID of the parent Action Item row being created or edited.

-- Assign the parent foreign key to the team row from the grid
:ACTION_ID := :P5_ID;

As shown below, by setting the Editable Region property of the process to the Interactive Grid region Action Team, APEX runs it once for each created or modified child row in the grid region.

Ensuring child rows have their parent foreign key set correctly

If an APEX page has multiple child grids on an edit page with their parent row — perhaps each one on a different tab — just repeat this technique for each grid involved. Sequence all the foreign key assignment processes after the parent row save process and before any of the child grids’ save processes.

Adding Aggregate Validations

Next we can add the aggregate validations as additional Invoke API page processes following the page processes that save the data. The Enforce Exactly One Lead process shown below invokes the one_lead_per_action_team procedure in the eba_demo_action_validation package, passing the value of the P5_ID page item for the p_action_id parameter.

Adding the aggregate validation procedures using Invoke API page processes

The PL/SQL package containing the validation procedures has the specification shown below in the Object Browser.

eba_demo_action_validation package spec in the Object Browser

In the package body, the code for one_lead_per_action_team is below. It first checks the Action Item parent row is valid by using a zero-or-one-row for loop based on the primary key value in the p_action_id parameter. This is important since one of the operations the page can perform is to delete the action item. We want our validation code to work correctly — by consciously doing nothing — in that situation. If the loop’s SELECT returns a row, then the code inside the loop runs once. It counts how many Team child rows for the current Action Item have the role = 'LEAD' using a simple select count(*). If the l_lead_count does not equal one, then the validation check fails. We use a case statement to conditionally display the appropriate error message depending on whether the lead count is less than one or greater than one. If the lead count equals one, then by not reporting any error it lets the transaction commit successfully.

-- Ensure the action has exactly one Lead
procedure one_lead_per_action_team(
p_action_id in number)
is
l_lead_count number;
begin
-- Ensure the action id still exists since it might
-- have been deleted. If it exists, perform the check
-- on the children rows.
for j in (select id
from eba_demo_action
where id = p_action_id) loop
select count(*)
into l_lead_count
from eba_demo_team
where action_id = p_action_id
and role = 'LEAD';
if l_lead_count != 1 then
error( case
when l_lead_count < 1
then c_msg_team_must_have_lead
else c_msg_team_max_one_lead
end);
end if;
end loop;
end;

The simple, package-private error() helper procedure is below. It calls the add_error() procedure in the apex_error package with a display location of On Error Page using the appropriate package constant. This causes the APEX engine to rollback the transaction and display the error message to the user. It uses the apex_lang.message() function to return the translated error message based on the message key passed in. The net result is that no changes submitted from this parent/child page get saved to the database when this aggregate validation error occurs.

-- Raise an error with indicated message key
procedure error(
p_message_key in varchar2)
is
begin
apex_error.add_error (
p_message => apex_lang.message(p_message_key),
p_display_location => apex_error.c_on_error_page);
end;

The no_duplicate_team_members procedure uses a similar technique, with a slightly different select statement. Its code is shown below. We count the number of team member rows, grouping by the user_id and only returning ones having a count greater than one. If at least one of such row is returned, then we have a duplicate team member name and we report the error as above using a different error message key constant.

-- Ensure the action has no duplicate team members
procedure no_duplicate_team_members(
p_action_id in number)
is
begin
-- Ensure the action id still exists since it might
-- have been deleted. If it exists, perform the check
-- on the children rows.
for j in (select id
from eba_demo_action
where id = p_action_id) loop
for k in (select user_id, count(*)
from eba_demo_team
where action_id = p_action_id
group by user_id
having count(*) > 1
fetch first row only) loop
error(c_msg_team_no_duplicates);
exit;
end loop;
end loop;
end;

Using Translatable Error Messages

Since the application might need to be translated into multiple languages, it’s best practice to define the error messages as Text Messages in the Shared Components area of the APEX builder. The figure below shows the three text messages I defined to support the error messages my aggregate validation code may display to end users.

Translatable text messages in Shared Components showing message key and base language text

The eba_demo_action_validation package body defines the following three constants to hold the message keys:

------------------------------------------------------------
-- Constants for error message keys
------------------------------------------------------------
c_msg_team_must_have_lead constant varchar2(25)
:= 'TEAM_MUST_HAVE_A_LEAD';
c_msg_team_max_one_lead constant varchar2(25)
:= 'TEAM_MAX_ONE_LEAD';
c_msg_team_no_duplicates constant varchar2(25)
:= 'TEAM_NO_DUPE_MEMBERS';

Hiding the Grid’s Toolbar Save Button

Since we want the aggregate validation to always take into account both parent and child data, we use the Action Item form region’s default CREATE and SAVE buttons that both submit the page. APEX automatically submits the data from all page regions when the user clicks one of these buttons. Accordingly, to avoid the user’s saving child grid row changes on their own, we hide the default (Save) button in the Interactive Grid’s toolbar. To perform this task, the figure below shows where to find the checkboxes on the Attributes tab of the property editor after selecting the Interactive Grid region. While I was there, to keep things simple, I turned off a number of the default toolbar controls.

Hiding Interactive Grid’s built-in (Save) toolbar button to use a page submit button instead

Taking the Sample For a Spin

If the end user tries to create or edit an action item and save without having designated a team Lead, then she’ll see the error message below.

Error message when user tries to save an action item with team containing no Lead

If she tries to create or edit an action item whose team contains more than one Lead, when she tries to save she’ll see this different error message:

Error message when user tries to save action item with team having multiple Leads

Finally, if the user inadvertently adds the same staff member twice to the action item team, she’ll see the error message:

Error message when user tries to save action item with team having duplicate members

Using the SQL Commands page in the APEX builder, you can have a quick peek at the eba_demo_action and eba_demo_team tables before and after to convince yourself that no data has been inserted or updated when these parent/child validations fail.

What Changed in 23.1 to Make This Easy?

APEX 23.1 introduced a new Session State Commits setting in the Session Management section of the app definition’s Security tab. For new apps, it defaults to the value End of Request which defers the database transaction commit to the end of the current submit page request. The figure below shows where this setting lives in the APEX App Builder.

Reading the help text for the Session State Commits application definition setting

This one change, combined with the Oracle database’s read consistency model, lets aggregate validations be implemented so easily. They can use simple SQL select statements to reason over the pending parent and child row changes that APEX has made in the current database transaction. Importantly, they can do so before the transaction gets committed. This means they are still in time to “veto” the transaction by adding an error to the APEX error stack.

In the remaining sections, I highlight some additional techniques I learned while building this sample app that I found interesting. They are not directly related to parent/child validation, but they made me smile when I learned how to do them so I wanted to share the knowledge…

Dynamic Grid Cell Defaulting

The Action Item page can guide end users to follow the “One Lead on a Team” rule by using dynamic defaulting. When the first team member row is added to the grid, it should default to being a Lead. In constrast, the role should default to Member for other rows added. I wanted to learn how this could be done on the client side, so I asked my colleagues for advice.

John Snyders taught me about the Interactive Grid’s ability to associate a dynamic defaultValue function with a column using its Column Initialization JavaScript Function property. After selecting the ROLE grid column whose default value I wanted to be dynamic, the figure below shows where I needed to add my code in the Page Designer.

Configuring a dynamic defaultValue function for the ROLE grid column in Page Designer

The few lines of JavaScript appear below. The initialization function receives an options object on which it sets the defaultGridColumnOptions property to configure a custom defaultValue function for the column. This one-line function calls getTotalRecords() on the interactive grid’s model object it receives and returns the literal string LEAD if the model’s row count is zero and MEMBER otherwise.

function( options ) {
options.defaultGridColumnOptions = {
// Setup a dynamic default value for the ROLE column
// to return LEAD when there aren't any rows yet, and
// MEMBER otherwise.
defaultValue: function(model) {
return model.getTotalRecords(true) == 0 ? "LEAD"
: "MEMBER";
}
};
return options;
}

NOTE: If you use this technique in your own applications to return a default value for a number column or date column, remember to return the value as a String from this function.

Setting Width of the IR Columns

While fine-tuning the sample, I noticed the link column of the Interactive Reports pages for Staff and Action Items was extra wide and didn’t look so great. The Staff page initially appeared like this:

Extra wide link column in Interactive Report on the Staff page

The Action Items page had three columns, but still the column widths looked less than ideal…

Extra wide link column and Lead columns in the Action Items page

I learned how to set the width of the interactive report link column across all Interactive Reports in the application, and added the following snippet of CSS to a global app.css file in the Static Application Files area of Shared Components.

th#a-IRR-header--linkCol,
td.a-IRR-linkCol
{
width: 6em;
text-align: center !important;
}

Colleagues taught me it was best practice to reference the desired column width using em CSS units instead of pixels. So above I’m setting the link column to be 6 characters wide relative to the base font size of a letter M. That way, if the user zooms their browser — increasing the base font size — then the column always remains wide enough to show six of those larger-sized characters.

After creating the app.css file, I copied its path and pasted it into the CSS > File URLs section of the application definition’s User Interface tab as shown below. At runtime, APEX replaces #APP_FILES# with the URL to access the static app files content, and replaces the #MIN# as appropriate to use the more compact “minified” version of the CSS style sheet when not running in debug mode.

Setting the path of a global app.css file that “lives” in Static Application Files

To set the width of the Lead column in the Action Items page, first I set the Static Id of the LEAD column to action-team-lead, then I added the following small amount of CSS to the page-level CSS > Inline property:

td[headers=action-team-lead], th#action-team-lead {
width: 10em
}

The result is that the link column is set across the app to 6 characters wide — affecting both the Staff and Actions Items page — and the Lead column is set to 10 characters wide. We can see below that the Staff page now looks more professional:

Staff page with 6-character-wide link column from application-wide app.css styling

Similarly, the Action Items page also looks more polished, with the same 6-character-wide link column, and a 10-character-wide Lead column. This leaves the most amount of horizontal space for the action item name:

Action Items page with 6-character-wide link column and 10-character-wide Lead column

Merging Seed Data from Static App JSON

The last interesting technique I used in the sample app is how I’m seeding the data for the 26 example Staff members. I’m aware of the APEX built-in Supporting Objects feature called Data Package. It lets developers choose a set of tables whose data they want to install when an APEX app is first imported into a target workspace. That feature is great, but I wanted to explore how to gain more control over seed data installation to more seamlessly handle the situation where new seed data rows could be added over time in later versions of the app.

First, I added a staff.json file with the following format into the Static Application Files area in Shared Components:

{
"staff": [
{"NAME": "Abigail"},
:
etc.
:
{"NAME": "Zelda"}
]
}

Then I added the following Supporting Objects installation script that uses a merge statement to insert any Staff names from the staff.json file that don’t yet exist into the eba_demo_staff table. Its using clause selects the contents of staff.json from the static application files view and uses json_table() to select the NAME column data out of that JSON file. With this technique, subsequent versions of the app could add new NAME entries to the staff.json file and they would be added to the eba_demo_staff table at application import time. If the seed data were more complicated and multi-columned, the merge statement could also use a when matched then clause — which wasn’t needed for this simple sample — to update the values of other seed data columns that might have changed since the last time they were inserted/updated.

merge into eba_demo_staff tgt
using (
with x as (
select aaf.blob_content
from apex_application_files aaf,
apex_applications aa
where aa.application_id =
coalesce(
apex_application_install.get_application_id,
to_number(v('APP_ID')))
and aaf.flow_id = aa.application_id
and aaf.filename = 'staff.json'
)
select y.name
from x,
json_table(x.blob_content, '$.staff[*]' columns
(
name varchar2(255) path '$.NAME'
)) y
) src
on (tgt.name = src.name)
when not matched then
insert (name)
values (src.name)

Trying the Sample App

You can download the sample app from here. Try creating a new action item and causing some of the validation messages to appear. Then try editing an existing action item and repeating the process to cause the aggregate validations to fail. Finally make sure you can delete an action item. I hope you find useful ways to incorporate this technique into the next APEX app you build.

Final Thoughts

Using appropriate SELECT statements and the technique outlined in this article, you can make quick work of other kinds of typical aggregate validation use cases like checking that the …

  • sum of a child row column does not exceed a limit
  • sum of percentages across child rows equals 100%
  • count of child rows is within an expected range

The validations can get even more complex like ensuring the changes to the line items of an order don’t put the customer’s total monthly orders above their credit limit. Let your imagination be your guide!

Bulk Data Loading from a Zip

A friend is learning APEX to automate his business mentoring athletes. In his practice, he captures training performance metrics, then needs to analyze clients’ progress toward their goals over time. His instruments record an athlete’s performance data in a comma-separated values (CSV) format. Recently, he asked if APEX could load data from a CSV file.

After showing him how easy it was, he added another twist:”What if I have a directory full of CSV files, organized into subdirectories by athlete? After a week’s sessions with clients, there could be a lot of files. Will I have to import them one by one? ” Great question!

He said each directory reflects the client name, each file name includes the date and time of the session (SESSION_2023_08_11_13_45.csv), and each file has the same format. A perfect chance to show him three more APEX features:

  • Easy file uploading,
  • Simplified Zip file processing, and
  • Automatic data loading using a pre-defined column mapping.

The sample app you can download using the link at the end of the article illustrates the declarative configuration and small amount of code required to put these techniques into action.

Setting Up the Data Model

For simulation purposes, I chose a CSV file format that includes the name of an activity followed by two numerical values in each row like this:

AAA,333,887
BBB,123,899
CCC,445,1299

I created the following tables to store the athletes, the training sessions for each athlete, and the athlete session training activity data. I added a “staging” table to hold the uploaded data temporarily before moving it to the athlete session data table, related to the appropriate athlete’s training session.

Four tables used by the sample application to upload athlete training session data

Creating the Data Load Definition

The next step was creating an APEX Data Load Definition to capture how the CSV column data maps to columns in the EBA_DEMO_UPLOADED_SESSION_DATA table. In the APEX App Builder, under Shared Components, in the Data Sources section, I chose Data Load Definitions and clicked Create. On step 1, as shown below, I entered the name athlete_session_data_csv and chose the EBA_DEMO_UPLOADED_SESSION_DATA table.

Step 1 of defining a new data load definition: providing a name and picking a table.

On step 2 of the wizard, I dragged and dropped an example SESSION_2023_08_04_18_20.csv data file into the file upload drop zone.

Step 2 of defining a data load definition: provide a sample CSV file

On step 3, I unchecked the First line contains headers checkbox and mapped the values of the three CSV file columns to appropriate columns of the EBA_DEMO_UPLOADED_SESSION_DATA table. If the CSV file had contained an initial “header” row of column names, APEX might have been able to guess the mapping automatically. However, the files my friend showed me did not have headers so I wanted to show him that use case.

Step 3 of defining a data load definition: mapping the columns

To complete the wizard, I clicked on (Create Data Load).

Create Package for Handling the Upload

I used the Object Browser to create an eba_demo_bulk_data_load package with the spec below. The from_uploaded_zip_file() procedure accepts a single parameter for the name of the uploaded zip file to process. In the package body, I initially left the implementation of from_uploaded_zip_file() as a “TODO” task by using a null; statement. I’d later circle back to add in the upload processing code that I explain in detail below. For the moment, having the specification in place was enough to “wire up” the declarative interaction between the page in the next section and the procedure.

create or replace package eba_demo_bulk_data_load is 
    procedure from_uploaded_zip_file( 
        p_file_name in varchar2); 
end;

Uploading the Zip File

On the Home page, I added a single page item named P1_ZIP_FILE_UPLOAD of type File Browse… , set its Storage Type property to Table APEX_APPLICATION_TEMP_FILES, and its Purge File at property to End of Request. I also configured its File Types property to application/zip to guide the user to only choose zip files. Finally, I added a button named Upload_and_Load_All_Data to submit the page. This is the only configuration needed to upload a file for server-side processing during the page submit.

Adding a File Browse… page item to let an end user upload a zip file.

Processing the Zip File Contents

In the Processing tab of the Home page, I added an Invoke API page process to call the from_uploaded_zip_file() procedure in the eba_demo_bulk_data_load package. I configured its Server-side Condition so it only executes when the Upload_and_Load_All_Data button is pressed.

Adding an InvokeAPI page process to execute when the Upload button is pressed

As shown below, I selected the p_file_name function parameter in the Processing tab and used the Property Editor to configure its value to come from the P1_ZIP_FILE_UPLOAD page item. A File Browse… page item’s value gives the unique name of the uploaded file in the APEX_APPLICATION_TEMP_FILES view. We’ll use this info below to process the CSV files inside the uploaded zip file.

Configuring PL/SQL function argument value to come from P1_ZIP_FILE_UPLOAD page item

With these pieces in place, I had a working zip file upload page. Next, I turned my attention to the small amount of code required to “unpack” the zip file contents and process each CSV file it contained…

Loading Each CSV File in the Zip

In the implementation of the the from_uploaded_zip_file() procedure, I started by selecting the binary contents of the uploaded zip file from the APEX_APPLICATION_TEMP_FILES view into an l_zipfile BLOB variable, using the unique file name passed in the p_file_name parameter. Note that in the WHERE clause I’m using the NAME column and not the FILENAME column. The former is the unique name APEX assigns to the uploaded file (e.g. 12345678/athletes.zip), while the latter is just the “plain” name of the file the end-user provided (e.g. athletes.zip).

-- Get the uploaded zip file
select blob_content
into l_zipfile
from apex_application_temp_files
where name = p_file_name;

Next, using the handy apex_zip package, I get the list of CSV files inside this uploaded zip file:

-- Get list of files from the zip
l_files := apex_zip.get_files(l_zipfile);

Then I loop over the file names and load them one by one into the “staging” table using the pre-defined Data Loading Definition I created above. Before each iteration, I delete all the rows from the staging table, and in the next section I’ll explain how I determine the athlete and athlete session with which to associate the loaded data from the current CSV file in the loop. Notice I use another apex_zip package function get_file_content() to access the contents of the current CSV file. I pass that as the value of the p_data_to_load parameter of the apex_data_loading.load_data() function, referencing the static id of the data loading definition as the second parameter value.

-- Process the individual files inside the zip
for j in 1..l_files.count loop
-- Start by clearing any existing rows out of the temporary upload table
delete from eba_demo_uploaded_session_data;
-- Get the contents of the current CSV file in the zip file
l_csv_file := apex_zip.get_file_content(l_zipfile,l_files(j));
-- Load data from current CSV file into temp data loading table
-- using 'athlete_session_data_csv' data loading definition
l_result := apex_data_loading.load_data(
p_data_to_load => l_csv_file,
p_static_id => 'athlete_session_data_csv');
/* See below for logic to associate data with athlete session */
end loop;

Moving Uploaded Data to Final Destination

After loading each CSV file into the “staging” table, I use the name of the file and the name of its directory to determine the date and time of the session as well as the athlete name. The split() function in the apex_string package comes in handy to break the file name into parts using the slash as the separator character. Assuming the file names will all be of the form athletes/AMINA/SESSION_2023_08_01_12_10.csv, we can see that the athlete name is the second part of the fully-qualified file name and the session date and time is encoded in the third part. Notice I’ve used a date format mask that includes the literal text "SESSION_" and ".csv" to easily extract the date with no additional fuss.

for j in 1..l_files.count loop
/* See above for logic to load the current CSV file */
-- Extract the name of the athlete from the current file name
-- that we are assuming will be of the form:
-- athletes/AMINA/SESSION_2023_08_01_12_10.csv
l_filename_parts := apex_string.split(l_files(j),'/');
l_athlete_name := l_filename_parts(2);
l_session_date := to_date(l_filename_parts(3),
'"SESSION_"YYYY_MM_DD_HH24_MI".csv"');
/* See below for logic to lookup or create athlete */
end loop;

Using straightforward code, I lookup the current athlete’s id based on their name. If the athlete name does not exist, I create a new Athlete row for them and return the new athlete id into l_athlete_id. Then I create a new athlete session for the athlete with the appropriate date and time extracted from the current CSV file name.

for j in 1..l_files.count loop
/* See above for previous logic in the loop */
-- Lookup the athlete id from their name. If not found, then
-- create a new athlete with this name and return the newly
-- assigned athlete id into l_athlete_id
begin
select id
into l_athlete_id
from eba_demo_athletes
where name = l_athlete_name;
exception
when no_data_found then
insert into eba_demo_athletes(name)
values(l_athlete_name)
returning id into l_athlete_id;
end;
-- Create a new athlete session for the athlete id
-- and session date (inferred from the CSV file name above)
insert into eba_demo_athlete_sessions(athlete_id, session_date)
values (l_athlete_id, l_session_date)
returning id into l_new_session_id;
/* See below for final logic to move uploaded data in place */
end loop;

Finally, I select the uploaded performance data from the “staging” table and insert it into the Athlete Session Data table along with the correct Athlete Session foreign key so it’s related to the new athlete session for the current file’s athlete.

for j in 1..l_files.count loop
/* See above for previous logic in the loop */
-- Finally, move all the uploaded performance data for the current
-- athlete's session into the newly created athlete session
-- using the l_new_session_id returned above
insert into eba_demo_athlete_session_data
(athlete_session_id, activity, value1, value2)
select l_new_session_id, activity, value1, value2
from eba_demo_uploaded_session_data;
end loop;

Enabling Background Processing

Using the techniques I described in Page Processing Power Unchained, it would be very easy to introduce an execution chain into the Home page’s processing tab and make the current Invoke API page process be its child process. You would accomplish that by setting the Execution Chain property of the latter to the name of the newly-created chain. By setting the new execution chain’s Execute in Background property to ON, its Temporary File Handling property to Move, and its Temporary File Items to P1_ZIP_FILE_UPLOAD, the zip file CSV upload processing could be effortlessly done in the background while the user moves on to do other tasks. This extra flourish would be appropriate if there might be a large number of CSV files to process inside.

Trying Out the Sample App

You can download the APEX 23.1 sample app from here. This example athletes.zip file contains simulated training data for multiple sessions for four different athletes: BIFF, CHIP, ANNEENA, and WILMA. Try uploading the athletes.zip file then browsing the Athletes Session Data page.

Pushing the PWA Envelope

If you have a smartphone, you’re already familiar with push notifications. They’re the timely alerts with some useful info from your favorite apps. In 23.1, your APEX solutions can send them, too, using a page process or send_push_notification() API. You simply set the message details and APEX securely notifies the user on all their subscribed devices, if any.

In this article, see a sample app in action, how to try it out yourself, and where to download the slides from my recent APEX Alpe Adria conference talk with more in-depth info about push notifications in APEX.

Overview of the Sample App

The sample app uses APEX’s application access control with roles for Customer and Staff. App pages use an appropriate authorization scheme so customers can request reimbursements while staff members approve them. The app declaratively enables Progressive Web App (PWA) installation and push notifications, and the overview video below shows you how the app works on iPhone and Mac desktop PWAs for a customer user BO and a backoffice user PAT.

Regenerating the Signing Key Credentials

After downloading and importing the sample app, the first required setup step is regenerating the public/private key pair credentials. It’a is a one-time step that’s necessary the first time an APEX app using push notifications is imported into a new workspace. This is a new kind of credential used to cryptographically sign the push notifications your app sends.

In practice, it’s a one-click operation. Under Shared Components > Progressive Web App > Push Notifications, click the (Regenerate Credentials) button, and then in the confirmation dialog that appears click (Regenerate Credentials) again to confirm. Ok, you got me. It’s a two-click operation! You can regenerate the credentials any time in the future as well, but be aware that doing so invalidates and removes any existing user push notification subscriptions. Therefore, should you decide later to regenerate the credentials, your users will need to opt-in again to receive your app’s push notifications.

Creating the Two Sample Users

The sample app depends on a backoffice staff user named PAT who is configured as the approver on the Reimbursement Approval task definition and another user BO who is a customer. As shown in the demo video above, customer BO uses the app to request reimbursement of an expense, then backoffice user PAT approves or rejects the request, which results in sending the requesting user a push notification to alert them of the outcome.

If you have recently tried the Sample Approvals app from the gallery, you might already have workspace users PAT and BO, but if not then you’ll need to create them. Login to your APEX workspace as a workspace admin user and click the Administration (i.e. “person with wrench”) icon in the toolbar and choose the Manage Users and Groups option. Add the missing account(s) on this page using the Create User button.

Assigning Sample Users an Application Role

Once users PAT and BO exist, next you need to assign them an appropriate application role so that the staff member user PAT sees the approval pages and the customer BO sees the reimbursement request pages. To do this, in the context of the sample application in the App Builder, click Shared Components > Application Access Control. Use the (Add User Role Assignment) button twice on this page to add two user role assignments:

  • PAT -> Staff
  • BO -> Customer

HTTPS & Trusted Certificate Requirement

Keep in mind if you’re trying the sample on your own APEX instance that both on-device PWA installation as well as subscribing to push notifications depend on a secure, trusted connection. This means your desktop or mobile browser needs to access the APEX app over HTTPS and the client device must trust the server’s security certificate. If your APEX is running plain HTTP or it’s using a self-signed certificate that you haven’t configured your client device to trust, exploring the sample won’t work as expected. In that case, I recommend trying it on apex.oracle.com or your Oracle Cloud always free tier APEX instance instead.

Installing the Sample App as a PWA

The public APEX PWA Reference App’s Push Notifications page documents the compatibility matrix of supported operating systems and devices. If you are an iPhone user, notice that subscribing to push notifications on iOS and iPadOS require version 16.4 or later, as well as your installing the app as a PWA first. Other supported combinations allow push notifications either from the browser or when installed as a PWA. In the video above, I used Chrome on MacOS Ventura to install the PWA for user BO, Microsoft Edge to install the PWA for user PAT, and an iPhone 11 Plus running iOS 16.4 to install the mobile PWA for BO. You can use any of the supported combinations highlighted in the compatibility matrix.

I recorded the demo video on an APEX instance with instance-level settings Allow Persistent Auth set to Yes and Rejoin Sessions set to Enabled for All Sessions, so users can stay logged in for a month by default and where tapping on the push notification does not require the user to login again to see the related detail information in the APEX PWA application.

Opting-In to Receive Push Notifications

As shown in the demonstration video above, each user of your app needs to opt-in to receive push notifications from your app. And they need to do this on each device where they want to receive the notifications. When creating a new app with the Push Notification feature enabled in the Create App wizard, APEX generates a user settings modal drawer page containing the link to the Push Notification Settings page. For an existing app, there is a button on the Push Notifications page of the PWA app settings to generate the user settings page with a single click. At runtime, if the settings page shows a “Not Supported” badge, check to make sure you’re using HTTPS and a trusted certificate. If tapping or clicking on the Enable push notifications checkbox produces an error, that’s a signal you probably forgot to regenerate the push notification key pair credentials after importing the app the first time.

Using an App Page as Push Notification Target URL

When sending a push notification, you can configure a target URL that the device uses when the user taps or clicks on the notification. It must be an absolute URL, so for example the Reimbursement Approval task definition in the sample app contains an action that executes in response to the Complete event. Its action PL/SQL code sends the push notification about the task approval or rejection using the following code:

apex_pwa.send_push_notification(
p_user_name => :CREATED_BY,
p_title => 'Reimbursement '||initcap(:APEX$TASK_OUTCOME),
p_body => 'Your reimbursement of ' || :AMOUNT ||
' from ' || :RECEIPT_FROM ||
' was ' || lower(:APEX$TASK_OUTCOME)||'.',
p_target_url => apex_util.host_url||
apex_page.get_url(
p_page => 'reimbursement-notification',
p_items => 'p7_id',
p_values => :APEX$TASK_PK)
);

Notice how the value being passed to the p_target_url parameter prefixes the result of the apex_page.get_url() result by the apex_util.host_url expression. This ensures that the URL is a fully-qualified absolute URL. When using a declarative Send Push Notification page process, the APEX engine handles this for you, so this tip only pertains to the send_push_notification() API.

When the target URL is a page of your APEX app, the page must have the following properties configured:

  • Authentication = Public
  • Deep Linking = Enabled
  • Rejoin Sessions = Enabled for All Sessions
  • Page Protection = Arguments Must Have Checksum

This public target page will typically use a Before Header branch to redirect to an authenticated page in the app, passing along appropriate parameters to show the user the expected detail information for the notification. When combined with the use of APEX’s persistent authentication “Remember me” functionality, this combination gives the most seamless user experience. Page 7 in the sample app, whose alias reimbursement-notification appears in the send_push_notification call above, meets all of these requirements and performs the forwarding to the authenticated page 6, passing along the reimbursement request id to show the end-user the request details.

Granting Outbound Network ACLs for Push Services

The APEX engine sends push notifications by invoking secure notification REST services from Apple, Google, Microsoft, and Mozilla depending on the subscribing user’s device. If you try the sample app on apex.oracle.com, APEX Service, or APEX on Autonomous Database, you won’t have to worry about granting outbound network access for these notification service domains. However it’s a task you must perform when you use push notifications on your own on-premises APEX instance. In case you need it, the following PL/SQL block is an example of how to grant the appropriate ACLs to the push notification REST API domains and optional a proxy server if you are behind a corporate firewall. If your APEX applications are already using REST services, you will likely already be familiar with these steps. It’s included here for reference:

-- Run as SYS or DBA user
declare
l_principal varchar2(20) := apex_application.g_flow_schema_owner;
-- e.g. 'APEX_230100';
l_proxy varchar2(100) := null; -- e.g. 'proxy.example.org'
l_proxy_port number := 80;
l_hosts apex_t_varchar2 := apex_t_varchar2(
'*.push.apple.com',
'*.notify.windows.com',
'updates.push.services.mozilla.com',
'android.googleapis.com',
'fcm.googleapis.com');
procedure add_priv(p_priv varchar2, p_host varchar2, p_port number) is
begin
dbms_network_acl_admin.append_host_ace (
host => p_host,
lower_port => p_port,
upper_port => p_port,
ace =>
xs$ace_type(privilege_list => xs$name_list(p_priv),
principal_name => l_principal,
principal_type => xs_acl.ptype_db));
end;
begin
if l_proxy is not null then
add_priv('connect',l_proxy,l_proxy_port);
end if;
for j in (select column_value as hostname from table(l_hosts)) loop
add_priv('connect',j.hostname,443);
end loop;
commit;
end;

Configuring Wallet to Validate Certificates

On your own APEX instance, in addition to the outbound REST service ACLs required for push notification, you may also need to add certificates into the wallet your instance is using for validating secure HTTP communications. If you are running APEX in the Oracle Cloud, this step should not be necessary. However, on your own instance you may find my colleague Daniel Hochleitner’s open-source Oracle CA Wallet Creator script useful for that purpose.

NOTE: I had success using Daniel’s utility only after removing the $ORACLE_HOME/bin directory from my system path on my Linux server. This causes the script to use openssl to create the wallet instead of using orapki. For no fault of Daniel’s script itself, the wallet created using orapki was causing APEX’s attempt to deliver a push notification to encounter the error ORA-29106: Cannot import PKCS #12 wallet. This orapki-related issue does not affect all Oracle Database versions, so your mileage may vary. I’m just reporting what worked for my case which involved using Oracle XE 21c on CentOS 7 Linux.

Downloading the Sample App

You can download the sample app from here.

Downloading My Slides

You can download the slides from my recent talk on this topic at the APEX Alpe Adria 2023 conference from here: APEX 23.1: Native Push Notifications & Easy Background Processing.

Page Processing Power Unchained

APEX 23.1 execution chains greatly expand the power of your page processes. This new process type lets you assign a name to a group of child processes that will execute in sequence if the parent’s condition is true. Any of the chain’s child processes can be another chain if needed, so you can organize page logic into a more self-documenting tree structure.

In addition, you can configure any branch of this “processing tree” to run in the background with the flick of a switch in the Property Editor. Your end-users move on to tackle other tasks in your app while longer-running work happens behind the scenes. In this article, you’ll learn more about this new feature, how to try out a sample app, and where to download the slides from my recent APEX Alpe Adria conference presentation about it.

Chains Enable Increased Clarity

APEX developers know that their page processes execute in sequence. But many page processes, like the three in the figure below, have a Server-side Condition configured. When a teammate opens a page like this, to understand the “flow” of the page logic they must select each conditional page process and study its conditional expression.

Three conditional page processes: which runs when?

The figure below shows how introducing two execution chains with meaningful names helps to clarify the intent of the processing logic. If we are rendering the page in order to duplicate a Venue record, then two of the page processes indented inside that “branch” of the tree will execute. Otherwise, the Initialize from Venue process will run. Notice how we’ve “refactored” common conditions on the individual page processes to instead be associated with the owning parent chain.

“Refactored” page processing logic using well-named execution chains with common conditions

Defining an Execution Chain

To define an execution chain, just create a page process and set its type to Execution Chain. To add child processes to it, choose Add Child Process from the chain’s right-click context menu. As shown in the figure below, for any page process you select in the component or processing tab, its Execution Chain property shows the name of the execution chain it belongs to, or displays None if it is a top-level page process. To change which execution chain a page process belongs to, simply use this Execution Chain select list in the Property Editor and change it to the new parent execution chain name.

The Execution Chain property of a page process shows the parent chain it belongs to

Switching On Background Execution

While APEX Automations let you schedule periodic background jobs, beyond sending email they are a essentially a code-focused affair. Execution chains extend your processing capabilities deeper into the declarative realm. You can organize page processes of any type into chains whose sequence of child processes either run immediately in the user session or else in the background. A chain can send an email, send a push notification, perform data loading, initiate a human approval task, execute PL/SQL code, or engage any other page process type, including custom plug-in types. Used with the InvokeAPI page process type, it offers a simple way to orchestrate the conditional execution of APIs implemented in PL/SQL packages or as REST services.

When running in the background, your chain’s child processes automatically have access to a cloned copy of all the session state, and they can update page item values in the usual way. You can configure the chain to move selected temporary files to the background session, or to make a copy if both the foreground and background need to access a file for some reason. The only difference to be aware of is that, by design, changes to session state performed by page processes running in the background are not reflected in the end user’s application session that originally initiated the background execution chain.

A Deeper Processing Tree from the Sample

The figure below shows the processing tree of the Department page in the sample app we explore below. Notice how we can read the processing logic like an outline to more easily understand what it’s doing. After first processing the Department form, if the (Process) button was pressed, it first checks whether the current department is being processed already. If it’s not being processed the Else if Deptno Available to Process… chain runs. It starts by registering the username who is kicking off the processing of the current department. Then it proceeds to run the Process Department in Background chain. Notice in the Property Editor that the Execute in Background switch is turned on for this chain. It’s configured to return the unique ID of the execution background process into the P3_EXECUTION_ID page item.

Page processing tree with multiple levels and one branch marked to Execute in Background

Once the background processing is enqueued — which happens very quickly — the current chain proceeds to execute the next child process at the same level: If Background Process Succeeded… This process can test whether the value of P3_EXECUTION_ID is not null to conclude that enqueuing the background work was successful. Finally, the Close Dialog process closes the modal dialog. In short, the rule of thumb is that every page process at the same level of the tree runs sequentially, with background chains getting enqueued to be executed as soon as is feasible. We explore below why that might not be immediately, due to limits imposed on concurrent background jobs.

Overview of the Sample App

The short video demonstration below shows off the sample app for this article. The Departments page lets you open a modal drawer to edit the department. That Department page also contains a (Process) button to trigger the execution of the When Process Button Pressed… action chain shown above. This action chain’s “tree” of child processes ultimately contains the Process Department in Background action chain that is configured to run in the background. The Processing Status page shows the progress of each background process. The Processing History page shows average running time of the background processes, and the average time background processes wait before starting.

Short video demonstrating the sample app

What’s Going on in Process Department?

The long-running Process Department process in the sample calls the PL/SQL procedure PROCESS_DEPTNO in the EBA_DEMO_BG_PROC package using InvokeAPI. This code simulates a long-running process that will take between 60 and 100 seconds. Notice it uses the SET_PROGRESS() procedure in the APEX_BACKGROUND_PROCESS package to report progress of the background processing to the APEX engine. The Processing Status page references this information about units of total work and units completed so far using the APEX dictionary view APEX_APPL_PAGE_BG_PROC_STATUS.

procedure process_deptno(p_deptno number)
is
c_total_secs constant integer := 60 + round(dbms_random.value(0,40));
c_steps constant integer := 10;
begin
-- Simulate long-running process taking 60-100 secs
apex_background_process.set_progress(p_totalwork => c_steps,
p_sofar => 0);
for j in 1..c_steps loop
dbms_session.sleep(round(c_total_secs/10));
apex_background_process.set_progress(p_totalwork => c_steps,
p_sofar => j);
end loop;
end;

Imposing Limits on Background Processing

APEX lets developers set a per-session limit on the number of times an end user can simultaneously enqueue a background execution chain. You accomplish this by setting a value for the Executions Limit property of the chain. It also allows configuring both an application-level and workspace-level maximum number of concurrent background processes. The per-session limit will raise an error if a user tries to exceed the limit. In contrast, the limit on concurrent executions throttles the processing speed of the background execution chain jobs queue. As shown in the demo video above, background processes may have to wait longer to get scheduled and execute. To show the difference, the figure below shows the same sample app running on an APEX instance where the concurrent background processing limit is at least three or higher. You can see that the user scheduled three DEPT rows to be processed and they are all in some stage of execution progress simultaneously.

With higher concurrent background process limits, multiple background processes run in parallel

An obvious result of a system with higher resource limits is that enqueued background execution chain jobs get scheduled and execute more quickly. As shown in the figure below, the average time a job waits to start is around one second on the system I used. In the demo video I recorded using apex.oracle.com, the average wait-to-start time was over a minute where resources were more constrained.

Background execution chain process jobs start quickly when concurrency limits allow it

Handling Failure to Enqueue Background Process

On page 3 in the sample app, the Process Department in Background execution chain is configured with an Executions Limit property value of 3. This means a user session can initiate a maximum of three background execution chain jobs. An attempt to launch a fourth one will produce the error shown in the figure below.

Error message when user tries to launch a fourth simultaneous background process

We configure the error message using the Error Message property on the execution chain. However, this sample application inserts a row into the table EBA_DEMO_BG_PROC_PROCESSES to track which user is processing which department, and later the background execution chain process updates that row with the execution chain id once the background job gets scheduled and begins to run. In the case that the user encounters an error while the background execution chain is enqueued due to having hit the Executions Limit, I need to delete the tracking row in the EBA_DEMO_BG_PROC_PROCESSES table that had been inserted previously. I accomplished this by registering the page 3 error handler function to use the p3_handle_bg_processing_error function below (in the eba_demo_bg_proc package). It checks whether the error encountered relates to the Process Department in Background process, and if so, it calls unregister_bg_proc_for_deptno() to delete the tracking row.

function p3_handle_bg_processing_error(p_error apex_error.t_error)
return apex_error.t_error_result is
l_result apex_error.t_error_result;
begin
l_result := apex_error.init_error_result(
p_error => p_error);
if is_process_error(p_error,'Process Department in Background') then
unregister_bg_proc_for_deptno(
p_deptno => V('P3_DEPTNO'),
p_orig_id => V('P3_ONGOING_WORK_ID'),
p_success => false
);
end if;
return l_result;
end;

Getting the Sample App

Download the sample app from here. Before running the application, first ensure you’ve installed the DEPT/EMP sample dataset. If you’re not sure, choose SQL Workshop > Utilities > Sample Datasets to verify and install it if needed.

Downloading My Slides

You can download the slides from my recent talk on this subject at the APEX Alpe Adria 2023 conference from here: APEX 23.1: Native Push Notifications & Easy Background Processing.

Designer Collab for Date Ranges

When searching for hotels, I often specify check in and check out dates with two clicks on the same mini-calendar. The new date picker’s dayFormatter function let me achieve the same user experience in an APEX app, with some CSS help from my designer colleague Jeff Langlais. I got the basic functionality working; then Jeff updated the stylesheet with his CSS magic to make it sing. Finally, a tip from colleague Stefan Dobre about JavaScript classes unlocked how my Java programming experience could be an asset while learning this new language. It inspired me to refactor my code to make using a date range picker very simple in future APEX apps I build.

Overview of the Strategy

My strategy involved using an inline date picker page item as the “surface” the user interacts with to set and see the date range. The date picker page item works together with two other date fields that capture the actual start and end dates. Depending on the application, it might be desirable for the user to see the start and end dates in an alternative display format. However, in my sample application I decided to set them to be Hidden page items. As shown in the figure below, the dayFormatter function associated with the date range picker, considers the values of the hidden Check In and Check Out dates to decide how to format the days in the date range between start date and end date. It also decides the appropriate tooltip to show the user based on these values.

The dayFormatter function on the date range picker styles the range of days and tooltips

CSS Style Classes Involved

The date picker used as the date range picker is tagged with the CSS class date-range-picker. This allows targeting the CSS style rules so that they only affect date range picker page items, without disturbing the styling of other date pickers used in the application. Next, I identified three different styles required to render the “stripe with rounded ends” look I imagined in my head. As shown below, the CSS class dateRangeStart represents the start date of the range, the dateRangeEnd for the end date, and thedateRangeMiddle class for those days in between. I wrote the dayFormatter function to return null for the CSS class property for any days in the “mini-month” that were before or after the date range. For those days within the range, it returns one of these three CSS class names depending on whether the day being formatted is the beginning, middle, or end of the range. The apex.date namespace functions parse(), isSame(), isBefore(), isAfter(), and isBetween() came in handy for writing the date-related logic in the dayFormatter function and the date change handler function described later.

Three CSS class names involved in formatting a date range as a “stripe with rounded ends”

After getting the initial dayFormatter logic working, I realized that some use cases might need a date range that starts and ends on the same day. For example, this would be the case for the dates of a single-day event. To allow for a more visually pleasing single-day date range, I decided a fourth CSS class dateRangeSingleDay was needed to achieve the appropriate “pill” shape the user would expect a one-day event to have. I adjusted the dayFormatter function to return this new class name if start date and end date were the same.

Additional CSS class to handle single-day events as a special case

Handling Date Range Input & Reset

When the user clicks on a day in the “mini-month” calendar of the date range picker, the Change event will fire for that page item. I wrote the logic of the change handler to work as follows:

  • If start date is not set, then set it to the clicked-on date
  • Otherwise, if the clicked day is after the start date, then set the end date to the clicked-on date
  • If the clicked day is before the current start date, then set start date to the clicked-on date
  • Finally, set the date range picker to the value of the start date again, and
  • Refresh the date picker item to re-evaluate the dayFormatter in the process

When the user clicks on the button to reset the date range picker, the Click event will fire for that button. I wrote the logic of the click handler to:

  • Set the value of the start date to null
  • Set the value of the end date to null
  • Set the value of the date picker to null
  • Refresh the date picker item to re-evaluate the dayFormatter in the process

Following good practice, I had written the bulk of my JavaScript logic in a shared application file dateRangePicker.js It defined a dateRangePicker JavaScript object with three functions:

  • assignDayFormatter() called from the Page Load dynamic action event
  • onChanged() called from the Change dynamic action event on the date picker
  • reset() called from the Click dynamic action event of the reset button

In the page containing the date range picker page item, the hidden start date item, the hidden end date item, and the reset button, I setup dynamic actions to invoke the helper methods like this:

Initial implementation using dynamic actions to call JavaScript functions in a helper object

Abstracting Interesting Bits into Metadata

After initially hard-coding the values of the date range picker item, the start date and end date page items, I next tried to add a second date range picker on the same page and rework my code to accept the interesting information as parameters that made the two instances unique. Instead of passing in 10 separate parameters, I decided to pass all the info required as a single parameter in a structured JavaScript object. An example of this parameter object appears below. It captures the names of the page items involved in a single date range picker:

{
picker: {
name: "P2_CHECKIN_CHECKOUT_PICKER",
format: "DD-MON-YYYY",
allowSingleDay: false
},
start: {
name: "P2_CHECKIN",
label: "Check In"
},
end: {
name: "P2_CHECKOUT",
label:"Check Out"
}
}

By passing the appropriate JavaScript object to each of the helper methods, I was able to rework the code to easily support date range pickers on any page in my application and even multiple ones on the same page.

Working in Parallel with a Designer

Since I’m not a CSS expert, I started with the simplest possible dateRangePicker.css file containing the style classes for the four states the date range picker needed, setting a different font color and italic style for the different date range classes. I used the Chrome browser tools Inspect Element… feature to study what elements and classes would need to be selected by these basic CSS rules. In words, for example, the first rule below selects a <td> element having the CSS class dateRangeStart wherever it’s nested inside a containing element with class a-DatePicker-calendar (the “mini-month”) where that is nested inside a containing <a-date-picker> element having the class date-range-picker:

a-date-picker.date-range-picker .a-DatePicker-calendar td.dateRangeStart
{
color: yellow;
font-style: italic;
}
a-date-picker.date-range-picker .a-DatePicker-calendar td.dateRangeMiddle
{
color: darkmagenta;
font-style: italic;
}
a-date-picker.date-range-picker .a-DatePicker-calendar td.dateRangeEnd
{
color: green;
font-style: italic;
}
a-date-picker.date-range-picker .a-DatePicker-calendar td.dateRangeSingleDay
{
color: blue;
font-style: italic;
}

The effect wasn’t exactly what I had predicted, but as shown below I could see after selecting February 13th as the start date and 16th as the end date, that the dates in the date range were showing with the indicated colors and in italic. As you can see below, there was something about the date picker’s default styling of the current date (which, recall, coincides with the start date of the date range) that was overriding my styles. That current date was colored with a blue circle. However, I could see that the font style was italic, so I knew my style rule was correctly selecting that dateRangeStart day. I also noticed that today’s date was showing in the calendar with a different colored circle.

Initial attempt at CSS stylesheet to style the date range days differently

Rather than trying to become a CSS expert, I decided to pass these requests along to Jeff the designer so that he could incorporate solutions into the final CSS stylesheet he gave me back. In addition to the “stripe with rounded ends” look for the date range, I also asked him to explore hiding the current day indicator. You can explore the sample application’s dateRangePicker.css static application file to see the CSS magic that Jeff worked to make the date range picker look great. This was a concrete example of how an APEX developer with only the most basic CSS skills could easily collaborate with a highly-skilled CSS web designer to produce a nice-looking result.

Leaning Into JavaScript Classes

As a final step, I asked my colleague Stefan Dobre to review my JavaScript newbie code to suggest any improvements. He recommended I explore further encapsulating the logic of the date range picker into a self-contained DateRangePicker class. Its constructor could accept the JavaScript object describing the combination of picker, start date, and end date page items, and then internalize the details of:

  • Setting the date-range-picker CSS class on the picker page item
  • Assigning the dayFormatter function to the picker page item
  • Adding an event listener to the picker’s Change event to call onChanged()

By expanding the metadata captured by the constructor to also include the static id of the reset button, the DateRangePicker class could also internalize adding an event listener to the button’s Click event to call reset().

Since I’d programmed for many years in Java in my previous roles at Oracle, the idea of using a class felt second nature. But as a JavaScript neophyte, the idea never crossed my mind. So Stefan’s suggestion unlocked a positive path in my Java brain that will hopefully make future JavaScript development more familiar. You can see the full code for the DateRangePicker JavaScript class in the sample application’s dateRangePicker.js static application file, but the skeleton of the implementation looks like this. Its constructor accepts the JavaScript object describing the configuration details of the date range picker page items, sets the date-range-picker CSS style class on the picker page item, assigns the initial value to the date picker from the start date, assigns a dayFormatter function to the picker, and wires up the change and click event listeners to run the appropriate code to handle those actions.

window.DateRangePicker = class DateRangePicker {
// Construct the DateRangePicker accepting object that describes
// the picker, start date, end date names, and reset button id
constructor(pConfig) {
this.#config = pConfig;
// Assign the date-range-picker CSS class to the picker
this.#pickerItem().element.addClass("date-range-picker");
// Assign the initial value of the picker from the start date
this.#assignInitialValueFromStartDate();
// Assign the dayFormatter funtion
this.#assignDayFormatter();
// Wire up the change event on the picker to call onChanged()
this.#pickerItem().element.on("change", () => {
this.#onChanged();
});
// Wire up the click event on the reset button to call reset()
document.getElementById(this.#resetId()).addEventListener(
"click", () => {
this.#reset();
})
}
// Private fields ==================================================
#config;
// Private methods =================================================
#assignDayFormatter() {...}
#onChanged() {...}
#reset(){...}
}

With this class in place, you can see how it’s used in page 2 and page 4 of the sample app. Their respective page load JavaScript code contains two simple calls like the following to construct two DateRangePicker class instances, passing the interesting info into each’s constructor.

// Example from Page 4 in the sample app's Page Load JavaScript
// Setup config for Event Start/End Date Range Picker
// Allows a single day to be both start and end
window.eventStartEndDateRangePicker = new DateRangePicker({
picker: {
name: "P4_EVENT_DATE_RANGE",
format: "DD-MON-YYYY",
allowSingleDay: true
},
start: {
name: "P4_EVENT_STARTS",
label: "Event Start"
},
end: {
name: "P4_EVENT_ENDS",
label:"Event Start"
},
reset: {
id:"Reset_Event_Dates"
}
});

With all the logic encapsulated in the JavaScript class, there is no setup left in the page other than making sure the picker, start date, and end date page items have their Value Protected property set to false and that they all use the same format mask. This resulted in a page you can experiment with in the sample to create or edit the details of an Event. Each Event in the sample app has a start and end date (which can be the same day) as well as a default check in and check out day for event attendees (which must be at least two different days).

Two date range pickers in action in a sample app editing Event details

Get the Sample App

You can download the APEX 22.2 sample application by clicking here. Thanks again to designer Jeff Langlais for helping me with the CSS styles to deliver the visual idea I had in mind, and to Stefan Dobre for teaching me about JavaScript classes to simplify how to uptake the date range picker functionality in future APEX apps I will build.

Custom Date Picker Formatting

Overview

APEX 22.2 introduced a new lighter-weight date picker that is faster, offers better accessibility, and features a flexible day formatter function. Using the Format Date Picker Days plug-in, low-coders can easily format a date picker using a SQL query or an ICS calendar file to style a set of days in the “mini month” calendar used to select a date. In this article we explore the day formatter function, learn what the plug-in can do declaratively, and see how to extend what the plug-in can do while gaining some JavaScript newbie insights in the process.

A Simple dayFormatter Function

The APEX 22.2 date picker supports an optional day formatter function. You can use it to customize the appearance of any day in its “mini month” calendar. Once you’ve assigned a dayFormatter function to a date picker, it gets invoked for each day in the mini month when the page item is rendered or refreshed. On each invocation, your function returns an object whose property values determine three aspects of the current day in the mini month:

  • Whether the day is enabled or disabled,
  • What CSS class (if any) should be used to format the day, and
  • What tooltip (if any) should be shown when the user hovers over the day.

Your dayFormatter function receives a single parameter whose value is the date being formatted. This date parameter value will always be in the standard format YYYY-MM-DD. Your function must return an object with three properties named disabled, class, and tooltip. The following simple dayFormatter function ignores the value of the date passed in and just returns the same values for every date. This results in all days being enabled, styled using the CSS class named customDayStyle, and having the tooltip “Choose a delivery date”:

function (pCurrentDate) {
// Ignore the pCurrentDate and format every day the same
return {
disabled: false, /* Day is not disabled */
class: "customDayStyle", /* Use this CSS class */
tooltip: "Choose a delivery date" /* Use this tooltip */
};
}

In practice, your day formatter function will typically implement conditional behavior based on other factors, using the values returned for the class and tooltip parameters to call attention to interesting dates in the mini month. By returning null for the class property, it signals that the day should be styled in the normal way, and returning null for tooltip indicates that the day will have no tooltip.

Assigning the dayFormatter During Page Load

Typically, you will assign your day formatter function at page load time. You can either use the Execute when Page Loads block of JavaScript code to accomplish this, or write a JavaScript action step on an Page Load dynamic action event handler. For example, to assign the above dayFormatter function to a date picker page item named P13_DELIVERY_DATE, you would write the following block of JavaScript in the Execute when Page Loads script of page number 13. Notice that the last line calls refresh() on the page item to which you’ve just assigned the day formatter function. This refresh()call causes the new day formatting function to get used immediately before the user first sees the date picker.

// Assign a dayFormatter function to P13_DELIVERY_DATE date picker
apex.items.P13_DELIVERY_DATE.dayFormatter = function (pCurrentDate) {
// Ignore the pCurrentDate and format every day the same
return {
disabled: false, /* Day is not disabled */
class: "customDayStyle", /* Use this CSS class */
tooltip: "Choose a delivery date" /* Use this tooltip */
};
};
// Refresh the page item to engage the new day formatter
apex.items.P13_DELIVERY_DATE.refresh();

Disabling Weekends in the Mini Month

A common use case is disabling weekends to guide the user to pick a weekday. You can accomplish this by assigning a simple dayFormatter function that returns true for its disabled property for any day that is Saturday or Sunday. The apex.date namespace contains many useful functions for working with date values in JavaScript. Its parse() function converts the string format of the day being formatted into a JavaScript Date object. You can then call the getDay() method on the Date object to get the day of the week (0=Sunday, 1=Monday, …, 6=Saturday). So, the following slightly modified dayFormatter only allows the user to pick a weekday for a delivery date, uses no CSS class, and returns no tooltip for weekend days that are disabled:

// Assign a dayFormatter function to P13_DELIVERY_DATE date picker
apex.items.P13_DELIVERY_DATE.dayFormatter = function (pCurrentDate) {
// Parse the current day's date using ISO8860 format mask
const curDate = apex.date.parse(pCurrentDate,"YYYY-MM-DD");
const dayOfWeek = curDate.getDay();
const isWeekend = (dayOfWeek == 6 /*Sat*/ || dayOfWeek == 0 /*Sun*/);
return {
disabled: isWeekend,
class: null, /* no day formatting class */
tooltip: isWeekend ? null /* no tooltip for weekend, else... */
: "Choose a delivery date"
};
};
// Refresh the page item to engage the new day formatter
apex.items.P13_DELIVERY_DATE.refresh();

This produces the following effect:

P13_DELIVERY_DATE date picker showing disabled weekends and a custom tooltip

Getting the Format Date Picker Days Plug-in

The APEX team’s GitHub site has a Format Date Picker Days plug-in you can use to handle some day formatting use cases declaratively using either an ICS calendar file or a SQL query. To download the plug-in, visit https://oracle.github.io/apex/, scroll down to the Plug-ins section of the page shown below, and right-click on the Download Plug-In link to choose the “Save Link As…” (or similar) option in your browser. This will save a plug-in file named dynamic_action_plugin_format_datepicker_days.sql to your computer. You can import this file as a plug-in into any APEX 22.2 application or later.

Plug-ins section on APEX GitHub Page at https://oracle.github.io/apex/

Formatting Dates Using an ICS File

The 22.2.2 version of the Sample Calendar app in the gallery has a Day Formatting (Plug-in) page 540 that contains two examples of date pickers using the Format Date Picker Days plug-in to show American holidays using the Universal Theme color class u-hot-text. One date picker is the page item P540_US_HOLIDAYS_PLUGIN that references a public Google Calendar URL to the ICS file for holidays in the USA. It is configured using Page Load dynamic action named Load US Holidays into Date Picker that uses an action step of type Format Date Picker Days [Plug-In] as shown below.

Declaratively formatting a date picker using an ICS calendar file

The result is that american holidays in the calendar show with the universal theme’s “Hot Text” CSS class like this:

Date Picker showing US holidays using Universal Theme u-hot-text CSS class

The same page uses a Row Initialization [Interactive Grid] dynamic action to format the START_DATE date picker column in an Interactive Grid region on the page using the same ICS calendar file.

Formatting Dates Using a SQL Query

The Announcements sample you can download at the end of this article is a simple app that lets the user enter time-sensitive announcements. Each announcement has a display from date, a display to date, and a purge date. For the sake of argument, let’s say that announcements are only purged on the last day of the month and that we want to let the user pick a purge date only in the next three months.

By configuring the Page Load dynamic action to use an action step of Format Date Picker Days [Plug-In], we can choose the SQL Query option and use a query like the one below to return the next three last days of the month. The query uses two union all operators and the SQL last_day() and add_months() functions to return a total of three rows. The first has the last day of the current month. The second row has the last day of the next month. And the third row has the last day of two months from now. Notice that each of the three select statements returns the same date value for the start_date and end_date column since they are single-day periods. However, the plug-in supports returning a range of consecutive days as a single (start_date , end_date) pair of values in a row. All the days in that date range will be styled as indicated by the other column values in that row.

select /* last day of this month */
trunc(last_day(sysdate)) as start_date,
trunc(last_day(sysdate)) as end_date,
'bold-and-red' as css_class,
'Purge Day' as tooltip,
0 as is_disabled
from dual
union all
select /* last day of next month */
trunc(last_day(add_months(sysdate,1))) as start_date,
trunc(last_day(add_months(sysdate,1))) as end_date,
'bold-and-red' as css_class,
'Purge Day' as tooltip,
0 as is_disabled
from dual
union all
select /* last day of month after that */
trunc(last_day(add_months(sysdate,2))) as start_date,
trunc(last_day(add_months(sysdate,2))) as end_date,
'bold-and-red' as css_class,
'Purge Day' as tooltip,
0 as is_disabled
from dual

Next you ensure that your plug-in usage correctly configures the names of the query result columns that provide the appropriate values that drive the formatting as shown below, making sure to enter any page item names into the Item to submit field if the query references them as bind variables:

Configuring the query result column names that provide day formatting information

The bold-and-red CSS class is defined in the Inline CSS section of the page. I originally had defined it like this:

.bold-and-red {
font-weight: 900 !important;
color: var(--ut-palette-danger) !important;
}

However, when that was not working for me, my colleague Ronny gave me the tip that some important changes to improve the accessibility of the date picker required that I write the style rule like this instead:

.bold-and-red {
}
.bold-and-red > span {
font-weight: 900 !important;
color: var(--ut-palette-danger) !important;
}

This produces the Purge Date date picker that shows the last day of the next three months in bold red text like this:

Using SQL to Specify Only Available Days

Using the three rows returned by my SQL query above, the Format Date Picker Days plug-in formatted those three days in a special way. But, what if I wanted those three days to be the only three days the user can pick? Using the plug-in on its own, this requires writing a query that retrieves a row for each start and end date range that I wanted to declaratively disable, using 31-DEC-9999 to represent the “End of Time”. Maybe for some use cases that would be easy to do, but as a general strategy it seemed complicated to determine. So, I experimented with the idea of combining the declarative plugin’s behavior with the ability to write a custom dayFormatter function to see if I could accomplish my desired goal in a more general way.

What I discovered is that the plug-in implements its declaratively-configured functionality by assigning an appropriate dayFormatter function to the date picker page item. It’s a day formatter function the low-code developer did not have to write herself, but a day formatter function nonetheless. My strategy was to first let the plugin assign its day formatter function, then to incorporate that day formatter function into my own day formatter function implementation. This way, I could “intercept” the day by day formatting and override the disabled property to disable all days by default unless the day was explicitly enabled by the plug-in’s day formatter function.

The day formatter function I devised is configured in a Page Load dynamic action event handler step of type Execute JavaScript that immediately follows the action step using the Format Date Picker Days [Plug-in] type to perform the SQL-based declarative configuration. I’ve used the 22.2 feature of more descriptive action step names to make the intent of the dynamic action more easy to read:

Dynamic Action steps configure a combination of declarative and programmatic day formatting

The code of the “…and Enable ONLY Those Three Days” action steps appears below. It effectively replaces the day formatter function configured by the previous dynamic action step by the plugin, with a new day formatter function that first delegates to the plug-in-configured function, then overrides the return value of the disabled property to return true for all other days in the mini month:

// Access the dayFormatter the Plug-in just assigned before this
const pluginFormatter = apex.items.P3_PURGE_DATE.dayFormatter;
// Replace the date picker's dayFormatter with my own
apex.items.P3_PURGE_DATE.dayFormatter = function (pDateISOString) {
// That first invokes original pluginFormatter to get its result object
const plugInFormatterResult = pluginFormatter(pDateISOString);
// Then returns a result that defaults disabled to true
return {
// disable by default if not in plug-in formatter's result object
disabled: 'disabled' in plugInFormatterResult
? plugInFormatterResult.disabled : true,
// return class if in plug-in formatter's result object
class: 'class' in plugInFormatterResult
? plugInFormatterResult.class : null,
// return tooltip if in the plug-in formatter's result object
tooltip: 'tooltip' in plugInFormatterResult
? plugInFormatterResult.tooltip : null
};
};
apex.items.P3_PURGE_DATE.refresh();

The result, is a mini month for the Purge Date field of an announcement that only allows the last day of the current and two successive months to be selected. And these three days are formatted with an extra bold, “urgent” (red) color to highlight the destructive nature of the announcement purge operation.

Complementing declarative day formatting with custom formatting

A Lesson Learned on Async Nature of JavaScript

My earlier attempts to get this “delegating day formatter” idea to work were not functioning as expected. My colleagues Stefan Dobre and Ronny Weiss helped me understand that this was due to the asynchronous nature of the JavaScript language. When two dynamic action steps are listed in sequence, if an earlier step performs an AJAX call to the APEX server, the second step in the sequence might execute before the first one has completed. The Format Date Picker Days plug-in retrieves the results of the SQL statement by performing a round-trip to the APEX engine on the server. However fast this operation may be, it is still a network exchange that takes some time. I learned from my colleagues that all I needed to do was ensure the “Wait for Result” property of the plug-in was switched to the on position as shown in the figure below. This setting guaranteed my delegating day formatter code would only run after the plug-in had retrieved its data and successfully setup its day formatter function first. After checking that setting, everything starting working correctly.

Avoiding asynch timing complexities by simply ensuring the “Wait For Result” switch is on

Getting the Sample App

To check out the APEX 22.2 Announcements sample app that has the Purge Date date picker page item described above on the modal drawer page (3) used to create or edit an announcement, download the app from here. As mentioned above, also explore page 540 in the 22.2.2 version of the Sample Calendar app. Thanks again to my colleagues Stefan and Ronny for the precious advice they offered while working on this example.

Simplify Sharing Region Data

I’ve frequently found it useful to share one region’s data in another. For example, as a user narrows her results with a faceted search or smart filter, a chart, map or dynamic content region shows the same results in a different way. My colleague Carsten explains the technique in his Add a Chart to your Faceted Search Page blog post, and it has come in handy for me many times. But after the fifth pipelined table function and object types I authored to enable the data sharing, I set out to automate the process to save myself time in the future. As an APEX developer, the ability to create development productivity apps for yourself is a cool super power!

The Region Data Sharing Helper app featured in this article lets you pick a region from any app in your workspace and easily download scripts to share that region’s results in another region in the same application. After first explaining how to use the app, I highlight some interesting details of its implementation.

Overview

The app lets you select regions for which to generate data sharing artifacts. Since data sharing requires both data and a unique region identifier, the app only shows regions with a static id having a source location of Local Database, REST Enabled SQL, or REST Source. After adding a region to the selected list for artifact generation, if needed you can adjust the columns to include and the base name. Then, you can download the generated artifacts for that region. By incorporating the SQL scripts into your APEX app, you can configure additional regions in the same app to use the original one’s data using the SELECT statement provided in the accompanying README file.

The app maintains the list of selected regions to remember the subset of columns you configure and the base name of the generated artifacts you prefer for each region. If you later update one of the original regions in the App Builder in a way that affects its columns, just click the (Regenerate) button to refresh its datasource info and download the artifacts again.

Choosing a Region

As shown in the figure below, choose a region whose data you want to share with other regions in the same app. Click (Add Region) to add it to the list of selected regions below.

Region Data Sharing Helper app showing Employees region in HR Sample app

Sometimes the selected region is immediately ready for artifact generation, but other times it may take a few seconds to show a status of Ready. If you see a status of Working, as shown below, wait 10 seconds and click the refresh icon to see if it’s ready yet.

Click (Refresh) after 10 seconds if status shows as Working

Once the status of a selected region is Ready, you can adjust the base name and included columns and save any changes. If you’ve modified the original region in your app in a way that affects its data source columns, click Regenerate to refresh the set of available columns to choose from.

Adjusting the included columns and base name as necessary

Once you’ve saved any changes, the download button will reappear. Click it to get a zip file containing the generated SQL scripts and a README file explaining what they are and how to use them.

Clicking the download button to produce the data sharing artifacts

When you no longer anticipate needing to download data sharing artifacts for a selected region, you can remove it from the list of selected ones by clicking the Delete button. This simply removes it from the helper app’s list of selected regions. You can always add it again later as a selected region if the need arises.

Exploring the Downloaded Artifacts

After clicking the download button for the Employees region on page 1 of the HR Sample application shown above, since the Base Name was employees a zip file named employees-region-data-sharing-artifacts.zip is downloaded. Extracting the zip file reveals three generated files as shown below.

Mac finder showing the contents of the downloaded zip file

The README.html is the place to start, since it explains the other two files.

README.html file explains the generated artifacts and suggests SELECT statement to use

The employees_types.sql script defines the employees_row and employees_tab types used by the pipelined table function in the other file. The employees_function.sql defines the employees_data pipelined table function and depends on the types. You can include these scripts directly into your APEX application as Supporting Objects scripts, or add them to the set of your existing installation scripts. Due to the function’s dependency on the types, however, just ensure that the types script is sequenced before the function script.

For example, incorporating the generated SQL scripts into the HR Sample app above as supporting objects scripts would look like this:

Supporting Objects scripts after adding the two data sharing SQL files

Using Pipelined Table Function in a Region

The README file contains a suggested SELECT statement to use as the source of the region where you want to reuse the original region’s data. After running the types SQL script then running the function SQL script, you can try the suggested statement in another region in the same HR Sample application. In the example below, I’ve used it as the source of a chart region on the same page as the original faceted search region.

Using the SELECT statement suggested in the README file in another region in the same app

The query I’ve used appears below, and of course I could leave out columns that are not necessary in the new region. For simplicity, I used the statement verbatim as I found it in the README file:

select ename,
empno,
sal,
comm,
deptno
from table(employees_data)

After also configuring a dynamic action on the After Refresh event of the Employees region to declaratively refresh the Salaries chart region, we immediately see the data sharing working in action, reflecting the filtering the current user performs in the original region, too.

HR Sample app showing generated data-sharing artifacts in use to reflect filtered data in a chart

The rest of the article explains some interesting details of the Region Data Sharing Helper app implementation. If you’re primarily interested in trying out the functionality, you’ll find the download link at the end.

Cascading Select Lists for App, Page, Region

An extremely handy consequence of Oracle APEX’s model-driven architecture is that all application metadata is queryable using SQL. By using the APEX dictionary views, it’s incredibly easy to create new APEX applications that introspect application definitions and provide new value. In the case of the Region Data Sharing Helper app, I needed three select lists to let the user choose the application, page, and region for artifact generation. The query for the P1_APPLICATION select list page item appears below. It uses appropriate where clauses to avoid showing itself in the list and to only show applications that have at least one region with a non-null static id configured and a local database, remote database, or REST service data source.

select a.application_name||' ('||a.application_id||')' as name,
a.application_id
from apex_applications a
where a.application_id != :APP_ID
and exists (select null
from apex_application_page_regions r
where r.application_id = a.application_id
and r.static_id is not null
and r.location in ('Local Database',
'Remote Database',
'Web Source'))
order by a.application_name

The query for the P1_PAGE select list page item is similar, retrieving only those pages in the selected application having some qualifying region. Notice how the value of P1_APPLICATION is referenced as a bind variable in the WHERE clause:

select page_name||' ('||page_id||')' as name, page_id
from apex_application_pages p
where p.application_id = :P1_APPLICATION
and p.page_function not in ('Global Page','Login')
and exists (select null
from apex_application_page_regions r
where r.application_id = p.application_id
and r.page_id = p.page_id
and r.static_id is not null
and r.location in ('Local Database',
'Remote Database',
'Web Source'))
order by p.page_id

By simply mentioning P1_APPLICATION in the Parent Items(s) property of the P1_PAGE select list, the APEX engine automatically handles the cascading behavior. When the user changes the value of P1_APPLICATION, the value of P1_PAGE is reset to null, or its default value if it defines one. It also implicitly submits the value of any parent items to the server when the select list’s query needs refreshing on parent value change. To save the user a click, I’ve defined the default value for P1_PAGE using a SQL query to retrieve the id of the first page in the available list of pages.

The P1_REGION select list page item uses a similar query against the apex_application_page_regions view, listing P1_PAGE as its parent item and providing an appropriate query for the item’s default value to automatically choose the first region in the list whenever the list gets reset by the cascading select list interaction.

Adding Chosen Region to Selected List

When you choose a region and click the (Add Region) button to add it to the list selected for artifact generation, the Add Region to Selected List page process runs. It uses the built-in Invoke API action to call the add_region_to_selected_list() function in the eba_demo_region_data_sharing package. If it’s the first time this combination of app id, page id, and region static id has been added, it inserts a new row in the eba_demo_reg_data_requests table to remember the user’s selection. Then it proceeds to describe the “shape” of the region’s data source: the names and data types of its columns. That info will be recorded in the xml_describe column in this row by a background job. I reveal next why a background job was required…

Describing a Region’s Data Source Profile

No dictionary view provides the names and data types of a region’s datasource in a way that works for all kinds of data-backed regions, so I had to think outside the box. I applied a meta-flavored twist on Carsten’s data-sharing strategy and created a pipelined table function get_region_source_columns() to programmatically fetch the region datasource column metadata I needed using the following approach:

  1. Use apex_region.open_context() on the region in question
  2. Retrieve the column count using apex_exec.get_column_count()
  3. Loop through the columns to discover the name and data type of each
  4. For each one, call pipe row to deliver a row of region column metadata

The complication I encountered was that apex_region.open_context() only works on regions in the current application. However, when the Region Data Sharing Helper app is running, it is the current app in the APEX session. I needed a way to momentarily change the current application to the one containing the region to describe.

I first tried using an APEX automation to run the region describe process in the background. I hoped a call to apex_session.create_session() inside the job could establish the appropriate “current app” context before using apex_region.open_context() to describe the region. However, I discovered the APEX engine already establishes the APEX session for the background automation job, and my attempt to change it to another app id didn’t produce the desired effect.

Carsten suggested trying a one-time DBMS Scheduler job where my code would be the first to establish an APEX session without bumping into the built-in session initialization. Of course, his idea worked great! Things went swimmingly from there. The code I use inside add_region_to_selected_list() to run the DBMS Scheduler one-time background job looks like this:

-- Submit one-time dbms_scheduler job to process the
-- request to describe the region in some app in the
-- workspace other than the current utility app
dbms_scheduler.create_job (
job_name => dbms_scheduler.generate_job_name,
job_type => 'plsql_block',
job_action => replace(c_gen_xml_job_plsql,c_id_token,l_id),
start_date => systimestamp,
enabled => true,
auto_drop => true);

The PL/SQL block submitted to the scheduler comes from the c_gen_xml_job_plsql string constant whose value appears below, after substituting the #ID# token with the primary key of the row in eba_demo_reg_data_requests representing the region that needs describing:

begin
eba_demo_region_data_sharing.describe_region(#ID#);
commit;
end;

When the background job runs describe_region(12345), that procedure retrieves the application id, page id, and region id from the eba_demo_reg_data_requests table using the id provided, calls create_apex_session_with_nls() to establish the right application context, then calls the xml_for_sql() function in my eba_demo_transform_group package to retrieve an XML document that represents the query results from the following query against the region metadata pipelined table function:

select *
from eba_demo_region_data_sharing.get_region_source_columns(
:app_id,
:page_id,
:region_static_id)

It then updates the row in eba_reg_data_requests to assign this XML region column profile as the value of its xml_describe column. This XML document will have the following format:

<ROWSET>
<ROW>
<NAME>EMPNO</NAME>
<DDL_NAME>empno</DDL_NAME>
<DATA_TYPE>number</DATA_TYPE>
<DECLARED_SIZE/>
</ROW>
<ROW>
<NAME>ENAME</NAME>
<DDL_NAME>ename</DDL_NAME>
<DATA_TYPE>varchar2</DATA_TYPE>
<DECLARED_SIZE>50</DECLARED_SIZE>
</ROW>
<!-- etc. -->
</ROWSET>

If it’s the first time we’re describing this region, it also assigns a default value to the include_columns column to reflect that all columns are selected by default. The situation when it’s not the first time we’re performing the region describe has an interesting twist I explain later when we explore regenerating the artifacts for a region.

Forms with Previous/Next Navigation

The two user-editable fields in the eba_demo_reg_data_requests row are basename and include_columns . The former represents the base name that will be used to generate the name of the object type (basename_row), the collection type (basename_tab), and the pipelined table function (basename_data). The latter is a stored as a colon-separated list of included column positions, relative to the positional order they appear in the xml_describe data profile XML document. Since you can add multiple regions to the selected list, I wanted to let the user page forward and backward through those selected entries.

To implement that row navigation, I learned a new trick by reading an article by my colleage Jeff Kemp. It revealed a neat feature of the APEX Form region that supports easy paging through an ordered set of rows. You configure it with a combination of settings on the form region itself, as well as on its Form – Initialization process in the Pre-Rendering section of the page.

On the form region, you set the data source and make sure to impose a sort order. That’s important to establish a predictable next/previous ordering for the rows the user navigates. For example, in the helper app the form region’s source is the local table eba_demo_reg_data_requests with an Order By Clause of generate_requested_on desc . This ensures the user sees the requests in most recently generated order.

The other half of the setup involves the Form – Initialization process. As shown below, after creating three page items with in-memory-only storage to hold their values, you configure the Next Primary Key Item(s), Previous Primary Key Item(s), and Current Row/Total Item with the respective names of the page items.

Form Initialization process settings for next/previous navigation

Informed by the form region’s source and sort order, along with these navigation related settings, the APEX engine automatically computes the values of these page items when the page renders. I left the P1_REQUEST_COUNT visible on the form as a display only page item so the user can see she’s on region “3 of 5”. I made the other two page items hidden, but referenced their value as appropriate in the Handle Previous and Handle Next branches I configured in my After Processing section of my page’s Processing tab.

I chose to handle the navigation buttons with a Submit Page action combined with branches so the form’s Automatic Row Processing (DML) process would save any changes the user made on the current page before proceeding to the next or previous one. If the form had been read-only, or I didn’t want to save the changes on navigation, the values of P1_NEXT_REQUEST_ID and P1_PREVIOUS_REQUEST_ID could also be referenced as page number targets in buttons that redirect to another page in the current application. Lastly, I referenced these page item values again in the server-side conditions of the NEXT and PREVIOUS buttons so that they only display when relevant.

Using Transform Group to Generate Artifacts

The artifact generation and download is handled declaratively using a transform group, a capability I explain in more detail in a previous blog post. For generating the region data sharing artifacts to be downloaded in a single zip file, I added the following generate-data-sharing-artifacts.xml static application file. It includes a single data transform whose parameterized query retrieves the region’s column names and data types, filtered by the developer’s choice of columns to include in the generated artifacts. The SELECT statement uses the xmltable() function to query the region’s data profile stored in the xml_describe column. This offered me a chance to learn about the for ordinality clause to retrieve the sequential position of the <ROW> elements that xmltable() turns into relational rows. This made it easy to combine with the apex_string.split() function to retrieve only the columns whose sequential position appears in the colon-separated list of include_columns values.

<transform-group directory="{#basename#}-region-data-sharing-artifacts">
<data-transform>
<query bind-var-names="id">
select x.name, x.ddl_name, x.data_type, x.declared_size
from eba_demo_reg_data_requests r,
xmltable('/ROWSET/ROW' passing r.xml_describe
columns
seq for ordinality,
name varchar2(255) path 'NAME',
ddl_name varchar2(80) path 'DDL_NAME',
data_type varchar2(80) path 'DATA_TYPE',
declared_size number path 'DECLARED_SIZE'
) x
where r.id = to_number(:id)
and x.seq in (select to_number(column_value)
from apex_string.split(r.include_columns,':'))
order by x.seq
</query>
<transformation stylesheet="generate-types.xsl"
output-file-name="{#basename#}_types.sql"/>
<transformation stylesheet="generate-function.xsl"
output-file-name="{#basename#}_function.sql"/>
<transformation stylesheet="generate-readme.xsl"
output-file-name="README.html"/>
</data-transform>
</transform-group>

The transform group includes three transformations that each use an appropriate XSLT stylesheet to transform the region data profile information into a SQL script defining the object types, a SQL script defining the pipelined table function, and a README.html file.

Replacing Strings in XSLT 1.0 Stylesheets

XSLT 2.0 has a replace() function that works like Oracle’s regexp_replace(), but the Oracle database’s native XSLT processor implements only the XSLT 1.0 feature set. Therefore, we need an alternative to perform string substitution in a stylesheet that generates an artifact by replacing tokens in a template.

For example, the generate-readme.xsl stylesheet in the helper app defines a variable named query-template with an example of the SQL query you’ll use to select data from the pipelined table function. This template contains a token #COLUMNS# that we’ll replace with the comma-separated list of selected column names. It also has #FUNCNAME# token we’ll replace with the name of the pipelined table function.

<xsl:variable name="query-template">select #COLUMNS#
from table(#FUNCNAME#)</xsl:variable>

After first computing the value of the variable columns by using an <xsl:for-each> to loop over the names of the selected columns, the stylesheet performs the double token substitution while defining the example-query variable. If we were able to use XSLT 2.0, the example-query variable definition would look like this:

<!-- XSLT 2.0 string replace example -->
<xsl:variable name="example-query"
select="replace(replace($query-template,'#COLUMNS#',$columns),
'#FUNCNAME#',$function-name)"/>

However, as mentioned above we need to limit our stylesheets to functionality available in XSLT 1.0 to use the native Oracle database XSLT processor. Instead, we use nested calls to a named template replace-string. Think of a named XSLT template like a function that accepts parameters as input and returns an output. So, the following example-query variable declaration calls the replace-string named template to replace the token #FUNCNAME# in the value of the stylesheet variable query-template with the value of the stylesheet variable named function-name:

<!-- Partial solution, replace first #FUNCNAME# token -->
<xsl:variable name="example-query">
<xsl:call-template name="replace-string">
<xsl:with-param name="text" select="$query-template"/>
<xsl:with-param name="replace">#FUNCNAME#</xsl:with-param>
<xsl:with-param name="with" select="$function-name"/>
</xsl:call-template>
</xsl:variable>

But the result of the above would be the query template with only the #FUNCNAME# token replaced, leaving the #COLUMNS# token intact. XSLT variables are immutable: once assigned their value cannot be updated. So we are not allowed to create multiple, consecutive <xsl:variable> statements that update the value of the same example-query variable, replacing one token at a time. Instead, XSLT relies on nested calls to the replace-string function while performing the initial (and only allowed) assignment of the example-query variable. So after calling the replace-string template once to replace #FUNCNAME# with the value of $function-name, we use that result as the value of the input text parameter in a second, outer call to replace-string to swap #COLUMNS# with the value of $columns like this:

<!-- Final solution, replace #FUNCNAME#, then #COLUMNS# -->
<xsl:variable name="example-query">
<xsl:call-template name="replace-string">
<xsl:with-param name="text">
<xsl:call-template name="replace-string">
<xsl:with-param name="text" select="$query-template"/>
<xsl:with-param name="replace">#FUNCNAME#</xsl:with-param>
<xsl:with-param name="with" select="$function-name"/>
</xsl:call-template>
</xsl:with-param>
<xsl:with-param name="replace">#COLUMNS#</xsl:with-param>
<xsl:with-param name="with" select="$columns"/>
</xsl:call-template>
</xsl:variable>

The generate-types.xsl and generate-function.xsl stylesheets perform this same nested invocation of replace-string, but have more tokens to substitute. As expected, this results in more deeply-nested calls. However, the concept is the same as this two-token example from generate-readme.xsl.

Vertically Centering the Add Region Button

When a button appears in the same row of a form as other page items, by default its vertical alignment with respect to its “row mates” doesn’t look as eye-pleasing as it could.

A button’s default vertical alignment in a row with other page items

The trick to improve the button’s visual appeal, is to add the CSS class u-align-self-center to the Column CSS Classes property in the Page Designer like this:

Using u-align-self-center to vertically center button with page items in the same row

Show Buttons Based on Row Existence

I wanted the user to see an (Add Region) button if the region they choose is not yet in the selected list, and instead see a (Regenerate) button if the region is already in the list. And I wanted the correct button to show both when the page initially renders, as well as when the user changes the values of the select list interactively. I implemented this feature using a dynamic action with conditional hide and show action steps based on an existence check query.

As shown below, I started by using drag and drop in the Page Designer’s Layout editor to drag the (Regenerate) button into the same grid cell as the (Add Region) button. Since the user will see only one or the other at a time, they both can occupy that same grid cell just to the right of the P1_REGION select list.

Two buttons stacked in the same grid cell since the user will see only one or the other at runtime

Next, I added a dynamic action on the Change event of the P1_REGION page item. Recall that in the helper app, being in the selected list means that a row exists in the eba_demo_reg_data_requests table with the region’s unique combination of application id, page id, and region static id. The first action step in the dynamic action event handler uses Execute Server-side Code to run the following query that always returns a row with either ‘Y‘ or ‘N‘ into the hidden P1_REGION_IN_SELECTED_LIST page item. This provides the info about whether the region exists in the list or not.

with region_in_selected_list as (
select max(id) as id
from eba_demo_reg_data_requests
where app_id = :P1_APPLICATION
and page_id = :P1_PAGE
and region_static_id = :P1_REGION
)
select case
when x.id is null then 'N'
else 'Y' end
into :P1_REGION_IN_SELECTED_LIST
from region_in_selected_list x;

Then I followed that action step with four conditional steps that use a client-side condition based on the value Y or N to hide the button that needs hiding and show the button that needs showing. Notice how the new action step name can be very useful in making the steps self-documenting with a more descriptive label than the old “Hide” or “Show” that appeared before 22.2.

Dynamic action on P1_REGION change event to hide/show appropriate buttons

To finish the job, I set the Fire on Initialization switch to true for the four Hide/Show action steps, and provided the same existence SQL query as the default value of the P1_REGION_IN_SELECTED_LIST hidden page item. This ensured that the correct button shows both during the initial page render, as well as after the user interactively changes the region select list.

To Defer or Not to Defer (Rendering)

With the above configuration in place, the appropriate (Add Region) or (Regenerate) button was appearing conditionally as desired. However, I noticed that when the page first rendered I would momentarily see both buttons flash before the inappropriate one for the currently selected region would get hidden by my dynamic action. The solution to avoid the user’s seeing this “behind the scenes” page setup behavior is to enable the Deferred Page Rendering template option shown below. This setting allows you to decide when faster, incremental page rendering is more appropriate, or whether APEX should wait until page-load-time dynamic behavior is complete before revealing the final state of the page to the user.

Deferred Page Rendering option hides page-load-time hide and show activity

Preserving Column Selection on Regeneration

When you click the (Regenerate) button for a region you’ve already added to the selected list, the add_region_to_selected_list() function updates the existing eba_demo_reg_data_requests row to set READY = ‘N‘ and it runs the background job to call describe_region() again. The region might have changed the set of available columns since the previous time we described it, but the user may have carefully decided which of the previous region’s columns to include and exclude. So it’s important for usability to retain the included columns across the region data profile regeneration.

At the moment the describe_region() code has produced the fresh region data profile XML document and is about to update the existing row in eba_demo_reg_data_requests, we have the following “ingredients” available to work with:

  1. The old xml_describe region profile XML document
  2. The old include_columns value with a colon-separated list of index positions relative to the old region profile XML document
  3. The new region profile XML document just produced

What we need to “bake” with these ingredients is a new list of included columns that retains any columns that were previously in the included list while ignoring any of those included columns that are no longer available to reference. Also worth considering is that the index positions of the previous column names might be different in the new region data profile XML document.

After initially writing the code using multiple loops in PL/SQL, I challenged myself to come up with a single SQL statement to accomplish the job. In words, what I needed the statement to do was, “select a colon-separated list of index positions relative to the new XML describe document where the column name is in the list of names whose whose index positions (relative to the old XML describe document) were in the colon-separated list currently stored in include_columns .” I adjusted the query to also handle the situations when the old XML document was null and when the list of include_columns was null. This let me use the same routine to calculate the default value for the include_columns list for both new and updated rows in eba_demo_reg_data_requests. The private get_default_included_columns() function in the eba_demo_region_data_sharing package has the SELECT statement I use to tackle the job.

select listagg(x.seq,':') within group (order by x.seq)
into l_ret
from xmltable('/ROWSET/ROW' passing p_new_xml_describe
columns
seq for ordinality,
name varchar2(128) path 'NAME') x
where p_old_xml_describe is null
or
x.name in (
select y.name
from xmltable('/ROWSET/ROW'
passing p_old_xml_describe
columns
seq for ordinality,
name varchar2(128) path 'NAME') y
where p_before_list is null
or y.seq in (
select to_number(column_value)
from apex_string.split(p_before_list,':')));

Conclusion

This was a fun learning project that taught me many new things about Oracle APEX in the process of building it. Always keep in mind that Oracle APEX is built with Oracle APEX, and that you, too, can use APEX to build yourself any kind of development productivity helper app you can imagine, not to mention plug-ins of all kinds to extend the core functionality of the platform. Thanks to colleagues Carsten, Jeff, and Vlad who offered me tips on aspects of this sample.

Getting the Sample Apps

You can download the Oracle APEX 22.2 export of the Region Data Sharing Helper app from here. In case you’re interested in the HR Sample app used to show off the generated artifacts in action, you can download that from here. The latter requires that you’ve first installed the EMP/DEPT sample dataset from the Gallery. Enjoy the simplified data sharing!

Refitting Filtered Map Points

Using a public dataset of New York City high schools, I built a page that lets students or parents filter the list based on various criteria. As they narrow their options, a map region reflects where the remaining schools are located, and a stacked bar chart lets them compare the contenders on a few important metrics. While the map initially centered and zoomed itself to fit the unfiltered set of schools, to give users the sense of “homing in” on their perfect school I wanted to refit the map around the narrowed search results. To reflect the filtered results from the cards region in the map and chart, I used the region data sharing technique from my colleague Carsten’s article Add a Chart to your Faceted Search Page. Then I got a little help from my colleagues Christian Lara and Stefan Dobre to learn how to build my first simple “Center/Zoom Map Around Points” dynamic action plug-in to make it easy to build this feature into any map-based pages I build in the future. You can check out the sample using the link at the end of the article.

Refreshing the Map and Chart

The data source for the map is a query from a pipelined table function I created following Carsten’s technique. It retrieves the high school data using the same filters currently applied to the schools region in the page:

select school_name, latitude, longitude
from eba_demo_nyc_highschool_data(
p_page_id => :APP_PAGE_ID,
p_region_static_id => 'schools')

Similarly, the chart’s datasource is a query that selects a few different columns from the same pipelined table function:

select school_name, graduation_rate,
attendance_rate, college_career_rate
from eba_demo_nyc_highschool_data(
p_page_id => :APP_PAGE_ID,
p_region_static_id => 'schools')

When the cards region changes due to the user’s applying new filters, we want to refresh the map and chart regions. The lesson I learned while getting this to work was that rather than using the “After Refresh” event on the cards region, I needed to instead use that region’s “Page Change [Cards]” event to trigger the dynamic action refresh, using two dynamic action steps of type “Refresh”.

Centering & Zooming the Map After Refresh

Whenever the map region gets refreshed, my goal was to have it refocus the user’s attention by using the new set of filtered data points to center and zoom the map appropriately. After hunting for a built-in APEX map JavaScript API, or a built-in dynamic action, I realized the solution would take a bit more research. My teammate Christian Lara pointed me at the MapLibre Fit a map to a bounding box example, and gave me this snippet of JavaScript below to consider.

The first statement accesses the map’s (post-refresh) bounding box from its mapData.map.bbox member and defines a new bounds array that contains the two points representing that rectangle. The second line gets the MapLibre map object from the APEX map region on my page, and calls its fitBounds() method to perform the centering and zooming to the new dimensions. It uses 30 pixels of padding so points near the edge of the box stay visible.

// Define the bounds using refreshed map bounding box coordinates
let bbox = apex.region("map").mapData.map.bbox,
bounds = [
[ bbox[0], bbox[1] ],
[ bbox[2], bbox[3] ]
];
// Fit the map to the new bounds
apex.region("map").getMapObject().fitBounds(bounds,{padding: 30});

Creating a Reusable Dynamic Action Plug-in

With the code above in a dynamic action step triggered by the “After Refresh” event on the map region, the functionality I desired was working, but I wanted to learn how to encapsulate that little bit of code into a reusable dynamic action plug-in. I first watched Stefan Dobre’s The Ultimate Guide to APEX Plug-ins video from the APEX@Home 2020 conference, and then created a new dynamic action plugin named “Center & Zoom Map Around Points” in my application. Following best practice, I put the JavaScript code in a with a centerZoomMap.js file, and referenced its qualified name in the File URLs to Load section using the syntax PLUGIN_FILES#centerZoomMap#MIN#.js

I instinctively knew that to be reusable, the name of the map region’s static id would have to be a function parameter, so my first attempt at writing the contents of this centerZoomMap.js file looked like this:

// centerZoomMap.js -- First Attempt
function centerZoomMap(staticId) {
// Define bounds using refreshed map bounding box coordinates
let bbox = apex.region(staticId).mapData.map.bbox,
bounds = [
[ bbox[0], bbox[1] ],
[ bbox[2], bbox[3] ]
];
// Fit the map to the new bounds
apex.region(staticId).getMapObject().fitBounds(bounds, {padding: 30});
}

After that, I defined a custom attribute in the plug-in named “Map Region Static Id” as attribute slot number one. However, I admit to getting a bit confused on how to pass the value of the plug in’s dynamic attribute to the JavaScript function. After asking my colleague Stefan Dobre for a tip, he used the occasion as a teachable moment to show me about the two standard plug-in attributes:

  • For Region
  • Affected Element Required

By leveraging these standard plug-in attributes, the developer using the plug-in gets a more native-feeling experience of picking the region to associate the plug-in with. It also allowed me to remove the custom attribute I had created in the plug in. The developer now configures the map she wants to center and zoom by simply picking the map region in the Affected Element section as shown below:

Configuring Affected Elements in the Properties editor to pick Map region to zoom and center

Stefan also took the opportunity to teach me a best practice of defining the centerZoomMap function as a property on the window to make its scope more clear when reading the code. So the final contents of centerZoomMap.js after consulting with Stefan looked like this:

// centerZoomMap.js - Final version
window.centerZoomMap = function() {
// Ensure dev-configured affected element has a static id
const id = this.affectedElements.attr( "id" );
if ( !id ) {
throw new Error( "Affected Region must have an ID" );
}
// Use static id to ensure dev chose a map region
const region = apex.region( id );
if( !region || region.type !== "SpatialMap" ) {
throw new Error( "Affected Region must be a Map" );
}
// Define bounds using refreshed map bounding box coordinates
let bbox = region.mapData.map.bbox,
bounds = [
[bbox[0], bbox[1]],
[bbox[2], bbox[3]]
];
// Fit the map to the new bounds
region.getMapObject().fitBounds(bounds, {padding: 30});
};

The last piece of the simple dynamic action plug-in was writing the render function using the appropriate function specification that I copied from the online help to use as a guide. The only job it had to do was tell the APEX engine the name of my JavaScript function to invoke when the dynamic action gets used at runtime:

function render (
p_dynamic_action in apex_plugin.t_dynamic_action,
p_plugin in apex_plugin.t_plugin )
return apex_plugin.t_dynamic_action_render_result is
l_result apex_plugin.t_dynamic_action_render_result;
begin
l_result.javascript_function := 'centerZoomMap';
return l_result;
end;

Using the New Plugin in the Page

With the plug-in now defined, I went back to the Page Designer and removed the dynamic action step that was directly calling Christian’s snippet of JavaScript and replaced it by using the new “Center & Zoom Map Around Points” plugin we built. After picking the “Map” region from the Affected Elements section, it was ready to go.

Page Designer showing use of new plug-in to center & zoom the map after the map gets refreshed

Giving the Sample a Spin

To try out the sample, you can download the APEX 22.2 application from here. It gives the end user a nice school-searching experience like what you see in the screen recording below. Thanks again to Christian and Stefan for sharing their wisdom in getting to this end result.

Declarative Data-Driven Downloads

While developing APEX apps, on multiple occasions I’ve needed to generate data-driven artifacts for external use. My app lets staff manage the data that drives their organization, but for historical reasons a related public-facing website can’t be replaced in the near term. The site is usually static files or was built years before on a different tech stack like LAMP (Linux, Apache, MySQL, PHP). This article walks through a simple example of generating all the HTML files for a static website based on database data. It uses a declarative “transform group” capability I wrote for myself to combine the declarative power of SQL, XML, and XSLT to generate and download a zip file of generated artifacts. See the README file that accompanies this article’s sample app to learn more about how transform groups can be applied in your own APEX apps.

Background Motivation

One APEX app I built as a “nerd volunteer” for a non-profit in Italy required generating:

  • HTML pages for a conference schedule in a particular format
  • SQL scripts to update another system’s MySQL database
  • JSON files to import into an auth provider’s user management console
  • PHP source code files to “drop in” to an online portal site

For example, the conference schedule data shown in the APEX app screenshot in the banner above, turns into a static HTML file to display the parallel tracks of the conference program like this:

Static conference program HTML file generated from data managed by an APEX app

A Favorite Technology Trio: SQL + XML + XSLT

For anyone who may have read my O’Reilly book Building Oracle XML Applications published in October 2000, it should come as no surprise that I find the combination of SQL, XML, and XSLT stylesheets very useful. Your mileage may vary, but over the intervening years I have generated many data-driven artifacts using this trio of technologies. It only made sense that I’d reach for them again as APEX became my tool of choice for building new applications over the past few years.

While each distinct task of generating data-driven artifacts is slightly different, the high-level similarities shared by all tasks I’ve had to implement involve:

  • A SQL query to produce XML-formatted system-of-record data
  • One or more XSLT stylesheets to transform the XML data into appropriate text-based artifact files

The Oracle database natively supports generating XML from any SQL query results and transforming XML using XSLT (version 1.0) stylesheets. After learning the APEX_ZIP package makes it easy to generate zip files, I devised a generic facility to use in my current and future APEX apps called “transform groups”.

Defining a Transform Group

I use an XML file to declaratively define the “interesting bits” that make each transform task unique, and include this file along with the XSLT stylesheets required to generate the artifacts as static application files in my APEX app. My “transform group processor” package interprets the transform group file and processes the data transforms in it to download a single zipfile containing all the results. This way, with a single click my apps can produce and download all necessary generated artifacts.

For example, consider the following basic transform group definition file. It defines a single data transform whose SQL query retrieves the rows from the familiar DEPT table and uses the home-page.xsl stylesheet to produce the index.html home page of a hypothetical company directory website.

<!-- generate-hr-site.xml: Transform Group definition file -->
<transform-group directory="hr-site">
<data-transform>
<query>
select deptno, dname, loc
from dept
</query>
<transformation stylesheet="home-page.xsl"
output-file-name="index.html"/>
</data-transform>
</transform-group>

The in-memory XML document representing the results of the data transform’s query looks like this:

<ROWSET>
<ROW>
<DEPTNO>10</DEPTNO>
<DNAME>ACCOUNTING</DNAME>
<LOC>NEW YORK</LOC>
</ROW>
<!-- etc. -->
<ROW>
<DEPTNO>40</DEPTNO>
<DNAME>OPERATIONS</DNAME>
<LOC>BOSTON</LOC>
</ROW>
</ROWSET>

The XSLT stylesheet referenced in the <transformation> element looks like the example below. It contains a single template that matches the root of the XML document above, then uses the <xsl:for-each> to loop over all the <ROW> elements to format a bulleted list of departments.

<!-- home-page.xsl: XSLT stylesheet to generate home page -->
<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="html"/>
<xsl:template match="/">
<html>
<body>
<h1>Departments</h1>
<ul>
<xsl:for-each select="/ROWSET/ROW">
<li>
<xsl:value-of select="DNAME"/>
(<xsl:value-of select="LOC"/>)
</li>
</xsl:for-each>
</ul>
</body>
</html>
</xsl:template>
</xsl:stylesheet>

When viewed in a web browser, the index.html file it produces will looks like this:

HTML home page showing department data

After saving the transform group XML file and corresponding XSLT stylesheet to our static application files, we’re ready to wire up the transform group download.

Wiring Up a Transform Group Download Button

You’ll typically call the transform group’s PL/SQL API from an APEX application process with a process point of “Ajax Callback”. For example, if we create an application process named Download_Static_Website, its code will invoke the download() procedure like this:

eba_demo_transform_group.download(
p_file_name => 'generate-hr-site.xml',
p_zipfile_name => 'hr-website.zip');

A button or link in your APEX app can initiate the transform group zipfile download. Its link target will redirect to the current page and have the Request parameter in the Advanced section of the link definition set to APPLICATION_PROCESS=Download_Static_Website

With these elements in place, clicking on the button will download an hr-website.zip file containing a single hr-site/index.html file.

Enhancing the Transform Group Definition

A data transform can process the same SQL query results using multiple, different stylesheets. XSLT stylesheets can accept parameters to influence their output, and the data transform supports an optional <parameter-query> element whose SQL query can return rows to provide values of these XSLT parameters. The column-to-parameter-map attribute defines how a column in the parameter query’s value should map to the name of an XSLT parameter. The transform group processor runs the stylesheet once for each row in the parameter query’s results. On each iteration it maps the XSLT parameter to the corresponding column value in the current parameter query row and runs the transformation to produce an output file. Notice that the output-file-name attribute can also reference parameter names as part of the file name.

<!-- generate-hr-site.xml: Transform Group definition file -->
<transform-group directory="hr-site">
<data-transform>
<query>
select deptno, dname, loc
from dept
</query>
<transformation stylesheet="home-page.xsl"
output-file-name="index.html"/>
<transformation stylesheet="dept-page.xsl"
output-file-name="dept_{#depid#}.html">
<parameter-query column-to-parameter-map
="DEPTNO:depid">
select deptno from dept
</parameter-query>
</transformation>
</data-transform>
</transform-group>

To generate a page for each employee in each department, we can further enhance the transform group to include a nested set of EMP table rows for each DEPT table row, and add a third <transformation> element to generate the employee pages.

<!-- generate-hr-site.xml: Transform Group definition file -->
<transform-group directory="hr-site">
<data-transform>
<query>
select deptno, dname, loc,
cursor( select empno, ename, job
from emp
where deptno = d.deptno) as staff
from dept d
</query>
<transformation stylesheet="home-page.xsl"
output-file-name="index.html"/>
<transformation stylesheet="dept-page.xsl"
output-file-name="dept_{#depid#}.html">
<parameter-query column-to-parameter-map
="DEPTNO:depid">
select deptno from dept
</parameter-query>
</transformation>
<transformation stylesheet="emp-page.xsl"
output-file-name="emp_{#empid#}.html">
<parameter-query column-to-parameter-map
="DEPTNO:depid,EMPNO:empid">
select empno, deptno from emp
</parameter-query>
</transformation>
</data-transform>
</transform-group>

Next we make appropriate updates to home-page.xsl to generate hyperlinked department names and upload the dept-page.xsl and emp-page.xsl stylesheets to our static application files. After this, clicking on the button now downloads the entire company directory static website in the hr-website.zip file. It contains an hr-site top-level directory with all the generated HTML pages as shown in the Finder screenshot below after extracting the zip file contents.

Mac Finder showing contents of downloaded hr-website.zip file

Exploring the Sample App

The APEX 22.2 sample application you can download from here installs the eba_demo_transform_group package and includes a working example of the declaratively-generated website download explained in this article. After importing the sample and ensuring you have the EMP/DEPT sample dataset installed, just click the Generate and Download Static Site button. The sample has tabs to easily explore the syntax of the transform group XML file and accompanying XSLT stylesheets, too. Its README page provides more tips on how transform groups might be useful to your own APEX apps in the future.

Since XSLT transformations are great at declaratively generating text-based artifacts of any kind, hopefully I won’t be the only APEX developer to benefit from the productivity the transform group functionality offers.

Screenshot of sample application that accompanies this article