I Made Every Oracle APEX Page Explain Itself: A Contextual Help Drawer in Plain JavaScript

Make every page explain itself: a contextual help drawer for Oracle APEX and any web app
The support calls every app gets
“What do I put in this field?”
“Why is the Save button not there?”
“What does this error mean?”

Every business application gets them. This post shows how to make every page explain itself: a lazy-loaded help drawer driven by one JSON file per page, a build check that refuses to ship a page without help, and the complete, tested code to add it to your own app in a few days. It works in Oracle APEX, React, Angular, or any server-rendered web app.

0
help requests during page load
~50 ms
to reopen from the cache
22 / 22
automated browser checks
36
pages live in production

01The problem

The answers to users' questions usually live in someone's head, in a PDF manual nobody opens, or in a tooltip that says "Enter the name" next to a field called Name. Manuals go stale the day a page changes. Tooltips explain the obvious and skip the rules that actually trip people up.

The goal here is different: help that's about this page, in plain language, there the moment the user needs it, without slowing the app down, and kept up to date by the build itself.

When I rolled this out across 36 pages of a production app, the help ended up with 491 field explanations, 226 business rules, 147 buttons and actions, and 222 common errors with their fix. It added zero requests to page load.

02What the user sees

A Help button on every page opens a side drawer (a full-screen sheet on phones) about this page, in eight collapsible sections:

1About this page
what it's for, why the business needs it, the goal
2What should I do here?
numbered steps
3Field explanations
what, why, what to enter, mandatory or not, effect on other data, an example
4Business rules
the rule and why it matters
5Buttons and actions
what each does, when, who sees it, what to do if it fails
6Common errors and solutions
the exact message, the cause, the fix
7What happens next?
the process before and after this page
8Working with the app
general help, at the bottom of every page
The help drawer open on an edit form, with About this page, the steps for editing, and collapsed sections for fields, rules, buttons, errors and next steps
Figure 1. The real drawer on an edit form. The first two sections open, the rest wait one click away.

Four details are why people actually open it:

It knows the situation
A form in create mode shows different steps than the same form in edit or view. It also leaves out fields and buttons the user can't see. It never explains a button you don't have.
A "?" next to tricky fields
Fields that are only sometimes mandatory, or that change other data, get a small "?". One click jumps to the right explanation. Keep it to 5 per page.
Search inside the help
It filters entries as you type and opens every section that has a match.
Keyboard and screen-reader friendly
A real dialog with a focus trap. Esc closes it and puts focus back where you were.

Here's the situation filter at work. Same page, same help file, two different sets of steps:

Two help panels side by side: create mode shows four steps for a new customer, edit mode shows three steps for an existing one
Figure 2. Create mode on the left, edit mode on the right. Nobody reads steps that don't apply to them.

And the field "?" in action. It skips the scrolling and lands on the one explanation the user needs:

Clicking the question mark next to the IBAN field opens the drawer scrolled to the IBAN explanation, highlighted with a red outline
Figure 3. One click on the "?" opens the drawer right at the IBAN entry and highlights it for two seconds.
Searching the help for the word debit keeps only the matching step, the two matching fields and the matching rule
Figure 4. Search filters the help as you type and opens every section with a hit.

03Six design decisions that make it work

01

Content is data, not code

Each page has one JSON file, for example help/p00031.json for page 31 (padded to five digits), plus one global.json. Writers fill a fixed structure: about, steps, fields, rules, actions, errors, next. The drawer renders whatever is in the file, so a content change never needs a code change.

Every entry carries the real element id of its field or button (P31_IBAN, P31_SAVE). That one link powers three features: the situation filter, hiding what the user can't see, and the field "?".

Each key of the page JSON file maps to one section of the help drawer, while verified_by and gaps are stripped at build time
Figure 5. Every key in the file becomes one section in the drawer. The internal notes never leave the build.
02

Nothing loads with the page

The page carries only a tiny click handler. The page's JSON is fetched on the first click, then cached in sessionStorage. The URL includes the build version, so a new build fetches fresh content automatically. Expect a first open of roughly 80 to 140 ms and later opens of about 50 ms.

03

Safe by design

The help is static text with no customer data. Every string goes into the page with textContent, and the JSON is never parsed as HTML. The test below feeds it <script> and <img onerror> samples, and they have to render as plain text. Internal notes (verified_by, gaps) are stripped at build time, so they never reach the browser.

04

The build fails without help

A small Python check fails the build when a page has no help file or a file breaks the format. It also warns about technical words (ORA-, SQL, NULL, package names) in visible text. Help can't be "forgotten for later". Every new page needs it to ship.

05

Every statement is verified against the code

Wrong help is worse than none. Check each rule, mandatory flag and error message against the real implementation (page definitions, packages, specs) and record the evidence in verified_by. Anything you can't verify goes into gaps. It's never shown to users, and a developer reviews it instead of anyone guessing.

AI assistants are great for first drafts at scale, as long as each one has to cite the code for every statement. The prompt is in Step 5.

If you can't point to the line of code that proves it, it doesn't go in the help.

06

Show only what the user has

The drawer asks the page for its mode (create, edit, view or list) and drops entries whose field or button isn't on the page. A Show all situations toggle lifts the filter. That's safe because the content is static text without data.

04How it fits together

Flow diagram: page load only wires a click handler; the first click fetches, filters and renders; later clicks read the cache; the build checks files, strips notes and uploads clean JSON
Figure 6. The page only wires a click handler. Everything else waits for the first click and is cached after that.

05Build it step by step

The kit is framework-free: plain JavaScript, no jQuery, no dependencies. Every file below was run and tested exactly as printed (22 automated browser checks pass).

