/** * DataCleaner (community edition) * Copyright (C) 2014 Neopost - Customer Information Management * * This copyrighted material is made available to anyone wishing to use, modify, * copy, or redistribute it subject to the terms and conditions of the GNU * Lesser General Public License, as published by the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License * for more details. * * You should have received a copy of the GNU Lesser General Public License * along with this distribution; if not, write to: * Free Software Foundation, Inc. * 51 Franklin Street, Fifth Floor * Boston, MA 02110-1301 USA */ package org.datacleaner.widgets.properties; import java.awt.event.ActionListener; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeMap; import javax.inject.Inject; import javax.swing.JButton; import javax.swing.JCheckBox; import javax.swing.JComponent; import javax.swing.border.CompoundBorder; import javax.swing.event.DocumentEvent; import org.apache.commons.lang.ArrayUtils; import org.apache.metamodel.util.CollectionUtils; import org.apache.metamodel.util.HasNameMapper; import org.datacleaner.actions.AddExpressionBasedColumnActionListener; import org.datacleaner.actions.ReorderColumnsActionListener; import org.datacleaner.api.ExpressionBasedInputColumn; import org.datacleaner.api.InputColumn; import org.datacleaner.data.MutableInputColumn; import org.datacleaner.descriptors.ComponentDescriptor; import org.datacleaner.descriptors.ConfiguredPropertyDescriptor; import org.datacleaner.job.builder.AnalysisJobBuilder; import org.datacleaner.job.builder.ComponentBuilder; import org.datacleaner.job.builder.SourceColumnChangeListener; import org.datacleaner.job.builder.TransformerChangeListener; import org.datacleaner.job.builder.TransformerComponentBuilder; import org.datacleaner.panels.DCPanel; import org.datacleaner.util.DCDocumentListener; import org.datacleaner.util.IconUtils; import org.datacleaner.util.LabelUtils; import org.datacleaner.util.ReflectionUtils; import org.datacleaner.util.StringUtils; import org.datacleaner.util.WidgetFactory; import org.datacleaner.util.WidgetUtils; import org.datacleaner.widgets.DCCheckBox; import org.datacleaner.widgets.DCCheckBox.Listener; import org.jdesktop.swingx.HorizontalLayout; import org.jdesktop.swingx.JXTextField; import org.jdesktop.swingx.VerticalLayout; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Property widget for multiple input columns. Displays these as checkboxes. */ public class MultipleInputColumnsPropertyWidget extends AbstractPropertyWidget<InputColumn<?>[]> implements SourceColumnChangeListener, TransformerChangeListener, MutableInputColumn.Listener, ReorderColumnsActionListener.ReorderColumnsCallback { private static final Logger logger = LoggerFactory.getLogger(MultipleInputColumnsPropertyWidget.class); private final Listener<InputColumn<?>> checkBoxListener = (item, selected) -> { if (isBatchUpdating()) { return; } fireValueChanged(); }; private final Class<?> _dataType; private final Map<InputColumn<?>, DCCheckBox<InputColumn<?>>> _checkBoxes; private final ActionListener selectAllActionListener = e -> selectAll(); private final ActionListener selectNoneActionListener = e -> selectNone(); private final Map<DCCheckBox<InputColumn<?>>, JComponent> _checkBoxDecorations; private final DCPanel _buttonPanel; private final JXTextField _searchDatastoreTextField; private final DCCheckBox<InputColumn<?>> _notAvailableCheckBox; private volatile boolean _firstUpdate; @Inject public MultipleInputColumnsPropertyWidget(final ComponentBuilder componentBuilder, final ConfiguredPropertyDescriptor propertyDescriptor) { super(componentBuilder, propertyDescriptor); _checkBoxes = new LinkedHashMap<>(); _checkBoxDecorations = new IdentityHashMap<>(); _firstUpdate = true; _dataType = propertyDescriptor.getTypeArgument(0); setLayout(new VerticalLayout(2)); _searchDatastoreTextField = WidgetFactory.createTextField("Search/filter columns"); _searchDatastoreTextField .setBorder(new CompoundBorder(WidgetUtils.BORDER_CHECKBOX_LIST_INDENTATION, WidgetUtils.BORDER_THIN)); _searchDatastoreTextField.getDocument().addDocumentListener(new DCDocumentListener() { @Override protected void onChange(final DocumentEvent event) { String text = _searchDatastoreTextField.getText(); if (StringUtils.isNullOrEmpty(text)) { // when there is no search query, set all datastores // visible for (final JCheckBox cb : _checkBoxes.values()) { cb.setVisible(true); } } else { // do a case insensitive search text = text.trim().toLowerCase(); for (final JCheckBox cb : _checkBoxes.values()) { final String name = cb.getText().toLowerCase(); cb.setVisible(name.contains(text)); } } } }); if (_dataType == null || _dataType == Object.class) { _notAvailableCheckBox = new DCCheckBox<>("<html><font color=\"gray\">- no columns available -</font></html>", false); } else { _notAvailableCheckBox = new DCCheckBox<>( "<html><font color=\"gray\">- no <i>" + LabelUtils.getDataTypeLabel(_dataType) + "</i> columns available -</font></html>", false); } _notAvailableCheckBox.setEnabled(false); _buttonPanel = new DCPanel(); _buttonPanel.setLayout(new HorizontalLayout(2)); _buttonPanel.setBorder(WidgetUtils.BORDER_CHECKBOX_LIST_INDENTATION); final JButton selectAllButton = WidgetFactory.createDefaultButton("Select all"); selectAllButton.setFont(WidgetUtils.FONT_SMALL); selectAllButton.addActionListener(selectAllActionListener); _buttonPanel.add(selectAllButton); final JButton selectNoneButton = WidgetFactory.createDefaultButton("Select none"); selectNoneButton.setFont(WidgetUtils.FONT_SMALL); selectNoneButton.addActionListener(selectNoneActionListener); _buttonPanel.add(selectNoneButton); if (propertyDescriptor.isArray()) { if (_dataType == String.class || _dataType == Object.class) { final JButton expressionColumnButton = WidgetFactory.createSmallButton(IconUtils.MODEL_COLUMN_EXPRESSION); expressionColumnButton.setToolTipText("Create expression/value based column"); expressionColumnButton .addActionListener(AddExpressionBasedColumnActionListener.forMultipleColumns(this)); _buttonPanel.add(expressionColumnButton); } final JButton reorderColumnsButton = WidgetFactory.createSmallButton(IconUtils.ACTION_REORDER_COLUMNS); reorderColumnsButton.setToolTipText("Reorder columns"); reorderColumnsButton.addActionListener(new ReorderColumnsActionListener(this)); _buttonPanel.add(reorderColumnsButton); } add(_buttonPanel); add(_searchDatastoreTextField); } @Override public void initialize(final InputColumn<?>[] value) { updateComponents(value); _firstUpdate = false; if (value != null && value.length > 0) { // update selections in checkboxes batchUpdateWidget(() -> { reorderValue(value); for (final DCCheckBox<InputColumn<?>> cb : _checkBoxes.values()) { if (ArrayUtils.contains(value, cb.getValue())) { cb.setSelected(true); } else { cb.setSelected(false); } } onValuesBatchSelected(Arrays.asList(value)); }); } updateUI(); } protected boolean isAllInputColumnsSelectedIfNoValueExist() { return false; } private void updateComponents() { final InputColumn<?>[] currentValue = getCurrentValue(); updateComponents(currentValue); } private void updateComponents(final InputColumn<?>[] value) { // fetch available input columns final List<InputColumn<?>> availableColumns = getAnalysisJobBuilder().getAvailableInputColumns(getComponentBuilder(), _dataType); final Set<InputColumn<?>> inputColumnsToBeRemoved = new HashSet<>(); inputColumnsToBeRemoved.addAll(_checkBoxes.keySet()); if (value != null) { // retain selected expression based input columns for (final InputColumn<?> col : value) { if (col instanceof ExpressionBasedInputColumn) { inputColumnsToBeRemoved.remove(col); availableColumns.add(col); } else { if (!availableColumns.contains(col)) { logger.warn( "The value contains a column which is not found in the 'available' set of input columns: {}", col); } } } } for (final InputColumn<?> col : availableColumns) { inputColumnsToBeRemoved.remove(col); final DCCheckBox<InputColumn<?>> checkBox = _checkBoxes.get(col); if (checkBox == null) { addAvailableInputColumn(col, isEnabled(col, value)); } else { // handle updated names from transformed columns. checkBox.setText(col.getName()); } } for (final InputColumn<?> col : inputColumnsToBeRemoved) { removeAvailableInputColumn(col); } updateVisibility(); } private void updateVisibility() { _searchDatastoreTextField.setVisible(_checkBoxes.size() > 16); if (_checkBoxes.isEmpty()) { add(_notAvailableCheckBox); } else { remove(_notAvailableCheckBox); } } /** * Method that allows decorating a checkbox for an input column. Subclasses * can eg. wrap the checkbox in a panel, in order to make additional widgets * available. * * @param checkBox * @return a {@link JComponent} to add to the widget's parent. */ protected JComponent decorateCheckBox(final DCCheckBox<InputColumn<?>> checkBox) { return checkBox; } private boolean isEnabled(final InputColumn<?> inputColumn, final InputColumn<?>[] currentValue) { if (_firstUpdate) { if (currentValue == null || currentValue.length == 0) { // set all to true if this is the only required inputcolumn // property final ComponentDescriptor<?> componentDescriptor = getPropertyDescriptor().getComponentDescriptor(); if (componentDescriptor.getConfiguredPropertiesForInput(false).size() == 1) { return isAllInputColumnsSelectedIfNoValueExist(); } return false; } } for (final InputColumn<?> col : currentValue) { if (inputColumn == col) { return true; } } return false; } @Override public boolean isSet() { for (final JCheckBox checkBox : _checkBoxes.values()) { if (checkBox.isSelected()) { return true; } } return false; } @Override public InputColumn<?>[] getValue() { final List<InputColumn<?>> result = getSelectedInputColumns(); if (logger.isDebugEnabled()) { final List<String> names = CollectionUtils.map(result, new HasNameMapper()); logger.debug("getValue() returning: {}", names); } return result.toArray(new InputColumn<?>[result.size()]); } @Override protected void setValue(final InputColumn<?>[] values) { if (values == null) { logger.debug("setValue(null) - delegating to setValue([])"); setValue(new InputColumn[0]); return; } if (logger.isDebugEnabled()) { final List<String> names = CollectionUtils.map(values, new HasNameMapper()); logger.debug("setValue({})", names); } // if checkBoxes is empty it means that the value is being set before // initializing the widget. This can occur in subclasses and automatic // creating of checkboxes should be done. if (_checkBoxes.isEmpty()) { for (final InputColumn<?> value : values) { addAvailableInputColumn(value, true); } } // add expression based input columns if needed. for (final InputColumn<?> inputColumn : values) { if (inputColumn instanceof ExpressionBasedInputColumn && !_checkBoxes.containsKey(inputColumn)) { addAvailableInputColumn(inputColumn, true); } } // update selections in checkboxes batchUpdateWidget(() -> { final List<InputColumn<?>> valueList = new ArrayList<>(); for (final DCCheckBox<InputColumn<?>> cb : _checkBoxes.values()) { if (ArrayUtils.contains(values, cb.getValue())) { cb.setSelected(true, true); valueList.add(cb.getValue()); } else { cb.setSelected(false, true); } } onValuesBatchSelected(valueList); }); updateUI(); } protected List<InputColumn<?>> getSelectedInputColumns() { final List<InputColumn<?>> result = new ArrayList<>(); final Collection<DCCheckBox<InputColumn<?>>> checkBoxes = _checkBoxes.values(); for (final DCCheckBox<InputColumn<?>> cb : checkBoxes) { if (cb.isSelected()) { final InputColumn<?> value = cb.getValue(); if (value != null) { result.add(value); } } } return result; } @Override public void onAdd(final InputColumn<?> sourceColumn) { if (_dataType == Object.class || ReflectionUtils.is(sourceColumn.getDataType(), _dataType)) { addAvailableInputColumn(sourceColumn); updateVisibility(); updateUI(); } } @Override public void onRemove(final InputColumn<?> sourceColumn) { removeAvailableInputColumn(sourceColumn); updateVisibility(); updateUI(); } @Override public void onAdd(final TransformerComponentBuilder<?> transformerJobBuilder) { } @Override public void onRemove(final TransformerComponentBuilder<?> transformerJobBuilder) { } @Override protected void onPanelAdd() { super.onPanelAdd(); final AnalysisJobBuilder analysisJobBuilder = getAnalysisJobBuilder(); analysisJobBuilder.addSourceColumnChangeListener(this); analysisJobBuilder.addTransformerChangeListener(this); } @Override public void onPanelRemove() { super.onPanelRemove(); final AnalysisJobBuilder analysisJobBuilder = getAnalysisJobBuilder(); analysisJobBuilder.removeSourceColumnChangeListener(this); analysisJobBuilder.removeTransformerChangeListener(this); // remove all listeners on columns too final Set<InputColumn<?>> inputColumns = _checkBoxes.keySet(); for (final InputColumn<?> inputColumn : inputColumns) { if (inputColumn instanceof MutableInputColumn) { ((MutableInputColumn<?>) inputColumn).removeListener(this); } } } @Override public void onOutputChanged(final TransformerComponentBuilder<?> transformerJobBuilder, final List<MutableInputColumn<?>> outputColumns) { // Makes sure it makes sense to do this (rather destructive) update if (transformerJobBuilder == getComponentBuilder() || transformerJobBuilder.getAnalysisJobBuilder() != getAnalysisJobBuilder()) { return; } // we need to save the current value before we update the components // here. Otherwise any previous selections will be lost. final InputColumn<?>[] value = getValue(); getComponentBuilder().setConfiguredProperty(getPropertyDescriptor(), value); updateComponents(value); updateUI(); } @Override public void onConfigurationChanged(final TransformerComponentBuilder<?> transformerJobBuilder) { if (transformerJobBuilder == getComponentBuilder()) { return; } updateComponents(); updateUI(); } @Override public void onRequirementChanged(final TransformerComponentBuilder<?> transformerJobBuilder) { } /** * Overrideable method for subclasses to get informed when values have been * selected in a batch update * * @param values */ protected void onValuesBatchSelected(final List<InputColumn<?>> values) { } protected void selectAll() { batchUpdateWidget(() -> { final List<InputColumn<?>> valueList = new ArrayList<>(); for (final DCCheckBox<InputColumn<?>> cb : _checkBoxes.values()) { if (cb.getValue() instanceof MutableInputColumn) { final MutableInputColumn<?> mutableInputColumn = (MutableInputColumn<?>) cb.getValue(); if (mutableInputColumn.isHidden()) { // skip hidden columns continue; } } if (cb.isEnabled()) { cb.setSelected(true); valueList.add(cb.getValue()); } } onValuesBatchSelected(valueList); }); } protected void selectNone() { batchUpdateWidget(() -> { for (final DCCheckBox<InputColumn<?>> cb : _checkBoxes.values()) { cb.setSelected(false); } onValuesBatchSelected(Collections.emptyList()); }); } private void addAvailableInputColumn(final InputColumn<?> col) { addAvailableInputColumn(col, false); } private void addAvailableInputColumn(final InputColumn<?> col, final boolean selected) { final JComponent decoration = getOrCreateCheckBoxDecoration(col, selected); add(decoration); } private void removeAvailableInputColumn(final InputColumn<?> col) { boolean valueChanged = false; final DCCheckBox<InputColumn<?>> checkBox = _checkBoxes.remove(col); if (checkBox != null) { if (checkBox.isSelected()) { valueChanged = true; } final JComponent decoration = _checkBoxDecorations.remove(checkBox); remove(decoration); } if (col instanceof MutableInputColumn) { ((MutableInputColumn<?>) col).removeListener(this); } if (valueChanged) { fireValueChanged(); } } @Override public void reorderColumns(final InputColumn<?>[] newValue) { reorderValue(newValue); } @Override public InputColumn<?>[] getColumns() { return getValue(); } /** * Reorders the values * * @param sortedValue */ public void reorderValue(final InputColumn<?>[] sortedValue) { // the offset represents the search textfield and the button panel final int offset = 2; // reorder the visual components for (int i = 0; i < sortedValue.length; i++) { final InputColumn<?> inputColumn = sortedValue[i]; final JComponent decoration = getOrCreateCheckBoxDecoration(inputColumn, true); final int position = offset + i; add(decoration, position); } updateUI(); // recreate the _checkBoxes map final TreeMap<InputColumn<?>, DCCheckBox<InputColumn<?>>> checkBoxesCopy = new TreeMap<>(_checkBoxes); _checkBoxes.clear(); for (final InputColumn<?> inputColumn : sortedValue) { final DCCheckBox<InputColumn<?>> checkBox = checkBoxesCopy.get(inputColumn); _checkBoxes.put(inputColumn, checkBox); } _checkBoxes.putAll(checkBoxesCopy); setValue(sortedValue); } private JComponent getOrCreateCheckBoxDecoration(final InputColumn<?> inputColumn, final boolean selected) { DCCheckBox<InputColumn<?>> checkBox = _checkBoxes.get(inputColumn); if (checkBox == null) { final String name = inputColumn.getName(); checkBox = new DCCheckBox<>(name, selected); checkBox.addListener(checkBoxListener); checkBox.setValue(inputColumn); _checkBoxes.put(inputColumn, checkBox); } JComponent decoration = _checkBoxDecorations.get(checkBox); if (decoration == null) { decoration = decorateCheckBox(checkBox); _checkBoxDecorations.put(checkBox, decoration); if (inputColumn instanceof MutableInputColumn) { final MutableInputColumn<?> mutableInputColumn = (MutableInputColumn<?>) inputColumn; mutableInputColumn.addListener(this); if (mutableInputColumn.isHidden()) { //If the column was already selected before it was hidden, we still show it. if (!checkBox.isSelected()) { decoration.setVisible(false); } } } } return decoration; } public Map<InputColumn<?>, DCCheckBox<InputColumn<?>>> getCheckBoxes() { return Collections.unmodifiableMap(_checkBoxes); } public Map<DCCheckBox<InputColumn<?>>, JComponent> getCheckBoxDecorations() { return Collections.unmodifiableMap(_checkBoxDecorations); } public DCPanel getButtonPanel() { return _buttonPanel; } @Override public void onNameChanged(final MutableInputColumn<?> inputColumn, final String oldName, final String newName) { final DCCheckBox<InputColumn<?>> checkBox = getCheckBoxes().get(inputColumn); if (checkBox == null) { return; } checkBox.setText(newName); } @Override public void onVisibilityChanged(final MutableInputColumn<?> inputColumn, final boolean hidden) { final DCCheckBox<InputColumn<?>> checkBox = getCheckBoxes().get(inputColumn); if (checkBox == null) { return; } if (checkBox.isSelected()) { // don't hide columns that are selected return; } final JComponent decoration = getCheckBoxDecorations().get(checkBox); if (decoration == null) { return; } decoration.setVisible(!hidden); } }