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.freemarker; 018 019import java.util.*; 020 021import org.apache.juneau.console.*; 022 023/** 024 * A dashboard: a rail of filter sections beside a grid of card panels, with a status header and optional persistence. 025 * 026 * <p> 027 * Added to a builder page with {@link PageSpec#dashboard(DashboardSpec)}, which adds the panel and card-section cards 028 * to the page and the {@code dashboard} toolkit pack. A page has at most one dashboard. Every structural problem is 029 * E-77 when the page is built. 030 * </p> 031 * 032 * <h5 class='section'>Example:</h5> 033 * <p class='bjava'> 034 * <jv>page</jv>.dashboard(DashboardSpec.<jsm>create</jsm>(<js>"orders"</js>).title(<js>"Orders"</js>) 035 * .rail(Rail.<jsm>left</jsm>().sections( 036 * RangeSection.<jsm>quick</jsm>(<js>"7d"</js>, <js>"30d"</js>).publishes(Topics.<jsm>filter</jsm>(<js>"range"</js>)))) 037 * .grid(2) 038 * .panel(<jv>daily</jv>).panel(<jv>byReason</jv>).panel(<js>"notes"</js>, 2) 039 * .status(Topics.<jsm>data</jsm>(<js>"orders"</js>)) 040 * .persist(PersistSpec.<jsm>page</jsm>(<js>"app.orders"</js>).topics(Topics.<jsm>filter</jsm>(<js>"range"</js>)).shareLink())); 041 * </p> 042 * 043 * @since 10.0.0 044 */ 045@SuppressWarnings({ 046 "java:S3776" // Validation and rendering chains many independent guards; splitting them adds no clarity. 047}) 048public final class DashboardSpec { 049 050 /** A panel: a card this dashboard adds, or the id of a card already on the page. */ 051 private record Panel(CardSpec card, String cardId, int span) { 052 String id() { 053 return card != null ? card.id() : cardId; 054 } 055 } 056 057 private final String id; 058 private String title; 059 private Rail rail; 060 private int grid = 2; 061 private final List<Panel> panels = new ArrayList<>(); 062 private final List<String> status = new ArrayList<>(); 063 private PersistSpec persist; 064 065 private DashboardSpec(String id) { 066 this.id = id; 067 } 068 069 /** 070 * Creates a dashboard. 071 * 072 * @param id The dashboard id: the {@code viewId} of every section payload, and the default storage namespace 073 * {@code dashboard.<id>} for the rail width. 074 * @return A new dashboard. 075 */ 076 public static DashboardSpec create(String id) { 077 return new DashboardSpec(id); 078 } 079 080 /** 081 * Sets the heading. 082 * 083 * @param value The title. 084 * @return This object. 085 */ 086 public DashboardSpec title(String value) { 087 title = value; 088 return this; 089 } 090 091 /** 092 * Sets the rail. 093 * 094 * @param value The rail. 095 * @return This object. 096 */ 097 public DashboardSpec rail(Rail value) { 098 rail = value; 099 return this; 100 } 101 102 /** 103 * Sets the number of grid columns. 104 * 105 * @param value 1 to 4. Defaults to 2. 106 * @return This object. 107 */ 108 public DashboardSpec grid(int value) { 109 grid = value; 110 return this; 111 } 112 113 /** 114 * Appends a chart panel spanning one column. 115 * 116 * <p> 117 * The chart is snapshotted when it is added; later changes to the {@link ChartDef} are not reflected. 118 * </p> 119 * 120 * @param chart The chart. 121 * @return This object. 122 */ 123 public DashboardSpec panel(ChartDef chart) { 124 return panel(chart, 1); 125 } 126 127 /** 128 * Appends a chart panel. 129 * 130 * <p> 131 * The chart is snapshotted when it is added; later changes to the {@link ChartDef} are not reflected. 132 * </p> 133 * 134 * @param chart The chart. 135 * @param span The columns it spans, 1 to {@link #grid(int)}. 136 * @return This object. 137 */ 138 public DashboardSpec panel(ChartDef chart, int span) { 139 panels.add(new Panel(Objects.requireNonNull(chart, "chart must not be null").toCardSpec(), null, span)); 140 return this; 141 } 142 143 /** 144 * Appends a card panel spanning one column. 145 * 146 * @param card The card. Not {@code html} or {@code datatables}; use {@link #panel(String)} for those. 147 * @return This object. 148 */ 149 public DashboardSpec panel(CardSpec card) { 150 return panel(card, 1); 151 } 152 153 /** 154 * Appends a card panel. 155 * 156 * @param card The card. Not {@code html} or {@code datatables}; use {@link #panel(String, int)} for those. 157 * @param span The columns it spans, 1 to {@link #grid(int)}. 158 * @return This object. 159 */ 160 public DashboardSpec panel(CardSpec card, int span) { 161 panels.add(new Panel(card, null, span)); 162 return this; 163 } 164 165 /** 166 * Appends a panel for a card already added to the page (for example with {@code PageSpec.html(...)}), spanning one 167 * column. 168 * 169 * @param cardId The card id. The card must be on the page before {@link PageSpec#dashboard(DashboardSpec)}. 170 * @return This object. 171 */ 172 public DashboardSpec panel(String cardId) { 173 return panel(cardId, 1); 174 } 175 176 /** 177 * Appends a panel for a card already added to the page. 178 * 179 * @param cardId The card id. See {@link #panel(String)}. 180 * @param span The columns it spans, 1 to {@link #grid(int)}. 181 * @return This object. 182 */ 183 public DashboardSpec panel(String cardId, int span) { 184 panels.add(new Panel(null, cardId, span)); 185 return this; 186 } 187 188 /** 189 * Binds the status header to data sources. 190 * 191 * @param topics {@code data:<sourceId>} topics, normally {@code Topics.data(id)}. Each must be a declared source. 192 * @return This object. 193 */ 194 public DashboardSpec status(String...topics) { 195 status.addAll(List.of(topics)); 196 return this; 197 } 198 199 /** 200 * Saves and restores state topics. 201 * 202 * @param value The persistence spec. 203 * @return This object. 204 */ 205 public DashboardSpec persist(PersistSpec value) { 206 persist = value; 207 return this; 208 } 209 210 /** @return The dashboard id. */ 211 String id() { 212 return id; 213 } 214 215 /** @return The cards this dashboard adds to the page: the built panels, then the card sections, in order. */ 216 List<CardSpec> ownedCards() { 217 var out = new ArrayList<CardSpec>(panels.stream().map(Panel::card).filter(Objects::nonNull).toList()); 218 if (rail != null) 219 for (var s : rail.sections()) 220 out.addAll(s.cards()); 221 return out; 222 } 223 224 /** 225 * Checks this dashboard against no page cards and returns its JSON. {@code PageSpec.dashboard} calls 226 * {@code toJson(Map)} instead, so by-id panels resolve. 227 * 228 * @return The {@code dashboard} contract object. 229 * @throws IllegalArgumentException E-77 on the first problem. 230 */ 231 public Map<String,Object> toJson() { 232 return toJson(Map.of()); 233 } 234 235 /** 236 * Checks this dashboard and returns its JSON. 237 * 238 * @param pageCards The cards already on the page, by id. 239 * @return The {@code dashboard} contract object. 240 * @throws IllegalArgumentException E-77 on the first problem. 241 */ 242 Map<String,Object> toJson(Map<String,CardSpec> pageCards) { 243 if (id == null || ! Topics.ID.matcher(id).matches()) 244 throw Section.e77(id, "dashboard id '" + id + "' is not a valid id"); 245 if (grid < 1 || grid > 4) 246 throw Section.e77(id, "grid " + grid + " is outside 1-4"); 247 248 var members = new LinkedHashMap<String,CardSpec>(); 249 var panelList = new ArrayList<Map<String,Object>>(); 250 for (var p : panels) { 251 CardSpec card; 252 if (p.card() != null) { 253 card = p.card(); 254 Section.checkOwnedCard(id, card); 255 } else { 256 card = p.cardId() == null ? null : pageCards.get(p.cardId()); 257 if (card == null) 258 throw Section.e77(id, "panel '" + p.cardId() + "' names no card on this page"); 259 Section.checkMemberCard(id, card); 260 } 261 if (p.span() < 1 || p.span() > grid) 262 throw Section.e77(id, "panel '" + p.id() + "' span " + p.span() + " is outside 1-" + grid); 263 if (members.put(card.id(), card) != null) 264 throw Section.e77(id, "card '" + card.id() + "' is in the dashboard twice"); 265 var m = new LinkedHashMap<String,Object>(); 266 m.put("card", card.id()); 267 m.put("span", p.span()); 268 panelList.add(m); 269 } 270 271 var sectionIds = new LinkedHashSet<String>(); 272 if (rail != null) { 273 rail.check(id); 274 for (var s : rail.sections()) { 275 for (var c : s.cards()) 276 if (members.put(c.id(), c) != null) 277 throw Section.e77(id, "card '" + c.id() + "' is in the dashboard twice"); 278 if (s.id() != null && ! sectionIds.add(s.id())) 279 throw Section.e77(id, "section id '" + s.id() + "' is used twice"); 280 } 281 for (var sid : sectionIds) 282 if (members.containsKey(sid) || pageCards.containsKey(sid)) 283 throw Section.e77(id, "section id '" + sid + "' duplicates card '" + sid + "'"); 284 } 285 286 var statusTopics = new HashSet<String>(); 287 for (var t : status) { 288 if (t == null || ! t.startsWith("data:") || ! Topics.isKey(t.substring(5))) 289 throw Section.e77(id, "status topic '" + t + "' is not a data: topic"); 290 if (! statusTopics.add(t)) 291 throw Section.e77(id, "status lists topic '" + t + "' twice"); 292 } 293 294 if (persist != null) { 295 persist.check(id); 296 for (var t : persist.topics()) { 297 var key = t.substring(t.indexOf(':') + 1); 298 if (t.startsWith("filter:")) { 299 if (! sectionIds.contains(key)) 300 throw Section.e77(id, "persist topic '" + t + "' names no section of this dashboard"); 301 } else if (! publishes(members.get(key), t)) { 302 throw Section.e77(id, "persist topic '" + t + "' is not published by a card of this dashboard"); 303 } 304 } 305 } 306 307 var m = new LinkedHashMap<String,Object>(); 308 m.put("id", id); 309 if (title != null) 310 m.put("title", title); 311 m.put("grid", grid); 312 m.put("panels", panelList); 313 if (rail != null) 314 m.put("rail", rail.toMap()); 315 if (! status.isEmpty()) 316 m.put("status", List.copyOf(status)); 317 if (persist != null) 318 m.put("persist", persist.toMap()); 319 return m; 320 } 321 322 /** @return <jk>true</jk> if {@code card} lists {@code topic} in its {@code publishes}. */ 323 private static boolean publishes(CardSpec card, String topic) { 324 if (card == null || ! (card.toMap().get("publishes") instanceof List<?> list)) 325 return false; 326 for (var o : list) 327 if (o instanceof Map<?,?> p && topic.equals(p.get("topic"))) 328 return true; 329 return false; 330 } 331}