1
Step 1 of 7

Create the folder structure

help-kit/
├── help-drawer.js        ← Step 2  (deploy)
├── help-drawer.css       ← Step 3  (deploy)
├── help-src/             ← Step 5  the help content you write
│   ├── global.json
│   └── p00031.json       ← one file per page: "p" + page id padded to 5 digits
├── help_guard.py         ← Step 6  build check
├── build/help/           ← created by help_guard.py: the cleaned JSON you deploy
└── test/                 ← Step 7  browser test
    ├── test.html
    ├── test_help.py
    └── help/
        ├── global.json
        └── p00031.json

On your web server you only need help-drawer.js, help-drawer.css and the files from build/help/.

2
Step 2 of 7

Add the drawer script

This is the whole drawer: lazy loading, the cache, the situation filter, search, text-only rendering, keyboard handling and the field "?" buttons. Save it as help-drawer.js.

help-drawer.js
/* help-drawer.js - contextual end-user help (starter kit). No dependencies.
 * Opens a side drawer with the help of the CURRENT page, loaded on the first click from <base>/p<5-digit page>.json + global.json.
 * Config (before or after this file): window.helpConfig = { baseUrl: '/static/help/', pageId: 31, appName: 'My App', mode: function () { return 'edit'; } }
 * Open from code: window.appHelp.open(openerElement, fieldItemName?)
 */
