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