Petstore: Template and Builder Twins
Every page in the petstore console is written twice. One copy is a FreeMarker template (.ftlh) that uses the
console directives. The other is a Java method that builds the same page with PageSpec and TableSpec. The two are
called twins, and a test proves they stay the same page. This page explains why the petstore keeps both, shows one
pair side by side, and ends with a recipe for your own console.
Why twins exist
The console renders a page from a page contract: a JSON document in #juneau-page plus one <template> per card.
A template and a builder are two ways to produce that same contract.
- A template reads like the page. The card body is JSON5, so what you write is what the browser gets. It is the right default for a page whose shape you know when you write it.
- A builder composes the page in Java. It can loop, call helpers, share a table definition between two pages, and check column names against a bean at compile time. It is the right tool when the page is computed.
The petstore writes both for every page so that each form has a worked, tested example, and so the tests can prove the builder covers everything a template can say. Each page on the tour has a "Builder twin" section with its own pair.
The template is what a normal request serves. Adding ?builder to the URL renders the builder twin instead, and the
response carries the header X-Petstore-Page: builder so a test can tell the two paths apart. ?mixed renders a
third form, covered below.
The audit page, side by side
The Audit page (Petstore: Audit) is the smallest pair. The template is audit.ftlh:
<@page tab="ops/audit" toolkit="views">
<@card type="datatables" id="audit">{
dataMode: 'server',
dataUrl: '/console/ops/audit/query',
defaultOrder: [{ key: 'at', dir: 'desc' }],
columns: [
{ key: 'at', label: 'When', render: { id: 'timestamp' }, searchType: 'timestamp' },
{ key: 'actor', label: 'Actor', searchType: 'text' }
],
ribbon: [
{ type: 'export', buttons: ['copy', 'csv'] },
{ type: 'refresh', id: 'refresh' }
]
}</@card>
</@page>
The builder is OpsPageBuilders.audit:
PageSpec.create().tab("ops/audit").toolkit("views")
.table(TableSpec.create("audit").dataMode(DataMode.SERVER).dataUrl("/console/ops/audit/query")
.operators(SearchOperatorSet.standard())
.defaultOrder("at", Sort.DESC)
.columns(
Column.create("at").label("When").render("timestamp").searchType(SearchType.TIMESTAMP),
Column.create("actor").label("Actor").searchType(SearchType.TEXT))
.ribbon(RibbonItem.export("copy", "csv"), RibbonItem.refresh().id("refresh")));
Both excerpts trim four of the six columns. The mapping is mechanical:
| Template | Builder |
|---|---|
<@page tab="ops/audit" toolkit="views"> | PageSpec.create().tab("ops/audit").toolkit("views") |
<@card type="datatables" id="audit"> | .table(TableSpec.create("audit") |
dataMode: 'server' | .dataMode(DataMode.SERVER) |
defaultOrder: [{ key: 'at', dir: 'desc' }] | .defaultOrder("at", Sort.DESC) |
{ key: 'at', render: { id: 'timestamp' } } | Column.create("at").render("timestamp") |
{ type: 'export', buttons: [...] } | RibbonItem.export("copy", "csv") |
The pets page
The Pets page (Petstore: Pets) is the large pair. pets.ftlh is one datatables card
with about fifty lines of JSON5, and PetsPageBuilders.pets builds the same table from petsTable(). The pieces that
repay a close look:
- Ribbon. The two
optionGroupentries (pet status, species) becomeRibbonItem.optionGroup(...)calls whose options carrycolumnand a$eq(...)or$in(...)value. The+ Add petdialog isRibbonItem.dialog(...). Export and refresh areRibbonItem.export(...)andRibbonItem.refresh(). - Row rules.
rowClassRulesbecomes.rowClass("status", Op.EQ, "SOLD", "is-muted"). Thestatuscolumn'srender: { id: 'pill' }is.render("pill"). - Selection and bulk actions. The template spells
selectionandbulkas plain objects. The builder builds aSelectionDefonce and hands it toBulkMutateDef.create(...), so the bulk action cannot name a selection the table does not have. - Badges. The pending-changes badge in the page header is declared once, in
base.ftlh, with<@badge>. Both twins inherit it. A page may also declare a badge on itsPageSpecwith.badge(BadgeDef...), but declaring the same id in both places fails loud; see Petstore: FailLoud Catalogue, testa10.
The other builder types you will meet across the tour are ChartDef (the Store charts), JobLogCardType (the Jobs
page) and VisibilityRule (conditional row actions on the orders, pet and pending-changes pages). Each is the builder spelling of a
card type or a key that the template writes as JSON5.
Mixed mode
?mixed renders the third form. pets-mixed.ftlh is a four-line template:
<@page tab="pets" toolkit="views">
<@card ref="pets"/>
</@page>
<@card ref="pets"/> does not define a card. It places the card named pets that the Java side supplies.
PetsPageBuilders.petsMixed() is PageSpec.create().template("pets-mixed.ftlh").table(petsTable(), "Pets"): the
layout is FreeMarker and the table is Java. Because pets() and petsMixed() both call petsTable(), the table
cannot drift between the two.
Use this form when the page frame is hand-written but one card is too large or too dynamic to write as JSON5. Add
?builder&mixed to render the mixed spec through the builder path.
How the tests prove parity
PetstoreTwins_Test renders every page both ways and compares the results with PageContractAssert.assertTwins. The
comparison is deliberately about the contract, not the HTML around it:
- the contract JSON is compared with its keys sorted, but list order is kept, because card, nav and option order is part of the page;
- the
<template>ids must match, and each template's markup is compared with whitespace stripped.
The pages come from PetstorePages_ContractTest.PAGE_PATHS, so a page added to that list gets a twin check without
any change to the twin test. Extra tests (b01 to b06) cover the pages with the most moving parts: Store, the
HTML, FreeMarker and Mustache flavors, a pet's Orders tab and Jobs. a03_mixedEqualsFtl checks that ?mixed and
?builder&mixed both equal the plain template.
The test class also has a GAPS list: pages whose twin cannot equal the template yet, each with a reason and the
card or key that only one side renders. GAPS is empty. Every builder page matches its template, and the list is
kept so a future known difference has a place to be written down, with a reason, instead of a skipped test.
The three builder-only developer pages (Charts, Data, Dashboard; see Petstore: Developer Pages) have no template and are not part of this check.
Recipe for your own console
- Start with the template. Write the page as an
.ftlhfile with<@page>and<@card>. It is the shortest path, and the JSON5 is easy to read. - Add the twin when you need to compose. When two pages share a table, when a card list depends on data, or when
you want compile-time checks, move the page into a
PageSpecmethod. Keep the template and the builder together until the builder is trusted. - Share the pieces. Put a table in a method like
petsTable()and call it from both the full builder and the mixed one. - Pin parity. Add the page path to one list that a twin test reads, and use
assertTwins, so a change to one side fails the build until the other follows. - Retire a side deliberately. Once the builder is the source of truth, delete the template and the
?builderswitch. The petstore keeps both only because it is a teaching example.
Source: PetstoreConsolePage (the ?builder switch and the page header), PetsRest, PetsPageBuilders and
OpsPageBuilders under juneau-petstore/juneau-petstore-core/src/main/java/org/apache/juneau/petstore/console/, and the
templates audit.ftlh, pets.ftlh and pets-mixed.ftlh under the matching resources directory.
Tests: PetstoreTwins_Test and PetstorePages_ContractTest.
See also
- Petstore: FailLoud Catalogue
- Petstore: Audit
- Petstore: Pets
- Building a Console Page: the template directives and the page contract.
- Rich Table Views: the table card and its builder.