(function () {
  "use strict";
  function cfg() { return window.helpConfig || {}; }        // read when used: the config may be defined after this file
  var NAMES = { about: 'About this page', steps: 'What should I do here?', fields: 'Field explanations', rules: 'Business rules',
                actions: 'Buttons and actions', errors: 'Common errors and solutions', next: 'What happens next?' };
  var NOT_YET = 'Help for this page is not available yet.';
  var S = { el: null, opener: null };

  function pad5(id) { return ('00000' + id).slice(-5); }
  function arr(v) { return Array.isArray(v) ? v : (v ? [v] : []); }
  function txt(v) { return v === null || v === undefined ? '' : String(v).trim(); }
  // text-only element builder: content is NEVER parsed as HTML (XSS-safe)
  function h(tag, attrs, kids) {
    var e = document.createElement(tag);
    Object.keys(attrs || {}).forEach(function (k) {
      var v = attrs[k]; if (v === null || v === undefined || v === false) { return; }
      if (k === 'text') { e.textContent = v; } else if (k === 'cls') { e.className = v; } else { e.setAttribute(k, v === true ? '' : v); }
    });
    arr(kids).forEach(function (c) { if (c) { e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); } });
    return e;
  }

  function load(name) {
    var c0 = cfg(), url = (c0.baseUrl || 'help/') + name + '.json' + (c0.version ? '?v=' + c0.version : ''), key = 'help|' + url;
    try { var c = sessionStorage.getItem(key); if (c) { return Promise.resolve(JSON.parse(c)); } } catch (e) { /* storage off */ }
    return fetch(url, { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; })
      .then(function (d) {
        if (d && typeof d === 'object') { try { sessionStorage.setItem(key, JSON.stringify(d)); } catch (e) { /* full */ } return d; }
        return null;
      });
  }

  function currentMode(doc) {
    var modes = arr(doc.modes);
    if (modes.length < 2) { return modes[0] || null; }
    var m = typeof cfg().mode === 'function' ? cfg().mode() : null;
    return modes.indexOf(m) >= 0 ? m : modes[0];
  }
  // show an entry only in its modes and only when its item / button is on the page (nothing about what the user cannot see)
  function visible(entry, mode, all) {
    if (all) { return true; }
    if (entry.modes && mode && arr(entry.modes).indexOf(mode) < 0) { return false; }
    var ref = entry.item || entry.button;
    if (ref && /^[A-Z][A-Z0-9_]+$/.test(ref)) {
      return !!(document.getElementById(ref) || document.querySelector('[data-help-name="' + ref + '"]'));
    }
    return true;
  }

  function dl(pairs) {
    var d = h('dl', { cls: 'hd-dl' });
    pairs.forEach(function (p) { if (txt(p[1])) { d.appendChild(h('dt', { text: p[0] })); d.appendChild(h('dd', { text: txt(p[1]) })); } });
    return d;
  }
  function entry(title, pairs, key) {
    return h('div', { cls: 'hd-entry', 'data-key': key || null, tabindex: '-1' }, [h('h4', { text: title }), dl(pairs)]);
  }

  function build(doc, glob, mode, all) {
    var out = [];
    function sec(id, kids) { if (kids.length) { out.push({ id: id, kids: kids }); } }
    if (doc) {
      var a = doc.about || {};
      sec('about', [txt(a.summary) && h('p', { text: a.summary }), dl([['Why', a.why], ['Goal', a.goal]])].filter(Boolean));
      var st = arr(doc.steps).filter(function (s) { return visible(s, mode, all); });
      sec('steps', st.length ? [h('ol', {}, st.map(function (s) { return h('li', { text: txt(s.text) }); }))] : []);
      sec('fields', arr(doc.fields).filter(function (f) { return visible(f, mode, all); }).map(function (f) {
        return entry(f.label, [['What', f.what], ['Why', f.why], ['What to enter', f.enter], ['Mandatory', f.mandatory],
                               ['Effect', f.effect], ['Example', f.example]], f.item);
      }));
      sec('rules', arr(doc.rules).map(function (r) { return entry(r.title, [['Rule', r.rule], ['Why', r.why]]); }));
      sec('actions', arr(doc.actions).filter(function (x) { return visible(x, mode, all); }).map(function (x) {
        return entry(x.label, [['Does', x.does], ['When', x.when], ['Afterwards', x.after], ['Who sees it', x.visible_for],
                               ['If it does not work', x.if_fails]], x.button);
      }));
      sec('errors', arr(doc.errors).map(function (e) { return entry(e.message, [['Why', e.why], ['Fix', e.fix]]); }));
      var n = doc.next || {};
      sec('next', [dl([['Before', n.before], ['After', n.after]]),
                   arr(n.workflow).length && h('ol', {}, arr(n.workflow).map(function (w) { return h('li', { text: txt(w) }); }))].filter(Boolean));
    }
    if (glob) {
      sec('global', [txt((glob.about || {}).summary) && h('p', { text: glob.about.summary })].concat(
        arr(glob.rules).map(function (r) { return entry(r.title, [['Rule', r.rule], ['Why', r.why]]); })).filter(Boolean));
    }
    return out;
  }

  function render(doc, glob, field, all) {
    var mode = doc ? currentMode(doc) : null, body = S.el.querySelector('.hd-body');
    body.textContent = '';
    S.el.querySelector('.hd-sub').textContent = doc ? txt(doc.page_name) : '';
    if (mode && arr(doc.modes).length > 1) {
      var b = h('button', { type: 'button', cls: 'hd-all', text: all ? 'Show only my situation' : 'Show all situations' });
      b.addEventListener('click', function () { render(doc, glob, null, !all); });
      body.appendChild(h('p', { cls: 'hd-mode' }, ['Shown for: ' + mode + ' ', b]));
    }
    var secs = build(doc, glob, mode, all);
    if (!secs.length || !doc) { body.appendChild(h('p', { text: NOT_YET })); }
    var q = null;
    if (secs.length && doc) {
      q = h('input', { type: 'search', cls: 'hd-search', placeholder: 'Search this help', 'aria-label': 'Search this help' });
      body.appendChild(q);
    }
    secs.forEach(function (s, i) {
      var open = i < 2 && s.id !== 'global', id = 'hd-sec-' + s.id;
      var tog = h('button', { type: 'button', cls: 'hd-tog', 'aria-expanded': String(open), 'aria-controls': id,
                              text: s.id === 'global' ? 'Working with ' + (cfg().appName || 'this app') : NAMES[s.id] });
      var panel = h('div', { id: id, hidden: !open, 'data-open': String(open) }, s.kids);
      tog.addEventListener('click', function () { var o = tog.getAttribute('aria-expanded') !== 'true'; tog.setAttribute('aria-expanded', String(o)); panel.hidden = !o; });
      body.appendChild(h('section', {}, [h('h3', {}, tog), panel]));
    });
    // search: keep only the entries / list items that contain the words, open the sections with a hit
    if (q) {
      var searching = false;
      q.addEventListener('input', function () {
        var term = q.value.trim().toLowerCase();
        if (term && !searching) {        // remember which sections were open, to restore them when the search is cleared
          body.querySelectorAll('[id^="hd-sec-"]').forEach(function (p) { p.setAttribute('data-open', String(!p.hidden)); });
        }
        searching = !!term;
        body.querySelectorAll('section').forEach(function (sec) {
          var tog = sec.querySelector('.hd-tog'), panel = sec.querySelector('[id^="hd-sec-"]'), hits = 0;
          var parts = panel.querySelectorAll('.hd-entry, li');
          if (!parts.length) { parts = [panel]; }
          Array.prototype.forEach.call(parts, function (el) {
            var match = !term || el.textContent.toLowerCase().indexOf(term) >= 0;
            if (el !== panel) { el.hidden = !match; }
            if (match) { hits++; }
          });
          var titleHit = term && tog.textContent.toLowerCase().indexOf(term) >= 0;
          if (titleHit) { Array.prototype.forEach.call(parts, function (el) { if (el !== panel) { el.hidden = false; } }); }
          var show = !term || hits > 0 || titleHit, open = term ? show : panel.getAttribute('data-open') === 'true';
          sec.hidden = !show; panel.hidden = !open; tog.setAttribute('aria-expanded', String(open));
        });
      });
    }
    if (field) {
      var target = body.querySelector('[data-key="' + field + '"]');
      if (!target && !all) { return render(doc, glob, field, true); }
      if (target) {
        var p = target.closest('[id^="hd-sec-"]'); p.hidden = false; p.previousSibling.firstChild.setAttribute('aria-expanded', 'true');
        target.scrollIntoView({ block: 'start' }); target.focus(); target.classList.add('hd-hit');
        setTimeout(function () { target.classList.remove('hd-hit'); }, 2000);
        return;
      }
    }
    S.el.querySelector('.hd-title').focus();
  }

  function close() {
    if (!S.el || S.el.hidden) { return; }
    S.el.hidden = true; document.documentElement.classList.remove('hd-open');
    if (S.opener && S.opener.focus) { S.opener.focus(); }
  }
  function shell() {
    if (S.el) { return S.el; }
    var x = h('button', { type: 'button', cls: 'hd-x', 'aria-label': 'Close help', text: '×' });
    var panel = h('div', { cls: 'hd-panel', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'hd-title' }, [
      h('header', { cls: 'hd-head' }, [h('h2', { id: 'hd-title', cls: 'hd-title', tabindex: '-1', text: 'Help' }), h('span', { cls: 'hd-sub' }), x]),
      h('div', { cls: 'hd-body' })]);
    S.el = h('div', { cls: 'hd', hidden: true }, [h('div', { cls: 'hd-backdrop' }), panel]);
    x.addEventListener('click', close);
    S.el.querySelector('.hd-backdrop').addEventListener('click', close);
    S.el.addEventListener('keydown', function (e) {
      if (e.key === 'Escape') { e.preventDefault(); close(); return; }
      if (e.key !== 'Tab') { return; }
      var f = panel.querySelectorAll('button, [href], input, [tabindex]:not([tabindex="-1"])'), first = f[0], last = f[f.length - 1];
      if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
      else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
    });
    document.body.appendChild(S.el);
    return S.el;
  }

  function open(opener, field) {
    S.opener = opener || document.activeElement;
    shell().hidden = false; document.documentElement.classList.add('hd-open');
    S.el.querySelector('.hd-body').textContent = 'Loading…';
    Promise.all([load('p' + pad5(cfg().pageId)), load('global')]).then(function (r) { render(r[0], r[1], field, false); });
  }

  // entry points: any element with [data-help] (header button, floating button) and field "?" buttons [data-help-field]
  document.addEventListener('click', function (e) {
    var t = e.target.closest && e.target.closest('[data-help], [data-help-field]');
    if (!t) { return; }
    e.preventDefault(); open(t, t.getAttribute('data-help-field'));
  });
  // "?" next to the labels of window.helpFields = [[itemName, label], ...] (only enterable fields)
  function fieldButtons() {
    arr(window.helpFields).forEach(function (p) {
      var item = document.getElementById(p[0]), label = document.querySelector('label[for="' + p[0] + '"]');
      if (!item || !label || item.disabled || item.readOnly || label.querySelector('.hd-q')) { return; }
      label.appendChild(h('button', { type: 'button', cls: 'hd-q', 'data-help-field': p[0], 'aria-label': 'Help for ' + p[1], text: '?' }));
    });
  }
  if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', fieldButtons); } else { fieldButtons(); }
  window.appHelp = { open: open, close: close };
})();
3
Step 3 of 7

