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.views;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.util.*;
022
023/**
024 * The author-facing declaration of a job-log row-detail region, compiled to a {@link RegionDef}.
025 *
026 * <p>
027 * The console's {@code job-log} card, hosted in a row's detail: a form whose submit runs a row-bound job through
028 * {@code JuneauViews.jobs}, streams its log, and optionally writes its result into a field.  {@link #toRegion()}
029 * produces a {@link RegionDef#TYPE_ROW_DETAIL} region with {@code populate("job-log")}, opts that populator in, and
030 * carries the whole declaration as {@link RegionDef#config}.  The populator ships in the console's {@code joblog}
031 * pack.
032 *
033 * <p>
034 * The submit always uses the action envelope {@code {action, targetId, idempotencyKey, fields}}: {@code targetId} is
035 * the row id, and the key comes from {@code submit.keyUrl}, an endpoint that answers
036 * {@link JobKeyLedger#response(IdempotencyKey)}.  The shape rules live in {@link JobLogChecks}, shared with the page
037 * card.
038 *
039 * <h5 class='section'>Example:</h5>
040 * <p class='bjava'>
041 *    RegionDef <jv>region</jv> = JobLogRegion.<jsm>create</jsm>(<js>"restock-species"</js>)
042 *       .submit(JobLogRegion.Submit.<jsm>create</jsm>(<js>"/jobs/species/{id}/restock"</js>, <js>"restock-species"</js>, <js>"/jobs/species/{id}/key"</js>)
043 *          .submitLabel(<js>"Restock"</js>)
044 *          .field(JobLogRegion.Field.<jsm>of</jsm>(<js>"count"</js>).label(<js>"How many"</js>).type(<js>"number"</js>).defaultValue(1))
045 *          .field(JobLogRegion.Field.<jsm>of</jsm>(<js>"species"</js>).type(<js>"hidden"</js>).source(<js>"row.species"</js>)))
046 *       .output(JobLogRegion.Output.<jsm>field</jsm>(<js>"available"</js>).from(<js>"row.available"</js>))
047 *       .toRegion();
048 * </p>
049 *
050 * @since 10.0.0
051 */
052@SuppressWarnings({
053   "java:S1192" // Repeated literals are contract wire keys; named constants would obscure the contract shape.
054})
055public final class JobLogRegion {
056
057   /** The populator name. */
058   public static final String POPULATOR = "job-log";
059
060   /** The smallest accepted {@code height}, in pixels; matches the page card's floor. */
061   public static final int MIN_HEIGHT = 80;
062
063   /** The top-level config keys, in emission order. */
064   public static final List<String> KEYS = List.of("submit", "detail", "height", "clearOnSubmit", "output");
065
066   /** Region id. */
067   public final String id;
068
069   private final Map<String,Object> config = new LinkedHashMap<>();
070
071   private JobLogRegion(String id) {
072      this.id = id;
073   }
074
075   /**
076    * Creates a declaration.
077    *
078    * @param id The region id.
079    * @return A new declaration.
080    */
081   public static JobLogRegion create(String id) {
082      return new JobLogRegion(id);
083   }
084
085   /**
086    * Creates a declaration from a parsed map (a datatables card's {@code detail.region.config}).
087    *
088    * @param id The region id.
089    * @param config The {@code config} object; keys must be a subset of {@link #KEYS}.
090    * @return A new declaration (not yet validated).
091    * @throws IllegalArgumentException If {@code config} is not an object or has an unknown key.
092    */
093   public static JobLogRegion fromMap(String id, Map<String,?> config) {
094      var d = create(id);
095      if (config == null)
096         return d;
097      for (var e : config.entrySet()) {
098         var k = e.getKey();
099         if ("urlParam".equals(k))
100            throw iaex("JobLogRegion '%s' urlParam is not allowed in a row-detail region; a page reload does not reattach.", id);
101         if (! KEYS.contains(k))
102            throw iaex("JobLogRegion '%s' unknown key '%s'; allowed: %s.", id, k, KEYS);
103         d.config.put(k, e.getValue());
104      }
105      return d;
106   }
107
108   /** @param value The submit declaration. @return This object. */
109   public JobLogRegion submit(Submit value) { config.put("submit", value.map); return this; }
110   /** @param value The optional field grid painted above the form. @return This object. */
111   public JobLogRegion detail(Detail value) { config.put("detail", value.map); return this; }
112   /** @param px The log pane height, in pixels. @return This object. */
113   public JobLogRegion height(int px) { config.put("height", px); return this; }
114   /** @param value Whether a submit clears the log first. @return This object. */
115   public JobLogRegion clearOnSubmit(boolean value) { config.put("clearOnSubmit", value); return this; }
116   /** @param value Where a terminal result is written. @return This object. */
117   public JobLogRegion output(Output value) { config.put("output", value.map); return this; }
118
119   /**
120    * Validates this declaration (fail-closed).
121    *
122    * @return This object.
123    * @throws IllegalArgumentException If a rule is broken.
124    */
125   public JobLogRegion validate() {
126      if (ib(id))
127         throw iaex("JobLogRegion id must not be blank.");
128      try {
129         if (! (config.get("submit") instanceof Map<?,?> submit))
130            throw iaex("requires a 'submit' object.");
131         JobLogChecks.submit(submit, JobLogChecks.Mode.REGION);
132         if (config.containsKey("detail"))
133            JobLogChecks.detail(config.get("detail"));
134         JobLogChecks.height(config.get("height"), MIN_HEIGHT);
135         JobLogChecks.clearOnSubmit(config.get("clearOnSubmit"));
136         if (config.containsKey("output"))
137            JobLogChecks.output(config.get("output"));
138      } catch (IllegalArgumentException e) {
139         throw iaex("JobLogRegion '%s' %s", id, e.getMessage());
140      }
141      return this;
142   }
143
144   /**
145    * Returns the config in canonical key order, so the builder and the console-card path emit the same sidecar.
146    *
147    * @return A new ordered map.
148    */
149   public Map<String,Object> toMap() {
150      var m = order(config, KEYS);
151      if (m.get("submit") instanceof Map<?,?> s) {
152         // A region always uses the action envelope; stamp it so an authored config that left it implicit emits the
153         // same sidecar as the builder.
154         var withEnvelope = new LinkedHashMap<Object,Object>(s);
155         withEnvelope.putIfAbsent("envelope", "action");
156         var submit = order(withEnvelope, JobLogChecks.REGION_SUBMIT_KEYS);
157         if (submit.get("fields") instanceof List<?> l)
158            submit.put("fields", l.stream().map(f -> f instanceof Map<?,?> fm ? order(fm, JobLogChecks.FIELD_KEYS) : f).toList());
159         m.put("submit", submit);
160      }
161      if (m.get("detail") instanceof Map<?,?> d)
162         m.put("detail", order(d, JobLogChecks.DETAIL_KEYS));
163      if (m.get("output") instanceof Map<?,?> o) {
164         var output = order(o, JobLogChecks.OUTPUT_KEYS);
165         if (output.get("target") instanceof Map<?,?> t)
166            output.put("target", order(t, List.of("field", "selector")));
167         m.put("output", output);
168      }
169      return m;
170   }
171
172   private static Map<String,Object> order(Map<?,?> in, List<String> keys) {
173      var m = new LinkedHashMap<String,Object>();
174      for (var k : keys)
175         if (in.containsKey(k))
176            m.put(k, in.get(k));
177      for (var e : in.entrySet())
178         if (! m.containsKey(String.valueOf(e.getKey())))
179            m.put(String.valueOf(e.getKey()), e.getValue());
180      return m;
181   }
182
183   /**
184    * Validates and compiles this declaration.
185    *
186    * @return A validated row-detail region.
187    */
188   public RegionDef toRegion() {
189      validate();
190      var r = RegionDef.create(id).type(RegionDef.TYPE_ROW_DETAIL).populate(POPULATOR).allowPopulators(POPULATOR).config(toMap());
191      r.validate();
192      return r;
193   }
194
195   /** The {@code submit} declaration. */
196   public static final class Submit {
197      final Map<String,Object> map = new LinkedHashMap<>();
198
199      private Submit() {}
200
201      /**
202       * Creates a submit declaration.
203       *
204       * @param endpoint The same-origin submit path template; {@code {id}} is the only token.
205       * @param action The action id the key is bound to.
206       * @param keyUrl The same-origin key-endpoint path template; {@code {id}} is the only token.
207       * @return A new declaration.
208       */
209      public static Submit create(String endpoint, String action, String keyUrl) {
210         var s = new Submit();
211         s.map.put("endpoint", endpoint);
212         s.map.put("envelope", "action");
213         s.map.put("action", action);
214         s.map.put("keyUrl", keyUrl);
215         return s;
216      }
217
218      /** @param value {@code POST} (the default) or {@code PUT}. @return This object. */
219      public Submit method(String value) { map.put("method", value); return this; }
220      /** @param value The submit button's label. @return This object. */
221      public Submit submitLabel(String value) { map.put("submitLabel", value); return this; }
222
223      /**
224       * Adds a field.
225       *
226       * @param value The field.
227       * @return This object.
228       */
229      @SuppressWarnings({
230         "unchecked" // The "fields" entry is always created here as a List<Object>.
231      })
232      public Submit field(Field value) {
233         ((List<Object>)map.computeIfAbsent("fields", k -> new ArrayList<>())).add(value.map);
234         return this;
235      }
236   }
237
238   /** One {@code submit.fields} entry. */
239   public static final class Field {
240      final Map<String,Object> map = new LinkedHashMap<>();
241
242      private Field() {}
243
244      /**
245       * @param name The field name, sent under {@code fields}.
246       * @return A new field.
247       */
248      public static Field of(String name) {
249         var f = new Field();
250         f.map.put("name", name);
251         return f;
252      }
253
254      /** @param value The label. @return This object. */
255      public Field label(String value) { map.put("label", value); return this; }
256      /** @param value {@code text}, {@code textarea}, {@code number} or {@code hidden}. @return This object. */
257      public Field type(String value) { map.put("type", value); return this; }
258      /** @param value The placeholder. @return This object. */
259      public Field placeholder(String value) { map.put("placeholder", value); return this; }
260      /** @param value Whether the field must be filled in. @return This object. */
261      public Field required(boolean value) { map.put("required", value); return this; }
262      /** @param value The literal default, or the fallback when {@link #source(String)} is absent. @return This object. */
263      public Field defaultValue(Object value) { map.put("default", value); return this; }
264      /** @param value {@code row.<field>}: the parent row's own property. @return This object. */
265      public Field source(String value) { map.put("source", value); return this; }
266   }
267
268   /** The optional field grid painted above the form, whose slots {@code output.target.field} can address. */
269   public static final class Detail {
270      final Map<String,Object> map = new LinkedHashMap<>();
271
272      private Detail() {}
273
274      /**
275       * @param renderer The region helper that paints the grid, for example {@code fieldGrid}.
276       * @param fields The field catalog.
277       * @return A new grid declaration.
278       */
279      public static Detail of(String renderer, RegionDef.Field...fields) {
280         var d = new Detail();
281         d.map.put("renderer", renderer);
282         d.map.put("fields", Arrays.stream(fields).map(RegionDef.Field::toContractMap).toList());
283         return d;
284      }
285   }
286
287   /** Where a terminal result is written. */
288   public static final class Output {
289      final Map<String,Object> map = new LinkedHashMap<>();
290
291      private Output() {}
292
293      /**
294       * @param name The {@code data-juneau-field} name to write, within the region.
295       * @return A new output.
296       */
297      public static Output field(String name) {
298         var o = new Output();
299         o.map.put("target", new LinkedHashMap<>(Map.of("field", name)));
300         return o;
301      }
302
303      /**
304       * @param css A selector resolved within the region.
305       * @return A new output.
306       */
307      public static Output selector(String css) {
308         var o = new Output();
309         o.map.put("target", new LinkedHashMap<>(Map.of("selector", css)));
310         return o;
311      }
312
313      /** @param value {@code replace} (the default) or {@code append}. @return This object. */
314      public Output mode(String value) { map.put("mode", value); return this; }
315      /** @param value {@code message} (the default) or {@code row.<key>}. @return This object. */
316      public Output from(String value) { map.put("from", value); return this; }
317      /** @param value {@code success} (the default) or {@code any}. @return This object. */
318      public Output when(String value) { map.put("when", value); return this; }
319   }
320}