Skip to main content

Declarative Widgets (juneau-console-widgets)

juneau-console-widgets is a small, optional module of declarative widget beans: plain Java objects that describe a dialog, a form, a calendar, a row of stat tiles or a badge, plus the first-party browser scripts that paint them. A bean carries data and a fail-closed validate(); it carries no HTML-emitting code. The server-side emitters live in juneau-console-views, which composes these beans, and the dialog/form/bar runtime ships with that module's juneau-views.js.

Not the same thing as $W{...} widgets

Juneau has two unrelated things called "widget":

This page10.31.2 Widgets / 10.31.3 Predefined Widgets
Modulejuneau-console-widgetsjuneau-rest-server
Packageorg.apache.juneau.console.widgets (plural)org.apache.juneau.rest.server.widget (singular)
Core typeWidget — a marker interface with validate()Widget — an abstract class with getHtml() / getStyle() / getScript()
What it isTyped, data-only beans (modal, form, calendar, badges)Code that emits raw HTML/CSS/JS into an @HtmlDocConfig page via $W{Name}
Safety modelTyped fields painted with textContent; never an HTML sinkRaw markup authored by the application

The two share nothing but a simple class name, and a Widget in your imports is ambiguous if you use both. Import one by its fully-qualified name.

<dependency>
<groupId>org.apache.juneau</groupId>
<artifactId>juneau-console-widgets</artifactId>
<version>${juneau.version}</version>
</dependency>

The module depends only on juneau-rest-server and juneau-commons. It deliberately has no dependency on juneau-console-views, so a widget bean can never import a views type.

The runtime lives next to the views module

The script this module ships (juneau-calendar.js) resolves its icons through the views module's juneau-icons.js and pushes its popovers onto the single layer stack that juneau-views.js publishes. A page that loads it must load both of those too. See the WidgetsMixin section below.

The Widget contract​

Widget is a one-method marker interface:

public interface Widget {
void validate(); // throws IllegalArgumentException if not well-formed
}

Implementers: ModalDef, FormDef, CalendarDef, BarSlot and ServerValues.

Every one of them is fail-closed. validate() rejects a malformed bean rather than repairing it, and the REST serving path calls it for you (see the validation gate).

Beans that ride the wire also carry a CONTRACT_VERSION string that the browser runtime compares against a literal it bakes in; a mismatch refuses to paint rather than painting something subtly wrong.

BeanCONTRACT_VERSION
ModalDef"2"
FormDef"2"
CalendarDef"2"
BarSlot"1"
ServerValues"1" (Java-only, never on the wire)

ModalDef and FormDef move in lockstep with each other and with the runtime; bumping any two of the three makes every form-bearing dialog refuse to open.

Type catalog​

GroupTypes
Dialogs and formsModalDef (+ Field, ChildAction), FormDef (+ Input, Input.Option, Section)
CalendarCalendarDef (+ CalendarView, WeekStart), CalendarEvent, EventCategory (+ CategoryColor)
Bar slotBarSlot, BarWidget (sealed), BarBadge, BarText
Badges and menusBadge, StatusTone, MenuItem
ActionsActionBarItem, ActionRef (+ Emphasis, EnabledRule), SafeAction, Behavior, Op
Server valuesServerValues, ServerValuesValue, ServerValuesRegistry
ServingWidgetsMixin (+ WidgetValidationProcessor)

Dialogs: ModalDef​

ModalDef is the payload a row action with present=dialog opens: the response to the modal-open confirmation fetch. It is a REST response body, so you return one from an ordinary @RestGet.

Example
@RestGet("/incidents/{id}/ack-dialog")
public ModalDef ackDialog(@Path("id") String id) {
var incident = incidents.get(id);
return ModalDef.create("Acknowledge this incident?")
.field("Incident", incident.number())
.field("Title", incident.title())
.field("Service", incident.service())
.codeField("Correlation id", incident.correlationId())
.form(FormDef.create()
.field(FormDef.Input.of("comment", "Comment", "textarea").maxLength(500)))
.idempotencyKey(keys.mint("ack", id));
}

Safe by construction​

