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.commons.http.*;
024
025/**
026 * A count badge in the page contract, fed by a JSON endpoint and polled by {@code juneau-badges.js}.
027 *
028 * <p>
029 * The endpoint returns {@code {total, mine?, items?[]}}.  The badge is presentation only: the endpoint must
030 * still authorize the caller, and a page should not declare a badge for users who cannot read it.
031 *
032 * <h5 class='section'>Example:</h5>
033 * <p class='bjava'>
034 *    <jk>var</jk> <jv>badge</jv> = BadgeDef
035 *       .<jsm>create</jsm>(<js>"pending"</js>)
036 *       .src(<js>"/rest/changes/pending"</js>)
037 *       .scope(<js>"beanType"</js>, BadgeDef.ScopeBy.<jsf>NAV</jsf>, Map.<jsm>of</jsm>(<js>"suspensions"</js>, List.<jsm>of</jsm>(<js>"Suspension"</js>)))
038 *       .href(<js>"/ui/changes"</js>)
039 *       .stateFilter(<js>"beanType"</js>)
040 *       .refreshes(<js>"rules-table"</js>)
041 *       .refreshMs(60_000);
042 *    <jv>badge</jv>.validate();  <jc>// throws IllegalArgumentException with an E-6x message</jc>
043 * </p>
044 *
045 * @since 10.0.0
046 */
047@SuppressWarnings({
048   "java:S3776" // Validation and rendering chains many independent guards; splitting them adds no clarity.
049})
050public final class BadgeDef {
051
052   /** The minimum poll interval, in milliseconds ({@code refreshMs.minimum}). */
053   public static final long MIN_REFRESH_MS = 5000L;
054
055   /** Default poll interval, in milliseconds, applied by {@code juneau-badges.js} when {@link #refreshMs} is unset. */
056   public static final long DEFAULT_REFRESH_MS = 60_000L;
057
058   /** Default tooltip item cap, applied by {@code juneau-badges.js} when {@link #tooltipMax} is unset. */
059   public static final int DEFAULT_TOOLTIP_MAX = 10;
060
061   /** {@code scope.by} wire tokens. */
062   public enum ScopeBy {
063      /** Keys on the leaf of the page's {@code activeNav}. */
064      NAV("nav"),
065      /** Keys on the active tab of the card named by {@link BadgeDef#table}. */
066      VIEW("view");
067
068      private final String wire;
069      ScopeBy(String wire) { this.wire = wire; }
070      /** @return The lowercase wire token (e.g. <c>"nav"</c>). */
071      public String wire() { return wire; }
072   }
073
074   /** {@code placement} wire tokens. */
075   public enum Placement {
076      /** Renders into {@code .jc-header-badges} at the end of the header actions area. */
077      HEADER("header"),
078      /** Renders at the right end of {@link BadgeDef#table}'s ribbon. */
079      TOOLBAR("toolbar");
080
081      private final String wire;
082      Placement(String wire) { this.wire = wire; }
083      /** @return The lowercase wire token (e.g. <c>"header"</c>). */
084      public String wire() { return wire; }
085   }
086
087   /** {@code tone} wire tokens. */
088   public enum Tone {
089      /** Informational tone. */
090      INFO("info"),
091      /** Warning tone (the default). */
092      WARNING("warning"),
093      /** Danger tone. */
094      DANGER("danger");
095
096      private final String wire;
097      Tone(String wire) { this.wire = wire; }
098      /** @return The lowercase wire token (e.g. <c>"warning"</c>). */
099      public String wire() { return wire; }
100   }
101
102   private String id;
103   private String src;
104   private Map<String,Object> params;
105   private Long refreshMs;
106   private String scopeParam;
107   private ScopeBy scopeBy;
108   private Map<String,List<String>> scopeValues;
109   private Map<String,String> label;
110   private Integer tooltipMax;
111   private String href;
112   private String stateFilterColumn;
113   private List<String> refreshes;
114   private List<String> refreshesAllowMissing;
115   private Placement placement;
116   private String table;
117   private Tone tone;
118   private List<VisibilityRule> visibleWhen;
119
120   private BadgeDef() {}
121
122   /**
123    * Creates a badge with the given id.
124    *
125    * @param id This badge's own id, unique across every Java- and FTL-declared badge on the page (C3 E-B15).
126    *    Must not be <jk>null</jk> or blank.
127    * @return A new {@link BadgeDef}.
128    * @throws IllegalArgumentException If {@code id} is <jk>null</jk> or blank.
129    */
130   public static BadgeDef create(String id) {
131      if (ib(id))
132         throw iaex("BadgeDef id must not be null or blank.");
133      var b = new BadgeDef();
134      b.id = id;
135      return b;
136   }
137
138   /**
139    * Sets the same-origin endpoint this badge polls.
140    *
141    * @param value The endpoint path.  Checked by {@link #validate()}: present (E-61) and a same-origin path per
142    *    {@link SafePathTemplate#isSameOriginPath(String)}.
143    * @return This object.
144    */
145   public BadgeDef src(String value) {
146      src = value;
147      return this;
148   }
149
150   /**
151    * Sets extra request parameters merged into every poll (ahead of the scope params).
152    *
153    * @param value The parameters.
154    * @return This object.
155    */
156   public BadgeDef params(Map<String,Object> value) {
157      params = value;
158      return this;
159   }
160
161   /**
162    * Sets the poll interval.
163    *
164    * @param intervalMs The desired interval, in milliseconds.  Checked against {@link #MIN_REFRESH_MS} by
165    *    {@link #validate()} (E-62) &mdash; unlike {@link RegionDef#refreshMs(long)}, this is rejected, not clamped.
166    * @return This object.
167    */
168   public BadgeDef refreshMs(long intervalMs) {
169      refreshMs = intervalMs;
170      return this;
171   }
172
173   /**
174    * Sets this badge's scope: the request param to key the poll on, how to resolve its value, and
175    * (optionally) the authoritative value map.
176    *
177    * @param param The query parameter name sent with the poll.  Must not be <jk>null</jk> or blank.
178    * @param by {@link ScopeBy#NAV} or {@link ScopeBy#VIEW}.
179    * @param values The authoritative value map, keyed by nav-leaf or view-tab id, or <jk>null</jk> for an
180    *    unscoped badge that still carries a {@code by}.
181    * @return This object.
182    */
183   public BadgeDef scope(String param, ScopeBy by, Map<String,List<String>> values) {
184      scopeParam = param;
185      scopeBy = by;
186      scopeValues = values;
187      return this;
188   }
189
190   /**
191    * Sets this badge's label templates.
192    *
193    * @param value A map with any of the keys {@code one}, {@code other}, {@code mine}, each a {@code {total}}/
194    *    {@code {mine}} template.  Unset keys fall back to the stock defaults in {@code juneau-badges.js}.
195    * @return This object.
196    */
197   public BadgeDef label(Map<String,String> value) {
198      label = value;
199      return this;
200   }
201
202   /**
203    * Sets the tooltip popover's item cap.
204    *
205    * @param value The cap, 0-50.  Not range-checked here; schema `$defs/badge.tooltipMax` enforces it for
206    *    FTL-declared badges, and an out-of-range Java value is simply clamped by {@code juneau-badges.js} at
207    *    render time.
208    * @return This object.
209    */
210   public BadgeDef tooltipMax(int value) {
211      tooltipMax = value;
212      return this;
213   }
214
215   /**
216    * Sets the click-target URL.
217    *
218    * @param value The destination.  Must be a same-origin path per {@link SafePathTemplate#isSameOriginPath(String)};
219    *    {@link #validate()} rejects anything else, and {@code juneau-badges.js} refuses an unsafe {@code href} again at
220    *    run time (E-JS-82).
221    * @return This object.
222    */
223   public BadgeDef href(String value) {
224      href = value;
225      return this;
226   }
227
228   /**
229    * Sets the column {@code href}'s destination page filters on, via {@code JuneauViews.urlState.encode(...)}.
230    * Requires a non-empty {@link #scope} to have any effect.
231    *
232    * @param column The destination table's filter column name.
233    * @return This object.
234    */
235   public BadgeDef stateFilter(String column) {
236      stateFilterColumn = column;
237      return this;
238   }
239
240   /**
241    * Sets the card ids to {@code JuneauViews.tables.reload(el, {resetPaging: false})} when a previously-seen
242    * item id drains from the badge's response.
243    *
244    * <p>
245    * Every id here must name a card on the page being rendered, unless it is also listed in
246    * {@link #refreshesAllowMissing(String...)}.
247    *
248    * @param value The card ids, in reload order.
249    * @return This object.
250    */
251   public BadgeDef refreshes(String...value) {
252      refreshes = l(value);
253      return this;
254   }
255
256   /**
257    * Sets card ids, added to {@link #refreshes}, that are allowed to be absent from the page being rendered.
258    *
259    * <p>
260    * A page normally rejects a {@code refreshes} id that names no card on it, which catches typos at build time.  Use this
261    * for a header badge that refreshes a card living on another page: the client skips a card that is not mounted.  The
262    * ids named here are exempt from that check; every id passed to {@link #refreshes} stays strict.  The name follows the
263    * {@code refreshes} / {@code stateFilter} noun style of this builder: the opt-in is per id, not a page-wide switch.
264    *
265    * @param value The card ids that may live on another page.
266    * @return This object.
267    */
268   public BadgeDef refreshesAllowMissing(String...value) {
269      refreshesAllowMissing = l(value);
270      return this;
271   }
272
273   /**
274    * Sets where this badge renders.
275    *
276    * @param value {@link Placement#HEADER} (default) or {@link Placement#TOOLBAR}.
277    * @return This object.
278    */
279   public BadgeDef placement(Placement value) {
280      placement = value;
281      return this;
282   }
283
284   /**
285    * Sets the card id this badge is anchored to.  Required when {@link #placement} is {@link Placement#TOOLBAR}
286    * (E-66) and when {@link #scope}'s {@code by} is {@link ScopeBy#VIEW} (uncoded).
287    *
288    * @param value The card id.
289    * @return This object.
290    */
291   public BadgeDef table(String value) {
292      table = value;
293      return this;
294   }
295
296   /**
297    * Sets this badge's tone.
298    *
299    * @param value {@link Tone#INFO}, {@link Tone#WARNING} (default) or {@link Tone#DANGER}.
300    * @return This object.
301    */
302   public BadgeDef tone(Tone value) {
303      tone = value;
304      return this;
305   }
306
307   /**
308    * Sets the dynamic-visibility rules gating this badge; all must match (AND).
309    *
310    * @param value The rules, or none for an always-visible badge.
311    * @return This object.
312    */
313   public BadgeDef visibleWhen(VisibilityRule...value) {
314      visibleWhen = l(value);
315      return this;
316   }
317
318   /**
319    * Validates this definition.
320    *
321    * <p>
322    * Enforces E-61 (no {@link #src}), a same-origin {@link #src} and {@link #href}, E-62 ({@link #refreshMs} below {@link #MIN_REFRESH_MS}), E-66
323    * ({@link Placement#TOOLBAR} without {@link #table}), and the view-scope rule (uncoded: {@link ScopeBy#VIEW}
324    * without
325    * {@link #table}).  The "table/refreshes name a real card id" rule and the "scope.values key is a real nav
326    * node id" need the rest of the page and run in {@code PageCapture} at {@code </@console>} instead.
327    *
328    * @throws IllegalArgumentException If this definition is not well-formed.
329    */
330   public void validate() {
331      if (ib(src))
332         throw iaex("<@badge id='%s'> requires src=.", id);
333      if (! SafePathTemplate.isSameOriginPath(src))
334         throw iaex("<@badge id='%s'> src must be a same-origin path; got '%s'.", id, src);
335      if (href != null && ! SafePathTemplate.isSameOriginPath(href))
336         throw iaex("<@badge id='%s'> href must be a same-origin path; got '%s'.", id, href);
337      if (refreshMs != null && refreshMs < MIN_REFRESH_MS)
338         throw iaex("'%s' refreshMs='%s' is below the minimum '%s'.", id, refreshMs, MIN_REFRESH_MS);
339      if (placement == Placement.TOOLBAR && (ib(table)))
340         throw iaex("<@badge id='%s'> placement='toolbar' requires table=.", id);
341      if (scopeBy == ScopeBy.VIEW && (ib(table)))
342         throw iaex("BadgeDef '%s' scope.by='view' requires table=.", id);
343   }
344
345   /**
346    * Serializes this definition to the compact JSON the {@code header.badges[]} contract carries, hand-built
347    * (rather than reflected) so the key set and order match the schema exactly.
348    *
349    * @return An ordered {@link Map} ready for {@code Json.of(...)}.
350    */
351   public Map<String,Object> toContractMap() {
352      var m = new LinkedHashMap<String,Object>();
353      m.put("id", id);
354      if (src != null)
355         m.put("src", src);
356      if (params != null && !params.isEmpty())
357         m.put("params", params);
358      if (refreshMs != null)
359         m.put("refreshMs", refreshMs);
360      if (scopeParam != null) {
361         var scope = new LinkedHashMap<String,Object>();
362         scope.put("param", scopeParam);
363         scope.put("by", (scopeBy == null ? ScopeBy.NAV : scopeBy).wire());
364         if (scopeValues != null && !scopeValues.isEmpty())
365            scope.put("values", scopeValues);
366         m.put("scope", scope);
367      }
368      if (label != null && !label.isEmpty())
369         m.put("label", label);
370      if (tooltipMax != null)
371         m.put("tooltipMax", tooltipMax);
372      if (href != null)
373         m.put("href", href);
374      if (stateFilterColumn != null)
375         m.put("stateFilter", Map.of("column", stateFilterColumn));
376      if (refreshes != null && !refreshes.isEmpty() || refreshesAllowMissing != null && !refreshesAllowMissing.isEmpty()) {
377         var all = new LinkedHashSet<String>();
378         if (refreshes != null)
379            all.addAll(refreshes);
380         if (refreshesAllowMissing != null)
381            all.addAll(refreshesAllowMissing);
382         m.put("refreshes", new ArrayList<>(all));
383      }
384      if (refreshesAllowMissing != null && !refreshesAllowMissing.isEmpty())
385         m.put("refreshesAllowMissing", refreshesAllowMissing);
386      if (placement != null)
387         m.put("placement", placement.wire());
388      if (table != null)
389         m.put("table", table);
390      if (tone != null)
391         m.put("tone", tone.wire());
392      if (visibleWhen != null && !visibleWhen.isEmpty())
393         m.put("visibleWhen", VisibilityRule.toMaps(visibleWhen));
394      return m;
395   }
396}