Aquargin Way / Build Pages / Module P3

Page Archetypes

Almost every custom page you'll ever build is one of five shapes. Learn to recognise them and you stop reinventing layouts — you reach for the pattern that fits and wire it up the same way every time.

5 units ~20 min Earn the Archetype Ace stamp
Unit 1 of 5

Data Entry

The create/edit form — a handful of bound fields and a submit button. You already built one of these in Module P2.

The recipe never changes. You declare one object variable to hold what the user types, you ctx.bind each input element to a path on that object, and you bind a button to a function with {invoke,...}. The page is a single rtext component — HTML, plus a <script> that does the wiring.

rtext
HTML + fields
ctx.bind
field → path
button
invoke fn
data-entry.js
// one object variable, declared on the page: `lead`
ctx.bind('#name',  'lead.name', { required: true });
ctx.bind('#email', 'lead.email__m');
ctx.bind('#status', 'lead.status__m', {
  type: 'picklist', options: ['New', 'Working', 'Qualified']
});

// the submit button calls a function, passing the object
ctx.bind('#save', 'click', {
  invoke:      'create_lead__fx',
  params:      [{ name: 'data', value: 'lead' }],
  loadingWhen: 'saving',
  onSuccess:   () => ctx.toast('Saved')
});

That's the whole archetype: bind the inputs, bind the button. The function on the other end does the create (see the Function Reference for the DML nodes).

When this is the right shape

Reach for Data Entry when you need a dedicated create/edit screen reached from a menu or sidebar — a “New Application”, a “Log a Ticket”. If instead the action lives on an existing record (“Convert”, “Approve”), that's a button → function, not a page (the Zero Rule from the Functions trail). A page is justified when the user starts from nothing and needs a room of their own to fill in.

Unit 2 of 5

Data Display

A read-only screen driven entirely by templates. No ctx.bind, no inputs — just data flowing into the page and rendering itself.

