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&lt;String,Object&gt; <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} &mdash; {@code param} (request parameter name, default {@code ids}) and {@code max} (an integer
465    *       &gt;= 1, default 200).
466    *    <li>{@code window} &mdash; {@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} &mdash; {@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}