Add the styles

Change the tokens at the top to your own colours. Dark mode and the phone layout are included. Save it as help-drawer.css.

On a phone, tapping the question mark in the header opens the help as a full-screen sheet
Figure 7. Below 640 px the drawer turns into a full-screen sheet. No extra code needed.
help-drawer.css
/* help-drawer.css - starter kit; change the tokens to your design system */
:root { --hd-bg: #fff; --hd-fg: #1d1d1f; --hd-muted: #5f6368; --hd-line: #e5e5ea; --hd-accent: #a3123a; --hd-focus: 0 0 0 3px rgba(163,18,58,.35); }
@media (prefers-color-scheme: dark) { :root { --hd-bg: #1c1c1e; --hd-fg: #f2f2f7; --hd-muted: #a1a1aa; --hd-line: #3a3a3c; } }
.hd[hidden] { display: none; }
.hd { position: fixed; inset: 0; z-index: 1000; }
.hd-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,.35); }
.hd-panel { position: absolute; top: 0; right: 0; bottom: 0; width: min(34rem, 100%); display: flex; flex-direction: column;
            background: var(--hd-bg); color: var(--hd-fg); box-shadow: -8px 0 24px rgba(0,0,0,.18); }
.hd-head { display: flex; align-items: baseline; gap: .5rem; padding: 1rem 1.25rem; border-bottom: 1px solid var(--hd-line); }
.hd-title { margin: 0; font-size: 1.25rem; } .hd-sub { flex: 1; color: var(--hd-muted); }
.hd-x { border: 0; background: none; font-size: 1.5rem; line-height: 1; cursor: pointer; color: inherit; }
.hd-body { overflow-y: auto; padding: 1rem 1.25rem 2rem; }
.hd-body h3 { margin: 1rem 0 .25rem; font-size: 1rem; }
.hd-tog { width: 100%; text-align: left; border: 0; background: none; font: inherit; font-weight: 700; color: inherit; cursor: pointer; padding: .5rem 0; }
.hd-tog::before { content: '\25B8  '; } .hd-tog[aria-expanded="true"]::before { content: '\25BE  '; }
.hd-entry { padding: .5rem 0; border-top: 1px solid var(--hd-line); } .hd-entry h4 { margin: 0 0 .25rem; }
.hd-dl dt { font-weight: 600; color: var(--hd-muted); font-size: .85rem; } .hd-dl dd { margin: 0 0 .4rem; }
.hd-mode { color: var(--hd-muted); } .hd-all { border: 1px solid var(--hd-line); background: none; color: inherit; border-radius: 1rem; padding: .1rem .6rem; cursor: pointer; }
.hd-hit { outline: 2px solid var(--hd-accent); outline-offset: 4px; }
.hd-q { margin-left: .35rem; width: 1.25rem; height: 1.25rem; border-radius: 50%; border: 1px solid var(--hd-line); background: none; font-size: .75rem; cursor: pointer; }
.hd button:focus-visible, .hd-q:focus-visible, [data-help]:focus-visible { outline: none; box-shadow: var(--hd-focus); }
html.hd-open { overflow: hidden; }
.hd-search { width: 100%; box-sizing: border-box; margin: 0 0 .5rem; padding: .5rem .75rem; border: 1px solid var(--hd-line); border-radius: .5rem; background: none; color: inherit; font: inherit; }
@media (max-width: 640px) { .hd-panel { width: 100%; } }
4
Step 4 of 7

Add the Help button and the config to your page

Any element with a data-help attribute opens the drawer. Use a header link on normal pages and a floating button on sign-in pages or dialogs:

<!-- in the page header -->
<a href="#" data-help aria-haspopup="dialog">Help</a>

<!-- floating button, for sign-in pages and dialogs -->
<button type="button" data-help class="help-fab">Help</button>

Then tell the drawer where the help files are and which page this is. Put this once in your layout or on each page:

<link rel="stylesheet" href="/static/help-drawer.css">
<script>
  window.helpConfig = {
    baseUrl: '/static/help/',          // where the cleaned JSON files are served
    pageId: 31,                        // this page's id -> loads help/p00031.json
    appName: 'My App',                 // shown as "Working with My App"
    version: '2026-10-21-1',           // your build version: a new build = fresh help
    mode: function () {                // optional: 'create' | 'edit' | 'view' | 'list'
      var id = document.getElementById('P31_ID');
      return id && id.value ? 'edit' : 'create';
    }
  };
  window.helpFields = [['P31_IBAN', 'IBAN']];   // optional: fields that get a "?" (max 5)
</script>
<script src="/static/help-drawer.js" defer></script>
Good to know
How fields and buttons are found: by their element id. If the id differs from the name you use in the JSON, add data-help-name="P31_SAVE" to the element. The config can come before or after the script, because it's read each time the drawer opens.
5
Step 5 of 7

Write the help content

Start with the 3 to 5 pages that generate the most questions. Rules for writers:

  • Write for business users: plain language, short sentences, "you".
  • Use the exact labels visible on screen, and the real element ids of fields and buttons.
  • No technical words in visible text: no table or package names, no ORA- codes, no SQL, no NULL.
  • Verify every statement against the code and note the evidence in verified_by. Put anything uncertain in gaps (never shown).

The file format:

KeyShown asContents
page_id, page_nameDrawer titlepage_id must match the file name
modes"Shown for: …"Any of create, edit, view, list. Two or more turn on the situation filter
aboutAbout this pagesummary (required), why, goal
steps[]What should I do here?text, optional modes
fields[]Field explanationsitem (element id), label, what, why, enter, source (manual / select / automatic / calculated), mandatory, effect, example, optional modes
rules[]Business rulestitle, rule, why, verified_by (internal)
actions[]Buttons and actionsbutton (element id), label, does, when, after, visible_for, if_fails, optional modes
errors[]Common errorsmessage (exact text), why, fix
nextWhat happens next?before, after, workflow[]
gaps[]Never shownOpen questions for the developer

A complete page file:

help-src/p00031.json
{
  "page_id": 31,
  "page_name": "Customer Detail",
  "modes": [
    "create",
    "edit",
    "view"
  ],
  "about": {
    "summary": "Create or change a customer.",
    "why": "Visits and invoices need the customer.",
    "goal": "The customer is saved."
  },
  "steps": [
    {
      "text": "Enter the name.",
      "modes": [
        "create"
      ]
    },
    {
      "text": "Change what is wrong, then Save.",
      "modes": [
        "edit"
      ]
    }
  ],
  "fields": [
    {
      "item": "P31_NAME",
      "label": "Name",
      "what": "Full name.",
      "enter": "First and last name.",
      "source": "manual",
      "mandatory": "Yes"
    },
    {
      "item": "P31_IBAN",
      "label": "IBAN",
      "what": "Bank account.",
      "source": "manual",
      "mandatory": "Only when payment is direct debit",
      "effect": "Used for direct debit."
    }
  ],
  "rules": [
    {
      "title": "Unique name",
      "rule": "Two customers cannot have the same name and birthday.",
      "why": "No duplicates.",
      "verified_by": "customer_api.check_duplicate"
    }
  ],
  "actions": [
    {
      "button": "P31_SAVE",
      "label": "Save",
      "does": "Saves the customer.",
      "changes_data": true
    },
    {
      "button": "P31_DELETE",
      "label": "Delete",
      "does": "Deletes the customer.",
      "visible_for": "Administrators"
    }
  ],
  "errors": [
    {
      "message": "Please enter the name.",
      "why": "Name is mandatory.",
      "fix": "Enter the first and last name, then Save again."
    }
  ],
  "next": {
    "after": "Plan the first visit.",
    "workflow": [
      "Customer",
      "Assignment",
      "Visit",
      "Invoice"
    ]
  },
  "gaps": [
    "Is the IBAN also needed for transfer payers? (not verifiable in code - ask the business owner)"
  ]
}

The global file uses the same format with page_id 0. Usually it only needs about and rules: the menu, saving, signing out, anything that's true everywhere.

help-src/global.json
{
  "page_id": 0,
  "page_name": "Working with the app",
  "about": {
    "summary": "Use the menu on the left to move between pages. Every page has a Help button at the top."
  },
  "rules": [
    {
      "title": "Saving",
      "rule": "There is no autosave.",
      "why": "Click Save before you leave a page."
    },
    {
      "title": "Signing out",
      "rule": "You are signed out after 30 minutes without activity.",
      "why": "This protects the data on shared computers."
    }
  ]
}

Writing at scale with AI. Give one assistant per functional area this prompt, then review its gaps list afterwards:

You write end-user help files for the pages <page list> of <app name>.
Audience: <business users>, English, plain language, short sentences, "you" form.
Format: help-src/p<5-digit page>.json exactly as in the content format (about, steps, fields, rules, actions, errors, next).

For EVERY statement:
- verify it against the implementation: page definition <path>, packages/services <path>, specs <path>;
- put the evidence (file + procedure / spec id) into "verified_by";
- if you cannot verify it, do NOT write it as fact: describe only what the system visibly does and add the question to "gaps".
Use the exact visible labels and the real element ids of fields and buttons (they drive the situation filter and the "?" buttons).
Never mention technical internals in visible text (no table / package / column names, no ORA- codes, no SQL, no NULL).
Never use real personal data in examples.
Cover every input, select, checkbox, report column and filter a user sees; every button and row action; the error messages
the code actually raises; and the real process before and after the page.
When done, list per page: entries written, open gaps.
6
Step 6 of 7

Add the build check

This script fails the build (exit code 1) when a page has no help file or a file breaks the format. It also warns about technical words and writes cleaned copies without the internal notes. Save it as help_guard.py (Python 3, no packages needed).

help_guard.py
"""help_guard.py - build check for the contextual help (starter kit).
usage: python help_guard.py <help source dir> <out dir> <page id> [<page id> ...]
  * FAILS (exit 1) when a page has no p<5-digit page>.json, global.json is missing, or a file breaks the schema
  * warns about technical words in visible texts
  * writes cleaned, compact copies to <out dir> (internal keys verified_by / gaps removed) - upload those, never the sources"""
import json, os, re, sys

MODES = {'create', 'edit', 'view', 'list'}
SOURCES = {'manual', 'select', 'automatic', 'calculated'}
INTERNAL = {'verified_by', 'gaps'}
TECH = re.compile(r'ORA-\d|PL/SQL|\bNULL\b|foreign key|\bSQL\b|_PKG\b|\bAPI_', re.I)


def strip(v):
    if isinstance(v, dict): return {k: strip(x) for k, x in v.items() if k not in INTERNAL}
    if isinstance(v, list): return [strip(x) for x in v]
    return v


def texts(v):
    if isinstance(v, dict):
        for x in v.values(): yield from texts(x)
    elif isinstance(v, list):
        for x in v: yield from texts(x)
    elif isinstance(v, str): yield v


def check(doc, page):
    e = []
    if page is not None and doc.get('page_id') != page: e.append(f'page_id {doc.get("page_id")} <> {page}')
    if not str(doc.get('page_name', '')).strip(): e.append('page_name missing')
    if not set(doc.get('modes', [])) <= MODES: e.append(f'modes must be in {sorted(MODES)}')
    if page is not None and not str(doc.get('about', {}).get('summary', '')).strip(): e.append('about.summary missing')
    for k in ('steps', 'fields', 'rules', 'actions', 'errors', 'prepare'):
        if k in doc and not isinstance(doc[k], list): e.append(f'{k} must be a list')
    for i, f in enumerate(doc.get('fields', [])):
        if not f.get('label') or not f.get('item'): e.append(f'fields[{i}] needs label + item')
        if f.get('source') not in SOURCES: e.append(f'fields[{i}].source must be in {sorted(SOURCES)}')
    for i, a in enumerate(doc.get('actions', [])):
        if not a.get('label'): e.append(f'actions[{i}].label missing')
    for i, x in enumerate(doc.get('errors', [])):
        if not x.get('message'): e.append(f'errors[{i}].message missing')
    for i, r in enumerate(doc.get('next', {}).get('related', [])):
        if not isinstance(r.get('page'), int) or not r.get('label'): e.append(f'next.related[{i}] needs page + label')
    return e


def main(src, out, pages):
    errors, warn = [], []
    os.makedirs(out, exist_ok=True)
    for name, page in [('global', None)] + [(f'p{p:05d}', p) for p in pages]:
        path = os.path.join(src, name + '.json')
        if not os.path.exists(path): errors.append(f'{name}.json missing'); continue
        try: doc = json.load(open(path, encoding='utf-8'))
        except ValueError as x: errors.append(f'{name}.json invalid JSON: {x}'); continue
        errors += [f'{name}.json: {m}' for m in check(doc, page)]
        clean = strip(doc)
        warn += [f'{name}.json technical word: {t[:60]!r}' for t in texts(clean) if TECH.search(t)]
        json.dump(clean, open(os.path.join(out, name + '.json'), 'w', encoding='utf-8'), ensure_ascii=False, separators=(',', ':'))
    for w in warn: print('WARNING', w)
    if errors:
        for m in errors: print('ERROR', m)
        sys.exit(1)
    print(f'help guard OK: {len(pages)} pages + global')


if __name__ == '__main__':
    main(sys.argv[1], sys.argv[2], [int(p) for p in sys.argv[3:]])

Run it in your build with the list of all page ids, then deploy the cleaned files, never the sources:

# page ids of your app; exit code 1 = the build stops
python help_guard.py help-src/ build/help/ 1 10 20 21 30 31

# example output
help guard OK: 6 pages + global

# when a page has no help file
ERROR p00040.json missing
7
Step 7 of 7

Test it in a real browser

The test opens a small page, clicks around like a user and checks what matters: nothing loads with the page, the dialog gets focus, the situation filter and search work, hidden fields are left out, XSS renders as text, Esc returns focus, the "?" jumps to its field, the cache works, focus stays trapped, the phone layout works, and a missing file shows a fallback message.

The test page:

test/test.html
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="../help-drawer.css">
  <script src="../help-drawer.js"></script>
</head>
<body>
  <header><a href="#" data-help id="hb">Help</a></header>
  <input type="hidden" id="P31_ID" value="7">
  <label for="P31_NAME">Name</label><input id="P31_NAME">
  <label for="P31_IBAN">IBAN</label><input id="P31_IBAN">
  <input id="P31_X">
  <button id="P31_SAVE">Save</button>
  <script>
    window.helpConfig = { baseUrl: 'help/', pageId: 31, appName: 'Demo',
      mode: function () { return document.getElementById('P31_ID').value ? 'edit' : 'create'; } };
    window.helpFields = [['P31_IBAN', 'IBAN']];
  </script>
</body>
</html>

Test content. It deliberately includes a field that isn't on the page, a button that isn't on the page, and an XSS sample:

test/help/p00031.json
{"page_id": 31, "page_name": "Customer Detail", "modes": ["create", "edit", "view"], "about": {"summary": "Create or change a customer.", "why": "Visits and invoices need the customer.", "goal": "The customer is saved."}, "steps": [{"text": "Enter the name.", "modes": ["create"]}, {"text": "Change what is wrong, then Save.", "modes": ["edit"]}], "fields": [{"item": "P31_NAME", "label": "Name", "what": "Full name.", "enter": "First and last name.", "source": "manual", "mandatory": "Yes"}, {"item": "P31_IBAN", "label": "IBAN", "what": "Bank account.", "source": "manual", "mandatory": "Only when payment is direct debit", "effect": "Used for direct debit."}, {"item": "P31_SECRET", "label": "Hidden field", "what": "Not on the page.", "source": "manual"}, {"item": "P31_X", "label": "<img src=x onerror=alert(1)>", "what": "<script>alert(2)</script>", "source": "manual"}], "rules": [{"title": "Unique name", "rule": "Two customers cannot have the same name and birthday.", "why": "No duplicates.", "verified_by": "pkg.check"}], "actions": [{"button": "P31_SAVE", "label": "Save", "does": "Saves.", "changes_data": true}, {"button": "P31_DELETE", "label": "Delete", "does": "Deletes."}], "errors": [{"message": "Please enter the name.", "why": "Name is mandatory.", "fix": "Enter the first and last name, then Save again."}], "next": {"after": "Plan the first visit.", "workflow": ["Customer", "Assignment", "Visit", "Invoice"]}}
test/help/global.json
{"page_id": 0, "page_name": "Working with the app", "about": {"summary": "Menu on the left, Help on every page."}, "rules": [{"title": "Autosave", "rule": "There is none.", "why": "Click Save."}]}

The test script (needs pip install playwright and playwright install chromium):

test/test_help.py
"""Browser test for the help drawer.  usage: python test/test_help.py   (run from the kit folder)"""
import sys, os, http.server, threading, functools
from playwright.sync_api import sync_playwright

root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))          # the kit folder
class Quiet(http.server.SimpleHTTPRequestHandler):
    def log_message(self, *a): pass
