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.*;
022
023import org.apache.juneau.console.*;
024import org.apache.juneau.console.views.*;
025
026/**
027 * Type-agnostic card entry in the page contract: {@code id}, {@code type}, optional {@code src},
028 * {@code title}, {@code template}, plus a type-specific body map. C2 extends the card grammar.
029 *
030 * <h5 class='section'>Example:</h5>
031 * <p class='bjava'>
032 *    CardSpec <jv>c</jv> = CardSpec.<jsm>html</jsm>(<js>"intro"</js>).title(<js>"Welcome"</js>).template(<js>"intro"</js>);
033 *    CardSpec <jv>t</jv> = CardSpec.<jsm>of</jsm>(<js>"datatables"</js>, <js>"releases"</js>)
034 *       .body(<js>"table"</js>, <js>"/rest/releases/data"</js>);
035 *    PageCapture.<jsm>of</jsm>(<jv>env</jv>).addCard(<jv>c</jv>, <js>"&lt;p&gt;Hello&lt;/p&gt;"</js>);
036 * </p>
037 *
038 * @since 10.0.0
039 */
040public final class CardSpec {
041
042   /** Card types whose schema definition has no {@code bare} property, so a bare card of that type would fail validation. */
043   private static final Set<String> BARE_UNSUPPORTED = Set.of("terminal-view", "terminal");
044
045   private final String type;
046   private final String id;
047   private String title;
048   private String src;
049   private String template;
050   private boolean bare;
051   private boolean sanitize;
052   private String cssClass;
053   private final Map<String,Object> body = new LinkedHashMap<>();
054   private final List<Map<String,Object>> publishes = new ArrayList<>();
055   private final List<Map<String,Object>> subscribes = new ArrayList<>();
056   private List<VisibilityRule> visibleWhen = List.of();
057
058   private CardSpec(String type, String id) {
059      this.type = type;
060      this.id = id;
061   }
062
063   /** @param id The card id. @return A new {@code html} card. */
064   public static CardSpec html(String id) {
065      return new CardSpec("html", id);
066   }
067
068   /** @param type The card type. @param id The card id. @return A new card. */
069   public static CardSpec of(String type, String id) {
070      return new CardSpec(type, id);
071   }
072
073   /** @param v The card title. @return This object. */
074   public CardSpec title(String v) { title = v; return this; }
075
076   /** @param v A same-origin URL the card loads. @return This object. */
077   public CardSpec src(String v) { src = v; return this; }
078
079   /** @param templateId The id of a {@code <template data-card>} element. @return This object. */
080   public CardSpec template(String templateId) { template = templateId; return this; }
081
082   /**
083    * @param v
084    *    Whether the card drops its chrome: an html card is inserted with no wrapper element, any other type keeps a
085    *    wrapper element (class {@code jc-card-bare} instead of {@code jc-card}) but loses the card chrome and its
086    *    title.
087    * @return This object.
088    */
089   public CardSpec bare(boolean v) {
090      if (v && BARE_UNSUPPORTED.contains(type))
091         throw iaex("card '%s': a %s card does not support bare.", id, type);
092      bare = v;
093      return this;
094   }
095
096   /** @return Whether this card drops its chrome. */
097   boolean bare() { return bare; }
098
099   /** @return Whether this card declares any {@code publishes} topics. */
100   boolean hasPublishes() { return ! publishes.isEmpty(); }
101
102   /** @return Whether this card declares any {@code subscribes} entries. */
103   boolean hasSubscribes() { return ! subscribes.isEmpty(); }
104
105   /** @param v Whether an html card's fetched {@code src} fragment is rebuilt through the views attribute-allowlist sanitizer instead of being inserted as-is. @return This object. */
106   public CardSpec sanitize(boolean v) { sanitize = v; return this; }
107
108   /** @return Whether this card asks for its {@code src} fragment to be sanitized. */
109   boolean sanitize() { return sanitize; }
110
111   /** @param v Space-separated CSS class names stamped on the card wrapper element. @return This object. */
112   public CardSpec cssClass(String v) { cssClass = v; return this; }
113
114   /** @param key A type-specific field. @param value Its value. @return This object. */
115   public CardSpec body(String key, Object value) {
116      if ("publishes".equals(key) || "subscribes".equals(key))
117         throw iaex("card '%s': '%s' is a base key; use %s(...)", id, key, key);
118      body.put(key, value);
119      return this;
120   }
121
122   /**
123    * Declares custom topics this card's JS publishes; repeatable, appends.
124    *
125    * @param v The declarations; each needs {@code retain} and must not set {@code publisher}.
126    * @return This object.
127    */
128   public CardSpec publishes(TopicDecl... v) {
129      for (var d : v)
130         publishes.add(d.toPublicationMap());
131      return this;
132   }
133
134   /**
135    * Sets card-level visibility rules.  The client evaluates them against the page's facts; this only serializes them.
136    *
137    * @param rules The rules, ANDed together.  None clears them.
138    * @return This object.
139    */
140   public CardSpec visibleWhen(VisibilityRule... rules) {
141      if (rules == null || Arrays.stream(rules).anyMatch(Objects::isNull))
142         throw iaex("CardSpec visibleWhen rules must not be null.");
143      visibleWhen = List.of(rules);
144      return this;
145   }
146
147   /**
148    * Wires topics to roles this card's type implements; repeatable, appends.
149    *
150    * @param v The subscriptions.
151    * @return This object.
152    */
153   public CardSpec subscribes(Subscription... v) {
154      for (var s : v)
155         subscribes.add(s.toMap());
156      return this;
157   }
158
159   /** @return The id. */
160   public String id() { return id; }
161
162   /** @return The type. */
163   public String type() { return type; }
164
165   /** @return An independent copy of this card, safe to mutate without affecting this one. */
166   CardSpec copy() {
167      var c = new CardSpec(type, id);
168      c.title = title;
169      c.src = src;
170      c.template = template;
171      c.bare = bare;
172      c.sanitize = sanitize;
173      c.cssClass = cssClass;
174      c.body.putAll(body);
175      return c.wiringFrom(this);
176   }
177
178   /** Wiring already lowered to contract maps (FTL attributes or a JSON5 body). */
179   CardSpec wiring(List<?> publishesMaps, List<?> subscribesMaps) {
180      if (publishesMaps != null) for (var o : publishesMaps) publishes.add(asMap(o));
181      if (subscribesMaps != null) for (var o : subscribesMaps) subscribes.add(asMap(o));
182      return this;
183   }
184
185   /** @param other The card whose {@code publishes} / {@code subscribes} are appended to this one's, and whose {@code visibleWhen} replaces (does not merge with) this one's when it has any. @return This object. */
186   CardSpec wiringFrom(CardSpec other) {
187      publishes.addAll(other.publishes);
188      subscribes.addAll(other.subscribes);
189      if (! other.visibleWhen.isEmpty())
190         visibleWhen = other.visibleWhen;
191      return this;
192   }
193
194   @SuppressWarnings({
195      "unchecked" // Narrows the validated Map to Map<String,Object>; its keys are always strings.
196   })
197   private static Map<String,Object> asMap(Object o) {
198      if (! (o instanceof Map))
199         throw new IllegalArgumentException("card wiring entries must be objects; got " + (o == null ? "null" : o.getClass().getSimpleName()) + ".");
200      return (Map<String,Object>)o;
201   }
202
203   /** @return The template id, or <jk>null</jk>. */
204   String template() { return template; }
205
206   /** @return The title, or <jk>null</jk>. */
207   String title() { return title; }
208
209   /** @return The {@code src} URL, or <jk>null</jk>. */
210   String src() { return src; }
211
212   /** @return The wrapper CSS classes, or <jk>null</jk>. */
213   String cssClass() { return cssClass; }
214
215   /** @return An unmodifiable copy of the type-specific body fields, in insertion order. */
216   public Map<String,Object> bodyMap() { return Collections.unmodifiableMap(new LinkedHashMap<>(body)); }
217
218   /** @return The contract entry, common fields first. */
219   public Map<String,Object> toMap() {
220      var m = new LinkedHashMap<String,Object>();
221      m.put("id", id);
222      m.put("type", type);
223      if (nn(title)) m.put("title", title);
224      if (nn(src)) m.put("src", src);
225      if (nn(template)) m.put("template", template);
226      if (bare) m.put("bare", true);
227      if (sanitize) {
228         if (neq(type, "html") || ! nn(src))
229            throw iaex("card '%s': sanitize applies only to an html card with src.", id);
230         m.put("sanitize", true);
231      }
232      if (nn(cssClass)) m.put("class", cssClass);
233      if (! visibleWhen.isEmpty()) m.put("visibleWhen", visibleWhen.stream().map(VisibilityRule::toMap).toList());
234      if (! publishes.isEmpty()) m.put("publishes", List.copyOf(publishes));
235      if (! subscribes.isEmpty()) m.put("subscribes", List.copyOf(subscribes));
236      m.putAll(body);
237      return m;
238   }
239}