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