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}