handler = functools.partial(Quiet, directory=root)
srv = http.server.ThreadingHTTPServer(('127.0.0.1', 0), handler)
threading.Thread(target=srv.serve_forever, daemon=True).start()
url = f'http://127.0.0.1:{srv.server_port}/test/test.html'
results = []

def ok(name, cond):
    results.append(bool(cond)); print('PASS' if cond else 'FAIL', name)

def body_text(pg):
    return pg.inner_text('.hd-body')                                        # visible text only

with sync_playwright() as p:
    b = p.chromium.launch(); pg = b.new_page()
    alerts, errs, reqs = [], [], []
    pg.on('dialog', lambda d: (alerts.append(1), d.dismiss()))
    pg.on('pageerror', lambda e: errs.append(str(e)))
    pg.on('request', lambda r: reqs.append(r.url))
    pg.goto(url); pg.wait_for_load_state('networkidle')
    ok('nothing loaded with the page', not any('/help/' in r for r in reqs))

    pg.click('#hb'); pg.wait_for_selector('.hd-tog')
    ok('dialog opens', pg.is_visible('[role=dialog]'))
    ok('title gets focus', pg.evaluate("document.activeElement.id") == 'hd-title')
    t = body_text(pg)
    ok('mode detected (edit)', 'Shown for: edit' in t)
    ok('create-only step hidden', 'Enter the name.' not in t and 'Change what is wrong' in t)
    ok('first two sections open', pg.evaluate("[...document.querySelectorAll('.hd-tog')].slice(0,3).map(b=>b.getAttribute('aria-expanded')).join()") == 'true,true,false')

    pg.click('.hd-tog >> text=Field explanations'); t = body_text(pg)
    ok('field not on page is left out', 'Hidden field' not in t)
    ok('button not on page is left out', 'Deletes.' not in body_text(pg))
    ok('XSS shown as plain text', '<img src=x' in t and not alerts and pg.locator('.hd-body img, .hd-body script').count() == 0)
    ok('internal notes not shown', 'pkg.check' not in t)
    ok('global help at the bottom', 'Working with Demo' in body_text(pg))

    pg.fill('.hd-search', 'bank account'); t = body_text(pg)
    ok('search keeps matching entry', 'Bank account.' in t)
    ok('search hides other entries', 'Full name.' not in t and 'Unique name' not in t)
    pg.fill('.hd-search', ''); t = body_text(pg)
    ok('clearing search restores view', 'Change what is wrong' in t and 'Full name.' in t)

    pg.click('.hd-all'); full = pg.evaluate("document.querySelector('.hd-body').textContent")
    ok('"Show all situations" lifts the filter', 'Hidden field' in full and 'Enter the name.' in full)

    pg.keyboard.press('Escape')
    ok('Esc closes and returns focus', not pg.is_visible('[role=dialog]') and pg.evaluate("document.activeElement.id") == 'hb')

    n = len(reqs); pg.click('[data-help-field=P31_IBAN]'); pg.wait_for_selector('.hd-hit')
    ok('field "?" jumps to its entry', pg.evaluate("document.activeElement.dataset.key") == 'P31_IBAN')
    ok('second open uses the cache', not any('/help/' in r for r in reqs[n:]))
    for _ in range(14): pg.keyboard.press('Tab')
    ok('focus stays inside the drawer', pg.evaluate("!!document.activeElement.closest('.hd-panel')"))

    pg.keyboard.press('Escape'); pg.set_viewport_size({'width': 360, 'height': 740})
    pg.click('#hb'); pg.wait_for_selector('.hd-tog')
    ok('phone width: full screen, no side scroll', pg.evaluate("document.querySelector('.hd-panel').getBoundingClientRect().width") == 360
       and pg.evaluate("document.documentElement.scrollWidth <= 360"))

    pg.evaluate("helpConfig.pageId = 99; sessionStorage.clear()"); pg.keyboard.press('Escape')
    pg.click('#hb'); pg.wait_for_timeout(500)
    ok('missing file shows fallback text', 'not available yet' in body_text(pg))
    ok('no JavaScript errors', not errs)
    b.close()

