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) — 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}