Petstore: Pending Changes
The Pending changes page (/console/pets/changes) lists edits that were staged from the pet detail page but not yet
written to the pet. A staged change can be applied, retried if the write failed, or discarded. The same page is the
data source for the count badge in the console header, which is why this page is also where the <@badge>
directive is documented: the framework has no page of its own for it yet.
The PendingChange lifecycle
PendingChanges (obtained from PetStore.pendingChanges()) holds the staged edits, and a PendingChange has one
of three states:
| State | Meaning | Allowed next step |
|---|---|---|
PENDING | Staged. The pet is unchanged. | Apply, or discard |
APPLIED | Written to the pet, and the write is audited. | None |
FAILED | The apply failed, for example because the pet is gone. message says why. | Retry |
Staging records the field, the old value, the new value, the author and a one-line summary in the form
<pet name>: <field> '<old>' -> '<new>'. The staging call is POST /console/pets/{id}/stage, served by PetsRest.
Every editable Pet field stages into a pending change except the photo. The photo is a binary payload with no
meaningful diff and nothing sensible to discard, so staging it is rejected with a 400,
Field 'photo' cannot be staged; use one of: name, price, species, status, tags. The pet detail Photo tab edits it
directly. See Petstore: Pet Detail.
Apply, retry and discard
| Route | Does | Errors |
|---|---|---|
POST /console/pets/changes/{id}/apply | Writes one PENDING change to its pet | 404 unknown change, 409 not pending |
POST /console/pets/changes/{id}/retry | Re-applies one FAILED change | 404 unknown change, 409 not failed |
POST /console/pets/changes/discard | Discards many PENDING changes in one call | 400 missing ids or idempotencyKey |
Apply and retry answer with an action result: a success toast (Applied '<summary>') when the write landed, or a
failure carrying the changed row and its message when the pet is gone and the change is now FAILED.
Discard takes {ids, idempotencyKey} and returns a BulkResult. PendingChanges.discard produces a
DiscardOutcome with three lists: succeeded, notFound (including ids that do not parse) and failed (ids that
exist but are not pending, with the reason). A discarded change is removed from the list and an audit entry is
recorded. Replaying the same idempotencyKey returns the stored answer instead of discarding again. The demo toggle
?fail=discard answers 503 so you can watch the table handle a transport failure.
Only PENDING rows are selectable (selectableWhen), so a failed row offers only its Retry row action. The ribbon's
"Pending only" option is on by default, so turn it off to see FAILED rows.
Template
selection: { rowIdField: 'id', selectAll: true, scope: 'persistent', labelField: 'summary',
selectableWhen: [ { field: 'state', op: 'eq', value: 'PENDING', reason: 'Only pending changes can be discarded or applied.' } ] },
bulk: { actions: [
{ id: 'discard', label: 'Discard', endpoint: '/console/pets/changes/discard', method: 'POST', bulkMode: 'aggregate',
confirmTitle: 'Discard changes', confirm: 'Discard the selected changes?', tone: 'danger', confirmRenderer: 'typed' },
{ id: 'apply', label: 'Apply', endpoint: '/console/pets/changes/{id}/apply', method: 'POST', bulkMode: 'perRow' }
] }
Builder twin
var selection = SelectionDef.create("id").selectAll(true).scope(SelectionDef.Scope.PERSISTENT).labelField("summary")
.selectableWhen(RowActionEnabledRule.of("state", Op.EQ, "PENDING", "Only pending changes can be discarded or applied."));
...
.bulk(BulkMutateDef.create(WritePermit.forCapability("pets.changes"), selection).actions(
RowAction.create("discard").label("Discard").endpoint("/console/pets/changes/discard").method(Method.POST)
.bulkMode(BulkMode.AGGREGATE).confirmTitle("Discard changes").confirm("Discard the selected changes?")
.tone(Tone.DANGER).confirmRenderer("typed"),
RowAction.create("apply").label("Apply").endpoint("/console/pets/changes/{id}/apply").method(Method.POST)
.bulkMode(BulkMode.PER_ROW)))
For selection, bulk modes and WritePermit, see Row Actions and Bulk Mutation.
The header badge
base.ftlh declares one badge inside <@console>, after the navigation:
<@badge id="pending" src="/console/pets/changes/pending" href="/console/pets/changes" refreshMs=60000 placement="header" refreshesAllowMissing="pets"/>
It is unscoped on purpose. A nav scope would hide the badge on every page except the pets page. BadgeDef is the
Java form (PageSpec.badge(BadgeDef)); the FreeMarker directive builds the same definition. The full directive and
BadgeDef reference is in Admin-Console Chrome
(BadgeDef javadoc).
Attributes
| Attribute | BadgeDef | Meaning |
|---|---|---|
id | create(id) | Required. Unique among all badges on the page. |
src | src | Required. Same-origin JSON endpoint to poll. |
refreshMs | refreshMs | Poll interval. Default 60000; below 5000 is an error, not clamped. |
href | href | Same-origin path the badge navigates to when clicked. |
placement | placement | header (default) or toolbar. toolbar needs table. |
table | table | Card id the badge belongs to. Needed for toolbar and for scopeBy="view". |
tone | tone | info, warning (default) or danger. |
labelOne, labelOther, labelMine | label | Templates using {total} and {mine}. Defaults: {total} change pending, {total} changes pending, ({mine} yours). |
tooltipMax | tooltipMax | Items listed in the hover popover, 0 to 50. Default 10. |
refreshes | refreshes | Card ids to reload when a previously seen item leaves the response. Each must exist on the page. |
refreshesAllowMissing | refreshesAllowMissing | Like refreshes, but the card may live on another page. The petstore uses "pets". |
scopeParam, scopeBy | scope | Query parameter to send and how to key it: nav or view. Give both or neither. |
stateFilterColumn | stateFilter | Column the destination page filters on, added to the click URL as ?state=. Needs a scope. |
The directive's body is optional JSON5 with visibleWhen (a rule list), scopeValues (nav or view id to value
list) and params (extra query parameters). Any other key is rejected.
The endpoint and the poll
ChangesRest.pending (GET /console/pets/changes/pending) returns {total, mine, items}. Each item is
{id, label, href, mine, kind}: mine is true when the caller authored the change, and href points at the pet
summary. The badge script reads total, mine, and each item's id, label, href and mine. A badge is
presentation only; the endpoint still decides who may read it.
The script behaves as follows:
- It sends
Accept: application/jsonon every poll. Juneau negotiates content, so a plainGETwith noAcceptheader would be answered with HTML. - It hides itself at zero, and pulses when the count rises. A response without a numeric
totalis an error. - A 401 or 403 removes the badge and stops polling. Any other failure shows a stale
!and retries with exponential backoff, starting at the interval and capping at five minutes. - A fetch aborted by an
AbortError, or by the page unloading (pagehide;pageshowre-arms it for a restored page), is silent. It logs nothing and does not mark the badge stale. A failure on a live page still logs. - When an item id seen on one poll is missing from the next, it reloads the
refreshescards. That is how applying or discarding a change refreshes the pets table; a card that is not mounted is skipped. - An unsafe
src,hrefor itemhref(an absolute URL,//,.., a backslash or a control character) is refused.
Source: ChangesRest and PetsRest in juneau-petstore/juneau-petstore-core/src/main/java/org/apache/juneau/petstore/console/pets/;
PendingChanges and PendingChange in .../petstore/service/; ChangesPageBuilders in .../console/pages/;
changes.ftlh and base.ftlh under .../console/templates/. Framework: BadgeDef,
BadgeDirectiveModel and juneau-badges.js.
Tests: ChangesRest_Test, PetsRest_Test (the photo case), PetstorePending_BrowserTest.