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>"<p>Hello</p>"</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}