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}