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;
018
019import java.util.*;
020import java.util.regex.*;
021
022/**
023 * The model block of one element: handler actions, lifecycle bindings and handler subscriptions.
024 *
025 * <p>
026 * Builders that implement {@link Handled} own one of these.  Every mutator validates at once and throws
027 * {@link IllegalArgumentException} naming the element.  These checks duplicate the client rules, which are the authority.
028 *
029 * @since 10.0.0
030 */
031@SuppressWarnings({
032   "java:S1192" // Contract event and kind names repeat as inline literals by design.
033})
034public final class ModelBlock {
035
036   private static final Pattern TEMPLATE = Pattern.compile("\\{(rowKey|tabId|cardId)}");
037   private static final Pattern ANY_BRACE = Pattern.compile("[{}]");
038
039   private static final Map<String,Set<String>> EVENTS = Map.of(
040      "page", Set.of("loaded"),
041      "card", Set.of("loaded", "rendered"),
042      "table", Set.of("loaded", "rendered"),
043      "row", Set.of("rendered", "expanded", "collapsed"),
044      "rowdetail", Set.of("loaded", "rendered"),
045      "detailtab", Set.of("loaded", "rendered", "activated"),
046      "ribbonitem", Set.of("activated"),
047      "pagetab", Set.of("activated"),
048      "view", Set.of("activated"),
049      "chart", Set.of("loaded", "rendered"));
050
051   private static final Map<String,Set<String>> TEMPLATE_SCOPE = Map.of(
052      "rowKey", Set.of("row", "rowdetail", "detailtab"),
053      "tabId", Set.of("detailtab"));
054
055   private final String kind;
056   private final String elementId;
057   private final Map<String,ModelAction> actions = new LinkedHashMap<>();
058   private final Map<String,HandlerRef> on = new LinkedHashMap<>();
059   private final List<Map.Entry<String,HandlerRef>> subscribes = new ArrayList<>();
060
061   /**
062    * Creates a block.
063    *
064    * @param kind The element kind: page, card, table, row, rowdetail, detailtab, ribbonitem, pagetab, view or chart.
065    * @param elementId The element id, used in messages.
066    * @throws IllegalArgumentException If the kind is unknown.
067    */
068   public ModelBlock(String kind, String elementId) {
069      if (! EVENTS.containsKey(kind))
070         throw new IllegalArgumentException("Unknown element kind '" + kind + "': " + new TreeSet<>(EVENTS.keySet()));
071      this.kind = kind;
072      this.elementId = elementId;
073   }
074
075   private IllegalArgumentException err(String msg) {
076      return new IllegalArgumentException(kind + " '" + elementId + "': " + msg);
077   }
078
079   /**
080    * Adds an action.
081    *
082    * @param a The action; must have a handler.
083    * @return This object.
084    * @throws IllegalArgumentException If the kind takes no actions, the id repeats, or no handler is set.
085    */
086   public ModelBlock action(ModelAction a) {
087      if ("page".equals(kind))
088         throw err("a page takes no actions.");
089      if (a.handlerRef() == null)
090         throw err("action '" + a.id() + "' has no handler.");
091      if (actions.containsKey(a.id()))
092         throw err("duplicate action id '" + a.id() + "'.");
093      actions.put(a.id(), a);
094      return this;
095   }
096
097   /**
098    * Binds a lifecycle event.
099    *
100    * @param event A documented event of this kind.
101    * @param handler The handler.
102    * @return This object.
103    * @throws IllegalArgumentException If the event is not documented for this kind.
104    */
105   public ModelBlock on(String event, HandlerRef handler) {
106      if (! EVENTS.get(kind).contains(event))
107         throw err("event '" + event + "' is not one of " + new TreeSet<>(EVENTS.get(kind)) + ".");
108      on.put(event, Objects.requireNonNull(handler, "handler"));
109      return this;
110   }
111
112   /**
113    * Subscribes a handler to a bus topic.
114    *
115    * @param topic A topic; the key may hold {@code {rowKey}}, {@code {tabId}} or {@code {cardId}} where this kind provides them.
116    * @param handler The handler.
117    * @return This object.
118    * @throws IllegalArgumentException If the topic is invalid or a template is not available here.
119    */
120   public ModelBlock subscribe(String topic, HandlerRef handler) {
121      if ("page".equals(kind))
122         throw err("a page takes no subscriptions.");
123      if (topic == null)
124         throw err("topic must not be null.");
125      var m = TEMPLATE.matcher(topic);
126      while (m.find()) {
127         var scope = TEMPLATE_SCOPE.get(m.group(1));
128         if (scope != null && ! scope.contains(kind))
129            throw err("template {" + m.group(1) + "} is not available here.");
130      }
131      try {
132         var concrete = TEMPLATE.matcher(topic).replaceAll("k");
133         if (ANY_BRACE.matcher(concrete).find())
134            throw new IllegalArgumentException("invalid topic '" + topic + "'");
135         Topics.checkTopic(concrete);
136      } catch (IllegalArgumentException e) {
137         throw err(e.getMessage());
138      }
139      subscribes.add(Map.entry(topic, Objects.requireNonNull(handler, "handler")));
140      return this;
141   }
142
143   /**
144    * Re-checks the whole block.
145    *
146    * @throws IllegalArgumentException If any entry is invalid.
147    */
148   public void validate() {
149      on.keySet().forEach(e -> {
150         if (! EVENTS.get(kind).contains(e))
151            throw err("event '" + e + "' is not one of " + new TreeSet<>(EVENTS.get(kind)) + ".");
152      });
153      actions.values().forEach(a -> {
154         if (a.handlerRef() == null)
155            throw err("action '" + a.id() + "' has no handler.");
156      });
157   }
158
159   /**
160    * Appends every entry of another block to this one.
161    *
162    * @param other The block to copy from.
163    * @return This object.
164    */
165   public ModelBlock addAll(ModelBlock other) {
166      other.actions.values().forEach(this::action);
167      other.on.forEach(this::on);
168      other.subscribes.forEach(e -> subscribe(e.getKey(), e.getValue()));
169      return this;
170   }
171
172   /**
173    * Returns whether the block holds nothing.
174    *
175    * @return <jk>true</jk> if empty.
176    */
177   public boolean isEmpty() {
178      return actions.isEmpty() && on.isEmpty() && subscribes.isEmpty();
179   }
180
181   /**
182    * Returns whether any entry uses an inline handler.
183    *
184    * @return <jk>true</jk> if an inline reference is present.
185    */
186   public boolean hasInline() {
187      return on.values().stream().anyMatch(HandlerRef::isInline)
188         || subscribes.stream().anyMatch(e -> e.getValue().isInline())
189         || actions.values().stream().anyMatch(a -> a.handlerRef().isInline());
190   }
191
192   /**
193    * Returns the actions in insertion order.
194    *
195    * @return The actions, as contract maps.
196    */
197   public List<Map<String,Object>> actionMaps() {
198      return actions.values().stream().map(ModelAction::toMap).toList();
199   }
200
201   /**
202    * Returns the element id.
203    *
204    * @return The id.
205    */
206   public String elementId() {
207      return elementId;
208   }
209
210   /**
211    * Returns the element kind.
212    *
213    * @return The kind.
214    */
215   public String kind() {
216      return kind;
217   }
218
219   /**
220    * Serializes to the contract {@code model} object.
221    *
222    * @return The map, or <jk>null</jk> when empty so that builders not using the model emit unchanged JSON.
223    */
224   @SuppressWarnings({
225      "java:S1168" // Null is the documented contract so that builders not using the model emit unchanged JSON.
226   })
227   public Map<String,Object> toMap() {
228      if (isEmpty())
229         return null;
230      var m = new LinkedHashMap<String,Object>();
231      if (! actions.isEmpty())
232         m.put("actions", actionMaps());
233      if (! on.isEmpty()) {
234         var o = new LinkedHashMap<String,Object>();
235         on.forEach((k, v) -> o.put(k, v.toJson()));
236         m.put("on", o);
237      }
238      if (! subscribes.isEmpty()) {
239         var l = new ArrayList<Map<String,Object>>();
240         for (var e : subscribes) {
241            var s = new LinkedHashMap<String,Object>();
242            s.put("topic", e.getKey());
243            s.put("handler", e.getValue().toJson());
244            l.add(s);
245         }
246         m.put("subscribes", l);
247      }
248      return m;
249   }
250}