Skip to main content

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:

TemplateBuilder
<@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 optionGroup entries (pet status, species) become RibbonItem.optionGroup(...) calls whose options carry column and a $eq(...) or $in(...) value. The + Add pet dialog is RibbonItem.dialog(...). Export and refresh are RibbonItem.export(...) and RibbonItem.refresh().
  • Row rules. rowClassRules becomes .rowClass("status", Op.EQ, "SOLD", "is-muted"). The status column's render: { id: 'pill' } is .render("pill").
  • Selection and bulk actions. The template spells selection and bulk as plain objects. The builder builds a SelectionDef once and hands it to BulkMutateDef.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 its PageSpec with .badge(BadgeDef...), but declaring the same id in both places fails loud; see Petstore: FailLoud Catalogue, test a10.

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​

  1. Start with the template. Write the page as an .ftlh file with <@page> and <@card>. It is the shortest path, and the JSON5 is easy to read.
  2. 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 PageSpec method. Keep the template and the builder together until the builder is trusted.
  3. Share the pieces. Put a table in a method like petsTable() and call it from both the full builder and the mixed one.
  4. 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.
  5. Retire a side deliberately. Once the builder is the source of truth, delete the template and the ?builder switch. 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​