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.
// 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).
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.
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|}.
<!-- 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.
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 <=.
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.
// `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:[…]}.
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.
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:
-
Wire the filter inputs
Bind them, or listen with
ctx.on('#search','input', …)— wrapped inctx.debounceso you query after the user pauses, not on every keystroke. -
Run a query and stash the rows
Call
ctx.query(...)with your conditions, thenctx.set('results', rows)to put them where the template can see them. -
Render with
{|#each results|}The results table re-renders automatically when
resultschanges. Each row carries adata-idso it knows which record it represents. -
Open the record on click
One delegated listener on the row class survives every re-render and opens the clicked record.
// 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:
{|#each results|}
<tr class="row" data-id="{|._id|}">
<td>{|.name|}</td><td>{|.total__m|}</td>
</tr>
{|/each|}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.
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).
<!-- 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.
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.
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.
Earn the Archetype Ace stamp
Five questions to lock in Module P3.