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}