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 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 * Builder for a {@code switcher} card: one card slot that shows one of several named views, each holding one card or a
029 * strip of sections that each hold a card.
030 *
031 * <p>
032 * A view with a single section that has the default id and no model block serializes as a plain {@code card}; any
033 * other view serializes as {@code sections}.  A switcher cannot hold another switcher.  Validation runs in
034 * {@link #toCardSpec()} and is shared with {@link SwitcherCardType} through {@link #validate(String, Map)}, so both
035 * paths report the same messages.
036 *
037 * <h2 class='section'>Example:</h2>
038 * <p class='bjava'>
039 *    CardSpec <jv>c</jv> = SwitcherDef.<jsm>create</jsm>(<js>"revenue-sw"</js>).title(<js>"Revenue"</js>)
040 *       .view(<js>"chart"</js>, <js>"Chart"</js>, <jv>v</jv> -&gt; <jv>v</jv>.section(ChartDef.<jsm>line</jsm>(<js>"revenue-chart"</js>).src(<js>"/console/store/stats/revenue"</js>).toCardSpec()))
041 *       .view(<js>"data"</js>, <js>"Data"</js>, <jv>v</jv> -&gt; <jv>v</jv>.section(<js>"all"</js>, <js>"All"</js>, CardSpec.<jsm>of</jsm>(<js>"html"</js>, <js>"d1"</js>)).section(<js>"open"</js>, <js>"Open"</js>, CardSpec.<jsm>of</jsm>(<js>"html"</js>, <js>"d2"</js>)))
042 *       .initialView(<js>"chart"</js>)
043 *       .toCardSpec();
044 * </p>
045 *
046 * @since 10.0.0
047 */
048@SuppressWarnings({
049   "java:S1192" // Map keys read more clearly inline than as constants.
050})
051public final class SwitcherDef {
052
053   /** The grammar of a view or section id. */
054   static final Pattern ID_GRAMMAR = Pattern.compile("^[A-Za-z][A-Za-z0-9_-]{0,63}$");
055
056   /** The default switcher style: a folder-tab strip joined to the display. */
057   public static final String STYLE_TABS = "tabs";
058
059   /** The segmented-control style: a pill track with a sliding thumb. */
060   public static final String STYLE_SLIDER = "slider";
061
062   private static final String DEFAULT_SECTION = "main";
063
064   private final String id;
065   private String title;
066   private String initialView;
067   private String style;
068   private final List<View> views = new ArrayList<>();
069
070   private SwitcherDef(String id) {
071      if (ib(id))
072         throw iaex("SwitcherDef requires a non-blank id.");
073      this.id = id;
074   }
075
076   /** @param id The card id. @return A new switcher. */
077   public static SwitcherDef create(String id) { return new SwitcherDef(id); }
078
079   /** @param v The card title. @return This object. */
080   public SwitcherDef title(String v) { title = v; return this; }
081
082   /** @param v The switcher style, {@link #STYLE_TABS} (default) or {@link #STYLE_SLIDER}. @return This object. */
083   public SwitcherDef style(String v) { style = v; return this; }
084
085   /**
086    * Validates a switcher style token.
087    *
088    * @param id The switcher id, used in the message.
089    * @param v The style, or <jk>null</jk> for the default.
090    * @throws IllegalArgumentException If the value is neither {@link #STYLE_TABS} nor {@link #STYLE_SLIDER}.
091    */
092   public static void validateStyle(String id, Object v) {
093      if (nn(v) && ! STYLE_TABS.equals(v) && ! STYLE_SLIDER.equals(v))
094         throw iaex("SwitcherDef '%s' style '%s' must be one of tabs, slider.", id, v);
095   }
096
097   /** @param viewId The id of the view shown first when nothing else decides. @return This object. */
098   public SwitcherDef initialView(String viewId) { initialView = viewId; return this; }
099
100   /**
101    * Adds a view.
102    *
103    * @param viewId The view id, matching {@code [A-Za-z][A-Za-z0-9_-]{0,63}}.
104    * @param label The tab label.
105    * @param config Fills in the view's sections and model block.
106    * @return This object.
107    */
108   public SwitcherDef view(String viewId, String label, Consumer<View> config) {
109      var v = new View(viewId, label);
110      config.accept(v);
111      views.add(v);
112      return this;
113   }
114
115   /**
116    * One view of a switcher.
117    *
118    * <h2 class='section'>Example:</h2>
119    * <p class='bjava'>
120    *    <jv>v</jv>.section(<js>"all"</js>, <js>"All"</js>, <jv>card</jv>).initialSection(<js>"all"</js>).on(<js>"activated"</js>, <js>"orders.shown"</js>);
121    * </p>
122    */
123   public static final class View implements Handled<View> {
124      private final String id;
125      private final String label;
126      private String initialSection;
127      private final List<Section> sections = new ArrayList<>();
128      private final List<CardSpec> stack = new ArrayList<>();
129      private final ModelBlock model;
130
131      private View(String id, String label) {
132         this.id = id;
133         this.label = label;
134         this.model = new ModelBlock("view", id);
135      }
136
137      @Override /* Handled */
138      public ModelBlock modelBlock() { return model; }
139
140      @Override /* Handled */
141      public View self() { return this; }
142
143      /** @param card The only card of the view, in the default section. @return This object. */
144      public View section(CardSpec card) {
145         sections.add(new Section(DEFAULT_SECTION, label, card, true));
146         return this;
147      }
148
149      /** @param sectionId The section id. @param sectionLabel The section tab label. @param card The section's card. @return This object. */
150      public View section(String sectionId, String sectionLabel, CardSpec card) {
151         sections.add(new Section(sectionId, sectionLabel, card, false));
152         return this;
153      }
154
155      /** @param sectionId The section id. @param sectionLabel The section tab label. @param card The section's card. @param config Fills in the section's model block. @return This object. */
156      public View section(String sectionId, String sectionLabel, CardSpec card, Consumer<Section> config) {
157         var s = new Section(sectionId, sectionLabel, card, false);
158         config.accept(s);
159         sections.add(s);
160         return this;
161      }
162
163      /**
164       * Stacks the cards vertically in this view, with the card gap between them.
165       *
166       * @param cards The cards, two or more.
167       * @return This object.
168       */
169      public View cards(CardSpec...cards) {
170         stack.addAll(Arrays.asList(cards));
171         return this;
172      }
173
174      /** @param sectionId The section shown first when nothing else decides. @return This object. */
175      public View initialSection(String sectionId) { initialSection = sectionId; return this; }
176
177      private Map<String,Object> toMap() {
178         var m = new LinkedHashMap<String,Object>();
179         m.put("id", id);
180         m.put("label", label);
181         if (nn(initialSection)) m.put("initialSection", initialSection);
182         // A mix is emitted as authored; validate() rejects it with the switcher's id in the message.
183         if (! stack.isEmpty())
184            m.put("cards", stack.stream().map(CardSpec::toMap).toList());
185         if (sections.size() == 1 && sections.get(0).implicit && sections.get(0).model.isEmpty()) {
186            m.put("card", sections.get(0).card.toMap());
187         } else if (! sections.isEmpty()) {
188            m.put("sections", sections.stream().map(Section::toMap).toList());
189         }
190         var mm = model.toMap();
191         if (mm != null) m.put("model", mm);
192         return m;
193      }
194   }
195
196   /**
197    * One section of a switcher view.
198    *
199    * <h2 class='section'>Example:</h2>
200    * <p class='bjava'>
201    *    <jv>v</jv>.section(<js>"all"</js>, <js>"All"</js>, <jv>card</jv>, <jv>s</jv> -&gt; <jv>s</jv>.on(<js>"activated"</js>, <js>"orders.sectionShown"</js>));
202    * </p>
203    */
204   public static final class Section implements Handled<Section> {
205      private final String id;
206      private final String label;
207      private final CardSpec card;
208      private final boolean implicit;
209      private final ModelBlock model;
210
211      private Section(String id, String label, CardSpec card, boolean implicit) {
212         this.id = id;
213         this.label = label;
214         this.card = card;
215         this.implicit = implicit;
216         this.model = new ModelBlock("detailtab", id);
217      }
218
219      @Override /* Handled */
220      public ModelBlock modelBlock() { return model; }
221
222      @Override /* Handled */
223      public Section self() { return this; }
224
225      private Map<String,Object> toMap() {
226         var m = new LinkedHashMap<String,Object>();
227         m.put("id", id);
228         m.put("label", label);
229         m.put("card", card.toMap());
230         var mm = model.toMap();
231         if (mm != null) m.put("model", mm);
232         return m;
233      }
234   }
235
236   /**
237    * Builds the {@code switcher} card entry.
238    *
239    * @return The card.
240    * @throws IllegalArgumentException If the switcher is invalid.
241    */
242   public CardSpec toCardSpec() {
243      var vs = views.stream().map(View::toMap).toList();
244      var body = new LinkedHashMap<String,Object>();
245      if (nn(initialView)) body.put("initialView", initialView);
246      if (nn(style)) body.put("style", style);
247      body.put("views", vs);
248      validate(id, body);
249      var c = CardSpec.of("switcher", id);
250      if (nn(title)) c.title(title);
251      if (nn(initialView)) c.body("initialView", initialView);
252      if (nn(style)) c.body("style", style);
253      c.body("views", vs);
254      return c;
255   }
256
257   /**
258    * Validates a switcher body: the shape the builder emits and the page-contract JSON carries.
259    *
260    * @param id The switcher id, used in messages.
261    * @param body The body holding {@code views} and optionally {@code initialView}.
262    * @throws IllegalArgumentException On the first problem found.
263    */
264   @SuppressWarnings({
265      "java:S3776" // Branching is inherent to validate(), which checks many view/section combinations.
266   })
267   public static void validate(String id, Map<String,?> body) {
268      var who = "SwitcherDef '" + id + "'";
269      validateStyle(id, body.get("style"));
270      if (! (body.get("views") instanceof List<?> views) || views.isEmpty())
271         throw iaex("%s requires at least one view.", who);
272      var viewIds = new HashSet<String>();
273      var cardIds = new HashSet<String>();
274      for (var o : views) {
275         if (! (o instanceof Map<?,?> v))
276            throw iaex("%s views must be objects.", who);
277         var vid = String.valueOf(v.get("id"));
278         if (! (v.get("id") instanceof String) || ! ID_GRAMMAR.matcher(vid).matches())
279            throw iaex("%s view id '%s' must match %s.", who, vid, ID_GRAMMAR.pattern());
280         if (! viewIds.add(vid))
281            throw iaex("%s view id '%s' is declared more than once.", who, vid);
282         if (! (v.get("label") instanceof String))
283            throw iaex("%s view '%s' requires a string label.", who, vid);
284         var hasCard = v.get("card") != null;
285         var hasSections = v.get("sections") != null;
286         var hasCards = v.get("cards") != null;
287         if ((hasCard ? 1 : 0) + (hasSections ? 1 : 0) + (hasCards ? 1 : 0) > 1)
288            throw iaex("%s view '%s' takes exactly one of card, sections or cards.", who, vid);
289         if (! hasCard && ! hasCards && (! (v.get("sections") instanceof List<?> l) || l.isEmpty()))
290            throw iaex("%s view '%s' requires a card, sections or cards.", who, vid);
291         var sectionIds = new HashSet<String>();
292         if (hasCard) {
293            checkCard(who, vid, v.get("card"), cardIds);
294         } else if (hasCards) {
295            if (! (v.get("cards") instanceof List<?> stacked) || stacked.size() < 2)
296               throw iaex("%s view '%s' cards needs at least two cards.", who, vid);
297            for (var co : stacked)
298               checkCard(who, vid, co, cardIds);
299         } else {
300            for (var so : (List<?>)v.get("sections")) {
301               if (! (so instanceof Map<?,?> s))
302                  throw iaex("%s view '%s' sections must be objects.", who, vid);
303               var sid = String.valueOf(s.get("id"));
304               if (! (s.get("id") instanceof String) || ! ID_GRAMMAR.matcher(sid).matches())
305                  throw iaex("%s view '%s' section id '%s' must match %s.", who, vid, sid, ID_GRAMMAR.pattern());
306               if (! sectionIds.add(sid))
307                  throw iaex("%s view '%s' section id '%s' is declared more than once.", who, vid, sid);
308               if (! (s.get("label") instanceof String))
309                  throw iaex("%s view '%s' section '%s' requires a string label.", who, vid, sid);
310               checkCard(who, vid, s.get("card"), cardIds);
311            }
312         }
313         var is = v.get("initialSection");
314         if (is != null && ! sectionIds.contains(is))
315            throw iaex("%s view '%s' initialSection '%s' names no section.", who, vid, is);
316      }
317      var iv = body.get("initialView");
318      if (iv != null && ! viewIds.contains(iv))
319         throw iaex("%s initialView '%s' names no view.", who, iv);
320   }
321
322   private static void checkCard(String who, String vid, Object card, Set<String> cardIds) {
323      if (! (card instanceof Map<?,?> c) || ! (c.get("id") instanceof String cid) || ! (c.get("type") instanceof String type))
324         throw iaex("%s view '%s' cards must be objects with a string id and type.", who, vid);
325      if ("switcher".equals(type))
326         throw iaex("%s view '%s' cannot hold a switcher card ('%s').", who, vid, cid);
327      if (! cardIds.add(cid))
328         throw iaex("%s card id '%s' is declared more than once.", who, cid);
329   }
330}