001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.console.views;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.util.*;
022
023import org.apache.juneau.marshall.collections.*;
024import org.apache.juneau.console.widgets.*;
025
026/**
027 * One item in a table's quick-stats strip ({@code quickStats.items[]}): a plain tile, a meter (one value against a
028 * maximum) or a segmented tile (a proportional bar of labelled counts).
029 *
030 * <h5 class='section'>Example:</h5>
031 * <p class='bjava'>
032 *    <jv>table</jv>.quickStats(<js>"work-unavailable"</js>,
033 *       QuickStat.<jsm>of</jsm>(<js>"unavailable"</js>, <js>"Work data"</js>, <js>"unavailable"</js>).tone(QuickStat.Tone.<jsf>ERROR</jsf>),
034 *       QuickStat.<jsm>meter</jsm>(<js>"inventory"</js>, <js>"Inventory"</js>, 12, 40).text(<js>"12 of 40"</js>).statusTone(StatusTone.<jsf>SUCCESS</jsf>),
035 *       QuickStat.<jsm>segmented</jsm>(<js>"orders"</js>, <js>"Orders"</js>).segment(<js>"Placed"</js>, 3).segment(<js>"Delivered"</js>, 9, StatusTone.<jsf>SUCCESS</jsf>));
036 * </p>
037 *
038 * @since 10.0.0
039 */
040@SuppressWarnings({
041   "java:S1192" // Repeated literals are contract wire keys; named constants would obscure the contract shape.
042})
043public final class QuickStat {
044
045   /** The tile's visual tone. */
046   public enum Tone {
047
048      /** No particular emphasis. */
049      NEUTRAL,
050
051      /** A positive/healthy value. */
052      SUCCESS,
053
054      /** An informational value. */
055      INFO,
056
057      /** A value that deserves attention. */
058      WARNING,
059
060      /** A value that signals a problem. */
061      ERROR;
062
063      String wire() {
064         return name().toLowerCase(Locale.ROOT);
065      }
066   }
067
068   /** The most segments one segmented item may carry (one per chart palette colour). */
069   public static final int MAX_SEGMENTS = 8;
070
071   private enum Kind { TILE, METER, SEGMENTED }
072
073   private record Segment(String label, long count, String tone) {}
074
075   private final String id;
076   private final String label;
077   private final Object value;
078   private final Kind kind;
079   private Number max;
080   private String text;
081   private String tone;  // wire token, or null when unset
082   private final List<Segment> segments = new ArrayList<>();
083
084   private QuickStat(String id, String label, Object value, Kind kind) {
085      this.id = id;
086      this.label = label;
087      this.value = value;
088      this.kind = kind;
089   }
090
091   private static String checkId(String id) {
092      if (ib(id))
093         throw iaex("QuickStat id must not be null or blank.");
094      return id;
095   }
096
097   private IllegalArgumentException fail(String reason) {
098      return iaex("QuickStat '%s': %s", id, reason);
099   }
100
101   private void requireKind(Kind k, String method) {
102      if (kind != k)
103         throw fail(method + "(...) is only valid on a " + k.name().toLowerCase(Locale.ROOT) + " item.");
104   }
105
106   /**
107    * Creates a quick-stat tile.
108    *
109    * @param id This tile's own id, unique within its table.  Must not be <jk>null</jk> or blank.
110    * @param label The tile's label text.
111    * @param value The tile's value, serialized as-is.
112    * @return A new {@link QuickStat}.
113    * @throws IllegalArgumentException If {@code id} is <jk>null</jk> or blank.
114    */
115   public static QuickStat of(String id, String label, Object value) {
116      return new QuickStat(checkId(id), label, value, Kind.TILE);
117   }
118
119   /**
120    * Creates a meter item: one value drawn against a maximum.
121    *
122    * <p>
123    * A value above the maximum is allowed; it paints a full bar with an over-limit cue.
124    *
125    * @param id This item's own id, unique within its table.  Must not be <jk>null</jk> or blank.
126    * @param label The item's label text; it names the meter for assistive technology.  Must not be <jk>null</jk> or blank.
127    * @param value The current value.  Must be finite and {@code >= 0}.
128    * @param max The maximum.  Must be finite and {@code > 0}.
129    * @return A new {@link QuickStat}.
130    * @throws IllegalArgumentException If {@code id} or {@code label} is blank, or {@code value} or {@code max} is <jk>null</jk> or out of range.
131    */
132   public static QuickStat meter(String id, String label, Number value, Number max) {
133      var q = new QuickStat(checkId(id), label, value, Kind.METER);
134      q.checkLabel();
135      if (value == null || max == null)
136         throw q.fail("meter value and max must not be null.");
137      var v = value.doubleValue();
138      var mx = max.doubleValue();
139      if (! Double.isFinite(v) || v < 0)
140         throw q.fail("meter value must be a finite number >= 0.");
141      if (! Double.isFinite(mx) || mx <= 0)
142         throw q.fail("meter max must be a finite number > 0.");
143      q.max = max;
144      return q;
145   }
146
147   /**
148    * Creates a segmented item: a proportional bar plus a legend of labelled counts.
149    *
150    * <p>
151    * Add the segments with {@link #segment(String, long)}; at least one is required by the time the item is built.
152    *
153    * @param id This item's own id, unique within its table.  Must not be <jk>null</jk> or blank.
154    * @param label The item's label text; it names the group for assistive technology.  Must not be <jk>null</jk> or blank.
155    * @return A new {@link QuickStat}.
156    * @throws IllegalArgumentException If {@code id} or {@code label} is <jk>null</jk> or blank.
157    */
158   public static QuickStat segmented(String id, String label) {
159      var q = new QuickStat(checkId(id), label, null, Kind.SEGMENTED);
160      q.checkLabel();
161      return q;
162   }
163
164   private void checkLabel() {
165      if (ib(label))
166         throw fail("label must not be null or blank.");
167   }
168
169   /**
170    * Adds an untoned segment to a segmented item.
171    *
172    * @param label The segment label.  Must not be <jk>null</jk> or blank.
173    * @param count The segment count.  Must be {@code >= 0}.
174    * @return This object.
175    * @throws IllegalArgumentException If this is not a segmented item, the label is blank, the count is negative,
176    *    or the item already has {@link #MAX_SEGMENTS} segments.
177    */
178   public QuickStat segment(String label, long count) {
179      return segment(label, count, null);
180   }
181
182   /**
183    * Adds a segment to a segmented item.
184    *
185    * @param label The segment label.  Must not be <jk>null</jk> or blank.
186    * @param count The segment count.  Must be {@code >= 0}.
187    * @param tone The segment tone.  Can be <jk>null</jk> for an untoned segment.
188    * @return This object.
189    * @throws IllegalArgumentException If this is not a segmented item, the label is blank, the count is negative,
190    *    or the item already has {@link #MAX_SEGMENTS} segments.
191    */
192   public QuickStat segment(String label, long count, StatusTone tone) {
193      requireKind(Kind.SEGMENTED, "segment");
194      if (ib(label))
195         throw fail("segment label must not be null or blank.");
196      if (count < 0)
197         throw fail("segment count must be >= 0.");
198      if (segments.size() == MAX_SEGMENTS)
199         throw fail("a segmented item takes at most " + MAX_SEGMENTS + " segments.");
200      segments.add(new Segment(label, count, tone == null ? null : tone.wire()));
201      return this;
202   }
203
204   /**
205    * Sets a meter's display text, which replaces the default "value / max".
206    *
207    * @param text The text.  Must not be <jk>null</jk>; blank means the default text is used.
208    * @return This object.
209    * @throws IllegalArgumentException If this is not a meter item, or {@code text} is <jk>null</jk>.
210    */
211   public QuickStat text(String text) {
212      requireKind(Kind.METER, "text");
213      if (text == null)
214         throw fail("text must not be null.");
215      this.text = text;
216      return this;
217   }
218
219   /**
220    * Sets the tone of a tile or meter.
221    *
222    * <p>
223    * Writes the same tone as {@link #tone(Tone)}; the last call wins.
224    *
225    * @param t The tone.  Can be <jk>null</jk> to unset.
226    * @return This object.
227    * @throws IllegalArgumentException If this is a segmented item (tone each segment instead).
228    */
229   public QuickStat statusTone(StatusTone t) {
230      if (kind == Kind.SEGMENTED)
231         throw fail("statusTone(...) is not valid on a segmented item; tone each segment instead.");
232      tone = t == null ? null : t.wire();
233      return this;
234   }
235
236   /**
237    * Sets this tile's visual tone.
238    *
239    * @param t The tone.  Can be <jk>null</jk> to unset.
240    * @return This object.
241    * @throws IllegalArgumentException If this is a segmented item (tone each segment instead).
242    */
243   public QuickStat tone(Tone t) {
244      if (kind == Kind.SEGMENTED)
245         throw fail("tone(...) is not valid on a segmented item; tone each segment instead.");
246      tone = t == null ? null : t.wire();
247      return this;
248   }
249
250   /**
251    * Builds this item's {@code quickStats.items[]} entry.
252    *
253    * @return A tile {@code {id, label, value, tone?}} (no {@code type}); a meter
254    *    {@code {id, type, label, value, max, text?, tone?}}; or a segmented item
255    *    {@code {id, type, label, segments[{label, count, tone?}]}}.
256    * @throws IllegalArgumentException If this is a segmented item with no segments.
257    */
258   public JsonMap toMap() {
259      var m = new JsonMap();
260      m.put("id", id);
261      if (kind == Kind.TILE) {
262         m.put("label", label);
263         m.put("value", value);
264         if (tone != null)
265            m.put("tone", tone);
266         return m;
267      }
268      m.put("type", kind == Kind.METER ? "meter" : "segmented");
269      m.put("label", label);
270      if (kind == Kind.METER) {
271         m.put("value", value);
272         m.put("max", max);
273         if (inb(text))
274            m.put("text", text);
275         if (tone != null)
276            m.put("tone", tone);
277         return m;
278      }
279      if (segments.isEmpty())
280         throw fail("a segmented item needs at least one segment.");
281      var list = new ArrayList<JsonMap>(segments.size());
282      for (var s : segments) {
283         var sm = new JsonMap();
284         sm.put("label", s.label());
285         sm.put("count", s.count());
286         if (s.tone() != null)
287            sm.put("tone", s.tone());
288         list.add(sm);
289      }
290      m.put("segments", list);
291      return m;
292   }
293}