srv.shutdown()
print(f'{sum(results)} / {len(results)} passed')
sys.exit(0 if all(results) else 1)

Run it from the folder that contains help-drawer.js:

python test/test_help.py

PASS nothing loaded with the page
PASS dialog opens
PASS title gets focus
...
PASS no JavaScript errors
22 / 22 passed

Here's what both checks look like in a real run. I let the guard fail first on purpose, with a missing page and a technical word in a fix text:

Two terminals: the help guard fails on a missing page file and warns about the word NULL, then passes after the fix; the browser test passes 22 of 22 checks
Figure 8. Left: the build stops until every page has help. Right: the browser test, 22 of 22 checks passing.

06Oracle APEX setup

In APEX everything maps onto standard Shared Components and page 0. No plug-in needed.

APEX App Builder mockup: the Navigation Bar list entry Help with Link Attributes data-help, the Static Application Files list, and the page 0 config region
Figure 9. The three places you touch in APEX: the navigation bar entry, the static files, and one region on page 0.
  1. Upload the files. In Shared Components › Static Application Files, upload help-drawer.js and help-drawer.css. Upload the cleaned JSON files with the directory help/ (so they become help/p00031.json and help/global.json) and mime type application/json.
  2. Load the script and styles. In Shared Components › User Interface Attributes, add #APP_FILES#help-drawer#MIN#.js under JavaScript File URLs and #APP_FILES#help-drawer#MIN#.css under CSS File URLs. (#MIN# picks up a minified copy if you upload one.)
  3. Add the Help button. In your Navigation Bar List, add an entry "Help" with target URL #, icon fa-question-circle-o, and in Link Attributes: data-help aria-haspopup="dialog".
  4. Add the config on page 0. Create a Static Content region (template Blank with Attributes) with this source:
    <script>
      window.helpConfig = {
        baseUrl: '#APP_FILES#help/',
        pageId: &APP_PAGE_ID.,
        appName: 'My App'
      };
    </script>
    #APP_FILES# already carries the files version, so every new upload means a new URL and fresh help.
  5. Tell the drawer the mode. On a form page, in Execute when Page Loads (it runs after the page 0 config, so it adds to it):
    window.helpConfig.mode = function () {
      return apex.item('P31_ID').getValue() ? 'edit' : 'create';
    };
  6. Make buttons findable. Give each button a Static ID equal to the name used in the JSON (for example P31_SAVE). Page items already render with their name as the element id.
  7. Modal dialogs and the sign-in page. Page 0 isn't rendered in modal dialogs, so add a button with data-help (via Custom Attributes) to the dialog footer, plus a small config region on that page. On the public sign-in page, use a floating Help button.
  8. Build. Run help_guard.py in your export/import pipeline before the static files are uploaded.
