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