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.function.*;
023import java.util.regex.*;
024
025import org.apache.juneau.console.*;
026
027/**
028 * One tab of a tabbed {@link RowDetail}: a fields grid, a nested table, a chart or a client-rendered pane.
029 *
030 * <p>
031 * The tab is loaded lazily: its content is fetched the first time the user opens it.  A tab id is the slug of its
032 * title unless {@link #id(String)} is given.
033 *
034 * <h2 class='section'>Example:</h2>
035 * <p class='bjava'>
036 *    RowDetail.<jsm>inline</jsm>()
037 *       .tab(DetailTab.<jsm>fields</jsm>(<js>"Details"</js>, <js>"id"</js>, <js>"name"</js>, <js>"price"</js>).columns(3))
038 *       .tab(DetailTab.<jsm>client</jsm>(<js>"Labels"</js>, <js>"pet-labels"</js>).endpoint(<js>"/pets/{id}/labels"</js>));
039 * </p>
040 *
041 * @since 10.0.0
042 */
043@SuppressWarnings({
044   "java:S1192", // Map keys read more clearly inline than as constants.
045   "java:S3776" // Branching is inherent to validate and toMap, which check many independent tab settings.
046})
047public final class DetailTab implements Handled<DetailTab> {
048
049   /** The tab kinds. */
050   public enum Kind {
051
052      /** A label/value grid of row fields. */
053      FIELDS("fields"),
054
055      /** A nested table scoped to the parent row. */
056      TABLE("table"),
057
058      /** A chart fed by an endpoint. */
059      CHART("chart"),
060
061      /** A pane drawn by a client-side renderer. */
062      CLIENT("client");
063
064      private final String wire;
065
066      Kind(String wire) {
067         this.wire = wire;
068      }
069
070      /**
071       * Returns the wire token.
072       *
073       * @return The lower-case token.
074       */
075      public String wire() {
076         return wire;
077      }
078   }
079
080   /** How a row detail caches the content of its tabs. */
081   public enum Cache {
082
083      /** A tab is fetched once per expanded row (the default). */
084      PER_ROW("per-row"),
085
086      /** A tab is fetched again each time it is activated. */
087      NONE("none");
088
089      private final String wire;
090
091      Cache(String wire) {
092         this.wire = wire;
093      }
094
095      /**
096       * Returns the wire token.
097       *
098       * @return The token.
099       */
100      public String wire() {
101         return wire;
102      }
103   }
104
105   private static final Pattern ID_GRAMMAR = Pattern.compile("[a-z][a-z0-9-]{0,31}");
106   private static final Pattern RENDERER_GRAMMAR = Pattern.compile("[A-Za-z][A-Za-z0-9._-]{0,63}");
107
108   private record Group(String title, List<String> fields) {}
109
110   private final Kind kind;
111   private final String title;
112   private String id;
113   private String icon;
114   private String endpoint;
115   private String emptyText;
116   private String scopeParam = "parentId";
117   private String renderer;
118   private int columns;
119   private boolean refreshOnPoll;
120   private boolean countFromRows;
121   private boolean autoFields;
122   private final List<String> fields = new ArrayList<>();
123   private final List<Group> groups = new ArrayList<>();
124   private TableSpec view;
125   private RegionDef region;
126   private Function<String,Integer> counter;
127   private List<VisibilityRule> visibleWhen;
128   private final ModelBlock model;
129
130   private DetailTab(Kind kind, String title) {
131      if (ib(title))
132         throw iaex("DetailTab title must not be null or blank.");
133      this.kind = kind;
134      this.title = title;
135      this.model = new ModelBlock("detailtab", title);
136   }
137
138   @Override /* Handled */
139   public ModelBlock modelBlock() { return model; }
140
141   @Override /* Handled */
142   public DetailTab self() { return this; }
143
144   /**
145    * Starts a fields-grid tab.
146    *
147    * @param title The tab title.  Must not be blank.
148    * @param fields The row field names shown, in order.  Unique within the tab.
149    * @return A new tab.
150    */
151   public static DetailTab fields(String title, String...fields) {
152      var t = new DetailTab(Kind.FIELDS, title);
153      t.fields.addAll(Arrays.asList(fields));
154      return t;
155   }
156
157   /** Starts the generated Details tab whose field list the owning table fills in. */
158   static DetailTab autoFields(String title, List<String> fields) {
159      var t = new DetailTab(Kind.FIELDS, title);
160      t.autoFields = true;
161      t.fields.addAll(fields);
162      return t;
163   }
164
165   /**
166    * Starts a nested-table tab.
167    *
168    * @param title The tab title.  Must not be blank.
169    * @param view The nested table; it must declare a rows data URL.
170    * @return A new tab.
171    */
172   public static DetailTab table(String title, TableSpec view) {
173      var t = new DetailTab(Kind.TABLE, title);
174      t.view = view;
175      return t;
176   }
177
178   /**
179    * Starts a chart tab.
180    *
181    * @param title The tab title.  Must not be blank.
182    * @param region The chart region.
183    * @return A new tab.
184    */
185   public static DetailTab chart(String title, RegionDef region) {
186      var t = new DetailTab(Kind.CHART, title);
187      t.region = region;
188      return t;
189   }
190
191   /**
192    * Starts a client-rendered tab.
193    *
194    * @param title The tab title.  Must not be blank.
195    * @param renderer The name of a renderer registered in the browser; letters, digits, {@code . _ -}, at most 64.
196    * @return A new tab.
197    */
198   public static DetailTab client(String title, String renderer) {
199      var t = new DetailTab(Kind.CLIENT, title);
200      t.renderer = renderer;
201      return t;
202   }
203
204   /**
205    * Sets the tab id (default: the slug of the title).
206    *
207    * @param value The id; lower-case letter first, then lower-case letters, digits and {@code -}, at most 32.
208    * @return This object.
209    */
210   public DetailTab id(String value) {
211      id = value;
212      return this;
213   }
214
215   /**
216    * Sets the tab icon.
217    *
218    * @param value An icon name.
219    * @return This object.
220    */
221   public DetailTab icon(String value) {
222      icon = value;
223      return this;
224   }
225
226   /**
227    * Sets the per-tab GET endpoint.
228    *
229    * @param value A same-origin path template; {@code {id}} is replaced by the row id.
230    * @return This object.
231    */
232   public DetailTab endpoint(String value) {
233      endpoint = value;
234      return this;
235   }
236
237   /**
238    * Sets the text shown when the tab has nothing to show.
239    *
240    * @param value The text.
241    * @return This object.
242    */
243   public DetailTab emptyText(String value) {
244      emptyText = value;
245      return this;
246   }
247
248   /**
249    * Sets the grid column count of a fields tab.
250    *
251    * @param n The count, 1 to 4; 0 inherits the detail's count.
252    * @return This object.
253    */
254   public DetailTab columns(int n) {
255      columns = n;
256      return this;
257   }
258
259   /**
260    * Groups some of a fields tab's fields under a heading.  Fields in no group follow, under no heading.
261    *
262    * @param groupTitle The heading.
263    * @param groupFields The fields; each must be in the tab's field list and in no other group.
264    * @return This object.
265    */
266   public DetailTab group(String groupTitle, String...groupFields) {
267      groups.add(new Group(groupTitle, List.of(groupFields)));
268      return this;
269   }
270
271   /**
272    * Sets the query parameter carrying the parent row id to a nested table.
273    *
274    * @param value The parameter name (default {@code parentId}).
275    * @return This object.
276    */
277   public DetailTab scopeParam(String value) {
278      scopeParam = value;
279      return this;
280   }
281
282   /**
283    * Shows the nested table's row count in the tab label once the tab has loaded.  Table tabs only.
284    *
285    * @return This object.
286    */
287   public DetailTab countFromRows() {
288      countFromRows = true;
289      return this;
290   }
291
292   /**
293    * Sets a server-side counter shown in the tab label.  The value travels in the detail's {@code counts}.
294    *
295    * @param f Maps a row id to a count.
296    * @return This object.
297    */
298   public DetailTab count(Function<String,Integer> f) {
299      counter = f;
300      return this;
301   }
302
303   /**
304    * Sets whether the tab reloads when the table polls.
305    *
306    * @param value The flag (default <jk>false</jk>).
307    * @return This object.
308    */
309   public DetailTab refreshOnPoll(boolean value) {
310      refreshOnPoll = value;
311      return this;
312   }
313
314   /**
315    * Hides the tab unless every rule matches.  Presentation only; it never authorises.
316    *
317    * @param rules The rules.
318    * @return This object.
319    */
320   public DetailTab visibleWhen(VisibilityRule...rules) {
321      visibleWhen = l(rules);
322      return this;
323   }
324
325   String resolvedId() {
326      return id != null ? id : slug(title);
327   }
328
329   static String slug(String t) {
330      var s = t.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "-");
331      var b = 0;
332      var e = s.length();
333      while (b < e && s.charAt(b) == '-')
334         b++;
335      while (e > b && s.charAt(e - 1) == '-')
336         e--;
337      return s.substring(b, e);
338   }
339
340   Kind kind() {
341      return kind;
342   }
343
344   boolean hasCounter() {
345      return counter != null;
346   }
347
348   Integer count(String rowId) {
349      return counter == null ? null : counter.apply(rowId);
350   }
351
352   List<String> fieldNames() {
353      return List.copyOf(fields);
354   }
355
356   private void validate() {
357      var rid = resolvedId();
358      if (! ID_GRAMMAR.matcher(rid).matches())
359         throw iaex("DetailTab '%s' id '%s' must match %s.", title, rid, ID_GRAMMAR.pattern());
360      if (endpoint != null && ! RegionDef.isSafeDetailEndpoint(endpoint))
361         throw iaex("DetailTab '%s' endpoint '%s' is not a same-origin path.", rid, endpoint);
362      if (countFromRows && kind != Kind.TABLE)
363         throw iaex("DetailTab '%s' countFromRows applies to table tabs only.", rid);
364      switch (kind) {
365         case FIELDS -> validateFields(rid);
366         case TABLE -> {
367            if (view == null)
368               throw iaex("DetailTab '%s' needs a nested table.", rid);
369            if (ib(scopeParam))
370               throw iaex("DetailTab '%s' scopeParam must not be blank.", rid);
371         }
372         case CHART -> {
373            if (region == null)
374               throw iaex("DetailTab '%s' needs a chart region.", rid);
375         }
376         case CLIENT -> {
377            if (renderer == null || ! RENDERER_GRAMMAR.matcher(renderer).matches())
378               throw iaex("DetailTab '%s' renderer '%s' must match %s.", rid, renderer, RENDERER_GRAMMAR.pattern());
379         }
380      }
381   }
382
383   private void validateFields(String rid) {
384      if (columns != 0 && (columns < 1 || columns > 4))
385         throw iaex("DetailTab '%s' columns must be 1 to 4, was %s.", rid, columns);
386      if (fields.isEmpty() && ! autoFields)
387         throw iaex("DetailTab '%s' needs at least one field.", rid);
388      var seen = new HashSet<String>();
389      for (var f : fields)
390         if (ib(f) || ! seen.add(f))
391            throw iaex("DetailTab '%s' field '%s' is blank or repeated.", rid, f);
392      var grouped = new HashSet<String>();
393      for (var g : groups)
394         for (var f : g.fields()) {
395            if (! seen.contains(f))
396               throw iaex("DetailTab '%s' group '%s' names field '%s' which is not in the tab.", rid, g.title(), f);
397            if (! grouped.add(f))
398               throw iaex("DetailTab '%s' field '%s' is in more than one group.", rid, f);
399         }
400   }
401
402   /**
403    * Serializes this tab to its wire shape.
404    *
405    * @return The tab map.
406    * @throws IllegalArgumentException If the tab is invalid.
407    */
408   public Map<String,Object> toMap() {
409      validate();
410      var m = new LinkedHashMap<String,Object>();
411      m.put("id", resolvedId());
412      m.put("kind", kind.wire());
413      m.put("title", title);
414      if (icon != null)
415         m.put("icon", icon);
416      if (kind == Kind.FIELDS) {
417         if (columns != 0)
418            m.put("columns", columns);
419         m.put("fields", List.copyOf(fields));
420         if (! groups.isEmpty()) {
421            var gl = new ArrayList<Map<String,Object>>();
422            for (var g : groups) {
423               var gm = new LinkedHashMap<String,Object>();
424               gm.put("title", g.title());
425               gm.put("fields", g.fields());
426               gl.add(gm);
427            }
428            m.put("groups", gl);
429         }
430      }
431      if (kind == Kind.TABLE)
432         m.put("nested", NestedSlot.of(view, scopeParam));
433      if (countFromRows)
434         m.put("count", "rows");
435      if (emptyText != null)
436         m.put("emptyText", emptyText);
437      if (endpoint != null)
438         m.put("endpoint", endpoint);
439      if (renderer != null && kind == Kind.CLIENT)
440         m.put("renderer", renderer);
441      if (refreshOnPoll)
442         m.put("refreshOnPoll", true);
443      if (kind == Kind.CHART) {
444         var r = new LinkedHashMap<>(region.toContractMap());
445         r.remove("contractVersion");
446         m.put("region", r);
447      }
448      if (visibleWhen != null && ! visibleWhen.isEmpty())
449         m.put("visibleWhen", VisibilityRule.toMaps(visibleWhen));
450      model.validate();
451      var mm = model.toMap();
452      if (mm != null)
453         m.put("model", mm);
454      return m;
455   }
456}