Skip to main content

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:

StateMeaningAllowed next step
PENDINGStaged. The pet is unchanged.Apply, or discard
APPLIEDWritten to the pet, and the write is audited.None
FAILEDThe 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.

The photo is not stageable

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​

RouteDoesErrors
POST /console/pets/changes/{id}/applyWrites one PENDING change to its pet404 unknown change, 409 not pending
POST /console/pets/changes/{id}/retryRe-applies one FAILED change404 unknown change, 409 not failed
POST /console/pets/changes/discardDiscards many PENDING changes in one call400 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​

AttributeBadgeDefMeaning
idcreate(id)Required. Unique among all badges on the page.
srcsrcRequired. Same-origin JSON endpoint to poll.
refreshMsrefreshMsPoll interval. Default 60000; below 5000 is an error, not clamped.
hrefhrefSame-origin path the badge navigates to when clicked.
placementplacementheader (default) or toolbar. toolbar needs table.
tabletableCard id the badge belongs to. Needed for toolbar and for scopeBy="view".
tonetoneinfo, warning (default) or danger.
labelOne, labelOther, labelMinelabelTemplates using {total} and {mine}. Defaults: {total} change pending, {total} changes pending, ({mine} yours).
tooltipMaxtooltipMaxItems listed in the hover popover, 0 to 50. Default 10.
refreshesrefreshesCard ids to reload when a previously seen item leaves the response. Each must exist on the page.
refreshesAllowMissingrefreshesAllowMissingLike refreshes, but the card may live on another page. The petstore uses "pets".
scopeParam, scopeByscopeQuery parameter to send and how to key it: nav or view. Give both or neither.
stateFilterColumnstateFilterColumn 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/json on every poll. Juneau negotiates content, so a plain GET with no Accept header would be answered with HTML.
  • It hides itself at zero, and pulses when the count rises. A response without a numeric total is 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; pageshow re-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 refreshes cards. That is how applying or discarding a change refreshes the pets table; a card that is not mounted is skipped.
  • An unsafe src, href or item href (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.

See also​