Three template shapes cover almost everything you'll want to show:

  • A single value — drop {|customer__m.name|} straight into the HTML.
  • A repeating list — wrap rows in {|#each orders|}…{|/each|}.
  • A conditional — show a badge with {|#if status == 'Open'|}…{|/if|}.
display.html (rtext)
<!-- single values resolve right where you write them -->
<h3>{|customer__m.name|}</h3>
<p>Account owner: {|owner__m.name|}</p>

<!-- a status badge, only when the condition holds -->
{|#if status__m == 'Open'|}
  <span class="badge badge--open">Open</span>
{|#else|}
  <span class="badge">{|status__m|}</span>
{|/if|}

<!-- a table of rows — #each WRAPS the <tr> -->
<table>
  <thead><tr><th>Order</th><th>Total</th></tr></thead>
  <tbody>
  {|#each orders|}
    <tr><td>{|.name|}</td><td>{|.total__m|}</td></tr>
  {|/each|}
  </tbody>
</table>

Inside the each block, {|.name|} and {|.total__m|} refer to the current order. The leading dot is what scopes them to the loop's current item.

Two rules that catch everyone

1. {|#each|} must wrap the repeating markup — the <tr> goes between the open and close tags. An empty each (with the row outside it) renders nothing at all. 2. {|.field|} only resolves inside the each block; outside it there is no “current” record. And for a lookup field, display the .name: {|customer__m.name|}, never bare {|customer__m|} (which is an {_id, name} object, not text).

For developers — where the data comes from

A Data Display page is fed by its page_onload function: it queries and shapes the records, then hands them out through its outputs, and those become the top-level template paths (customer__m, orders, status__m). The template engine resolves {|orders|} against that output object. Keep the onload lean — query exactly the fields the template names, nothing more. The supported operators inside {|#if|} are ==, !=, >, <, >= and <=.

Unit 3 of 5

Edit Form

Like Data Entry, but it loads an existing record first — and it can lock fields conditionally so users can't edit what they shouldn't.

The current record loads automatically and lives at record.*. You bind your inputs to those paths, gray out fields with {disabledWhen:'...'} when a flag is true, and point the save button at an update function instead of a create.

edit-form.js
// `record` is the loaded record — bind straight to its fields
ctx.bind('#name',   'record.name', { required: true });
ctx.bind('#owner',  'record.owner__m', { type: 'lookup', object: 'user__m' });

// gray this field out whenever record.isLocked is truthy
ctx.bind('#amount', 'record.amount__m', { disabledWhen: 'record.isLocked' });

// the button invokes an UPDATE function with the edited record
ctx.bind('#save', 'click', {
  invoke:      'update_record__fx',
  params:      [{ name: 'data', value: 'record' }],
  loadingWhen: 'saving'
});

Note the lookup binding: {type:'lookup', object:'user__m'} turns a plain input into a record picker, and a picklist works the same way with {type:'picklist', options:[…]}.

disabledWhen & requiredWhen

Both take a path to a boolean, not a hard-coded value — so they react as the record changes. {disabledWhen:'record.isLocked'} grays the field the instant isLocked flips true; {requiredWhen:'record.isVip'} makes a field mandatory only for VIP records. Use them to encode rules in the UI instead of trusting users to remember them — the function on save should still enforce the same rules server-side.

Unit 4 of 5

Search + Results

Filters at the top, results below — the browsing archetype. Type, and the list narrows; click a row, and you jump to the record.

The pattern has four moving parts:

  1. Wire the filter inputs

    Bind them, or listen with ctx.on('#search','input', …) — wrapped in ctx.debounce so you query after the user pauses, not on every keystroke.

  2. Run a query and stash the rows

    Call ctx.query(...) with your conditions, then ctx.set('results', rows) to put them where the template can see them.

  3. Render with {|#each results|}

    The results table re-renders automatically when results changes. Each row carries a data-id so it knows which record it represents.

  4. Open the record on click

    One delegated listener on the row class survives every re-render and opens the clicked record.

search.js
// debounced search: query, then publish the rows to `results`
const run = ctx.debounce(async () => {
  const term = ctx.val('#search');
  const rows = await ctx.query(
    'order__m',
    { name: { c: term } },          // contains
    ['name', 'total__m'],
    50
  );
  ctx.set('results', rows);   // {|#each results|} re-renders
}, 300);
ctx.on('#search', 'input', run);

// ONE delegated listener — selector string survives re-renders
ctx.on('.row', 'click', e =>
  ctx.openRecord(e.currentTarget.getAttribute('data-id'))
);

And the matching template — every row stamps its own id onto data-id for the listener to read back:

search.html (rtext)
{|#each results|}
  <tr class="row" data-id="{|._id|}">
    <td>{|.name|}</td><td>{|.total__m|}</td>
  </tr>
{|/each|}
A justified page

A standalone Search + Results screen is one of the five reasons a custom page is the right tool (rather than a record button). It's a browsing surface in its own right — something a user navigates to, not an action they fire on a record.

Unit 5 of 5

Multi-page composition

Build bigger features by embedding pages inside a host page. A dashboard becomes a host; the panels inside it are full pages in their own right.

A host embeds a sub-page with the data-aqua="page" marker — the only data-aqua marker there is. You pass inputs down with data-input, and each child gets an isolated namespace (its own variables, its own bindings — no collisions with the host or its siblings).

host.html (rtext)
<!-- the host embeds a child page and feeds it an input -->
<div data-aqua="page"
     data-page-id="<child-page-id>"
     data-input="customerId:customer._id"
     data-uid="child"
     contenteditable="false"></div>

Children don't reach into each other directly — they talk through the event system. A child raises an event with emitToParent and the host catches it with onChildEvent; for something every page should hear, emitGlobal broadcasts and onGlobal subscribes. That keeps each page a sealed unit you can drop anywhere.

Isolated, but talking

Think of each embedded page as a component: inputs flow in via data-input, and events flow out via emitToParent / emitGlobal. The host orchestrates; the children stay reusable. The full event-and-navigation story is a trail of its own — see the rtext trail for events, navigation and live charts in depth.

That's all five

Data Entry, Data Display, Edit Form, Search + Results, Multi-page composition. Every custom page you build will be one of these — or a host that composes several. Pass the checkpoint to claim your Archetype Ace stamp.

Checkpoint

Earn the Archetype Ace stamp

Five questions to lock in Module P3.