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