001/*
002 * (C) Copyright 2006-2007 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 *     Nuxeo - initial API and implementation
018 *
019 * $Id$
020 */
021
022package org.nuxeo.ecm.platform.ui.web.directory;
023
024import java.io.IOException;
025import java.io.Serializable;
026import java.util.ArrayList;
027import java.util.Arrays;
028import java.util.HashMap;
029import java.util.Iterator;
030import java.util.LinkedHashMap;
031import java.util.List;
032import java.util.Map;
033
034import javax.el.ELException;
035import javax.el.ValueExpression;
036import javax.faces.FacesException;
037import javax.faces.application.FacesMessage;
038import javax.faces.component.UIComponent;
039import javax.faces.component.UIInput;
040import javax.faces.context.FacesContext;
041import javax.faces.context.ResponseWriter;
042
043import org.apache.commons.lang.StringUtils;
044import org.apache.commons.logging.Log;
045import org.apache.commons.logging.LogFactory;
046import org.nuxeo.ecm.core.api.NuxeoException;
047import org.nuxeo.ecm.platform.ui.web.component.ResettableComponent;
048import org.nuxeo.ecm.platform.ui.web.util.ComponentUtils;
049
050import com.sun.faces.facelets.component.UIRepeat;
051
052/**
053 * DOCUMENT ME.
054 * <p>
055 * Refactor me and it's christmas.
056 *
057 * @author <a href="mailto:glefter@nuxeo.com">George Lefter</a>
058 */
059public class ChainSelect extends UIInput implements ResettableComponent {
060
061    public static final String COMPONENT_TYPE = "nxdirectory.chainSelect";
062
063    public static final String COMPONENT_FAMILY = "nxdirectory.chainSelect";
064
065    public static final String DEFAULT_KEY_SEPARATOR = "/";
066
067    public static final String DEFAULT_PARENT_KEY = null;
068
069    private static final Log log = LogFactory.getLog(ChainSelect.class);
070
071    // Direct access from ChainSelectStatus
072    Map<Integer, NestedChainSelectComponentInfo> compInfos = new HashMap<Integer, NestedChainSelectComponentInfo>();
073
074    /**
075     * The keys of the selected items in chain controls.
076     */
077    private List<String> keyList = new ArrayList<String>();
078
079    private String onchange;
080
081    private Map<String, DirectorySelectItem>[] optionList;
082
083    private Integer size;
084
085    private boolean localize;
086
087    private boolean multiSelect = false;
088
089    private boolean allowRootSelection = false;
090
091    private boolean allowBranchSelection = false;
092
093    private boolean qualifiedParentKeys = false;
094
095    private Selection[] selections;
096
097    // XXX AT: this attribute is useless, value is already there to store that
098    private Selection[] componentValue;
099
100    private Boolean displayValueOnly;
101
102    private String displayValueOnlyStyle;
103
104    private String displayValueOnlyStyleClass;
105
106    private String cssStyle;
107
108    private String cssStyleClass;
109
110    private boolean multiParentSelect = false;
111
112    /**
113     * The index of the last selection box that was selected.
114     */
115    private int lastSelectedComponentIndex;
116
117    /**
118     * This field is used to separate the levels of on hierarchical vocabulary.This way all parents of a record will be
119     * separated through this field.
120     */
121    private String keySeparator;
122
123    /**
124     * Value used to filter on parent key when searching for a hierarchical directory roots.
125     * <p>
126     * If not set, will use null.
127     */
128    protected String defaultRootKey;
129
130    /**
131     * New attribute to handle bad behaviour on ajax re-render, forcing local cache refresh
132     *
133     * @since 5.6
134     */
135    protected Boolean resetCacheOnUpdate;
136
137    public boolean isAllowBranchSelection() {
138        return allowBranchSelection;
139    }
140
141    public void setAllowBranchSelection(boolean allowBranchSelection) {
142        this.allowBranchSelection = allowBranchSelection;
143    }
144
145    public boolean isAllowRootSelection() {
146        return allowRootSelection;
147    }
148
149    public void setAllowRootSelection(boolean allowRootSelection) {
150        this.allowRootSelection = allowRootSelection;
151    }
152
153    @Override
154    public String getFamily() {
155        return COMPONENT_FAMILY;
156    }
157
158    @Override
159    public String getRendererType() {
160        return null;
161    }
162
163    @Override
164    @SuppressWarnings("unchecked")
165    public void restoreState(FacesContext context, Object state) {
166        Object[] values = (Object[]) state;
167        super.restoreState(context, values[0]);
168        componentValue = (Selection[]) values[1];
169        optionList = (Map<String, DirectorySelectItem>[]) values[2];
170        localize = (Boolean) values[3];
171        size = (Integer) values[4];
172        multiSelect = (Boolean) values[5];
173        allowRootSelection = (Boolean) values[6];
174        allowBranchSelection = (Boolean) values[7];
175        selections = (Selection[]) values[8];
176        qualifiedParentKeys = (Boolean) values[9];
177        displayValueOnly = (Boolean) values[10];
178        displayValueOnlyStyle = (String) values[11];
179        displayValueOnlyStyleClass = (String) values[12];
180        multiParentSelect = (Boolean) values[13];
181        cssStyle = (String) values[14];
182        cssStyleClass = (String) values[15];
183        keySeparator = (String) values[16];
184        lastSelectedComponentIndex = (Integer) values[17];
185        compInfos = (Map<Integer, NestedChainSelectComponentInfo>) values[18];
186        keyList = (List<String>) values[19];
187        onchange = (String) values[20];
188        defaultRootKey = (String) values[21];
189        resetCacheOnUpdate = (Boolean) values[22];
190    }
191
192    @Override
193    public Object saveState(FacesContext arg0) {
194        Object[] values = new Object[23];
195        values[0] = super.saveState(arg0);
196        values[1] = componentValue;
197        values[2] = optionList;
198        values[3] = localize;
199        values[4] = size;
200        values[5] = multiSelect;
201        values[6] = allowRootSelection;
202        values[7] = allowBranchSelection;
203        values[8] = selections;
204        values[9] = qualifiedParentKeys;
205        values[10] = displayValueOnly;
206        values[11] = displayValueOnlyStyle;
207        values[12] = displayValueOnlyStyleClass;
208        values[13] = multiParentSelect;
209        values[14] = cssStyle;
210        values[15] = cssStyleClass;
211        values[16] = keySeparator;
212        values[17] = lastSelectedComponentIndex;
213        values[18] = compInfos;
214        values[19] = keyList;
215        values[20] = onchange;
216        values[21] = defaultRootKey;
217        values[22] = resetCacheOnUpdate;
218        return values;
219    }
220
221    public List<String> getSelectionKeyList() {
222        return keyList;
223    }
224
225    public void addToSelectionKeyList(String key) {
226        keyList.add(key);
227    }
228
229    @Override
230    public void decode(FacesContext context) {
231        if (getDisplayValueOnly()) {
232            return;
233        }
234
235        setValid(true);
236        rebuildOptions();
237
238        if (!multiParentSelect) {
239            componentValue = selections;
240            String[] value = encodeValue(componentValue);
241            if (!multiSelect) {
242                setSubmittedValue(value[0]);
243            } else {
244                if (!multiParentSelect) {
245                    // remove the "" entry from the submitted value
246                    List<String> list = new ArrayList<String>(Arrays.asList(value));
247                    list.remove("");
248                    value = list.toArray(new String[list.size()]);
249                }
250                setSubmittedValue(value);
251            }
252        } else {
253            String[] value = encodeValue(componentValue);
254            setSubmittedValue(value);
255        }
256
257        // identify the repeat child tag that displays
258        // current added selections to dynamically set
259        // it's iterable value
260        List<UIComponent> children = getChildren();
261        for (UIComponent child : children) {
262            if (!(child instanceof UIRepeat)) {
263                continue;
264            }
265            UIRepeat component = (UIRepeat) child;
266            if (component.getId().equals("current_selections")) {
267                component.setValue(componentValue);
268            }
269        }
270    }
271
272    public static String format(Object o) {
273        if (o == null) {
274            return "NULL";
275        }
276        if (o instanceof String[]) {
277            return formatAr((String[]) o);
278        } else if (o instanceof String) {
279            return (String) o;
280        } else {
281            return o.getClass().getName();
282        }
283    }
284
285    public static String formatAr(String[] ar) {
286        if (ar == null) {
287            return "NULL";
288        }
289        if (ar.length == 0) {
290            return "[]";
291        } else {
292            return '[' + StringUtils.join(ar, ", ") + ']';
293        }
294    }
295
296    @Override
297    public void encodeBegin(FacesContext context) throws IOException {
298        init();
299        rebuildOptions();
300        ResponseWriter writer = context.getResponseWriter();
301        writer.startElement("div", this);
302        if (cssStyle != null) {
303            writer.writeAttribute("style", cssStyle, "style");
304        }
305        if (cssStyleClass != null) {
306            writer.writeAttribute("class", cssStyleClass, "class");
307        }
308        writer.writeAttribute("id", getClientId(context), "id");
309
310        super.encodeBegin(context);
311    }
312
313    @Override
314    public void encodeEnd(FacesContext context) throws IOException {
315        ResponseWriter writer = context.getResponseWriter();
316        writer.endElement("div");
317    }
318
319    public Object getProperty(String name) {
320        ValueExpression ve = getValueExpression(name);
321        if (ve != null) {
322            try {
323                return ve.getValue(getFacesContext().getELContext());
324            } catch (ELException e) {
325                throw new FacesException(e);
326            }
327        } else {
328            Map<String, Object> attrMap = getAttributes();
329            return attrMap.get(name);
330        }
331    }
332
333    public String getStringProperty(String name, String defaultValue) {
334        String value = (String) getProperty(name);
335        return value != null ? value : defaultValue;
336    }
337
338    public Boolean getBooleanProperty(String name, boolean defaultValue) {
339        Boolean value = (Boolean) getProperty(name);
340        return value != null ? value : Boolean.valueOf(defaultValue);
341    }
342
343    public Boolean getLocalize() {
344        return localize;
345    }
346
347    public void setLocalize(Boolean localize) {
348        this.localize = localize;
349    }
350
351    public String getCssStyle() {
352        return cssStyle;
353    }
354
355    public void setCssStyle(String cssStyle) {
356        this.cssStyle = cssStyle;
357    }
358
359    public String getCssStyleClass() {
360        return cssStyleClass;
361    }
362
363    public void setCSsStyleClass(String cssStyleClass) {
364        this.cssStyleClass = cssStyleClass;
365    }
366
367    public String getOnchange() {
368        if (onchange != null) {
369            return onchange;
370        }
371        ValueExpression ve = getValueExpression("onchange");
372        if (ve != null) {
373            try {
374                return (String) ve.getValue(getFacesContext().getELContext());
375            } catch (ELException e) {
376                throw new FacesException(e);
377            }
378        }
379        return null;
380    }
381
382    public void setOnchange(String onchange) {
383        this.onchange = onchange;
384    }
385
386    public Selection getSelection(int i) {
387        if (selections == null) {
388            throw new NuxeoException("ChainSelect is mis-behaving, it's probable you're experiencing issue NXP-5762");
389        }
390        return selections[i];
391    }
392
393    public void setSelections(Selection[] sels) {
394        selections = sels;
395    }
396
397    public Integer getSize() {
398        return size;
399    }
400
401    @SuppressWarnings("unchecked")
402    public void setSize(Integer size) {
403        optionList = new LinkedHashMap[size];
404        this.size = size;
405    }
406
407    public Map<String, DirectorySelectItem> getOptions(int index) {
408        return optionList[index];
409    }
410
411    public void setOptions(int index, Map<String, DirectorySelectItem> opts) {
412        optionList[index] = opts;
413    }
414
415    /**
416     * If the user changes selection for position k, all options for n>k will be reset. We only have to rebuild options
417     * for position k+1.
418     */
419    public void rebuildOptions() {
420        // for (int i = 0; i < size; i++) {
421        // if (optionList[i] != null) {
422        // continue;
423        // }
424        // if (i == 0
425        // || (selections.length != 0 && selections[0].getColumnValue(i - 1) !=
426        // null)) {
427        // rebuildOptions(i);
428        // }
429        // }
430    }
431
432    public ChainSelectListboxComponent getComponent(UIComponent parent, int i) {
433        ChainSelectListboxComponent c = null;
434        Iterator<UIComponent> children = parent.getFacetsAndChildren();
435        if (children != null) {
436            UIComponent child = null;
437            while (children.hasNext()) {
438                child = (UIComponent) children.next();
439                if (child instanceof ChainSelectListboxComponent) {
440                    Integer index = ((ChainSelectListboxComponent) child).getIndex();
441                    if (i == index) {
442                        c = (ChainSelectListboxComponent) child;
443                        break;
444                    }
445                } else {
446                    // explore subcomps
447                    c = getComponent(child, i);
448                    if (c != null) {
449                        break;
450                    }
451                }
452            }
453        }
454        return c;
455    }
456
457    public ChainSelectListboxComponent getComponent(int i) {
458        return getComponent(this, i);
459    }
460
461    public boolean isMultiSelect() {
462        return multiSelect;
463    }
464
465    public void setMultiSelect(boolean multiSelect) {
466        this.multiSelect = multiSelect;
467    }
468
469    public Selection[] getSelections() {
470        return selections;
471    }
472
473    public boolean isQualifiedParentKeys() {
474        return qualifiedParentKeys;
475    }
476
477    public void setQualifiedParentKeys(boolean fullyQualifiedParentKey) {
478        qualifiedParentKeys = fullyQualifiedParentKey;
479    }
480
481    public Boolean getDisplayValueOnly() {
482        if (displayValueOnly != null) {
483            return displayValueOnly;
484        }
485        return false;
486    }
487
488    public void setDisplayValueOnly(Boolean displayValueOnly) {
489        this.displayValueOnly = displayValueOnly;
490    }
491
492    public String getDisplayValueOnlyStyle() {
493        return displayValueOnlyStyle;
494    }
495
496    public void setDisplayValueOnlyStyle(String displayValueOnlyStyle) {
497        this.displayValueOnlyStyle = displayValueOnlyStyle;
498    }
499
500    public String getDisplayValueOnlyStyleClass() {
501        return displayValueOnlyStyleClass;
502    }
503
504    public void setDisplayValueOnlyStyleClass(String displayValueOnlyStyleClass) {
505        this.displayValueOnlyStyleClass = displayValueOnlyStyleClass;
506    }
507
508    public boolean getMultiParentSelect() {
509        return multiParentSelect;
510    }
511
512    public void setMultiParentSelect(boolean multiParentSelect) {
513        this.multiParentSelect = multiParentSelect;
514        if (multiParentSelect) {
515            multiSelect = true;
516        }
517    }
518
519    public String[] encodeValue(Selection[] selections) {
520        String[] keys = new String[selections.length];
521        for (int i = 0; i < selections.length; i++) {
522            keys[i] = selections[i].getValue(keySeparator);
523        }
524        return keys;
525    }
526
527    private void init() {
528        if (componentValue == null) {
529            Object value = getValue();
530            if (value == null) {
531                componentValue = new Selection[0];
532                selections = new Selection[1];
533                selections[0] = new Selection(new DirectorySelectItem[0]);
534                return;
535            }
536            String[] rows;
537            if (multiSelect) {
538                if (value instanceof String[]) {
539                    rows = (String[]) value;
540                } else if (value instanceof Object[]) {
541                    Object[] values = (Object[]) value;
542                    rows = new String[values.length];
543                    for (int i = 0; i < rows.length; i++) {
544                        rows[i] = String.valueOf(values[i]);
545                    }
546                } else if (value instanceof List) {
547                    List valueList = (List) value;
548                    rows = new String[valueList.size()];
549                    for (int i = 0; i < rows.length; i++) {
550                        rows[i] = String.valueOf(valueList.get(i));
551                    }
552                } else {
553                    rows = new String[] {};
554                }
555            } else {
556                rows = new String[] { (String) value };
557            }
558
559            componentValue = new Selection[rows.length];
560            for (int i = 0; i < rows.length; i++) {
561                String[] columns = StringUtils.split(rows[i], getKeySeparator());
562                componentValue[i] = createSelection(columns);
563            }
564
565            if (multiParentSelect) {
566                selections = new Selection[1];
567                selections[0] = new Selection(new DirectorySelectItem[0]);
568            } else {
569                selections = componentValue;
570            }
571        }
572    }
573
574    public Selection createSelection(List<String> columns) {
575        return createSelection(columns.toArray(new String[columns.size()]));
576    }
577
578    public Selection createSelection(String[] columns) {
579        List<String> keyList = new ArrayList<String>();
580        List<DirectorySelectItem> itemList = new ArrayList<DirectorySelectItem>();
581        for (int i = 0; i < columns.length; i++) {
582            String id = columns[i];
583
584            String directoryName = null;
585            VocabularyEntryList directoryValues = null;
586            boolean displayObsoleteEntries = false;
587
588            NestedChainSelectComponentInfo compInfo = compInfos.get(i);
589            if (compInfo != null) {
590                directoryName = compInfo.directoryName;
591                directoryValues = compInfo.directoryValues;
592                displayObsoleteEntries = compInfo.displayObsoleteEntries;
593            } else {
594                // fallback to the old solution
595                ChainSelectListboxComponent comp = getComponent(i);
596                if (comp != null) {
597                    directoryName = comp.getStringProperty("directoryName", null);
598                    directoryValues = comp.getDirectoryValues();
599                    displayObsoleteEntries = comp.getBooleanProperty("displayObsoleteEntries", false);
600                }
601            }
602
603            Map<String, Serializable> filter = new HashMap<String, Serializable>();
604            filter.put("id", id);
605
606            if (i == 0) {
607                if (directoryName != null) {
608                    if (DirectoryHelper.instance().hasParentColumn(directoryName)) {
609                        filter.put("parent", getDefaultRootKey());
610                    }
611                }
612            } else {
613                String parentId;
614                if (qualifiedParentKeys) {
615                    parentId = StringUtils.join(keyList.iterator(), getKeySeparator());
616                } else {
617                    parentId = columns[i - 1];
618                }
619                filter.put("parent", parentId);
620            }
621
622            keyList.add(id);
623
624            if (!displayObsoleteEntries) {
625                filter.put("obsolete", 0);
626            }
627            List<DirectorySelectItem> items = null;
628            if (directoryName != null) {
629                items = DirectoryHelper.instance().getSelectItems(directoryName, filter);
630            } else {
631                items = DirectoryHelper.getSelectItems(directoryValues, filter);
632            }
633            if (items == null) {
634                throw new IllegalStateException(String.format("Item not found: directoryName=%s, filter=%s",
635                        directoryName, filter));
636            }
637            if (items.isEmpty()) {
638                log.warn(String.format("No selection for dir %s ", directoryName));
639                return new Selection(itemList.toArray(new DirectorySelectItem[0]));
640            } else {
641                if (items.size() != 1) {
642                    log.warn(String.format("Too many items (%s) found: directoryName=%s, filter=%s",
643                            Integer.toString(items.size()), directoryName, filter));
644                }
645                itemList.add(items.get(0));
646            }
647        }
648        return new Selection(itemList.toArray(new DirectorySelectItem[columns.length]));
649    }
650
651    public Selection[] getComponentValue() {
652        return componentValue;
653    }
654
655    public void setComponentValue(Selection[] componentValue) {
656        this.componentValue = componentValue;
657    }
658
659    public int getLastSelectedComponentIndex() {
660        return lastSelectedComponentIndex;
661    }
662
663    public void setLastSelectedComponentIndex(int index) {
664        lastSelectedComponentIndex = index;
665    }
666
667    /**
668     * This structure is needed to keep data for dynamically generated components.
669     */
670    static class NestedChainSelectComponentInfo {
671
672        String directoryName;
673
674        VocabularyEntryList directoryValues;
675
676        boolean displayObsoleteEntries;
677
678        boolean localize;
679
680        String display;
681
682    }
683
684    public void setCompAtIndex(int index, ChainSelectListboxComponent comp) {
685
686        NestedChainSelectComponentInfo compInfo = new NestedChainSelectComponentInfo();
687
688        compInfo.directoryName = comp.getStringProperty("directoryName", null);
689        compInfo.directoryValues = comp.getDirectoryValues();
690        compInfo.displayObsoleteEntries = comp.getBooleanProperty("displayObsoleteEntries", false);
691        compInfo.localize = comp.getBooleanProperty("localize", false);
692        compInfo.display = comp.getDisplay();
693
694        compInfos.put(index, compInfo);
695    }
696
697    public String getKeySeparator() {
698        return keySeparator != null ? keySeparator : DEFAULT_KEY_SEPARATOR;
699    }
700
701    public void setKeySeparator(String keySeparator) {
702        this.keySeparator = keySeparator;
703    }
704
705    public String getDefaultRootKey() {
706        ValueExpression ve = getValueExpression("defaultRootKey");
707        if (ve != null) {
708            return (String) ve.getValue(FacesContext.getCurrentInstance().getELContext());
709        } else {
710            return defaultRootKey;
711        }
712    }
713
714    public void setDefaultRootKey(String defaultRootKey) {
715        this.defaultRootKey = defaultRootKey;
716    }
717
718    @Override
719    public void validateValue(FacesContext context, Object newValue) {
720        super.validateValue(context, newValue);
721        if (!isValid()) {
722            return;
723        }
724
725        if (newValue instanceof String) {
726            String newValueStr = (String) newValue;
727            if (StringUtils.isEmpty(newValueStr)) {
728                return;
729            }
730
731            String[] rows = StringUtils.split(newValueStr, getKeySeparator());
732            boolean allowBranchSelection = Boolean.TRUE.equals(getBooleanProperty("allowBranchSelection", false));
733            if (!allowBranchSelection && rows.length != size) {
734                String messageStr = ComponentUtils.translate(context, "label.chainSelect.incomplete_selection");
735                FacesMessage message = new FacesMessage(FacesMessage.SEVERITY_ERROR, messageStr, messageStr);
736                context.addMessage(getClientId(context), message);
737                setValid(false);
738            }
739        }
740    }
741
742    /**
743     * @since 5.6
744     */
745    public Boolean getResetCacheOnUpdate() {
746        if (resetCacheOnUpdate != null) {
747            return resetCacheOnUpdate;
748        }
749        ValueExpression ve = getValueExpression("resetCacheOnUpdate");
750        if (ve != null) {
751            try {
752                return Boolean.valueOf(Boolean.TRUE.equals(ve.getValue(getFacesContext().getELContext())));
753            } catch (ELException e) {
754                throw new FacesException(e);
755            }
756        } else {
757            // default value
758            return Boolean.FALSE;
759        }
760    }
761
762    /**
763     * @since 5.6
764     */
765    public void setResetCacheOnUpdate(Boolean resetCacheOnUpdate) {
766        this.resetCacheOnUpdate = resetCacheOnUpdate;
767    }
768
769    /**
770     * Override update method to reset cached value and ensure good re-render in ajax
771     *
772     * @since 5.6
773     */
774    @Override
775    public void processUpdates(FacesContext context) {
776        super.processUpdates(context);
777        if (Boolean.TRUE.equals(getResetCacheOnUpdate()) && isValid()) {
778            componentValue = new Selection[0];
779        }
780    }
781
782    /**
783     * Reset the chain select cached model
784     *
785     * @since 5.7
786     */
787    @Override
788    public void resetCachedModel() {
789        if (getValueExpression("value") != null) {
790            setValue(null);
791            setLocalValueSet(false);
792        }
793        setSubmittedValue(null);
794        setComponentValue(null);
795    }
796
797}