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}