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.*; 022import java.util.regex.*; 023 024import org.apache.juneau.commons.beanquery.*; 025import org.apache.juneau.marshall.collections.*; 026import org.apache.juneau.marshall.marshaller.Json; 027import org.apache.juneau.console.widgets.Op; 028 029/** 030 * Java description of one datatables view: data source, columns, ribbon, row styling, row actions, row detail, 031 * selection and bulk actions. It replaces hand-written {@code <@card type="datatables">} JSON5 032 * for apps that build pages in Java, and {@link #toCardBody()} serializes to exactly the card body the FTL path 033 * produces. All behavior (paging, search, rendering, actions) stays in {@code juneau-views.js}; this type never 034 * calls a card-type handler directly — see the class's rendering note on {@link #toCardBody()}. 035 * 036 * <p> 037 * {@link Column#defaultVisible(boolean) Column.defaultVisible(false)} only hides a column when the client's 038 * {@code columnConfig} (juneau-config.js) is on. 039 * 040 * <h5 class='section'>Example:</h5> 041 * <p class='bjava'> 042 * TableSpec <jv>t</jv> = TableSpec.<jsm>create</jsm>(<js>"slo"</js>) 043 * .rowType(SloPod.<jk>class</jk>) 044 * .dataUrl(<js>"/rest/slo/data"</js>) 045 * .defaultOrder(<js>"pod"</js>, TableSpec.Sort.<jsf>ASC</jsf>) 046 * .columns( 047 * Column.<jsm>create</jsm>(<js>"pod"</js>).label(<js>"Pod"</js>).className(<js>"ssc-id-cell"</js>), 048 * Column.<jsm>create</jsm>(<js>"incidentKey"</js>).label(<js>"Incident"</js>).render(<js>"linked"</js>).href(<js>"{incidentUrl}"</js>), 049 * Column.<jsm>create</jsm>(<js>"rootCause"</js>).label(<js>"Root cause"</js>).defaultVisible(<jk>false</jk>)) 050 * .ribbon(RibbonItem.<jsm>refresh</jsm>(), RibbonItem.<jsm>export</jsm>(<js>"copy"</js>, <js>"csv"</js>)) 051 * .rowClass(<js>"analysisState"</js>, Op.<jsf>EQ</jsf>, <js>"degraded"</js>, <js>"row-degraded"</js>) 052 * .rowActions(RowAction.<jsm>create</jsm>(<js>"analyze"</js>).label(<js>"Analyze pod"</js>) 053 * .endpoint(<js>"/rest/slo/analyze"</js>).method(RowAction.Method.<jsf>POST</jsf>)) 054 * .detail(RowDetail.<jsm>create</jsm>(<js>"/rest/slo/data/{id}"</js>).title(<js>"{pod}"</js>) 055 * .region(RegionDef.<jsm>create</jsm>(<js>"slo-detail"</js>).populate(<js>"slo-detail"</js>))); 056 * <jv>t</jv>.validate(); 057 * Map<String,Object> <jv>body</jv> = <jv>t</jv>.toCardBody(); // the unwrapped catalog 058 * </p> 059 * 060 * @since 10.0.0 061 */ 062@SuppressWarnings({ 063 "java:S1192", // Duplicated literals read more clearly inline than as constants. 064 "java:S3776" // Validation and rendering chains many independent guards; splitting them adds no clarity. 065}) 066public final class TableSpec { 067 068 /** Where a view's rows come from. Wire tokens are lowercase. */ 069 public enum DataMode { 070 071 /** Paging, sorting and search run client-side, against the full {@code dataUrl} payload. */ 072 CLIENT("client"), 073 074 /** Paging, sorting and search are delegated to {@code dataUrl} on every draw. */ 075 SERVER("server"); 076 077 private final String wire; 078 079 DataMode(String wire) { 080 this.wire = wire; 081 } 082 083 /** @return The lowercase wire token. */ 084 public String wire() { 085 return wire; 086 } 087 } 088 089 /** A default-sort direction. Wire tokens are lowercase. */ 090 public enum Sort { 091 092 /** Ascending. */ 093 ASC("asc"), 094 095 /** Descending. */ 096 DESC("desc"); 097 098 private final String wire; 099 100 Sort(String wire) { 101 this.wire = wire; 102 } 103 104 /** @return The lowercase wire token. */ 105 public String wire() { 106 return wire; 107 } 108 } 109 110 /** Id grammar shared with cards and nav nodes. Mirrors {@code $defs/id} in {@code juneau-page.schema.json}. */ 111 private static final Set<String> URL_STATE_BUILTINS = Set.of("day", "ids", "window"); 112 private static final Pattern ID_PATTERN = Pattern.compile("^[A-Za-z][A-Za-z0-9_-]{0,63}$"); 113 114 private final String id; 115 private String rowType; 116 private String dataUrl; 117 private DataMode dataMode; 118 private final List<JsonMap> defaultOrder = new ArrayList<>(); 119 private final List<Column> columns = new ArrayList<>(); 120 private SearchOperatorSet operators; 121 private final List<RibbonItem> ribbon = new ArrayList<>(); 122 private final List<JsonMap> rowClassRules = new ArrayList<>(); 123 private final List<RowAction> rowActions = new ArrayList<>(); 124 private RowDetail detail; 125 private SelectionDef selection; 126 private BulkMutateDef bulk; 127 private Long pollIntervalMs; 128 private String quickStatsId; 129 private List<QuickStat> quickStats; 130 private boolean primary; 131 private Object columnConfig; 132 private Boolean copyLink; 133 private Boolean cleanAddress; 134 private Boolean pausePollingWhileEditing; 135 private String cssClass; 136 private final Map<String,Map<String,Object>> urlState = new LinkedHashMap<>(); 137 138 private TableSpec(String id) { 139 this.id = id; 140 } 141 142 /** 143 * Starts a new table definition. 144 * 145 * @param id The table's card id. Must not be <jk>null</jk> or blank, and must match 146 * {@code ^[A-Za-z][A-Za-z0-9_-]{0,63}$}. 147 * @return A new {@link TableSpec}, with no columns or {@code dataUrl} yet set. 148 * @throws IllegalArgumentException If {@code id} is <jk>null</jk>, blank, or malformed. 149 */ 150 public static TableSpec create(String id) { 151 if (ib(id)) 152 throw iaex("TableSpec id must not be null or blank."); 153 if (! ID_PATTERN.matcher(id).matches()) 154 throw iaex("TableSpec id '%s' must match ^[A-Za-z][A-Za-z0-9_-]{0,63}$.", id); 155 return new TableSpec(id); 156 } 157 158 /** 159 * Sets the row type by its simple name, as {@code TableCatalog} did — documentation metadata only; it has no 160 * effect on rendering or validation. 161 * 162 * @param type The row type. Can be <jk>null</jk> to unset. 163 * @return This object. 164 */ 165 public TableSpec rowType(Class<?> type) { 166 rowType = type == null ? null : type.getSimpleName(); 167 return this; 168 } 169 170 /** 171 * Sets the row type by name directly. 172 * 173 * @param name The row type name. Can be <jk>null</jk> to unset. 174 * @return This object. 175 */ 176 public TableSpec rowType(String name) { 177 rowType = name; 178 return this; 179 } 180 181 /** 182 * Sets the table's data source URL. 183 * 184 * @param url The URL. Required by {@link #validate()}. 185 * @return This object. 186 */ 187 public TableSpec dataUrl(String url) { 188 dataUrl = url; 189 return this; 190 } 191 192 /** 193 * Sets whether paging/sorting/search run client-side or against {@code dataUrl} on every draw. 194 * 195 * @param mode The mode. Defaults to {@link DataMode#CLIENT} when never called (omitted from the wire, since 196 * {@code juneau-views.js} already defaults to client mode). 197 * @return This object. 198 */ 199 public TableSpec dataMode(DataMode mode) { 200 dataMode = mode; 201 return this; 202 } 203 204 /** 205 * Appends one default sort column. Repeatable — later calls add secondary sort keys. 206 * 207 * @param column The column name. Must not be <jk>null</jk> or blank. 208 * @param dir The sort direction. Must not be <jk>null</jk>. 209 * @return This object. 210 * @throws IllegalArgumentException If {@code column} is <jk>null</jk>/blank, or {@code dir} is <jk>null</jk>. 211 */ 212 public TableSpec defaultOrder(String column, Sort dir) { 213 if (ib(column)) 214 throw iaex("TableSpec defaultOrder column must not be null or blank."); 215 if (dir == null) 216 throw iaex("TableSpec defaultOrder direction must not be null."); 217 var m = new JsonMap(); 218 m.put("key", column); 219 m.put("dir", dir.wire()); 220 defaultOrder.add(m); 221 return this; 222 } 223 224 /** 225 * Appends columns, in display order. 226 * 227 * @param cols The columns. Must not be <jk>null</jk>. 228 * @return This object. 229 * @throws IllegalArgumentException If {@code cols} names a {@link Column#name()} already appended by an 230 * earlier call. 231 */ 232 public TableSpec columns(Column...cols) { 233 for (var c : cols) { 234 for (var existing : columns) 235 if (existing.name().equals(c.name())) 236 throw iaex("TableSpec '%s' declares column '%s' twice.", id, c.name()); 237 columns.add(c); 238 } 239 return this; 240 } 241 242 /** 243 * Sets the table-level default {@link SearchOperatorSet}, inherited by any searchable column that declares 244 * none of its own. 245 * 246 * @param table The operator set. Can be <jk>null</jk> to clear it back to absent. 247 * @return This object. 248 */ 249 public TableSpec operators(SearchOperatorSet table) { 250 operators = table; 251 return this; 252 } 253 254 /** 255 * Appends ribbon (toolbar) items, in display order. 256 * 257 * @param items The items. Must not be <jk>null</jk>. 258 * @return This object. 259 */ 260 public TableSpec ribbon(RibbonItem...items) { 261 ribbon.addAll(List.of(items)); 262 return this; 263 } 264 265 /** 266 * Appends a value-based row-class rule ({@code juneau-views.js}'s {@code evaluateRowClassRules}): 267 * a row whose {@code field} is {@link Op#EQ eq}/{@link Op#NE ne} the given value gets {@code cssClass} added 268 * to its {@code <tr>}. 269 * 270 * @param field The row-data field to test. Must not be <jk>null</jk> or blank. 271 * @param op The operator. Must be {@link Op#EQ} or {@link Op#NE}. 272 * @param value The comparison value. Must not be <jk>null</jk>. 273 * @param cssClass The CSS class to add on a match (serialized as the wire key {@code class}). Must not be 274 * <jk>null</jk> or blank. 275 * @return This object. 276 * @throws IllegalArgumentException If any argument is missing, or {@code op} does not take a value. 277 */ 278 public TableSpec rowClass(String field, Op op, Object value, String cssClass) { 279 checkRowClassCommon(field, op, cssClass); 280 if (! op.requiresValue()) 281 throw iaex("TableSpec '%s' rowClass on '%s': op '%s' takes no value.", id, field, op.wire()); 282 if (value == null) 283 throw iaex("TableSpec '%s' rowClass on '%s': op '%s' requires a value.", id, field, op.wire()); 284 var m = new JsonMap(); 285 m.put("field", field); 286 m.put("op", op.wire()); 287 m.put("value", value); 288 m.put("class", cssClass); 289 rowClassRules.add(m); 290 return this; 291 } 292 293 /** 294 * Appends a presence-based row-class rule: a row whose {@code field} is {@link Op#PRESENT present}/ 295 * {@link Op#ABSENT absent} gets {@code cssClass} added to its {@code <tr>}. 296 * 297 * @param field The row-data field to test. Must not be <jk>null</jk> or blank. 298 * @param op The operator. Must be {@link Op#PRESENT} or {@link Op#ABSENT}. 299 * @param cssClass The CSS class to add on a match (serialized as the wire key {@code class}). Must not be 300 * <jk>null</jk> or blank. 301 * @return This object. 302 * @throws IllegalArgumentException If any argument is missing, or {@code op} requires a value. 303 */ 304 public TableSpec rowClass(String field, Op op, String cssClass) { 305 checkRowClassCommon(field, op, cssClass); 306 if (op.requiresValue()) 307 throw iaex("TableSpec '%s' rowClass on '%s': op '%s' requires a value.", id, field, op.wire()); 308 var m = new JsonMap(); 309 m.put("field", field); 310 m.put("op", op.wire()); 311 m.put("class", cssClass); 312 rowClassRules.add(m); 313 return this; 314 } 315 316 private void checkRowClassCommon(String field, Op op, String cssClass) { 317 if (ib(field)) 318 throw iaex("TableSpec '%s' rowClass field must not be null or blank.", id); 319 if (op == null) 320 throw iaex("TableSpec '%s' rowClass op must not be null.", id); 321 if (op.isCollectionOp()) 322 throw iaex("TableSpec '%s' rowClass on '%s': op '%s' is not supported; use eq, ne, present or absent.", id, field, op.wire()); 323 if (ib(cssClass)) 324 throw iaex("TableSpec '%s' rowClass cssClass must not be null or blank.", id); 325 } 326 327 /** 328 * Appends row actions, in menu order. 329 * 330 * @param actions The actions. Must not be <jk>null</jk>. 331 * @return This object. 332 */ 333 public TableSpec rowActions(RowAction...actions) { 334 rowActions.addAll(List.of(actions)); 335 return this; 336 } 337 338 /** 339 * Appends row actions from a list, in menu order. 340 * 341 * @param actions The actions. Must not be <jk>null</jk>. 342 * @return This object. 343 */ 344 public TableSpec rowActions(List<RowAction> actions) { 345 rowActions.addAll(actions); 346 return this; 347 } 348 349 /** 350 * Sets the per-row detail panel. 351 * 352 * @param value The detail config. Can be <jk>null</jk> to clear it. 353 * @return This object. 354 */ 355 public TableSpec detail(RowDetail value) { 356 detail = value; 357 return this; 358 } 359 360 /** 361 * Sets the row-selection opt-in. 362 * 363 * @param value The selection config. Can be <jk>null</jk> to clear it. 364 * @return This object. 365 */ 366 public TableSpec selection(SelectionDef value) { 367 selection = value; 368 return this; 369 } 370 371 /** 372 * Sets the bulk-mutate opt-in. Requires {@link #selection(SelectionDef)} to also be set. 373 * 374 * @param value The bulk config. Can be <jk>null</jk> to clear it. 375 * @return This object. 376 */ 377 public TableSpec bulk(BulkMutateDef value) { 378 bulk = value; 379 return this; 380 } 381 382 /** 383 * Sets the auto-refresh poll interval. 384 * 385 * @param ms The interval in milliseconds. Must be positive. 386 * @return This object. 387 * @throws IllegalArgumentException If {@code ms} is not positive. 388 */ 389 public TableSpec pollIntervalMs(long ms) { 390 if (ms <= 0) 391 throw iaex("TableSpec '%s' pollIntervalMs must be positive.", id); 392 pollIntervalMs = ms; 393 return this; 394 } 395 396 /** 397 * Sets the table's quick-stats strip. 398 * 399 * @param id The strip's own id, serialized as {@code quickStats.id}. Must not be <jk>null</jk> or blank. 400 * @param items The tiles, in display order. Must not be empty. 401 * @return This object. 402 * @throws IllegalArgumentException If {@code id} is <jk>null</jk>/blank, or {@code items} is empty. 403 */ 404 public TableSpec quickStats(String id, QuickStat...items) { 405 if (ib(id)) 406 throw iaex("TableSpec quickStats id must not be null or blank."); 407 if (items.length == 0) 408 throw iaex("TableSpec quickStats requires at least one item."); 409 quickStatsId = id; 410 quickStats = List.of(items); 411 return this; 412 } 413 414 /** 415 * Declares URL-state directives this table's address bar carries, so their values reach the table's server requests. 416 * 417 * <p> 418 * Each name selects one of the built-in directives ({@code day}, {@code ids} or {@code window}); any other name is 419 * refused, because this method does not register codecs and the page contract only carries the built-ins. A name 420 * not yet declared is emitted as an empty configuration object, which means the directive's defaults; a name already 421 * declared keeps its configuration (for example a {@code day} param set by {@link #urlStateDayParam(String)}). This 422 * method never clears configuration. Directives are emitted in the order they were first declared. The call is 423 * atomic: every name is validated before any is declared, so if one is refused nothing is changed. 424 * 425 * @param names The directive names; each one of {@code day}, {@code ids} or {@code window}. 426 * @return This object. 427 * @throws IllegalArgumentException If a name is null, blank or not a built-in directive. 428 */ 429 public TableSpec urlState(String...names) { 430 for (var n : names) 431 checkUrlStateName(n); 432 for (var n : names) 433 urlState.putIfAbsent(n, new LinkedHashMap<>()); 434 return this; 435 } 436 437 /** 438 * Declares the {@code day} URL-state directive and names the request query parameter it sends the selected day under. 439 * 440 * <p> 441 * {@code urlState("day")} sends the day as a {@code day} parameter; this form is for a server whose parameter has 442 * another name. The directive is emitted as {@code urlState.day.param}, in the position {@code day} was first 443 * declared. Calling this again replaces the parameter name (the last call wins). 444 * 445 * @param param The query-parameter name; must match {@code ^[A-Za-z][A-Za-z0-9_-]{0,63}$}. 446 * @return This object. 447 * @throws IllegalArgumentException If {@code param} is null, blank, or malformed. 448 */ 449 public TableSpec urlStateDayParam(String param) { 450 if (ib(param)) 451 throw iaex("urlState day param must not be null or blank."); 452 checkUrlStateParam("day param", param); 453 urlState.computeIfAbsent("day", k -> new LinkedHashMap<>()).put("param", param); 454 return this; 455 } 456 457 /** 458 * Declares a URL-state directive together with its options. 459 * 460 * <p> 461 * This is the configurable form of {@link #urlState(String...)} for the three built-in directives; every option the 462 * client's built-in codecs accept can be expressed, so a page needs no hand-written map to configure one: 463 * <ul> 464 * <li>{@code ids} — {@code param} (request parameter name, default {@code ids}) and {@code max} (an integer 465 * >= 1, default 200). 466 * <li>{@code window} — {@code params}, a map that may hold {@code start} and/or {@code end} (the request 467 * parameter names, defaults {@code start}/{@code end}). 468 * <li>{@code day} — {@code param} (request parameter name, default {@code day}); same as 469 * {@link #urlStateDayParam(String)}. 470 * </ul> 471 * Any other directive name is refused: this method does not register codecs, and the page contract only carries the 472 * built-ins. 473 * 474 * <p> 475 * Restrictions (deliberate, so the wire contract and the builder agree): every parameter name must match 476 * {@code ^[A-Za-z][A-Za-z0-9_-]{0,63}$} (the same grammar as {@code day.param}), and {@code max} must be an integer 477 * (Integer, Long, Short or Byte) of at least 1. The client's own fallbacks for a falsy, string or fractional 478 * {@code max}, or for an unusual parameter name, are refused here rather than reproduced. The result is the same 479 * value an FTL page writes as {@code urlState: {ids:{param:'pid',max:50}}}. 480 * 481 * <p> 482 * <b>Merge, not replace.</b> The options are merged into any entry already declared for {@code name}: each option 483 * given here overwrites the same option declared earlier (the last call wins, as with {@link #urlStateDayParam(String)}), 484 * options not named here are kept, and {@code window.params} is merged key by key. Like {@link #urlState(String...)} 485 * this never clears configuration, and an empty map just declares the directive. The directive keeps the position it 486 * was first declared in. The call is atomic: if any option is refused, nothing is changed. 487 * 488 * @param name The directive name; one of {@code day}, {@code ids} or {@code window}. 489 * @param config The directive options; deep-copied, so later changes to the map have no effect. Must not be null. 490 * @return This object. 491 * @throws IllegalArgumentException If the name is null, blank or not a built-in directive, the config is null, or an option is unknown for a 492 * built-in directive, mistyped, or out of range (a parameter name that is malformed, a {@code max} that is not a 493 * positive integer, a {@code window.params} key other than {@code start}/{@code end}). 494 */ 495 @SuppressWarnings({ 496 "unchecked" // the only nested values are the Map<String,Object> copies checkUrlStateOption builds for window.params 497 }) 498 public TableSpec urlState(String name, Map<String,Object> config) { 499 checkUrlStateName(name); 500 if (config == null) 501 throw iaex("urlState '%s' config must not be null.", name); 502 var checked = new LinkedHashMap<String,Object>(); 503 for (var e : config.entrySet()) { 504 var key = e.getKey(); 505 if (ib(key)) 506 throw iaex("urlState '%s' option name must not be blank.", name); 507 checked.put(key, checkUrlStateOption(name, key, e.getValue())); 508 } 509 var target = urlState.computeIfAbsent(name, k -> new LinkedHashMap<>()); 510 checked.forEach((k, v) -> { 511 if ("window".equals(name) && "params".equals(k) && target.get(k) instanceof Map<?,?> old) { 512 var merged = new LinkedHashMap<String,Object>(); 513 old.forEach((ok, ov) -> merged.put((String)ok, ov)); 514 merged.putAll((Map<String,Object>)v); 515 target.put(k, merged); 516 } else { 517 target.put(k, v); 518 } 519 }); 520 return this; 521 } 522 523 private static void checkUrlStateName(String name) { 524 if (ib(name)) 525 throw iaex("urlState name must not be blank."); 526 if (! URL_STATE_BUILTINS.contains(name)) 527 throw iaex("urlState '%s' is not a built-in directive; expected day, ids or window.", name); 528 } 529 530 private static JsonMap copyUrlStateConfig(Map<String,Object> cfg) { 531 var out = new JsonMap(); 532 cfg.forEach((k, v) -> out.put(k, v instanceof Map<?,?> m ? new JsonMap(m) : v)); 533 return out; 534 } 535 536 private static Object checkUrlStateOption(String name, String key, Object value) { 537 switch (name) { 538 case "day": 539 if (! "param".equals(key)) 540 throw iaex("urlState day: unknown option '%s'; expected param.", key); 541 return checkUrlStateParam("day param", value); 542 case "ids": 543 if ("param".equals(key)) 544 return checkUrlStateParam("ids param", value); 545 if ("max".equals(key)) { 546 if (! (value instanceof Integer || value instanceof Long || value instanceof Short || value instanceof Byte) || ((Number)value).longValue() < 1) 547 throw iaex("urlState ids max must be a positive integer but was %s.", value); 548 return value; 549 } 550 throw iaex("urlState ids: unknown option '%s'; expected param or max.", key); 551 case "window": 552 if (! "params".equals(key)) 553 throw iaex("urlState window: unknown option '%s'; expected params.", key); 554 if (! (value instanceof Map<?,?> m)) 555 throw iaex("urlState window params must be a map of start/end parameter names but was %s.", value); 556 var out = new LinkedHashMap<String,Object>(); 557 for (var e : m.entrySet()) { 558 var k = e.getKey(); 559 if (! "start".equals(k) && ! "end".equals(k)) 560 throw iaex("urlState window params: unknown key '%s'; expected start or end.", k); 561 out.put((String)k, checkUrlStateParam("window params." + k, e.getValue())); 562 } 563 return out; 564 default: 565 throw iaex("urlState '%s' is not a built-in directive; expected day, ids or window.", name); 566 } 567 } 568 569 private static String checkUrlStateParam(String what, Object param) { 570 if (! (param instanceof String s) || ! ID_PATTERN.matcher(s).matches()) 571 throw iaex("urlState %s '%s' must match ^[A-Za-z][A-Za-z0-9_-]{0,63}$.", what, param); 572 return s; 573 } 574 575 /** 576 * Sets whether this table is the page's primary table (affects layout only). 577 * 578 * @param v The new value. 579 * @return This object. 580 */ 581 public TableSpec primary(boolean v) { 582 primary = v; 583 return this; 584 } 585 586 /** 587 * Sets whether this table offers the View Settings dialog (column chooser, search and sort settings). 588 * 589 * @param v The new value. 590 * @return This object. 591 */ 592 public TableSpec columnConfig(boolean v) { 593 columnConfig = v; 594 return this; 595 } 596 597 /** 598 * Sets the View Settings dialog options, serialized as the {@code columnConfig} object (for example 599 * {@code {"chooser": true}}). 600 * 601 * @param options The options, copied at call time. Must not be <jk>null</jk>. 602 * @return This object. 603 */ 604 public TableSpec columnConfig(Map<String,Object> options) { 605 columnConfig = new LinkedHashMap<>(rnn(options)); 606 return this; 607 } 608 609 /** 610 * Sets whether the table offers a copy-link control for its current view state. 611 * 612 * @param v The new value. 613 * @return This object. 614 */ 615 public TableSpec copyLink(boolean v) { 616 copyLink = v; 617 return this; 618 } 619 620 /** 621 * Sets whether the browser address bar is cleaned of the table's view-state parameters. 622 * 623 * @param v The new value. 624 * @return This object. 625 */ 626 public TableSpec cleanAddress(boolean v) { 627 cleanAddress = v; 628 return this; 629 } 630 631 /** 632 * Sets whether polling pauses while an editing surface (an open detail row or action dialog) is bound to this table. 633 * 634 * @param v The new value. 635 * @return This object. 636 */ 637 public TableSpec pausePollingWhileEditing(boolean v) { 638 pausePollingWhileEditing = v; 639 return this; 640 } 641 642 /** 643 * Sets the CSS class applied to this table's card. It is carried on the card placement, not in the card body 644 * returned by {@link #toCardBody()}. 645 * 646 * @param value The class list. Can be <jk>null</jk> to unset. 647 * @return This object. 648 */ 649 public TableSpec cssClass(String value) { 650 cssClass = value; 651 return this; 652 } 653 654 /** @return This table's id. */ 655 public String id() { 656 return id; 657 } 658 659 /** @return The columns, in display order. */ 660 public List<Column> columns() { 661 return Collections.unmodifiableList(columns); 662 } 663 664 /** @return The row actions, in menu order. */ 665 public List<RowAction> rowActions() { 666 return Collections.unmodifiableList(rowActions); 667 } 668 669 /** @return The card CSS class, or <jk>null</jk>. */ 670 public String cssClass() { 671 return cssClass; 672 } 673 674 /** @return The per-row detail panel, if set. */ 675 public Optional<RowDetail> detail() { 676 return Optional.ofNullable(detail); 677 } 678 679 /** 680 * Validates this table. A duplicate column and a rowClass op/value mismatch are checked eagerly by 681 * {@link #columns(Column...)} and {@link #rowClass(String,Op,Object,String) rowClass}, and an unknown export button 682 * by {@link RibbonItem#export(String...)}, so none of those can be seen here. 683 * 684 * @throws IllegalStateException If any check fails. 685 */ 686 public void validate() { 687 if (ib(dataUrl)) 688 throw isex("TableSpec '%s' requires dataUrl.", id); 689 if (columns.isEmpty()) 690 throw isex("TableSpec '%s' requires at least one column.", id); 691 var names = columns.stream().map(Column::name).toList(); 692 for (var o : defaultOrder) 693 if (! names.contains(o.get("key"))) 694 throw isex("TableSpec '%s' defaultOrder column '%s' is not a column; columns: '%s'.", 695 id, o.get("key"), String.join(", ", names)); 696 for (var item : ribbon) { 697 var m = item.toMap(); 698 if ("dialog".equals(m.get("type")) && (m.get("form") == null || m.get("endpoint") == null)) 699 throw isex("TableSpec '%s' ribbon dialog '%s' requires form and endpoint.", id, m.get("id")); 700 } 701 var actionIds = new HashSet<String>(); 702 for (var a : rowActions) 703 if (! actionIds.add(a.id)) 704 throw isex("TableSpec '%s' declares row action '%s' twice.", id, a.id); 705 if (bulk != null && selection == null) 706 throw isex("TableSpec '%s' sets bulk without selection.", id); 707 if (bulk != null && (bulk.actions == null || bulk.actions.isEmpty())) 708 throw isex("TableSpec '%s' sets bulk with no actions.", id); 709 for (var a : rowActions) 710 if (a.endpoint == null || a.endpoint.isBlank()) 711 throw isex("TableSpec '%s' row action '%s' requires an endpoint.", id, a.id); 712 if (bulk != null && bulk.actions != null) 713 for (var a : bulk.actions) 714 if (a.endpoint == null || a.endpoint.isBlank()) 715 throw isex("TableSpec '%s' bulk action '%s' requires an endpoint.", id, a.id); 716 for (var c : columns) 717 if (c.searchable() && dataMode == DataMode.SERVER && c.operators() == null && operators == null) 718 throw isex("TableSpec '%s' column '%s' sets searchType but dataMode is '%s' and the table " 719 + "declares no operators.", id, c.name(), dataMode.wire()); 720 } 721 722 /** 723 * Builds this table's catalog-form card body, with no {@code contractVersion} anywhere (the client stamps every 724 * contract version). This is the only place in this type that knows the datatables wire shape. The result is 725 * the unwrapped catalog the {@code datatables} card type reads, and is rendered the same single way 726 * {@code <@card>} is, by the page mixin's {@code cardTypes.toCard(CardSource.create("datatables", id()) 727 * .bodyMap(toCardBody()).build())}; this type never calls a card-type handler directly. 728 * 729 * <p> 730 * The body holds only plain data (maps, lists, strings, numbers, booleans), the same shape a parsed body has. 731 * 732 * <p> 733 * Each column carries its effective operator set (its own, else {@link #operators(SearchOperatorSet)}). 734 * 735 * @return The catalog, with keys in a fixed order and absent optional values omitted. 736 */ 737 public JsonMap toCardBody() { 738 var t = new JsonMap(); 739 t.put("dataUrl", dataUrl); 740 t.put("columns", plain(columns.stream().map(c -> c.toCatalogMap(operators)).toList())); 741 if (dataMode != null) 742 t.put("dataMode", dataMode.wire()); 743 if (! defaultOrder.isEmpty()) 744 t.put("defaultOrder", List.copyOf(defaultOrder)); 745 if (! ribbon.isEmpty()) 746 t.put("ribbon", ribbon.stream().map(RibbonItem::toMap).toList()); 747 if (rowType != null) 748 t.put("rowType", rowType); 749 if (pollIntervalMs != null) 750 t.put("pollIntervalMs", pollIntervalMs); 751 if (primary) 752 t.put("primary", true); 753 if (columnConfig != null) 754 t.put("columnConfig", columnConfig); 755 if (copyLink != null) 756 t.put("copyLink", copyLink); 757 if (cleanAddress != null) 758 t.put("cleanAddress", cleanAddress); 759 if (pausePollingWhileEditing != null) 760 t.put("pausePollingWhileEditing", pausePollingWhileEditing); 761 if (! urlState.isEmpty()) { 762 var u = new JsonMap(); 763 urlState.forEach((n, cfg) -> u.put(n, copyUrlStateConfig(cfg))); 764 t.put("urlState", u); 765 } 766 if (! rowClassRules.isEmpty()) 767 t.put("rowClassRules", List.copyOf(rowClassRules)); 768 if (! rowActions.isEmpty()) 769 t.put("rowActions", plain(rowActions)); 770 if (selection != null) { 771 var s = new JsonMap(); 772 s.put("rowIdField", selection.rowIdField()); 773 s.put("selectAll", selection.selectAll()); 774 s.put("scope", selection.scope().wire()); 775 if (selection.labelField() != null) 776 s.put("labelField", selection.labelField()); 777 if (selection.selectableWhen() != null) 778 s.put("selectableWhen", plain(selection.selectableWhen())); 779 t.put("selection", s); 780 } 781 if (bulk != null) { 782 var b = new JsonMap(); 783 b.put("actions", plain(bulk.actions)); 784 t.put("bulk", b); 785 } 786 if (detail != null) 787 t.put("detail", detail.toMap()); 788 if (quickStats != null) { 789 var q = new JsonMap(); 790 q.put("id", quickStatsId); 791 q.put("items", quickStats.stream().map(QuickStat::toMap).toList()); 792 t.put("quickStats", q); 793 } 794 return t; 795 } 796 797 /** Converts a bean (or list of beans) to the plain maps, lists and scalars a parsed JSON body would hold. */ 798 private static Object plain(Object bean) { 799 return Json.to(Json.of(bean), Object.class); 800 } 801}