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
023import org.apache.juneau.commons.bean.*;
024import org.apache.juneau.console.widgets.Op;
025
026/**
027 * A presentation-only visibility rule: the element is shown only when every rule in its list matches.
028 *
029 * <p>
030 * Rules hide UI; they never authorize.  The endpoint behind a hidden action must still check the caller.
031 * Use {@code visible(false)} instead when the element must not reach the client at all.
032 *
033 * <p>
034 * The evaluation map depends on the element's level.  A row action, detail field or region sees the row plus the
035 * page's {@code facts} under a {@code facts.} prefix; a card, badge or ribbon item sees the page's top-level
036 * {@code facts} alone.  A field absent from the map fails closed for every operator except {@code absent}.
037 *
038 * <h5 class='section'>Example:</h5>
039 * <p class='bjava'>
040 *    RowAction.<jsm>create</jsm>(<js>"escalate"</js>)
041 *       .visibleWhen(
042 *          VisibilityRule.<jsm>when</jsm>(<js>"status"</js>).in(<js>"open"</js>, <js>"acked"</js>),
043 *          VisibilityRule.<jsm>when</jsm>(<js>"facts.viewer.roles"</js>).contains(<js>"oncall"</js>));
044 * </p>
045 *
046 * @since 10.0.0
047 */
048@BeanType(properties="field,op,value")
049public final class VisibilityRule {
050
051   private static final Object MISSING = new Object();
052   static final Set<String> KNOWN_OPS = Set.of("eq", "ne", "present", "absent", "in", "contains");
053
054   /** The field this rule tests, a dotted path into the evaluation map (e.g. {@code "facts.viewer.roles"}). */
055   public String field;
056
057   /** The wire token of the comparison operator (see {@link Op#wire()}). */
058   public String op;
059
060   /** The comparison value for {@code eq}/{@code ne}/{@code in}/{@code contains}; omitted for {@code present}/{@code absent}. */
061   public Object value;
062
063   private VisibilityRule() {}
064
065   /**
066    * Starts a rule for the given field.
067    *
068    * @param field The dotted-path field to test.  Must not be <jk>null</jk> or blank.
069    * @return A {@link Builder} to pick the operator.
070    * @throws IllegalArgumentException If {@code field} is <jk>null</jk> or blank.
071    */
072   public static Builder when(String field) {
073      if (ib(field))
074         throw iaex("VisibilityRule field must not be null or blank.");
075      return new Builder(field);
076   }
077
078   /**
079    * Evaluates a list of rules against an evaluation map; every rule must match (AND).
080    *
081    * <p>
082    * A <jk>null</jk> or empty list always matches.  A field absent from {@code map} never satisfies
083    * {@code eq}/{@code ne}/{@code in}/{@code contains}/{@code present}; only {@code absent} treats a missing field
084    * as a match.  Mirrors the JS {@code JuneauViews.rules.test} evaluator exactly.
085    *
086    * @param rules The rules to test, or <jk>null</jk>.
087    * @param map The evaluation map.
088    * @return <jk>true</jk> when every rule matches.
089    * @throws IllegalArgumentException If a rule's {@code op} is not one of the known tokens.
090    */
091   public static boolean test(List<VisibilityRule> rules, Map<String,Object> map) {
092      if (ie(rules))
093         return true;
094      for (var r : rules)
095         if (! r.matches(map))
096            return false;
097      return true;
098   }
099
100   /**
101    * Serializes this rule to its wire shape.
102    *
103    * @return An ordered {@link Map} of {@code field}, {@code op} and (when set) {@code value}.
104    */
105   public Map<String,Object> toMap() {
106      var m = new LinkedHashMap<String,Object>();
107      m.put("field", field);
108      m.put("op", op);
109      if (value != null)
110         m.put("value", value);
111      return m;
112   }
113
114   static List<Map<String,Object>> toMaps(List<VisibilityRule> rules) {
115      return rules.stream().map(VisibilityRule::toMap).toList();
116   }
117
118   private boolean matches(Map<String,Object> map) {
119      if (! KNOWN_OPS.contains(op))
120         throw iaex("VisibilityRule op '%s' is not one of '%s'.", op, String.join(", ", new TreeSet<>(KNOWN_OPS)));
121      var actual = resolve(map, field);
122      var missing = actual == MISSING;
123      return switch (op) {
124         case "eq" -> ! missing && eq(actual, value);
125         case "ne" -> ! missing && neq(actual, value);
126         case "present" -> ! missing && actual != null && ! (actual instanceof String s && s.isBlank());
127         case "absent" -> missing || actual == null || (actual instanceof String s && s.isBlank());
128         case "in" -> ! missing && value instanceof Collection<?> c && c.contains(actual);
129         default -> ! missing && actual instanceof Collection<?> c && c.contains(value);
130      };
131   }
132
133   private static Object resolve(Map<?,?> map, String dottedPath) {
134      Object cur = map;
135      for (var part : dottedPath.split("\\."))
136         if (cur instanceof Map<?,?> m && m.containsKey(part))
137            cur = m.get(part);
138         else
139            return MISSING;
140      return cur;
141   }
142
143   /** Fluent operator picker returned by {@link VisibilityRule#when(String)}. */
144   public static final class Builder {
145      private final String field;
146
147      private Builder(String field) {
148         this.field = field;
149      }
150
151      /**
152       * Matches when the field equals the value.
153       *
154       * @param value The comparison value.  Must not be <jk>null</jk>.
155       * @return A new {@link VisibilityRule}.
156       */
157      public VisibilityRule eq(Object value) { return of(Op.EQ, value); }
158
159      /**
160       * Matches when the field is present and does not equal the value.
161       *
162       * @param value The comparison value.  Must not be <jk>null</jk>.
163       * @return A new {@link VisibilityRule}.
164       */
165      public VisibilityRule ne(Object value) { return of(Op.NE, value); }
166
167      /**
168       * Matches when the field is non-null and non-blank.
169       *
170       * @return A new {@link VisibilityRule}.
171       */
172      public VisibilityRule present() { return of(Op.PRESENT, null); }
173
174      /**
175       * Matches when the field is missing, null or blank.
176       *
177       * @return A new {@link VisibilityRule}.
178       */
179      public VisibilityRule absent() { return of(Op.ABSENT, null); }
180
181      /**
182       * Matches when the field equals one of the values.
183       *
184       * @param values The candidate values.
185       * @return A new {@link VisibilityRule}.
186       */
187      public VisibilityRule in(Object...values) { return of(Op.IN, List.of(values)); }
188
189      /**
190       * Matches when the field (itself a list) contains the value.
191       *
192       * @param value The value the list must contain.  Must not be <jk>null</jk>.
193       * @return A new {@link VisibilityRule}.
194       */
195      public VisibilityRule contains(Object value) { return of(Op.CONTAINS, value); }
196
197      private VisibilityRule of(Op op, Object value) {
198         if (op.requiresValue() && value == null)
199            throw iaex("VisibilityRule op '%s' requires a non-null value.", op.wire());
200         var r = new VisibilityRule();
201         r.field = field;
202         r.op = op.wire();
203         r.value = value;
204         return r;
205      }
206   }
207}