001/**
002 * Copyright 2005-2018 The Kuali Foundation
003 *
004 * Licensed under the Educational Community 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.opensource.org/licenses/ecl2.php
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 */
016package org.kuali.rice.krad.uif.modifier;
017
018import org.apache.commons.lang.StringUtils;
019import org.kuali.rice.krad.datadictionary.parse.BeanTag;
020import org.kuali.rice.krad.datadictionary.parse.BeanTagAttribute;
021import org.kuali.rice.krad.datadictionary.parse.BeanTags;
022import org.kuali.rice.krad.uif.UifConstants;
023import org.kuali.rice.krad.uif.UifPropertyPaths;
024import org.kuali.rice.krad.uif.component.Component;
025import org.kuali.rice.krad.uif.container.Group;
026import org.kuali.rice.krad.uif.element.Header;
027import org.kuali.rice.krad.uif.field.DataField;
028import org.kuali.rice.krad.uif.field.Field;
029import org.kuali.rice.krad.uif.field.SpaceField;
030import org.kuali.rice.krad.uif.layout.GridLayoutManager;
031import org.kuali.rice.krad.uif.lifecycle.ViewLifecycle;
032import org.kuali.rice.krad.uif.lifecycle.ViewLifecycleUtils;
033import org.kuali.rice.krad.uif.util.ComponentFactory;
034import org.kuali.rice.krad.uif.util.ComponentUtils;
035import org.kuali.rice.krad.uif.util.ObjectPropertyUtils;
036import org.kuali.rice.krad.uif.view.ExpressionEvaluator;
037import org.kuali.rice.krad.uif.view.View;
038
039import java.util.ArrayList;
040import java.util.HashMap;
041import java.util.HashSet;
042import java.util.List;
043import java.util.Map;
044import java.util.Set;
045
046/**
047 * Generates <code>Field</code> instances to produce a comparison view among
048 * objects of the same type
049 *
050 * <p>
051 * Modifier is initialized with a List of <code>ComparableInfo</code> instances.
052 * For each comparable info, a copy of the configured group field is made and
053 * adjusted to the binding object path for the comparable. The comparison fields
054 * are ordered based on the configured order property of the comparable. In
055 * addition, a <code>HeaderField<code> can be generated to label each group
056 * of comparison fields.
057 * </p>
058 *
059 * @author Kuali Rice Team (rice.collab@kuali.org)
060 */
061@BeanTags({@BeanTag(name = "compareFieldCreateModifier", parent = "Uif-CompareFieldCreate-Modifier"),
062        @BeanTag(name = "maintenanceCompareModifier", parent = "Uif-MaintenanceCompare-Modifier")})
063public class CompareFieldCreateModifier extends ComponentModifierBase {
064    private static final long serialVersionUID = -6285531580512330188L;
065
066    private int defaultOrderSequence;
067    private boolean generateCompareHeaders;
068
069    private Header headerFieldPrototype;
070    private List<ComparableInfo> comparables;
071
072    public CompareFieldCreateModifier() {
073        defaultOrderSequence = 1;
074        generateCompareHeaders = true;
075
076        comparables = new ArrayList<ComparableInfo>();
077    }
078
079    /**
080     * {@inheritDoc}
081     */
082    @Override
083    public void performInitialization(Object model, Component component) {
084        super.performInitialization(model, component);
085
086        if ((component != null) && !(component instanceof Group)) {
087            throw new IllegalArgumentException(
088                    "Compare field initializer only support Group components, found type: " + component.getClass());
089        }
090
091        if (component == null) {
092            return;
093        }
094
095        Group group = (Group) component;
096
097        // add the renderOnComparableModifier to allow for optional field rendering based on the comparable
098        for (Component item : group.getItems()) {
099            item.pushObjectToContext("renderOnComparableModifier", Boolean.TRUE);
100        }
101    }
102
103    /**
104     * Generates the comparison fields
105     *
106     * <p>
107     * First the configured List of ComparableInfo instances are
108     * sorted based on their order property. Then if generateCompareHeaders is
109     * set to true, a HeaderField is created for each comparable
110     * using the headerFieldPrototype and the headerText given by the
111     * comparable. Finally for each field configured on the Group,
112     * a corresponding comparison field is generated for each comparable and
113     * adjusted to the binding object path given by the comparable in addition
114     * to suffixing the id and setting the readOnly property
115     * </p>
116     *
117     * {@inheritDoc}
118     */
119    @Override
120    public void performModification(Object model, Component component) {
121        if ((component != null) && !(component instanceof Group)) {
122            throw new IllegalArgumentException(
123                    "Compare field initializer only support Group components, found type: " + component.getClass());
124        }
125
126        if (component == null) {
127            return;
128        }
129
130        Group group = (Group) component;
131
132        // list to hold the generated compare items
133        List<Component> comparisonItems = new ArrayList<Component>();
134
135        // sort comparables by their order property
136        List<ComparableInfo> groupComparables = ComponentUtils.sort(comparables, defaultOrderSequence);
137
138        // evaluate expressions on comparables
139        Map<String, Object> context = new HashMap<String, Object>();
140
141        View view = ViewLifecycle.getView();
142
143        Map<String, Object> viewContext = view.getContext();
144        if (viewContext != null) {
145            context.putAll(view.getContext());
146        }
147
148        context.put(UifConstants.ContextVariableNames.COMPONENT, component);
149
150        ExpressionEvaluator expressionEvaluator = ViewLifecycle.getExpressionEvaluator();
151
152        for (ComparableInfo comparable : groupComparables) {
153            expressionEvaluator.evaluateExpressionsOnConfigurable(view, comparable, context);
154        }
155
156        // generate compare header
157        if (isGenerateCompareHeaders()) {
158            // add space field for label column
159            SpaceField spaceField = ComponentFactory.getSpaceField();
160            comparisonItems.add(spaceField);
161
162            for (ComparableInfo comparable : groupComparables) {
163                Header compareHeaderField = ComponentUtils.copy(headerFieldPrototype, comparable.getComparableId());
164                compareHeaderField.setHeaderText(comparable.getHeaderText());
165                comparisonItems.add(compareHeaderField);
166            }
167
168            // if group is using grid layout then some extra processing needed
169            if (group.getLayoutManager() instanceof GridLayoutManager) {
170                // make first row a header
171                ((GridLayoutManager) group.getLayoutManager()).setRenderFirstRowHeader(true);
172                // add blank row CSS class
173                ((GridLayoutManager) group.getLayoutManager()).getRowCssClasses().add("");
174            }
175        }
176
177        // find the comparable to use for comparing value changes (if configured)
178        boolean performValueChangeComparison = false;
179        String compareValueObjectBindingPath = null;
180        for (ComparableInfo comparable : groupComparables) {
181            if (comparable.isCompareToForValueChange()) {
182                performValueChangeComparison = true;
183                compareValueObjectBindingPath = comparable.getBindingObjectPath();
184            }
185        }
186
187        // generate the compare items from the configured group
188        boolean changeIconShowedOnHeader = false;
189        for (Component item : group.getItems()) {
190
191            // leave Header object as is, just increase colSpan and change css class
192            if (item instanceof Header) {
193                comparisonItems.add(item);
194                item.setColSpan(groupComparables.size() + 1);
195
196                // if group is using grid layout then some extra processing needed
197                if (group.getLayoutManager() instanceof GridLayoutManager) {
198                    // add row CSS class
199                    ((GridLayoutManager) group.getLayoutManager()).getRowCssClasses().add("row-separator");
200                }
201
202                continue;
203            }
204
205            int defaultSuffix = 0;
206            boolean suppressLabel = false;
207
208            String rowCssClass = "";
209
210            for (ComparableInfo comparable : groupComparables) {
211                String comparableId = comparable.getComparableId();
212                if (StringUtils.isBlank(comparableId)) {
213                    comparableId = UifConstants.IdSuffixes.COMPARE + defaultSuffix;
214                }
215
216                Component compareItem = ComponentUtils.copy(item, comparableId);
217
218                ComponentUtils.setComponentPropertyDeep(compareItem, UifPropertyPaths.BIND_OBJECT_PATH,
219                        comparable.getBindingObjectPath());
220                if (comparable.isReadOnly()) {
221                    compareItem.setReadOnly(true);
222                    if (compareItem.getPropertyExpressions().containsKey("readOnly")) {
223                        compareItem.getPropertyExpressions().remove("readOnly");
224                    }
225                }
226
227                // label will be enabled for first comparable only
228                if (suppressLabel && (compareItem instanceof Field)) {
229                    ((Field) compareItem).getFieldLabel().setRender(false);
230                }
231
232                // add the renderOnComparableModifier to allow for optional field rendering based on the comparable
233                compareItem.pushObjectToContext("renderOnComparableModifier", comparable.isCompareToForFieldRender());
234
235                // do value comparison
236                if (performValueChangeComparison && comparable.isHighlightValueChange() && !comparable
237                        .isCompareToForValueChange()) {
238                    boolean valueChanged = performValueComparison(group, compareItem, model,
239                            compareValueObjectBindingPath);
240
241                    // add icon to group header if not done so yet
242                    if (valueChanged && !changeIconShowedOnHeader && isGenerateCompareHeaders()) {
243                        Group groupToSetHeader = null;
244                        if (group.getDisclosure() != null && group.getDisclosure().isRender()) {
245                            groupToSetHeader = group;
246                        } else if (group.getContext().get(UifConstants.ContextVariableNames.PARENT) != null) {
247                            // use the parent group to set the notification if available
248                            groupToSetHeader = (Group) group.getContext().get(UifConstants.ContextVariableNames.PARENT);
249                        }
250
251                        if (groupToSetHeader != null) {
252                            if (groupToSetHeader.getDisclosure().isRender()) {
253                                groupToSetHeader.getDisclosure().setOnDocumentReadyScript(
254                                        "showChangeIconOnDisclosure('" + groupToSetHeader.getId() + "');");
255                            } else if (groupToSetHeader.getHeader() != null) {
256                                groupToSetHeader.getHeader().setOnDocumentReadyScript(
257                                        "showChangeIconOnHeader('" + groupToSetHeader.getHeader().getId() + "');");
258                            }
259                        }
260
261                        changeIconShowedOnHeader = true;
262                    }
263
264                    // if value changed then set row CSS class for later use if using GridLayoutManager
265                    if (valueChanged) {
266                        rowCssClass = "uif-compared";
267                    }
268                }
269
270                comparisonItems.add(compareItem);
271
272                defaultSuffix++;
273
274                suppressLabel = true;
275            }
276
277            // if group is using grid layout then some extra processing needed
278            if (group.getLayoutManager() instanceof GridLayoutManager) {
279                // add row CSS class
280                ((GridLayoutManager) group.getLayoutManager()).getRowCssClasses().add(rowCssClass);
281            }
282        }
283
284        // update the group's list of components
285        group.setItems(comparisonItems);
286    }
287
288    /**
289     * For each attribute field in the compare item, retrieves the field value and compares against the value for the
290     * main comparable. If the value is different, adds script to the field on ready event to add the change icon to
291     * the field and the containing group header
292     *
293     * @param group group that contains the item and whose header will be highlighted for changes
294     * @param compareItem the compare item being generated and to pull attribute fields from
295     * @param model object containing the data
296     * @param compareValueObjectBindingPath object path for the comparison item
297     * @return true if the value in the field represented by compareItem is equal to the comparison items value, false
298     *         otherwise
299     */
300    protected boolean performValueComparison(Group group, Component compareItem, Object model,
301            String compareValueObjectBindingPath) {
302        // get any attribute fields for the item so we can compare the values
303        List<DataField> itemFields = ViewLifecycleUtils.getElementsOfTypeDeep(compareItem, DataField.class);
304        boolean valueChanged = false;
305        for (DataField field : itemFields) {
306            String fieldBindingPath = field.getBindingInfo().getBindingPath();
307            if (field.getPropertyName() != null && field.getPropertyName().length() > 0 && !fieldBindingPath.endsWith(field.getPropertyName())) {
308                fieldBindingPath += "." + field.getPropertyName();
309            }
310            Object fieldValue = ObjectPropertyUtils.getPropertyValue(model, fieldBindingPath);
311
312            String compareBindingPath = StringUtils.replaceOnce(fieldBindingPath,
313                    field.getBindingInfo().getBindingObjectPath(), compareValueObjectBindingPath);
314            Object compareValue = ObjectPropertyUtils.getPropertyValue(model, compareBindingPath);
315
316            if (!((fieldValue == null) && (compareValue == null))) {
317                // if one is null then value changed
318                if ((fieldValue == null) || (compareValue == null)) {
319                    valueChanged = true;
320                } else {
321                    // both not null, compare values
322                    valueChanged = !fieldValue.equals(compareValue);
323                }
324            }
325            if (valueChanged) {
326                // add script to show change icon
327                String onReadyScript = "showChangeIcon('" + field.getId() + "');";
328                field.setRenderMarkerIconSpan(true);
329                field.setOnDocumentReadyScript(onReadyScript);
330            }
331            // TODO: add script for value changed?
332        }
333        return valueChanged;
334    }
335
336    /**
337     * Generates an comparableId suffix for the comparable item
338     *
339     * <p>
340     * If the comparableId to use if configured on the ComparableInfo
341     * it will be used, else the given integer index will be used with an
342     * underscore
343     * </p>
344     *
345     * @param comparable comparable info to check for id suffix
346     * @param index sequence integer
347     * @return id suffix
348     * @see org.kuali.rice.krad.uif.modifier.ComparableInfo#getComparableId()
349     */
350    protected String getComparableId(ComparableInfo comparable, int index) {
351        String comparableId = comparable.getComparableId();
352        if (StringUtils.isBlank(comparableId)) {
353            comparableId = "_" + index;
354        }
355
356        return comparableId;
357    }
358
359    /**
360     * {@inheritDoc}
361     */
362    @Override
363    public Set<Class<? extends Component>> getSupportedComponents() {
364        Set<Class<? extends Component>> components = new HashSet<Class<? extends Component>>();
365        components.add(Group.class);
366
367        return components;
368    }
369
370    /**
371     * @see org.kuali.rice.krad.uif.modifier.ComponentModifierBase#getComponentPrototypes()
372     */
373    public List<Component> getComponentPrototypes() {
374        List<Component> components = new ArrayList<Component>();
375
376        components.add(headerFieldPrototype);
377
378        return components;
379    }
380
381    /**
382     * Indicates the starting integer sequence value to use for
383     * <code>ComparableInfo</code> instances that do not have the order property
384     * set
385     *
386     * @return default sequence starting value
387     */
388    @BeanTagAttribute
389    public int getDefaultOrderSequence() {
390        return this.defaultOrderSequence;
391    }
392
393    /**
394     * Setter for the default sequence starting value
395     *
396     * @param defaultOrderSequence
397     */
398    public void setDefaultOrderSequence(int defaultOrderSequence) {
399        this.defaultOrderSequence = defaultOrderSequence;
400    }
401
402    /**
403     * Indicates whether a <code>HeaderField</code> should be created for each
404     * group of comparison fields
405     *
406     * <p>
407     * If set to true, for each group of comparison fields a header field will
408     * be created using the headerFieldPrototype configured on the modifier with
409     * the headerText property of the comparable
410     * </p>
411     *
412     * @return true if the headers should be created, false if no
413     *         headers should be created
414     */
415    @BeanTagAttribute
416    public boolean isGenerateCompareHeaders() {
417        return this.generateCompareHeaders;
418    }
419
420    /**
421     * Setter for the generate comparison headers indicator
422     *
423     * @param generateCompareHeaders
424     */
425    public void setGenerateCompareHeaders(boolean generateCompareHeaders) {
426        this.generateCompareHeaders = generateCompareHeaders;
427    }
428
429    /**
430     * Prototype instance to use for creating the <code>HeaderField</code> for
431     * each group of comparison fields (if generateCompareHeaders is true)
432     *
433     * @return header field prototype
434     */
435    @BeanTagAttribute
436    public Header getHeaderFieldPrototype() {
437        return this.headerFieldPrototype;
438    }
439
440    /**
441     * Setter for the header field prototype
442     *
443     * @param headerFieldPrototype
444     */
445    public void setHeaderFieldPrototype(Header headerFieldPrototype) {
446        this.headerFieldPrototype = headerFieldPrototype;
447    }
448
449    /**
450     * List of <code>ComparableInfo</code> instances the compare fields should
451     * be generated for
452     *
453     * <p>
454     * For each comparable, a copy of the fields configured for the
455     * <code>Group</code> will be created for the comparison view
456     * </p>
457     *
458     * @return comparables to generate fields for
459     */
460    @BeanTagAttribute
461    public List<ComparableInfo> getComparables() {
462        return this.comparables;
463    }
464
465    /**
466     * Setter for the list of comparable info instances
467     *
468     * @param comparables
469     */
470    public void setComparables(List<ComparableInfo> comparables) {
471        this.comparables = comparables;
472    }
473
474}