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}