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}