Tip
If your app has an old help region that queries help text from the database on every page view, remove it. Keep the built-in Help Text page attribute only as a fallback.

07React, Angular and other single-page apps

Load the script and CSS once in your app shell. Because the drawer reads window.helpConfig each time it opens, you just update it when the route changes:

// e.g. in your router's "after navigation" hook
router.afterEach(function (to) {
  window.helpConfig = {
    baseUrl: '/static/help/',
    pageId: to.meta.helpId,            // give each route a help id
    appName: 'My App',
    version: BUILD_VERSION
  };
});

Use stable id attributes (or data-help-name) on the fields and buttons you document, and render the Help button anywhere with data-help. The "?" buttons from window.helpFields are added once, when the page loads. For fields your framework renders later, render your own small button and open the field's help from code with window.appHelp.open(buttonElement, 'P31_IBAN').

08What to expect

MeasureTypical result
Page loadUnchanged, 0 help requests during load
First open of the drawerAbout 80 to 140 ms (fetches two small JSON files)
Later opensAbout 50 ms (from the session cache)
SafetyStatic text only, rendered as text, internal notes stripped, hidden fields and buttons left out
UpkeepThe build fails when a page has no help
The real payoff

The biggest win isn't technical.

Writing verified help for every page surfaces real problems: rules nobody can explain, buttons that aren't where the spec says, error messages that don't help anyone. Writing the help turns into a quality review of the app.

09Rollout checklist

Kit files copied, colours adapted to your design
Help button in the header, plus a floating button on sign-in pages and dialogs
global.json written (menu, saving, signing out)
Help files for the 3 to 5 most-asked pages, verified against the code
help_guard.py in the build with the full page list
Browser test running in CI
Page-load time measured before and after (it shouldn't change)
Open gaps reviewed by a developer or the business owner
Team rule agreed: a new page isn't done until its help file is done
In short
Summary
✓One JSON file per page holds the help. Content is data, so writers never touch code.
✓The drawer loads nothing until the first click, then serves from the session cache.
✓Everything renders as text, internal notes are stripped, and users only see help for what's on their screen.
✓help_guard.py fails the build when a page has no help, so it can't fall behind.
✓Verify every statement against the code. Anything you can't verify goes to gaps, not to users.

Start with your five noisiest pages this week, and let the build make sure every page after that explains itself.

A
Written by Awais, Oracle APEX developer. More hands-on posts at oraclewithawais.com.
Awais Ahmed

Written by

Awais Ahmed

Oracle APEX developer writing practical guides on Oracle APEX, Oracle Database, PL/SQL and application development, with working code you can reuse.

Comments

Pages