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.views; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.util.*; 022 023import org.apache.juneau.marshall.collections.*; 024 025/** 026 * One ribbon (toolbar) entry above a table. 027 * 028 * <p> 029 * The ribbon runtime ({@code juneau-ribbon.js}) implements every behavior; this type only names it and carries its 030 * parameters. {@link #toMap()} produces the same {@code {type, ...}} entry the runtime reads from {@code viewDef.ribbon}. 031 * 032 * <p> 033 * Setters are not gated per item type, but {@link #toMap()} fails loudly rather than emit a key the runtime ignores 034 * for that type (for example {@code form} on a {@code refresh}, or {@code symbol} on an {@code optionGroup}), and 035 * checks the invariants the runtime cannot: a top-level {@code option} needs {@code column} or {@code param} (exactly 036 * one) plus a {@code value}, and a group's {@code default} must name a member. Group members take only 037 * {@code id}, {@code title}, {@code symbol}, {@code column}, {@code param} and {@code value}. 038 * 039 * <h5 class='section'>Example:</h5> 040 * <p class='bjava'> 041 * <jv>table</jv>.ribbon( 042 * RibbonItem.<jsm>collapseAll</jsm>(), 043 * RibbonItem.<jsm>export</jsm>(<js>"copy"</js>, <js>"csv"</js>).optional(<js>"excel"</js>, <js>"pdf"</js>), 044 * RibbonItem.<jsm>option</jsm>(<js>"dropped-only"</js>).title(<js>"Dropped only"</js>).column(<js>"status"</js>).value(<js>"$eq(DROPPED)"</js>).persist(<jk>true</jk>), 045 * RibbonItem.<jsm>optionGroup</jsm>(<js>"phase"</js>, 046 * RibbonItem.<jsm>option</jsm>(<js>"pending"</js>).column(<js>"phase"</js>).value(<js>"$in(Waiting)"</js>), 047 * RibbonItem.<jsm>option</jsm>(<js>"done"</js>).column(<js>"phase"</js>).value(<js>"$in(Completed)"</js>) 048 * ).defaultOption(<js>"pending"</js>), 049 * RibbonItem.<jsm>dialog</jsm>(<js>"add"</js>).title(<js>"Add repo"</js>).form(<js>"/rest/skill-repos/add-modal"</js>) 050 * .endpoint(<js>"/rest/skill-repos/new"</js>).method(RowAction.Method.<jsf>POST</jsf>) 051 * .onSuccess(RowAction.OnSuccess.<jsf>REDRAW</jsf>), 052 * RibbonItem.<jsm>refresh</jsm>()); 053 * 054 * RibbonItem.<jsm>refresh</jsm>().title(<js>"Refresh tasks"</js>).target(<js>"tasks"</js>); 055 * RibbonItem.<jsm>publish</jsm>(<js>"Focus east"</js>, <js>"app.region-picked"</js>, Map.<jsm>of</jsm>(<js>"region"</js>, <js>"east"</js>)); 056 * </p> 057 * 058 * @since 10.0.0 059 */ 060@SuppressWarnings({ 061 "java:S1192", // Repeated literals are contract wire keys; named constants would obscure the contract shape. 062 "java:S3776" // Validation and rendering chains many independent guards; splitting them adds no clarity. 063}) 064public final class RibbonItem { 065 066 private static final List<String> EXPORT_BUTTONS = List.of("copy", "csv", "print", "excel", "pdf"); 067 private static final List<String> EXPORT_OPTIONAL = List.of("excel", "pdf"); 068 private static final List<String> APPEARANCES = List.of("icon"); 069 070 // The keys juneau-ribbon.js reads for each item type (everything else is ignored there, so toMap() rejects it). 071 private static final Set<String> COMMON = Set.of("type", "id", "title", "group", "appearance", "symbol", "visibleWhen"); 072 private static final Map<String,Set<String>> ALLOWED = Map.of( 073 "refresh", union(COMMON, "target"), 074 "collapseAll", union(COMMON, "target"), 075 "pausePolling", union(COMMON, "target"), 076 "dialog", union(COMMON, "form", "endpoint", "method", "onSuccess"), 077 "export", Set.of("type", "group", "appearance", "buttons", "optional", "visibleWhen"), 078 "option", union(COMMON, "column", "param", "value", "persist", "default", "target"), 079 "optionGroup", Set.of("type", "id", "title", "appearance", "persist", "default", "deselectable", "options", "target", "visibleWhen"), 080 "publish", union(COMMON, "topic", "payload"), 081 "divider", Set.of("type", "visibleWhen") 082 ); 083 private static final java.util.regex.Pattern CARD_ID = java.util.regex.Pattern.compile("^[A-Za-z][A-Za-z0-9_-]{0,63}$"); 084 private static final Set<String> MEMBER_ALLOWED = Set.of("id", "title", "symbol", "column", "param", "value"); 085 086 private final String type; 087 private String id; 088 private String title; 089 private String group; 090 private String appearance; 091 private String symbol; 092 private String form; 093 private String endpoint; 094 private String method; 095 private String onSuccess; 096 private List<String> buttons; 097 private List<String> optional; 098 private String column; 099 private String param; 100 private String value; 101 private Boolean persist; 102 private Object dflt; // Boolean for an option, String (member id) for an optionGroup. 103 private Boolean deselectable; 104 private List<RibbonItem> options; 105 private String target; 106 private List<VisibilityRule> visibleWhen; 107 private String topic; 108 private Map<String,Object> payload; 109 110 private RibbonItem(String type) { 111 this.type = type; 112 } 113 114 /** 115 * A visual separator between ribbon items; it also closes any open item cluster. 116 * 117 * @return A new {@link RibbonItem}. 118 */ 119 public static RibbonItem divider() { 120 return new RibbonItem("divider"); 121 } 122 123 /** 124 * The ribbon's reload-the-table button. 125 * 126 * @return A new {@link RibbonItem}. 127 */ 128 public static RibbonItem refresh() { 129 return new RibbonItem("refresh"); 130 } 131 132 /** 133 * The ribbon's collapse-all-detail-rows button. 134 * 135 * @return A new {@link RibbonItem}. 136 */ 137 public static RibbonItem collapseAll() { 138 return new RibbonItem("collapseAll"); 139 } 140 141 /** 142 * The ribbon's pause/resume auto-refresh toggle. 143 * 144 * @return A new {@link RibbonItem}. 145 */ 146 public static RibbonItem pausePolling() { 147 return new RibbonItem("pausePolling"); 148 } 149 150 /** 151 * The ribbon's export menu, offering the named buttons. 152 * 153 * <p> 154 * A button here is required: if its library is missing at runtime (JSZip for {@code excel}, pdfMake for 155 * {@code pdf}) it renders disabled and logs an error. Use {@link #optional(String...)} for buttons that should 156 * simply be omitted when their library is absent. The menu may consist of optional buttons alone, but 157 * {@link #toMap()} rejects one with no buttons at all. 158 * 159 * @param buttons The required buttons, each one of {@code copy}, {@code csv}, {@code print}, {@code excel} or 160 * {@code pdf}. 161 * @return A new {@link RibbonItem}. 162 * @throws IllegalArgumentException If {@code buttons} names a button not in that set. 163 */ 164 public static RibbonItem export(String...buttons) { 165 for (var b : buttons) 166 checkOneOf(b, EXPORT_BUTTONS, "export button"); 167 var r = new RibbonItem("export"); 168 r.buttons = buttons.length == 0 ? null : List.of(buttons); 169 return r; 170 } 171 172 /** 173 * A ribbon button that opens a modal dialog. 174 * 175 * @param actionId This item's stable id (also the dialog's logical name). Must not be <jk>null</jk> or blank. 176 * @return A new {@link RibbonItem}. 177 * @throws IllegalArgumentException If {@code actionId} is <jk>null</jk> or blank. 178 */ 179 public static RibbonItem dialog(String actionId) { 180 if (ib(actionId)) 181 throw iaex("RibbonItem dialog action id must not be null or blank."); 182 var r = new RibbonItem("dialog"); 183 r.id = actionId; 184 return r; 185 } 186 187 /** 188 * A server-query toggle button. 189 * 190 * <p> 191 * Set exactly one of {@link #column(String)} (a BeanQuery {@code $}-expression {@link #value(String) value} 192 * against that column) or {@link #param(String)} (a URL query parameter). Checked by {@link #toMap()}. 193 * 194 * @param id The toggle's stable id. Must not be <jk>null</jk> or blank. 195 * @return A new {@link RibbonItem}. 196 * @throws IllegalArgumentException If {@code id} is <jk>null</jk> or blank. 197 */ 198 public static RibbonItem option(String id) { 199 if (ib(id)) 200 throw iaex("RibbonItem option id must not be null or blank."); 201 var r = new RibbonItem("option"); 202 r.id = id; 203 return r; 204 } 205 206 /** 207 * A group of mutually exclusive {@link #option(String) option} toggles. 208 * 209 * <p> 210 * A member may omit {@code column}/{@code param}/{@code value}; selecting it then applies no filter (an "All" 211 * member). 212 * 213 * @param id The group's stable id. Must not be <jk>null</jk> or blank. 214 * @param members The members, each created by {@link #option(String)}. Must not be empty. 215 * @return A new {@link RibbonItem}. 216 * @throws IllegalArgumentException If {@code id} is <jk>null</jk> or blank, or {@code members} is empty or 217 * holds anything other than an {@code option}. 218 */ 219 public static RibbonItem optionGroup(String id, RibbonItem...members) { 220 if (ib(id)) 221 throw iaex("RibbonItem optionGroup id must not be null or blank."); 222 if (members.length == 0) 223 throw iaex("RibbonItem optionGroup '%s' requires at least one option.", id); 224 for (var m : members) 225 if (! "option".equals(m.type)) 226 throw iaex("RibbonItem optionGroup '%s' member must be an option, not '%s'.", id, m.type); 227 var r = new RibbonItem("optionGroup"); 228 r.id = id; 229 r.options = List.of(members); 230 return r; 231 } 232 233 /** 234 * A ribbon button that publishes a message when clicked. The topic must be a custom topic declared in the 235 * page's {@code topics} or a card's {@code publishes}, or {@code cmd:<cardId>}; the console's wiring check 236 * enforces that. Rendered only on a console page (no bus, no button). 237 * 238 * @param title The button label. 239 * @param topic The topic to publish. 240 * @param payload A JSON-serializable payload, or <jk>null</jk> for <code>{}</code>. 241 * @return A new {@link RibbonItem}. 242 * @throws IllegalArgumentException If {@code title} or {@code topic} is <jk>null</jk> or blank. 243 */ 244 public static RibbonItem publish(String title, String topic, Map<String,Object> payload) { 245 if (ib(title)) 246 throw iaex("RibbonItem publish requires a title."); 247 if (ib(topic)) 248 throw iaex("RibbonItem publish '%s' requires a topic.", title); 249 var i = new RibbonItem("publish"); 250 i.title = title; 251 i.topic = topic; 252 i.payload = payload == null ? null : new LinkedHashMap<>(payload); 253 return i; 254 } 255 256 /** 257 * Sends this item's command to another card instead of its own table. Accepted on {@code refresh}, 258 * {@code collapseAll}, {@code pausePolling}, {@code option} and {@code optionGroup}; {@link #toMap()} rejects it 259 * elsewhere. 260 * 261 * @param cardId The target card id; the console's wiring check verifies it names a card that handles the op. 262 * @return This object. 263 * @throws IllegalArgumentException If {@code cardId} is not a valid card id. 264 */ 265 public RibbonItem target(String cardId) { 266 if (cardId == null || ! CARD_ID.matcher(cardId).matches()) 267 throw iaex("RibbonItem target '%s' must match ^[A-Za-z][A-Za-z0-9_-]{0,63}$.", cardId); 268 target = cardId; 269 return this; 270 } 271 272 /** 273 * Hides this item unless every rule matches the console's facts. Evaluated by the client; this only serializes the 274 * rules. Accepted on every item type; not on an {@code optionGroup} member. 275 * 276 * @param rules The rules, ANDed together. None clears them. 277 * @return This object. 278 */ 279 public RibbonItem visibleWhen(VisibilityRule...rules) { 280 if (rules == null || Arrays.stream(rules).anyMatch(Objects::isNull)) 281 throw iaex("RibbonItem visibleWhen rules must not be null."); 282 visibleWhen = rules.length == 0 ? null : List.of(rules); 283 return this; 284 } 285 286 /** 287 * Sets this item's stable id. 288 * 289 * <p> 290 * The id is emitted on the item's ribbon entry, where it is scoped to its card (it is not stamped as a DOM id). 291 * {@link #toMap()} rejects an id on the types the runtime ignores it for 292 * ({@code divider} and {@code export}). 293 * 294 * @param v The id. Must not be <jk>null</jk> or blank. 295 * @return This object. 296 * @throws IllegalArgumentException If {@code v} is <jk>null</jk> or blank. 297 */ 298 public RibbonItem id(String v) { 299 if (ib(v)) 300 throw iaex("RibbonItem %s id must not be null or blank.", type); 301 id = v; 302 return this; 303 } 304 305 /** 306 * Sets this item's title/label text. 307 * 308 * <p> 309 * On an {@code optionGroup} the title is not drawn; it becomes the group's accessible name 310 * ({@code role="group"} with {@code aria-label}). 311 * 312 * @param v The title. Can be <jk>null</jk> to unset. 313 * @return This object. 314 */ 315 public RibbonItem title(String v) { 316 title = v; 317 return this; 318 } 319 320 /** 321 * Clusters this item with adjacent items sharing the same group name. 322 * 323 * @param v The group name. Can be <jk>null</jk> to unset. 324 * @return This object. 325 */ 326 public RibbonItem group(String v) { 327 group = v; 328 return this; 329 } 330 331 /** 332 * Sets how this item's button is drawn. 333 * 334 * @param v The appearance; currently only {@code icon}. Can be <jk>null</jk> to unset. 335 * @return This object. 336 * @throws IllegalArgumentException If {@code v} is not a known appearance. 337 */ 338 public RibbonItem appearance(String v) { 339 if (v != null) 340 checkOneOf(v, APPEARANCES, "appearance"); 341 appearance = v; 342 return this; 343 } 344 345 /** 346 * Sets the icon name overriding this item's default icon. 347 * 348 * @param v The icon name. Can be <jk>null</jk> to unset. 349 * @return This object. 350 */ 351 public RibbonItem symbol(String v) { 352 symbol = v; 353 return this; 354 } 355 356 /** 357 * Sets the form-source URL supplying a dialog item's input fields. 358 * 359 * @param url The form URL. Can be <jk>null</jk> to unset. 360 * @return This object. 361 */ 362 public RibbonItem form(String url) { 363 form = url; 364 return this; 365 } 366 367 /** 368 * Sets the URL a dialog item's form submits to. 369 * 370 * @param url The endpoint URL. Can be <jk>null</jk> to unset. 371 * @return This object. 372 */ 373 public RibbonItem endpoint(String url) { 374 endpoint = url; 375 return this; 376 } 377 378 /** 379 * Sets the non-safe HTTP method a dialog item's form submits with. 380 * 381 * @param m The method. Must not be <jk>null</jk>. 382 * @return This object. 383 */ 384 public RibbonItem method(RowAction.Method m) { 385 if (m == null) 386 throw iaex("RibbonItem method must not be null."); 387 method = m.wire(); 388 return this; 389 } 390 391 /** 392 * Sets what the runtime does with a successful dialog submission. 393 * 394 * @param s The success behavior. Must not be <jk>null</jk>. 395 * @return This object. 396 */ 397 public RibbonItem onSuccess(RowAction.OnSuccess s) { 398 if (s == null) 399 throw iaex("RibbonItem onSuccess must not be null."); 400 onSuccess = s.wire(); 401 return this; 402 } 403 404 /** 405 * Adds export buttons that are omitted, rather than rendered disabled, when their library is absent. 406 * 407 * @param names The buttons, each {@code excel} or {@code pdf}. 408 * @return This object. 409 * @throws IllegalArgumentException If a name is not one of those, or is already a required button. 410 */ 411 public RibbonItem optional(String...names) { 412 for (var n : names) { 413 checkOneOf(n, EXPORT_OPTIONAL, "optional export button"); 414 if (buttons != null && buttons.contains(n)) 415 throw iaex("RibbonItem export button '%s' is both required and optional.", n); 416 } 417 optional = names.length == 0 ? null : List.of(names); 418 return this; 419 } 420 421 /** 422 * Scopes an {@code option} to a table column. 423 * 424 * @param name The row-data column name. Can be <jk>null</jk> to unset. 425 * @return This object. 426 */ 427 public RibbonItem column(String name) { 428 column = name; 429 return this; 430 } 431 432 /** 433 * Scopes an {@code option} to a URL query parameter. 434 * 435 * @param name The parameter name. Can be <jk>null</jk> to unset. 436 * @return This object. 437 */ 438 public RibbonItem param(String name) { 439 param = name; 440 return this; 441 } 442 443 /** 444 * Sets an {@code option}'s value: a BeanQuery {@code $}-expression for a {@link #column(String) column}-scoped 445 * option, or the raw parameter value for a {@link #param(String) param}-scoped one. 446 * 447 * @param v The value. Can be <jk>null</jk> to unset. 448 * @return This object. 449 */ 450 public RibbonItem value(String v) { 451 value = v; 452 return this; 453 } 454 455 /** 456 * Sets whether this option's (or group's) state is remembered across page loads. 457 * 458 * @param v Whether to persist. 459 * @return This object. 460 */ 461 public RibbonItem persist(boolean v) { 462 persist = v; 463 return this; 464 } 465 466 /** 467 * Marks an {@code option} as on until the user (or persisted state) says otherwise. 468 * 469 * @return This object. 470 */ 471 public RibbonItem defaultOn() { 472 dflt = Boolean.TRUE; 473 return this; 474 } 475 476 /** 477 * Selects an {@code optionGroup} member until the user (or persisted state) says otherwise. 478 * 479 * @param memberId The member's id; checked against the members by {@link #toMap()}. 480 * @return This object. 481 * @throws IllegalArgumentException If this item is a plain {@code option} (use {@link #defaultOn()}). 482 */ 483 public RibbonItem defaultOption(String memberId) { 484 if ("option".equals(type)) 485 throw iaex("RibbonItem option '%s' cannot take defaultOption(); use defaultOn().", id); 486 dflt = memberId; 487 return this; 488 } 489 490 /** 491 * Lets an {@code optionGroup}'s selected member be clicked again to deselect it. 492 * 493 * @param v Whether the group is deselectable. 494 * @return This object. 495 */ 496 public RibbonItem deselectable(boolean v) { 497 deselectable = v; 498 return this; 499 } 500 501 /** 502 * Builds this item's ribbon entry. 503 * 504 * @return The entry, {@code type} first, then only the keys that were set. 505 * @throws IllegalArgumentException If the item sets a key the runtime ignores for its type, a top-level 506 * {@code option} sets neither or both of {@code column} and {@code param} or no {@code value}, an 507 * {@code export} has no buttons, or a {@code default} does not suit the item (an {@code option} takes 508 * {@link #defaultOn()}; an {@code optionGroup} takes {@link #defaultOption(String)} naming a member), or a 509 * {@code target} is set on a type the runtime does not route. 510 */ 511 public JsonMap toMap() { 512 var m = toMap(true); 513 check(m, null); 514 return m; 515 } 516 517 private JsonMap toMap(boolean withType) { 518 var m = new JsonMap(); 519 if (withType) 520 m.put("type", type); 521 if (id != null) 522 m.put("id", id); 523 if (title != null) 524 m.put("title", title); 525 if (group != null) 526 m.put("group", group); 527 if (appearance != null) 528 m.put("appearance", appearance); 529 if (symbol != null) 530 m.put("symbol", symbol); 531 if (topic != null) 532 m.put("topic", topic); 533 if (payload != null) 534 m.put("payload", payload); 535 if (target != null) 536 m.put("target", target); 537 if (form != null) 538 m.put("form", form); 539 if (endpoint != null) 540 m.put("endpoint", endpoint); 541 if (method != null) 542 m.put("method", method); 543 if (onSuccess != null) 544 m.put("onSuccess", onSuccess); 545 if (buttons != null) 546 m.put("buttons", buttons); 547 if (optional != null) 548 m.put("optional", optional); 549 if (column != null) 550 m.put("column", column); 551 if (param != null) 552 m.put("param", param); 553 if (value != null) 554 m.put("value", value); 555 if (persist != null) 556 m.put("persist", persist); 557 if (dflt != null) 558 m.put("default", dflt); 559 if (deselectable != null) 560 m.put("deselectable", deselectable); 561 if (visibleWhen != null) 562 m.put("visibleWhen", VisibilityRule.toMaps(visibleWhen)); 563 if (options != null) { 564 m.put("options", new JsonList(options.stream().map(o -> o.toMap(false)).toList())); 565 } 566 return m; 567 } 568 569 /** 570 * Validates an authored ribbon item map (for example from a JSON5 card body) with exactly the rules 571 * {@link #toMap()} applies to a built item: a known {@code type}, only keys the runtime reads, an 572 * {@code export} with at least one valid button, an {@code option} with exactly one of {@code column} or 573 * {@code param} plus a {@code value}, a {@code default} that suits the item, a valid {@code appearance} and 574 * {@code target}, a well-formed {@code visibleWhen} (a list of {@code field}/{@code op} rules with a known op), and the same rules for each {@code optionGroup} member. 575 * 576 * @param item The authored item. Must not be <jk>null</jk>. 577 * @throws IllegalArgumentException If the item is not valid. 578 */ 579 public static void validate(Map<?,?> item) { 580 check(item, null); 581 } 582 583 // The one rule set behind toMap() and validate(). parentId is non-null only for an optionGroup member (a map with no 'type'). 584 private static void check(Map<?,?> m, String parentId) { 585 var isMember = parentId != null; 586 var t = m.get("type"); 587 var declaredType = t instanceof String ts ? ts : null; 588 var type = isMember ? "option" : declaredType; 589 if (! isMember && (type == null || ! ALLOWED.containsKey(type))) { 590 if (t == null) 591 throw iaex("RibbonItem requires a 'type'; one of '%s'.", String.join(", ", new TreeSet<>(ALLOWED.keySet()))); 592 if (! (t instanceof String)) 593 throw iaex("RibbonItem 'type' must be a string, not '%s'; one of '%s'.", t, String.join(", ", new TreeSet<>(ALLOWED.keySet()))); 594 throw iaex("RibbonItem type '%s' is not one of '%s'.", t, String.join(", ", new TreeSet<>(ALLOWED.keySet()))); 595 } 596 var id = m.get("id"); 597 if (! isMember) { 598 if ("export".equals(type)) { 599 if (m.get("buttons") == null && m.get("optional") == null) 600 throw iaex("RibbonItem export requires at least one button."); 601 checkAll(m.get("buttons"), EXPORT_BUTTONS, "export button"); 602 checkAll(m.get("optional"), EXPORT_OPTIONAL, "optional export button"); 603 } 604 if (m.get("appearance") != null) 605 checkOneOf(String.valueOf(m.get("appearance")), APPEARANCES, "appearance"); 606 if (m.get("target") != null && ! (m.get("target") instanceof String ts && CARD_ID.matcher(ts).matches())) 607 throw iaex("RibbonItem target '%s' must match ^[A-Za-z][A-Za-z0-9_-]{0,63}$.", m.get("target")); 608 } 609 if (isMember || "option".equals(type)) 610 checkScope(m, id, ! isMember); 611 if (! isMember && m.get("default") != null) 612 checkDefault(m, type, id); 613 if (! isMember && m.get("visibleWhen") != null) 614 checkVisibleWhen(m.get("visibleWhen"), type, id); 615 if ("optionGroup".equals(type) && m.get("options") != null) { 616 if (! (m.get("options") instanceof List<?> members)) 617 throw iaex("RibbonItem optionGroup '%s' options must be a list.", id); 618 for (var o : members) { 619 if (! (o instanceof Map<?,?> mm)) 620 throw iaex("RibbonItem optionGroup '%s' each option must be an object.", id); 621 check(mm, String.valueOf(id)); 622 } 623 } 624 var allowed = isMember ? MEMBER_ALLOWED : ALLOWED.get(type); 625 for (var k : m.keySet()) 626 if (! allowed.contains(String.valueOf(k))) { 627 if (isMember) 628 throw iaex("RibbonItem optionGroup '%s' member '%s' does not accept '%s'.", parentId, id, k); 629 throw iaex("RibbonItem %s%s does not accept '%s'.", type, id == null ? "" : " '" + id + "'", k); 630 } 631 } 632 633 private static void checkVisibleWhen(Object v, String type, Object id) { 634 var ok = v instanceof List<?> l && l.stream().allMatch(r -> r instanceof Map<?,?> rm 635 && rm.get("field") instanceof String f && ! f.isBlank() 636 && rm.get("op") instanceof String op && VisibilityRule.KNOWN_OPS.contains(op)); 637 if (! ok) 638 throw iaex("RibbonItem %s%s visibleWhen must be a list of {field, op[, value]} rules with a known op.", type, id == null ? "" : " '" + id + "'"); 639 } 640 641 private static void checkAll(Object v, List<String> allowed, String what) { 642 if (v == null) 643 return; 644 if (! (v instanceof List<?> l)) 645 throw iaex("RibbonItem %s list must be a list.", what); 646 for (var b : l) 647 checkOneOf(String.valueOf(b), allowed, what); 648 } 649 650 private static void checkScope(Map<?,?> m, Object id, boolean topLevel) { 651 var column = m.get("column"); 652 var param = m.get("param"); 653 if (column != null && param != null) 654 throw iaex("RibbonItem option '%s' sets both column '%s' and param '%s'.", id, column, param); 655 if (topLevel && column == null && param == null) 656 throw iaex("RibbonItem option '%s' sets neither column nor param.", id); 657 if (topLevel && m.get("value") == null) 658 throw iaex("RibbonItem option '%s' requires a value.", id); 659 } 660 661 private static void checkDefault(Map<?,?> m, String type, Object id) { 662 var d = m.get("default"); 663 if ("optionGroup".equals(type)) { 664 var ok = d instanceof String && m.get("options") instanceof List<?> l 665 && l.stream().anyMatch(o -> o instanceof Map<?,?> mm && d.equals(mm.get("id"))); 666 if (! ok) 667 throw iaex("RibbonItem optionGroup '%s' default '%s' is not one of its options.", id, d); 668 } else if ("option".equals(type) && ! Boolean.TRUE.equals(d)) { 669 throw iaex("RibbonItem option '%s' default '%s' must be on; use defaultOn().", id, d); 670 } 671 } 672 673 private static Set<String> union(Set<String> base, String...more) { 674 var s = new LinkedHashSet<>(base); 675 s.addAll(Arrays.asList(more)); 676 return Set.copyOf(s); 677 } 678 679 private static void checkOneOf(String v, List<String> allowed, String what) { 680 if (! allowed.contains(v)) 681 throw iaex("RibbonItem %s '%s' is not one of '%s'.", what, v, String.join(", ", allowed)); 682 } 683}