The confirmation body is typed fields, not markup. Each ModalDef.Field is a label and a value, painted by the runtime with textContent, never innerHTML. Live data (an incident title anyone can edit) is attacker-influenceable and the page's origin holds the CSRF token, so an HTML sink here would be stored XSS leading to arbitrary writes. There is no way to express one.

Field.kind is "text" (the default) or "code" (monospaced and copyable — an id, a URL, a token). Use .field(label, value) and .codeField(label, value) respectively.

Properties​

PropertyMeaning
contractVersionAlways "2" from construction.
titleThe prompt / title. Required, non-blank.
fieldsThe typed confirmation fields, in display order.
formOptional FormDef. Omit for a confirm-only dialog.
idempotencyKeyA server-minted key, bound to the action and target, that the runtime echoes verbatim on submit so a double-click or retry collapses to one effect.
selfTargetedOpt-in: the key targets itself (a dialog with no row, whose submit creates the thing). See the warning below.
keepOpenOnSubmitOpt-in: do not close at confirm-click; paint the write's result into the open dialog as a receipt.
childActionsA catalog of actions scoped to this one open dialog, for action inputs that open a stacked step.
barSlotAn optional BarSlot painted immediately after the title. Additive; does not bump the contract version.
selfTargeted discards the real target

Set selfTargeted(true) only on a modal whose key came from the self-targeted mint helper. On a modal carrying an artifact-bound key it silently replaces the real target with the key itself, which makes the server's binding check vacuous. It defaults to unset, and a dialog that leaves it unset keeps sending the row's real id.

Receipts: keepOpenOnSubmit​

By default the runtime closes a dialog at confirm time and then submits, so the dialog is gone before the result exists. keepOpenOnSubmit suppresses the close and paints the result into the open dialog. A receipt dialog is non-submittable by construction (it has no confirm control, and the runtime refuses to paint one whose payload carries a form), and it is reached only after a committed write. A dialog synthesized from a confirm string alone cannot opt in; a receipt-bearing action must serve a real ModalDef. An older runtime that ignores the flag closes the dialog and drops the receipt; the write still happens.

Stacked steps: childActions​

A type="action" form input names an action id, which the runtime normally resolves against the enclosing view's row-action catalog. A step reached from inside an open dialog (a Review step in a wizard) is not a row action, and publishing it as one would surface it in every row's menu. ModalDef.ChildAction is that missing catalog, scoped structurally to one open dialog because it rides on the per-open payload. The existing row-action lookup runs first and unchanged, so a served payload can never shadow a declared row action.

ModalDef.create("Create rule")
.form(FormDef.create().field(FormDef.Input.of("review", "Review", "action").actionId("review")))
.childAction(ModalDef.ChildAction.of("review", "Review")
.form("/rules/review-form")
.endpoint("/rules")
.method("POST")
.carryDrafts(true));

