001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.console.widgets; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.util.*; 022 023import org.apache.juneau.commons.bean.*; 024 025/** 026 * The declarative modal (dialog) a {@code present=dialog} row action opens — the payload the 027 * <b>modal-open confirmation fetch</b> returns. 028 * 029 * <h5 class='section'>A widget contract driven by a view-module runtime</h5> 030 * <p> 031 * This bean is a general widget contract and lives in this module, but the browser runtime that paints it ships with 032 * the rich-view module (it is part of that module's {@code juneau-views.js} monolith, which is not split apart here). 033 * So a page that opens one of these dialogs loads the view runtime; nothing in this module reaches back to it. 034 * 035 * <h5 class='section'>Safe-by-construction confirmation body</h5> 036 * <p> 037 * The confirmation body is <b>typed structured fields</b> ({@link #fields}: incident #, title, service, current 038 * status — the confirmation field set), server-rendered against the current, authoritative record and 039 * bound to the target identifier. The {@code juneau-views.js} runtime paints each field's value with 040 * {@code textContent} — NEVER {@code innerHTML}, never raw markup, never the non-table panel-content sink. 041 * Live/remote data is attacker-influenceable (anyone who can title an incident or post in a watched thread) and this 042 * origin holds the CSRF token, so an HTML sink here would be stored-XSS → token-theft → 043 * arbitrary-write; typed fields painted with {@code textContent} close that off by construction. 044 * 045 * <h5 class='section'>The idempotency key rides here</h5> 046 * <p> 047 * {@link #idempotencyKey} is a server-minted key value, minted at modal-open and bound to 048 * {@code (action, targetId)}. The runtime carries it verbatim on the submit so a double-click / re-submit / browser 049 * retry all collapse to one effect; the server checks that binding on submit and answers a mismatch with a named 050 * refusal, never a replayed success. The minting/binding helper itself is a table-and-row-action concern and stays 051 * in the rich-view module, which is why this field is a plain {@code String} rather than that helper's type. 052 * 053 * <h5 class='section'>{@link #selfTargeted} — the opt-in for a key that targets itself</h5> 054 * <p> 055 * A dialog opened where there is <b>no row</b> (a ribbon-hosted dialog whose submit creates the thing it is about) 056 * has no artifact id to bind its key to at modal-open. The rich-view module's key helper can mint a key whose 057 * bound target <i>is its own value</i>; {@link #selfTargeted} is the signal that tells the runtime to send that 058 * value as the submitted target instead of the row's id. 059 * <p> 060 * The signal has to ride on this bean, because it cannot be inferred from the wire: {@link #idempotencyKey} is an 061 * opaque string either way, indistinguishable in shape from an artifact-bound key's value. It is therefore an 062 * explicit, additive, per-dialog opt-in — <b>not</b> a blanket runtime precedence rule. A dialog that leaves 063 * this unset behaves exactly as it does today, <i>including</i> one that sets {@link #idempotencyKey} for an 064 * artifact-bound reason: it keeps sending the row's real id, which is what its own mint call bound. 065 * <p> 066 * Set it <b>only</b> on a modal whose key came from the self-targeted mint helper. Setting it on a modal carrying 067 * an artifact-bound key silently discards the real target and makes the server's binding check vacuous. 068 * 069 * <h5 class='section'>{@link #keepOpenOnSubmit} — the opt-in for an in-dialog result receipt</h5> 070 * <p> 071 * By default the runtime closes a dialog at confirm-click time and then submits, so every dialog is gone before its 072 * result exists. {@link #keepOpenOnSubmit} suppresses that close, and the runtime instead paints the write's 073 * <b>result</b> into the dialog that is already open — a <i>receipt</i>. 074 * <p> 075 * A receipt host is <b>non-submittable by construction</b>, not by convention: the runtime paints it with a distinct 076 * function that creates no confirm control at all, and it refuses to paint one whose payload carries a 077 * {@link #form}. It is reached <b>only</b> after a committed write (an 078 * {@code ActionResult} whose outcome is {@code success}), so a result host can never appear for a write that did not 079 * happen. A follow-up read-only form source is named by that result's {@code resultForm}. 080 * <p> 081 * The opt-in has to live here rather than on the action bean, because the confirm handler must decide whether to 082 * close <i>before</i> any response exists. A dialog synthesized from a {@code confirm} string alone (no form-source 083 * URL) therefore cannot opt in — a receipt-bearing action must serve a real {@link ModalDef}. 084 * <p> 085 * Additive, and named degradation, accepted: an older runtime ignoring this flag closes the dialog and drops the 086 * receipt. The write still happens; the receipt is simply not shown. 087 * 088 * <h5 class='section'>{@link #childActions} — the dialog-scoped child-action catalog</h5> 089 * <p> 090 * A {@code type="action"} form input names an action id, and the runtime resolves it against the enclosing view's 091 * row-action catalog. A <b>stacked</b> step reached from inside an already-open dialog (a Review step in a wizard) 092 * has nothing legal to resolve against there: it is not a row action, and publishing it as one would surface it in 093 * every row's action menu. {@link #childActions} is that missing catalog, scoped to <b>one open dialog</b>. 094 * <p> 095 * Scoping is <b>structural</b>, not a flag some menu builder has to remember to filter on: this catalog rides on a 096 * per-open payload, so the row-action menu and the ribbon resolver never see it and there is no exclusion logic to 097 * rot. Resolution precedence is likewise fail-safe: the existing row-action check runs <b>first</b> and unchanged, 098 * so a served payload can never shadow — and thereby bypass the gating of — a declared row action. 099 * 100 * <h5 class='section'>{@link #chrome} and {@link #steps} — dialog styling and a built-in wizard</h5> 101 * <p> 102 * {@link #chrome} names a dialog chrome the page script registered with the runtime; the runtime hands that chrome the 103 * freshly built dialog parts so it can restyle them. An unregistered name is ignored and the default chrome is kept. 104 * <p> 105 * {@link #steps} turns the dialog into a wizard: the runtime shows one {@link Step} at a time with Back and Next 106 * buttons, and the last step's button is the dialog's own confirm. Both are additive-only and omitted from the wire 107 * when unset, so neither bumps {@link #CONTRACT_VERSION}. 108 * 109 * <h5 class='section'>Not a new row-action wire field</h5> 110 * <p> 111 * This is the response a row action's form-source URL returns, <b>not</b> a new field on the frozen row-action wire 112 * schema: the modal's declaration lives in this separately-served payload so the row-action contract stays frozen. 113 * The same reasoning is why {@link #keepOpenOnSubmit} and {@link #childActions} land here rather than on 114 * {@code RowAction} / {@code RibbonAction}, which stay frozen and untouched. 115 * 116 * <h5 class='section'>Example:</h5> 117 * <p class='bjava'> 118 * ModalDef <jv>modal</jv> = ModalDef.<jsm>create</jsm>(<js>"Acknowledge this incident?"</js>) 119 * .field(<js>"Incident"</js>, <jv>incidentNumber</jv>) 120 * .field(<js>"Title"</js>, <jv>incidentTitle</jv>) 121 * .field(<js>"Service"</js>, <jv>serviceName</jv>) 122 * .field(<js>"Current status"</js>, <jv>currentStatus</jv>) 123 * .form(FormDef.<jsm>ofTemplate</jsm>(<js>"servlet:/incidents/ack-form.ftl"</js>)) 124 * .idempotencyKey(<jv>keyValue</jv>); 125 * </p> 126 * 127 * <h5 class='section'>See Also:</h5> 128 * <ul> 129 * <li class='jc'>{@link FormDef} 130 * <li class='jc'>{@link Widget} 131 * </ul> 132 * 133 * <h5 class='section'>The third named bar-slot host</h5> 134 * <p> 135 * {@link #barSlot} is the <i>third</i> named {@link BarSlot} attachment — alongside the rich-view module's page 136 * and row-detail hosts (that module never depends on this one, so they are not linked here). Unlike those two 137 * — Java-only fields a server-side emitter paints directly, omitted from their wire — this modal itself 138 * <b>is</b> the wire payload the modal-open confirmation fetch returns, with no separate server-rendered pass the 139 * dialog could ride into. So this field <b>is</b> carried on the wire (additive-only: {@code null} when unset, and 140 * its presence does not bump {@link #CONTRACT_VERSION}), and the {@code juneau-views.js} runtime paints the region 141 * and its dynamic-count sidecar client-side from the JSON, the same way it already paints typed confirmation 142 * {@link #fields} and {@link #form} controls — {@code textContent} only, never {@code innerHTML}. The dialog 143 * anchors its own region immediately after the title (the {@code ANCHOR_DIALOG_TITLE} 144 * placement in {@code juneau-views.js}), owning that placement itself rather than leaning on the shared strip builder, 145 * exactly as the row-detail host owns its own ribbon-trailing relocation. 146 * 147 * @since 10.0.0 148 */ 149@BeanType(properties="contractVersion,title,fields,form,idempotencyKey,selfTargeted,barSlot,keepOpenOnSubmit,childActions,chrome,steps") 150@SuppressWarnings({ 151 "java:S1845", // Fluent-builder setters intentionally mirror field names (Juneau DSL convention). 152 "java:S3776" // Branching is inherent to validate() and validateChildActions(), which check many field/child-action combinations. 153}) 154public class ModalDef implements Widget { 155 156 /** 157 * The frozen modal contract version. Bumped only on a breaking wire change to this modal contract. 158 * 159 * <p> 160 * Moves in <b>lockstep</b> with {@link FormDef#CONTRACT_VERSION} and the runtime's baked-in literal: the runtime 161 * compares one literal against both this version and the nested form's, so bumping any two of the three makes 162 * every form-bearing dialog refuse to open. 163 */ 164 public static final String CONTRACT_VERSION = "2"; 165 166 /** The {@link Field#kind} token for an ordinary text value — the default when the field declares none. */ 167 public static final String FIELD_KIND_TEXT = "text"; 168 169 /** The {@link Field#kind} token for a monospaced, copyable value (an id, a URL, a generated token). */ 170 public static final String FIELD_KIND_CODE = "code"; 171 172 /** 173 * A single typed confirmation field: a label and a value, painted client-side with {@code textContent} (never 174 * {@code innerHTML}). 175 * 176 * @since 10.0.0 177 */ 178 @BeanType(properties="label,value,kind") 179 public static class Field { 180 181 /** The field label shown to the user. */ 182 public String label; 183 184 /** The field value, read back from the current authoritative record. */ 185 public String value; 186 187 /** 188 * How this field's value is <i>displayed</i>; omitted from the wire when unset (which reads as 189 * {@link ModalDef#FIELD_KIND_TEXT text}). 190 * 191 * <p> 192 * The allowlist is exactly {@link ModalDef#FIELD_KIND_TEXT text} and {@link ModalDef#FIELD_KIND_CODE code}; 193 * {@link ModalDef#validate()} rejects anything else server-side, and the runtime falls back to 194 * {@code text} for an unrecognized token rather than trusting it. 195 * 196 * <p> 197 * This is a <b>display</b> kind on a confirmation field, deliberately <b>not</b> a new 198 * {@code FormDef.Input} type: a {@code code} field carries no submit value, so expressing it as an input 199 * would mean teaching the collection, validation, prefill and submit paths about a control that is not one. 200 */ 201 public String kind; 202 203 /** 204 * Creates a typed confirmation field. 205 * 206 * @param label The field label. Must not be <jk>null</jk> or blank. 207 * @param value The field value. Can be <jk>null</jk> (rendered as an empty value). 208 * @return A new {@link Field}. 209 * @throws IllegalArgumentException If {@code label} is <jk>null</jk> or blank. 210 */ 211 public static Field of(String label, String value) { 212 if (ib(label)) 213 throw iaex("ModalDef.Field label must not be null or blank."); 214 var f = new Field(); 215 f.label = label; 216 f.value = value; 217 return f; 218 } 219 220 /** 221 * Creates a typed confirmation field displayed as monospaced, copyable {@link ModalDef#FIELD_KIND_CODE code}. 222 * 223 * <p> 224 * The runtime paints the value into a {@code <pre>} with {@code textContent} (never {@code innerHTML}) and 225 * adds a copy button that is feature-detected and never throws; the {@code <pre>} stays selectable, so 226 * manual copy always works. 227 * 228 * @param label The field label. Must not be <jk>null</jk> or blank. 229 * @param value The field value. Can be <jk>null</jk> (rendered as an empty value). 230 * @return A new {@link Field}. 231 * @throws IllegalArgumentException If {@code label} is <jk>null</jk> or blank. 232 */ 233 public static Field code(String label, String value) { 234 var f = of(label, value); 235 f.kind = FIELD_KIND_CODE; 236 return f; 237 } 238 } 239 240 /** 241 * One page of a {@link ModalDef#steps} wizard: an id, an optional title and confirm-button label, and the typed 242 * display fields painted for that page. 243 * 244 * @since 10.0.0 245 */ 246 @BeanType(properties="id,title,confirmLabel,fields") 247 public static class Step { 248 249 /** The step id, unique within the modal; a script selects a step by it. Must not be blank. */ 250 public String id; 251 252 /** The dialog title shown while this step is current; omitted from the wire when unset (the modal title stays). */ 253 public String title; 254 255 /** The confirm-button label for this step; omitted when unset (the runtime shows Next, or the final label on the last step). */ 256 public String confirmLabel; 257 258 /** The typed display fields painted for this step, in display order; omitted from the wire when none are declared. */ 259 public List<Field> fields; 260 261 /** 262 * Creates a step. 263 * 264 * @param id The step id. Must not be <jk>null</jk> or blank. 265 * @return A new {@link Step}. 266 * @throws IllegalArgumentException If {@code id} is <jk>null</jk> or blank. 267 */ 268 public static Step of(String id) { 269 if (ib(id)) 270 throw iaex("ModalDef.Step id must not be null or blank."); 271 var s = new Step(); 272 s.id = id; 273 return s; 274 } 275 276 /** 277 * Sets the dialog title shown while this step is current. 278 * 279 * @param value The title. Can be <jk>null</jk> to unset. 280 * @return This object. 281 */ 282 public Step title(String value) { 283 title = value; 284 return this; 285 } 286 287 /** 288 * Sets the confirm-button label for this step. 289 * 290 * @param value The label. Can be <jk>null</jk> to unset. 291 * @return This object. 292 */ 293 public Step confirmLabel(String value) { 294 confirmLabel = value; 295 return this; 296 } 297 298 /** 299 * Adds one typed display field to this step. 300 * 301 * @param label The field label. Must not be <jk>null</jk> or blank. 302 * @param value The field value. Can be <jk>null</jk>. 303 * @return This object. 304 */ 305 public Step field(String label, String value) { 306 if (fields == null) 307 fields = l(); 308 fields.add(Field.of(label, value)); 309 return this; 310 } 311 } 312 313 /** 314 * One entry in a dialog's {@link ModalDef#childActions} catalog — a stacked step a {@code type="action"} 315 * input inside <i>this</i> dialog's form may open. 316 * 317 * <h5 class='section'>Dialog-presenting by construction</h5> 318 * <p> 319 * A child action carries no {@code present} field: opening a stacked dialog is the only thing it does. A child 320 * with no {@link #form} is a legal confirm-only child dialog. 321 * 322 * <h5 class='section'>No gating vocabulary, on purpose</h5> 323 * <p> 324 * There is deliberately no {@code enabledWhen} here, for three independent reasons. The rule type lives in the 325 * rich-view module, which this module must not reference. A dialog opened where there is no row has no row data 326 * to evaluate against, so a field-keyed rule would fail closed forever with no signal to its author. And the 327 * subject of a child step is the <i>draft in the open dialog</i>, not the parent row — so evaluating the 328 * parent row's data would answer a question nobody asked. Gating a child step is the <b>server's</b> job at 329 * child-form-GET time; it already owns that GET and can refuse it. 330 * 331 * @since 10.0.0 332 */ 333 @BeanType(properties="id,label,form,endpoint,method,onSuccess,carryDrafts") 334 public static class ChildAction { 335 336 /** The action id a {@code type="action"} input in this dialog's form names. Required, non-blank. */ 337 public String id; 338 339 /** The label painted on the button that opens this child. Required, non-blank. */ 340 public String label; 341 342 /** 343 * The child dialog's read-only form-source URL; omitted from the wire when unset (a confirm-only child). 344 * 345 * <p> 346 * Required if this child declares {@link #carryDrafts} — with no URL there is nothing for the draft 347 * query to ride on, so the drafts would vanish silently. 348 */ 349 public String form; 350 351 /** The child's submit endpoint; omitted from the wire when unset. See {@link #endpoint(String)}. */ 352 public String endpoint; 353 354 /** The child's submit method; omitted from the wire when unset. See {@link #method(String)}. */ 355 public String method; 356 357 /** The child's on-success behaviour token; omitted from the wire when unset. */ 358 public String onSuccess; 359 360 /** 361 * Whether opening this child carries the <b>parent dialog's current draft values</b> to its form-source GET 362 * as one query parameter; omitted from the wire when unset or <jk>false</jk>. 363 * 364 * <p> 365 * Without it a Review step opens <b>empty</b> and loses everything the operator typed. With it, the 366 * runtime serializes the parent's collected form values, URL-encodes them, and appends them as a single 367 * {@code juneauDrafts} parameter, refusing visibly (and not opening the child) above a client-side byte cap 368 * measured on the <i>encoded</i> value. 369 * 370 * <p> 371 * <b>Drafts land in access logs and browser history.</b> No secret may ride this channel. 372 */ 373 public Boolean carryDrafts; 374 375 /** 376 * Creates a child action. 377 * 378 * @param id The action id a {@code type="action"} input names. Must not be <jk>null</jk> or blank. 379 * @param label The button label. Must not be <jk>null</jk> or blank. 380 * @return A new {@link ChildAction}. 381 * @throws IllegalArgumentException If {@code id} or {@code label} is <jk>null</jk> or blank. 382 */ 383 public static ChildAction of(String id, String label) { 384 if (ib(id)) 385 throw iaex("ModalDef.ChildAction id must not be null or blank."); 386 if (ib(label)) 387 throw iaex("ModalDef.ChildAction label must not be null or blank."); 388 var c = new ChildAction(); 389 c.id = id; 390 c.label = label; 391 return c; 392 } 393 394 /** 395 * Sets the child dialog's read-only form-source URL. 396 * 397 * @param value The URL. Can be <jk>null</jk> to unset (a confirm-only child). 398 * @return This object. 399 */ 400 public ChildAction form(String value) { 401 form = value; 402 return this; 403 } 404 405 /** 406 * Sets the child's submit endpoint. 407 * 408 * <p> 409 * Optional individually; a child action whose dialog is meant to be <b>submittable</b> must declare both a 410 * non-safe {@link #method} and an {@code endpoint}, or its Confirm visibly refuses with {@code safe-method} 411 * and sends nothing. A display-only child that never submits needs neither, which is why this is not a 412 * {@link ModalDef#validate()} rule — this bean cannot know which of its children are meant to submit. 413 * 414 * @param value The endpoint. Can be <jk>null</jk> to unset. 415 * @return This object. 416 */ 417 public ChildAction endpoint(String value) { 418 endpoint = value; 419 return this; 420 } 421 422 /** 423 * Sets the child's submit method. 424 * 425 * <p> 426 * Optional individually; see {@link #endpoint(String)} — a submittable child needs both, and a 427 * missing or safe method is a visible client-side refusal before any request is sent. 428 * 429 * @param value The method. Can be <jk>null</jk> to unset. 430 * @return This object. 431 */ 432 public ChildAction method(String value) { 433 method = value; 434 return this; 435 } 436 437 /** 438 * Sets the child's on-success behaviour token. 439 * 440 * @param value The token. Can be <jk>null</jk> to unset. 441 * @return This object. 442 */ 443 public ChildAction onSuccess(String value) { 444 onSuccess = value; 445 return this; 446 } 447 448 /** 449 * Declares that opening this child carries the parent dialog's drafts (see {@link #carryDrafts}). 450 * 451 * <p> 452 * Passing <jk>false</jk> <b>clears</b> the flag rather than serializing an explicit {@code false}, so an 453 * un-opted-in child's payload is byte-identical to one that never mentioned it. 454 * 455 * @param value <jk>true</jk> to carry the parent's drafts; <jk>false</jk> (or unset) to open empty. 456 * @return This object. 457 */ 458 public ChildAction carryDrafts(boolean value) { 459 carryDrafts = value ? Boolean.TRUE : null; 460 return this; 461 } 462 } 463 464 /** 465 * The frozen modal contract version discriminator. 466 * 467 * <p> 468 * Defaults to {@link #CONTRACT_VERSION} on <b>every</b> modal, confirm-only or form-bearing, from the instant 469 * it is constructed — the guarantee does not depend on {@link #checked()} being called. 470 * Guaranteed <b>present</b> is not the same as guaranteed <b>valid</b>: presence is this field's whole job, 471 * structural well-formedness is {@link #validate()}'s (see {@link #checked()}). 472 */ 473 public String contractVersion = CONTRACT_VERSION; 474 475 /** The modal title / confirmation prompt. */ 476 public String title; 477 478 /** The typed confirmation fields, in display order; omitted from the wire when none are declared. */ 479 public List<Field> fields; 480 481 /** The optional input form; omitted from the wire when unset (a confirm-only modal). */ 482 public FormDef form; 483 484 /** 485 * The server-minted idempotency key value the runtime carries on the submit; omitted from the wire when unset. 486 * The value is minted (and its {@code (action, targetId)} binding re-checked on submit) by the rich-view 487 * module's idempotency-key helper. 488 */ 489 public String idempotencyKey; 490 491 /** 492 * Whether {@link #idempotencyKey} is a <b>self-targeted</b> key whose own value the runtime must send as the 493 * submitted target (see the class Javadoc); omitted from the wire when unset or <jk>false</jk>. 494 * 495 * <p> 496 * Additive-only: like {@link #barSlot}, its presence does not bump {@link #CONTRACT_VERSION}. 497 */ 498 public Boolean selfTargeted; 499 500 /** 501 * Optional additive bar slot anchored to this dialog's title — the <i>third</i> named {@link BarSlot} 502 * attachment (see the class Javadoc for how this host differs from the other two). Omitted from the wire when 503 * unset; painted by the runtime immediately after {@link #title}, ahead of {@link #fields} and {@link #form}. 504 */ 505 public BarSlot barSlot; 506 507 /** 508 * Whether the runtime must <b>not</b> close this dialog at confirm-click time, so the write's result can be 509 * painted into it as a receipt (see the class Javadoc); omitted from the wire when unset or <jk>false</jk>. 510 * 511 * <p> 512 * Additive-only: like {@link #barSlot} and {@link #selfTargeted}, its presence does not bump 513 * {@link #CONTRACT_VERSION}. 514 */ 515 public Boolean keepOpenOnSubmit; 516 517 /** 518 * The dialog-scoped catalog of stacked child actions a {@code type="action"} input in {@link #form} may open 519 * (see the class Javadoc); omitted from the wire when unset or empty. 520 */ 521 public List<ChildAction> childActions; 522 523 /** 524 * The name of a dialog chrome the page script registered with the runtime, which restyles this dialog's parts 525 * (see the class Javadoc); omitted from the wire when unset (the default chrome). 526 * 527 * <p> 528 * Additive-only: like {@link #barSlot}, its presence does not bump {@link #CONTRACT_VERSION}. 529 */ 530 public String chrome; 531 532 /** 533 * The wizard steps the runtime shows one at a time with Back and Next (see the class Javadoc); omitted from the 534 * wire when unset or empty. 535 * 536 * <p> 537 * Additive-only: like {@link #barSlot}, its presence does not bump {@link #CONTRACT_VERSION}. 538 */ 539 public List<Step> steps; 540 541 /** 542 * Starts a new {@link ModalDef} with the specified title / confirmation prompt. 543 * 544 * @param title The modal title / confirmation prompt. Must not be <jk>null</jk> or blank. 545 * @return A new mutable {@link ModalDef} to chain builder calls on. 546 * @throws IllegalArgumentException If {@code title} is <jk>null</jk> or blank. 547 */ 548 public static ModalDef create(String title) { 549 if (ib(title)) 550 throw iaex("ModalDef title must not be null or blank."); 551 var m = new ModalDef(); 552 m.title = title; 553 return m; 554 } 555 556 /** 557 * Adds one typed confirmation field. 558 * 559 * @param label The field label. Must not be <jk>null</jk> or blank. 560 * @param value The field value (read back from the current record). Can be <jk>null</jk>. 561 * @return This object. 562 */ 563 public ModalDef field(String label, String value) { 564 if (fields == null) 565 fields = l(); 566 fields.add(Field.of(label, value)); 567 return this; 568 } 569 570 /** 571 * Adds one typed confirmation field displayed as monospaced, copyable {@link #FIELD_KIND_CODE code}. 572 * 573 * @param label The field label. Must not be <jk>null</jk> or blank. 574 * @param value The field value. Can be <jk>null</jk>. 575 * @return This object. 576 */ 577 public ModalDef codeField(String label, String value) { 578 if (fields == null) 579 fields = l(); 580 fields.add(Field.code(label, value)); 581 return this; 582 } 583 584 /** 585 * Adds one entry to this dialog's child-action catalog. 586 * 587 * @param value The child action. Must not be <jk>null</jk>. 588 * @return This object. 589 * @throws IllegalArgumentException If {@code value} is <jk>null</jk>. 590 */ 591 public ModalDef childAction(ChildAction value) { 592 if (value == null) 593 throw iaex("ModalDef.ChildAction must not be null."); 594 if (childActions == null) 595 childActions = l(); 596 childActions.add(value); 597 return this; 598 } 599 600 /** 601 * Selects the dialog chrome the page script registered under the given name. 602 * 603 * @param value The chrome name. Can be <jk>null</jk> to unset (the default chrome); a non-null value must not be blank. 604 * @return This object. 605 */ 606 public ModalDef chrome(String value) { 607 chrome = value; 608 return this; 609 } 610 611 /** 612 * Adds one wizard step. 613 * 614 * @param value The step. Must not be <jk>null</jk>. 615 * @return This object. 616 * @throws IllegalArgumentException If {@code value} is <jk>null</jk>. 617 */ 618 public ModalDef step(Step value) { 619 if (value == null) 620 throw iaex("ModalDef.Step must not be null."); 621 if (steps == null) 622 steps = l(); 623 steps.add(value); 624 return this; 625 } 626 627 /** 628 * Declares that the runtime must keep this dialog open across its submit so the result can be painted into it 629 * as a receipt (see the class Javadoc). 630 * 631 * <p> 632 * Passing <jk>false</jk> <b>clears</b> the flag rather than serializing an explicit {@code false}: the wire 633 * carries this opt-in only when it is on, so an un-opted-in dialog's payload is byte-identical to one that never set it. 634 * 635 * @param value <jk>true</jk> to opt in; <jk>false</jk> (or unset) keeps the default close-then-submit behaviour. 636 * @return This object. 637 */ 638 public ModalDef keepOpenOnSubmit(boolean value) { 639 keepOpenOnSubmit = value ? Boolean.TRUE : null; 640 return this; 641 } 642 643 /** 644 * Sets the optional input form. 645 * 646 * @param value The form. Can be <jk>null</jk> to unset (a confirm-only modal). 647 * @return This object. 648 */ 649 public ModalDef form(FormDef value) { 650 form = value; 651 return this; 652 } 653 654 /** 655 * Sets the server-minted idempotency key value carried on the submit. 656 * 657 * @param value The key value. Can be <jk>null</jk> to unset. 658 * @return This object. 659 */ 660 public ModalDef idempotencyKey(String value) { 661 idempotencyKey = value; 662 return this; 663 } 664 665 /** 666 * Declares that {@link #idempotencyKey} is a self-targeted key — the runtime sends the key's own value as 667 * the submitted target rather than a row id. 668 * 669 * <p> 670 * Set this <b>only</b> alongside a key minted by the rich-view module's self-targeted mint helper; pairing it 671 * with an artifact-bound key discards that key's real target. See the class Javadoc. 672 * 673 * <p> 674 * Passing <jk>false</jk> <b>clears</b> the flag rather than serializing an explicit {@code false}: the wire 675 * carries this opt-in only when it is on, so an un-opted-in dialog's payload is byte-identical to one that never set it. 676 * 677 * @param value <jk>true</jk> to opt in; <jk>false</jk> (or unset) keeps the default row-id behaviour. 678 * @return This object. 679 */ 680 public ModalDef selfTargeted(boolean value) { 681 selfTargeted = value ? Boolean.TRUE : null; 682 return this; 683 } 684 685 /** 686 * Declares the additive bar slot anchored to this dialog's title. 687 * 688 * <p> 689 * See {@link #barSlot} — a third named host for the same {@link BarSlot} bean, not a re-use of the 690 * rich-view module's page or row-detail hosts. 691 * 692 * @param value The bar slot. Can be <jk>null</jk> (no dialog bar slot). 693 * @return This object. 694 */ 695 public ModalDef barSlot(BarSlot value) { 696 barSlot = value; 697 return this; 698 } 699 700 /** 701 * Fail-closed bean validation. 702 * 703 * <p> 704 * Requires a non-blank {@link #title} and a non-blank label on each {@link Field}; when a {@link #form} is present 705 * it delegates to {@link FormDef#validate()}. Does <b>not</b> require {@link #contractVersion} to be set to 706 * {@link #CONTRACT_VERSION} (or set at all) — a modal whose version was explicitly cleared, or that 707 * arrived from a foreign producer that never set one, must not false-refuse on that alone. Version-presence 708 * and structural well-formedness are deliberately independent properties (design note, §5.4). 709 * 710 * <p> 711 * Also rejects, fail-closed at serve time rather than silently on the wire: a {@link Field#kind} outside the 712 * {@link #FIELD_KIND_TEXT text}/{@link #FIELD_KIND_CODE code} allowlist; a blank, duplicate or unlabelled 713 * {@link ChildAction} id; {@link ChildAction#carryDrafts} on a modal that declares no {@link #form} (nothing to 714 * collect <i>from</i>); and {@link ChildAction#carryDrafts} on a child that declares no 715 * {@link ChildAction#form} (nowhere to put them — the runtime would take its confirm-only branch, issue 716 * no GET, and the drafts would vanish silently). 717 * 718 * @throws IllegalArgumentException If this modal is not well-formed. 719 */ 720 @Override 721 public void validate() { 722 if (ib(title)) 723 throw iaex("ModalDef title must not be null or blank."); 724 if (fields != null) 725 for (var f : fields) { 726 if (f == null) 727 throw iaex("ModalDef field must not be null."); 728 if (f.label == null || f.label.isBlank()) 729 throw iaex("ModalDef.Field label must not be null or blank."); 730 if (f.kind != null && ! (eqa(f.kind, FIELD_KIND_TEXT, FIELD_KIND_CODE))) 731 throw iaex("ModalDef.Field kind must be '" + FIELD_KIND_TEXT + "' or '" + FIELD_KIND_CODE 732 + "', not '" + f.kind + "'."); 733 } 734 validateChildActions(); 735 validateSteps(); 736 if (chrome != null && chrome.isBlank()) 737 throw iaex("ModalDef chrome must not be blank."); 738 if (form != null) 739 form.validate(); 740 if (barSlot != null) 741 barSlot.validate(); 742 } 743 744 /** The {@link #steps} half of {@link #validate()}: non-blank unique ids and well-formed display fields. */ 745 private void validateSteps() { 746 if (steps == null) 747 return; 748 var seen = new HashSet<String>(); 749 for (var st : steps) { 750 if (st == null) 751 throw iaex("ModalDef.Step must not be null."); 752 if (st.id == null || st.id.isBlank()) 753 throw iaex("ModalDef.Step id must not be null or blank."); 754 if (! seen.add(st.id)) 755 throw iaex("ModalDef.Step id '" + st.id + "' is declared more than once on this modal."); 756 if (st.fields != null) 757 for (var f : st.fields) { 758 if (f == null || f.label == null || f.label.isBlank()) 759 throw iaex("ModalDef.Step '" + st.id + "' field label must not be null or blank."); 760 if (f.kind != null && ! (eqa(f.kind, FIELD_KIND_TEXT, FIELD_KIND_CODE))) 761 throw iaex("ModalDef.Step '" + st.id + "' field kind must be '" + FIELD_KIND_TEXT + "' or '" 762 + FIELD_KIND_CODE + "', not '" + f.kind + "'."); 763 } 764 } 765 } 766 767 /** 768 * The {@link #childActions} half of {@link #validate()}. 769 * 770 * <p> 771 * {@link ChildAction#of(String,String)} already argument-checks {@code id} and {@code label}; they are 772 * re-checked here as the wire-level backstop for a bean-deserialized instance that never went through the 773 * factory. 774 */ 775 private void validateChildActions() { 776 if (childActions == null) 777 return; 778 var seen = new HashSet<String>(); 779 for (var c : childActions) { 780 if (c == null) 781 throw iaex("ModalDef.ChildAction must not be null."); 782 if (c.id == null || c.id.isBlank()) 783 throw iaex("ModalDef.ChildAction id must not be null or blank."); 784 if (c.label == null || c.label.isBlank()) 785 throw iaex("ModalDef.ChildAction label must not be null or blank."); 786 if (! seen.add(c.id)) 787 throw iaex("ModalDef.ChildAction id '" + c.id + "' is declared more than once on this modal."); 788 if (Boolean.TRUE.equals(c.carryDrafts)) { 789 if (form == null) 790 throw iaex("ModalDef.ChildAction '" + c.id + "' declares carryDrafts, but this modal declares " 791 + "no form - there is nothing to collect drafts from."); 792 if (c.form == null || c.form.isBlank()) 793 throw iaex("ModalDef.ChildAction '" + c.id + "' declares carryDrafts, but declares no form URL " 794 + "- a confirm-only child issues no form GET, so the drafts would be dropped silently."); 795 } 796 } 797 } 798 799 /** 800 * The serving-path hook every app {@code @RestGet} that returns a {@link ModalDef} should invoke. 801 * 802 * <p> 803 * {@link #contractVersion} is guaranteed present on every modal from construction, so this is <b>no longer 804 * required for versioning</b>. It stays <b>strongly recommended</b> for one remaining duty and 805 * is <b>redundant-but-harmless</b> for another: 806 * <ul> 807 * <li><b>Non-REST producers</b> (a direct {@code Json.of(...)} serialization, or any consumer marshalling a 808 * modal outside a REST response) have no other gate: calling this is the only fail-closed structural 809 * {@link #validate() validation} they get, and skipping it means a malformed modal/form serializes as 810 * contract-<i>valid</i> with no complaint. 811 * <li><b>REST responses</b> are validated independently: 812 * {@link WidgetsMixin.WidgetValidationProcessor 813 * WidgetsMixin.WidgetValidationProcessor} calls {@link #validate()} on every REST response that is a 814 * {@code Widget}, fail-closed with a 500, whether or not the producing endpoint called this method. 815 * Calling it anyway is safe (validation is idempotent) and remains the only gate for the non-REST case 816 * above. 817 * </ul> 818 * When a {@link #form} is present it also re-stamps {@link #CONTRACT_VERSION} on the form and recurses into 819 * {@link FormDef#checked()}. 820 * 821 * @return This object. 822 * @throws IllegalArgumentException If this modal (or its form) is not well-formed. 823 */ 824 public ModalDef checked() { 825 if (form != null) { 826 contractVersion = CONTRACT_VERSION; 827 form.checked(); 828 } 829 validate(); 830 return this; 831 } 832}