001/* 002 * (C) Copyright 2015 Nuxeo SA (http://nuxeo.com/) and others. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 * 016 * Contributors: 017 * Anahide Tchertchian 018 */ 019 020package org.nuxeo.ecm.platform.ui.web.component.list; 021 022import java.io.IOException; 023import java.io.StringWriter; 024import java.util.ArrayList; 025import java.util.Collections; 026import java.util.HashMap; 027import java.util.Iterator; 028import java.util.List; 029import java.util.Map; 030 031import javax.faces.application.FacesMessage; 032import javax.faces.component.NamingContainer; 033import javax.faces.component.UIComponent; 034import javax.faces.context.FacesContext; 035import javax.faces.context.ResponseWriter; 036import javax.faces.event.PhaseId; 037 038import org.apache.commons.lang3.StringUtils; 039import org.apache.commons.logging.Log; 040import org.apache.commons.logging.LogFactory; 041import org.nuxeo.ecm.platform.ui.web.model.EditableModel; 042import org.nuxeo.ecm.platform.ui.web.model.impl.EditableModelImpl; 043import org.nuxeo.ecm.platform.ui.web.tag.fn.Functions; 044 045import com.sun.faces.facelets.tag.jsf.ComponentSupport; 046 047/** 048 * Editable list component, relying on client side javascript code to handle adding/removing element from the target 049 * list. 050 * 051 * @since 7.2 052 */ 053public class UIJavascriptList extends UIEditableList { 054 055 public static final String COMPONENT_TYPE = UIJavascriptList.class.getName(); 056 057 public static final String COMPONENT_FAMILY = UIJavascriptList.class.getName(); 058 059 private static final Log log = LogFactory.getLog(UIJavascriptList.class); 060 061 protected static final String TEMPLATE_INDEX_MARKER = "TEMPLATE_INDEX_MARKER"; 062 063 protected static final String ROW_INDEXES_PARAM = "rowIndex[]"; 064 065 protected static final String IS_LIST_TEMPLATE_VAR = "isListTemplate"; 066 067 protected enum PropertyKeys { 068 rowIndexes; 069 } 070 071 public void setRowIndexes(int[] rowIndexes) { 072 getStateHelper().put(PropertyKeys.rowIndexes, rowIndexes); 073 } 074 075 public int[] getRowIndexes() { 076 return (int[]) getStateHelper().eval(PropertyKeys.rowIndexes); 077 } 078 079 @Override 080 public String getFamily() { 081 return COMPONENT_FAMILY; 082 } 083 084 /** 085 * Override container client id resolution to handle recursion. 086 */ 087 @Override 088 @SuppressWarnings("deprecation") 089 public String getContainerClientId(FacesContext context) { 090 String id = super.getClientId(context); 091 int index = getRowIndex(); 092 if (index == -2) { 093 id += SEPARATOR_CHAR + TEMPLATE_INDEX_MARKER; 094 } else if (index != -1) { 095 id += SEPARATOR_CHAR + String.valueOf(index); 096 } 097 return id; 098 } 099 100 /** 101 * Renders an element using rowIndex -2 and client side marker {@link #TEMPLATE_INDEX_MARKER}. 102 * <p> 103 * This element will be used on client side by js code to handle addition of a new element. 104 */ 105 protected void encodeTemplate(FacesContext context) throws IOException { 106 int oldIndex = getRowIndex(); 107 Object requestMapValue = saveRequestMapModelValue(); 108 Map<String, Object> requestMap = getFacesContext().getExternalContext().getRequestMap(); 109 boolean hasVar = false; 110 if (requestMap.containsKey(IS_LIST_TEMPLATE_VAR)) { 111 hasVar = true; 112 } 113 Object oldIsTemplateBoolean = requestMap.remove(IS_LIST_TEMPLATE_VAR); 114 115 try { 116 setRowIndex(-2); 117 118 // expose a boolean that can be used on client side to hide this element without disturbing the DOM 119 requestMap.put(IS_LIST_TEMPLATE_VAR, Boolean.TRUE); 120 121 EditableModel model = new EditableModelImpl(Collections.singletonList(getTemplate()), null); 122 model.setRowIndex(0); 123 requestMap.put("model", model); 124 125 // render the template as escaped html 126 ResponseWriter oldResponseWriter = context.getResponseWriter(); 127 StringWriter cacheingWriter = new StringWriter(); 128 129 ResponseWriter newResponseWriter = context.getResponseWriter().cloneWithWriter(cacheingWriter); 130 131 context.setResponseWriter(newResponseWriter); 132 133 if (getChildCount() > 0) { 134 for (UIComponent kid : getChildren()) { 135 if (!kid.isRendered()) { 136 continue; 137 } 138 try { 139 ComponentSupport.encodeRecursive(context, kid); 140 } catch (IOException err) { 141 log.error("Error while rendering component " + kid); 142 } 143 } 144 } 145 146 cacheingWriter.flush(); 147 cacheingWriter.close(); 148 149 context.setResponseWriter(oldResponseWriter); 150 151 String html = Functions.htmlEscape(cacheingWriter.toString()); 152 ResponseWriter writer = context.getResponseWriter(); 153 writer.write("<script type=\"text/x-html-template\">"); 154 writer.write(html); 155 writer.write("</script>"); 156 157 } finally { 158 setRowIndex(oldIndex); 159 160 // restore 161 if (hasVar) { 162 requestMap.put(IS_LIST_TEMPLATE_VAR, oldIsTemplateBoolean); 163 } else { 164 requestMap.remove(IS_LIST_TEMPLATE_VAR); 165 } 166 restoreRequestMapModelValue(requestMapValue); 167 } 168 } 169 170 @Override 171 @SuppressWarnings("deprecation") 172 public void decode(FacesContext context) { 173 super.decode(context); 174 Map<String, String[]> requestMap = context.getExternalContext().getRequestParameterValuesMap(); 175 176 String clientId = getClientId() + NamingContainer.SEPARATOR_CHAR + ROW_INDEXES_PARAM; 177 String[] v = requestMap.get(clientId); 178 if (v == null) { 179 // no info => no elements to decode 180 setRowIndexes(null); 181 return; 182 } 183 184 try { 185 int[] indexes = new int[v.length]; 186 for (int i = 0; i < indexes.length; i++) { 187 indexes[i] = Integer.valueOf(v[i]); 188 } 189 setRowIndexes(indexes); 190 } catch (NumberFormatException e) { 191 throw new IllegalArgumentException( 192 String.format("Invalid value '%s' for row indexes at '%s'", StringUtils.join(v, ","), clientId)); 193 } 194 } 195 196 protected void processFacetsAndChildren(final FacesContext context, final PhaseId phaseId) { 197 List<UIComponent> stamps = getChildren(); 198 EditableModel model = getEditableModel(); 199 int oldIndex = getRowIndex(); 200 int[] rowIndexes = getRowIndexes(); 201 Object requestMapValue = saveRequestMapModelValue(); 202 203 try { 204 205 if (phaseId == PhaseId.APPLY_REQUEST_VALUES && rowIndexes != null) { 206 for (int i = 0; i < rowIndexes.length; i++) { 207 int idx = rowIndexes[i]; 208 setRowIndex(idx); 209 if (!isRowAvailable()) { 210 // new value => insert it, initialized with template 211 model.insertValue(idx, getEditableModel().getUnreferencedTemplate()); 212 } 213 } 214 } 215 216 List<Integer> deletedIndexes = new ArrayList<Integer>(); 217 if (phaseId == PhaseId.PROCESS_VALIDATIONS) { 218 // check deleted indexes, to avoid performing validation on them 219 // A map with the new index for each row key 220 Map<Integer, Integer> keyIndexMap = new HashMap<>(); 221 if (rowIndexes != null) { 222 for (int i = 0; i < rowIndexes.length; i++) { 223 int idx = rowIndexes[i]; 224 keyIndexMap.put(idx, i); 225 } 226 } 227 for (int i = 0; i < getRowCount(); i++) { 228 // This row has been deleted 229 if (!keyIndexMap.containsKey(i)) { 230 deletedIndexes.add(i); 231 } 232 } 233 } 234 235 int end = getRowCount(); 236 for (int idx = 0; idx < end; idx++) { 237 if (deletedIndexes.contains(idx)) { 238 continue; 239 } 240 setRowIndex(idx); 241 if (isRowAvailable()) { 242 for (UIComponent stamp : stamps) { 243 processComponent(context, stamp, phaseId); 244 } 245 if (phaseId == PhaseId.UPDATE_MODEL_VALUES) { 246 // detect changes during process update phase and fill 247 // the EditableModel list diff. 248 if (isRowModified()) { 249 recordValueModified(idx, getRowData()); 250 } 251 } 252 } else { 253 break; 254 } 255 } 256 257 if (phaseId == PhaseId.UPDATE_MODEL_VALUES) { 258 // A map with the new index for each row key 259 Map<Integer, Integer> keyIndexMap = new HashMap<>(); 260 if (rowIndexes != null) { 261 for (int i = 0; i < rowIndexes.length; i++) { 262 int idx = rowIndexes[i]; 263 keyIndexMap.put(idx, i); 264 } 265 } 266 267 // rows to delete 268 List<Integer> toDelete = new ArrayList<>(); 269 // client id 270 String cid = super.getClientId(context); 271 272 // move rows 273 for (int i = 0; i < getRowCount(); i++) { 274 setRowKey(i); 275 int curIdx = getRowIndex(); 276 277 // This row has been deleted 278 if (!keyIndexMap.containsKey(i)) { 279 toDelete.add(i); 280 } else { // This row has been moved 281 int newIdx = keyIndexMap.get(i); 282 if (curIdx != newIdx) { 283 model.moveValue(curIdx, newIdx); 284 // also move any messages in the context attached to the old index 285 String prefix = cid + SEPARATOR_CHAR + curIdx + SEPARATOR_CHAR; 286 String replacement = cid + SEPARATOR_CHAR + newIdx + SEPARATOR_CHAR; 287 Iterator<String> it = context.getClientIdsWithMessages(); 288 while (it.hasNext()) { 289 String id = it.next(); 290 if (id != null && id.startsWith(prefix)) { 291 Iterator<FacesMessage> mit = context.getMessages(id); 292 while (mit.hasNext()) { 293 context.addMessage(id.replaceFirst(prefix, replacement), mit.next()); 294 } 295 } 296 } 297 } 298 } 299 } 300 301 // delete rows 302 for (int i : toDelete) { 303 setRowKey(i); 304 model.removeValue(getRowIndex()); 305 } 306 } 307 308 } finally { 309 setRowIndex(oldIndex); 310 restoreRequestMapModelValue(requestMapValue); 311 } 312 } 313 314}