ChildAction has id, label, form (the child's form-source URL), endpoint, method, onSuccess and carryDrafts. carryDrafts sends the parent dialog's current values to the child's form GET as one URL-encoded juneauDrafts query parameter, so a Review step does not open empty. It requires both a form on the parent and a form URL on the child.

Drafts land in access logs and browser history

Never carry a secret through carryDrafts.

Forms: FormDef​

FormDef is the input form of a modal. A form is either a flat fields list or an ordered list of sections, never both (the flat list is the single-section case). Sections render as a strip above one visible pane; they are a layout of one dialog, not a stack of dialogs, and a value typed into one section survives a trip through another.

Example
var form = FormDef.create()
.field(FormDef.Input.of("resolution", "Resolution comment", "textarea").required().maxLength(1000))
.field(FormDef.Input.of("severity", "Severity", "select")
.option("low", "Low").option("high", "High").value("low"))
.field(FormDef.Input.of("notify", "Notify owner", "toggle"));

// Sectioned:
var wizard = FormDef.create()
.section(FormDef.Section.of("basics", "Basics")
.field(FormDef.Input.of("name", "Name", "text").required()))
.section(FormDef.Section.of("advanced", "Advanced")
.field(FormDef.Input.of("retries", "Retries", "text").pattern("[0-9]+")));

Input types​

The type allowlist is the XSS bound: hostile prefill cannot become an element. The client paints native controls with createElement, uses textContent for labels and options, and .value / .checked for prefills.

typeNotes
text, textareaThe default is text. Only these two accept pattern and maxLength.
passwordA masked, write-only input (<input type="password" autocomplete="new-password">). It is never pre-filled, so it is blank on edit and on re-open; validate() rejects a value on a password input, and it does not accept pattern or maxLength. Its typed value is excluded from the carryDrafts draft query, so it never lands in a URL.
checkbox, toggleBoolean inputs.
selectRequires at least one Option; a prefilled value must match an option. options is legal only here.
actionA button that opens the row action (or child action) named by actionId. actionId is required here and illegal elsewhere. Use .action(ActionRef) or .actionId(String).

Input properties: name (the submit-body key, required), label (required), type, required, value, options, pattern, maxLength, help, actionId. pattern is capped at 256 characters and must compile (ReDoS defense in depth); maxLength must be positive.

FormDef.template names an optional server-side template for authors and is ignored by the client (not an HTML sink). Create such a form with FormDef.ofTemplate(String).

validate() additionally rejects duplicate field names (across all sections of a sectioned form), duplicate section ids, and an empty sections list.

Calendars: CalendarDef​

CalendarDef describes a month calendar with navigation, categories and a display-only legend. The emitter (CalendarTable) is in the views module; the page mounts an empty data-juneau-calendar marker and juneau-calendar.js hydrates it. The server paints the initial month and seed events (progressive enhancement); the runtime computes later months client-side and fetches their events from a same-origin, data-only per-month GET.

Example
var cal = CalendarDef.create()
.id("oncall")
.endpoint("servlet:/oncall/events/{year}/{month}")
.categories(
EventCategory.create().id("primary").label("Primary").color(EventCategory.CategoryColor.BLUE),
EventCategory.create().id("backup").label("Backup").color(EventCategory.CategoryColor.AMBER))
.events(
CalendarEvent.create().id("e1").title("Alice").start("2026-03-02").end("2026-03-04").categoryId("primary"),
CalendarEvent.create().id("e2").title("Deploy window").start("2026-03-05T09:00").end("2026-03-05T10:00"))
.weekStart(CalendarDef.WeekStart.MONDAY)
.maxPerDay(4);
PropertyMeaning
idRequired; must match [A-Za-z][A-Za-z0-9_-]*. Emitted as data-juneau-calendar.
endpointSame-origin path template containing both {year} and {month}. null makes the calendar seed-only and disables month navigation.
categoriesLegend entries in display order. A seed event's categoryId must be declared here.
eventsOptional initial-month seed events.
viewCalendarView.MONTH is the only value (default).
weekStartSUNDAY (default) or MONDAY.
initialYear / initialMonthSet together with .initial(year, month), or neither (the server clock decides). Month is 1–12.
maxPerDayChips per day before "+N more". Default 3, minimum 1.

Spanning bars occupy lanes, with a budget equal to maxPerDay clamped to a hard ceiling of 8 per week row. The two caps are separate: a bar crossing a cell does not consume that cell's chip budget.

Events​

CalendarEvent has id, title, start, end, allDay, categoryId, href and tooltip. start/end are ISO-8601 local values: yyyy-MM-dd or yyyy-MM-ddTHH:mm. A trailing offset or Z is ignored rather than rejected, and the time renders in the server's clock zone.

end is layout-significant, with split inclusivity:

Shapeend isExample
All-day (date-only)inclusivestart=2026-03-02, end=2026-03-04 fills three day cells
Timed (date-time)exclusive09:00 to 10:00 is [09:00, 10:00) on the start day; crossing midnight makes a spanning bar
Omittedstart-only, validan all-day event fills the start day; a timed one is a zero-width chip

A timed event whose end equals its start, or any end before start, is malformed. So is an allDay/end disagreement (for example allDay=true with a date-time end). A malformed seed event is dropped, not fatal: it neither fails the definition nor the whole month payload, and a per-month GET handler should apply the same rule with CalendarEvent.retainWellFormed(...).

Event titles, tooltips and labels are painted with textContent. href must be a same-origin document URL.

Categories​

EventCategory has id, label, color and description. color is one of the fixed CategoryColor values BLUE, GREEN, AMBER, RED and NEUTRAL (the default). There is no author-supplied inline color, so the emitted class is built only from a sanitized id and a fixed token — no CSS-injection surface. An unknown categoryId on a wire event paints as NEUTRAL.

Why the contract is "2"

end became layout-significant, and the constant doubles as the data-juneau-calendar-contract handshake stamp, so a v1 runtime cannot silently draw a multi-day event as one chip.

Badges​

Badge is an overlay on a header action (a bell count) or inside a BarBadge. It is exactly one of a count badge or a dot badge, never both.

Badge.count(7).max(99).tone(StatusTone.ERROR).label("unread notifications");  // "7"; 120 would show "99+"
Badge.dot().tone(StatusTone.INFO); // presence only
PropertyRule
count>= 0. Mutually exclusive with dot.
dotPresence badge, no number.
max>= 1; a larger count renders as max+.
toneA StatusTone (INFO, SUCCESS, WARNING, ERROR, NEUTRAL); null keeps the default color. A tone outside that palette fails closed: nothing is painted.
labelVisually hidden screen-reader context.

Counts are painted with textContent. Badge is a plain bean, not a Widget; its validate() is called by the enclosing bean.

Bar slots: BarSlot​

BarSlot is an additive trailing region beside the subtab ribbon — a small row of BarWidgets such as "3 changes pending". It is a Widget with id, widgets (at least one, ids unique) and an optional same-origin refreshUrl for demand-refreshing the counts. BarWidget is sealed over two types:

  • BarBadge.of(id).label("change pending").badge(Badge.count(3).tone(StatusTone.WARNING))
  • BarText.of(id, "Last synced 10:42")
var slot = BarSlot.create("pending")
.widgets(BarBadge.of("chg").label("changes pending").badge(Badge.count(3).tone(StatusTone.WARNING)),
BarText.of("env", "prod"))
.refreshUrl("/pending/count");

A BarSlot can be attached to a ModalDef (barSlot(...), anchored right after the title) and, in the views module, to a page and to a Detail View. On the modal it is carried on the wire, because the modal is itself the wire payload.

Actions and menus​

TypeRole
ActionBarItemMarker for one item in an action bar: an ActionRef or a SafeAction.
ActionRefAn opaque id naming a write action on the enclosing view. The action's existence is checked in the views module, not here.
SafeActionA client-only action with no endpoint. The one constant is COLLAPSE (collapse the child row), which stays enabled even during an in-flight or failed expand so the user can dismiss it.
BehaviorHow a header or menu action acts when activated: LINK (same-origin href), SAFE (dispatch a format-validated client event token that the host app interprets) or MENU.
MenuItemOne row of a header or avatar menu: a link (MenuItem.link(id, label, href)), a safe item (MenuItem.safe(id, label, token)) or a divider (MenuItem.divider()).
Behavior.MENU is not functional yet

The menu manager has not shipped. Until it does, a MENU trigger is disabled and its list is omitted.

MenuItem.validate() requires exactly one of href/safe on a non-divider, a same-origin href, and a safe token matching ^[a-z][a-z0-9-]{0,63}$. Dividers are exempt from every id/label/target rule.

Gating an action on row state: ActionRef.enabledWhen​

ActionRef.of("resolve")
.emphasis(ActionRef.Emphasis.PRIMARY)
.enabledWhen("status", Op.EQ, "open", "Only open incidents can be resolved.")
.enabledWhen("owner", Op.PRESENT, "Assign an owner first.");

Op is the shared single-field comparison vocabulary: EQ and NE take a value, PRESENT and ABSENT do not (Op.requiresValue() discriminates; the wrong overload throws). The rules obey a few deliberate constraints:

  • Presentation only, never authorization. The server re-reads the row and refuses on its own authority.
  • Prefer disabled; hide only when irrelevant. An action that is merely impossible right now stays visible and disabled with a reason. A detail-panel action has no visibleWhen; to drop an action from a whole view, omit it from that view's list. (Row actions in a table view can also declare visibleWhen for the case where the action does not apply to a row at all.)
  • The first-declared failing rule wins, and its reason is shown via a tooltip and aria-describedby. Declaration order is the priority mechanism.
  • Fail closed. A rule on a field the panel never declares is rejected at startup; if the field is missing from a row at runtime the rule fails and the action disables (even ABSENT).

ActionRef.Emphasis is PRIMARY (solid accent; at most one per bar) or SECONDARY (the default). Both emphasis and enabledWhen are Java-only and never marshalled.

Server values: ServerValues​

ServerValues declares named scalar providers and interpolates them into widget chrome text (titles, labels) as $FV{name} or $FV{name,default} at serve time. It is a thin typed facade over the SVL engine, not a second templating language, and it is Java-only (lambda providers are never serialized).

ServerValues sv = ServerValues.create()
.value("failedCount", session -> metrics.failed())
.value("env", session -> "prod");
// Column title "Failures ($FV{failedCount})" resolves at serve time.
  • A provider returns a String, Number, Boolean or null. Anything else is rejected at resolve time; collections are not joined.
  • A name that is not declared, or a null result, falls through to the $FV{name,default} default.
  • A provider that throws propagates (fail-closed).
  • Providers are session-aware (Function<VarResolverSession,?>) and re-run on every reference unless the value is declared cacheable through ServerValuesValue.of(name, provider, true). Cache only values that are stable across a whole response.

$FV is not $W: $W emits raw widget HTML, while $FV resolves plain, serializer-encoded text and never injects markup. The $FV variable itself (ServerValuesVar) lives in juneau-console-views.

WidgetsMixin — serving the runtime assets​

Compose the mixin to serve the three first-party assets:

@Rest(mixins=WidgetsMixin.class)
public class MyRest extends BasicRestServlet { ... }
Path constantServed atPurpose
CALENDAR_JS_PATH/juneau-calendar.jsCalendar runtime
CALENDAR_CSS_PATH/juneau-calendar.cssCalendar stylesheet (declares --jc-* token fallbacks)

Assets are cacheable for one day and carry a content-sensitive cache-buster. Reference them from a page with WidgetsMixin.widgetAssetUrl(path) (a servlet:-prefixed URL that Juneau's own HTML serializer resolves) or, for a template engine that never sees that rewrite, WidgetsMixin.widgetAssetUrl(req, path) (an absolute, browser-fetchable URL).

Load order is a contract

juneau-calendar.js must come after the views module's juneau-views.js. The calendar defines no stack of its own and fails loudly if the shared one is absent.

The mixin also exposes the handshake stamps the runtimes bake in: CALENDAR_CONTRACT_VERSION ("2", alias of CalendarDef), BAR_CONTRACT_VERSION ("1", alias of BarSlot) and HEADER_CONTRACT_VERSION ("1"). They are deliberately independent, so a calendar-envelope change never forces a bar or header bump.

The views module keeps deprecated accessors at the paths it served these assets from before they moved; they read the same bytes from this module, so existing applications keep working.

The serving-path validation gate​

WidgetsMixin.WidgetValidationProcessor is a ResponseProcessor that calls validate() on any REST response whose content is a Widget, and answers a failure with HTTP 500 (a malformed widget is a server defect, not a client error). It returns NEXT, so it never claims the response or perturbs the chain.

It is registered twice, on purpose: through ServiceLoader (so it applies to every resource on a classpath that carries the module, with no opt-in) and through the mixin's own @Rest(responseProcessors=...) (so a host that composes the mixin is covered even if a shading step drops the services file).

Things to know:

  • Top level only. It tests getContent() itself. A Widget nested in a List or Map is not walked; nested widgets inside a ModalDef are covered by that bean's own recursive validate().
  • Non-REST producers are not gated. A direct Json.of(modal) has no processor in front of it. Call validate() or the chaining ModalDef.checked() / FormDef.checked() yourself.
  • Double validation is intentional. A modal that already called checked() is validated again by the processor; validate() is cheap and idempotent.
  • Escape hatch. To serve a deliberately invalid widget, replace the whole chain with a @Bean ResponseProcessorList factory method. There is no narrower opt-out.

See also​