Bug 581620 - Reapply combobox filter states on different collections

Signed-off-by: Dirk Fauth <dirk.fauth@googlemail.com>

Change-Id: Iadc6059ca0037819055dbbdcfdcdee2bf07e4c31
diff --git a/org.eclipse.nebula.widgets.nattable.core/.settings/.api_filters b/org.eclipse.nebula.widgets.nattable.core/.settings/.api_filters
index afddbc7..5fb9e5e 100644
--- a/org.eclipse.nebula.widgets.nattable.core/.settings/.api_filters
+++ b/org.eclipse.nebula.widgets.nattable.core/.settings/.api_filters
@@ -15,6 +15,18 @@
                 <message_argument value="COMMA_REPLACEMENT"/>
             </message_arguments>
         </filter>
+        <filter id="336658481">
+            <message_arguments>
+                <message_argument value="org.eclipse.nebula.widgets.nattable.filterrow.FilterRowDataProvider"/>
+                <message_argument value="EMPTY_REPLACEMENT"/>
+            </message_arguments>
+        </filter>
+        <filter id="336658481">
+            <message_arguments>
+                <message_argument value="org.eclipse.nebula.widgets.nattable.filterrow.FilterRowDataProvider"/>
+                <message_argument value="NULL_REPLACEMENT"/>
+            </message_arguments>
+        </filter>
     </resource>
     <resource path="src/org/eclipse/nebula/widgets/nattable/filterrow/combobox/ComboBoxFilterRowConfiguration.java" type="org.eclipse.nebula.widgets.nattable.filterrow.combobox.ComboBoxFilterRowConfiguration">
         <filter id="336658481">
diff --git a/org.eclipse.nebula.widgets.nattable.core/src/org/eclipse/nebula/widgets/nattable/filterrow/FilterRowDataProvider.java b/org.eclipse.nebula.widgets.nattable.core/src/org/eclipse/nebula/widgets/nattable/filterrow/FilterRowDataProvider.java
index 6eb8382..7493d31 100644
--- a/org.eclipse.nebula.widgets.nattable.core/src/org/eclipse/nebula/widgets/nattable/filterrow/FilterRowDataProvider.java
+++ b/org.eclipse.nebula.widgets.nattable.core/src/org/eclipse/nebula/widgets/nattable/filterrow/FilterRowDataProvider.java
@@ -13,9 +13,11 @@
 package org.eclipse.nebula.widgets.nattable.filterrow;
 
 import java.lang.reflect.InvocationTargetException;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.Iterator;
+import java.util.List;
 import java.util.Map;
 import java.util.Properties;
 
@@ -23,6 +25,8 @@
 import org.eclipse.nebula.widgets.nattable.config.IConfigRegistry;
 import org.eclipse.nebula.widgets.nattable.data.IDataProvider;
 import org.eclipse.nebula.widgets.nattable.data.convert.IDisplayConverter;
+import org.eclipse.nebula.widgets.nattable.edit.EditConstants;
+import org.eclipse.nebula.widgets.nattable.filterrow.combobox.FilterRowComboBoxDataProvider;
 import org.eclipse.nebula.widgets.nattable.filterrow.event.FilterAppliedEvent;
 import org.eclipse.nebula.widgets.nattable.layer.ILayer;
 import org.eclipse.nebula.widgets.nattable.layer.cell.LayerCell;
@@ -66,6 +70,26 @@
     public static final String COMMA_REPLACEMENT = "°#°"; //$NON-NLS-1$
 
     /**
+     * Replacement for the null value that is used for persisting collection
+     * values in case of combobox filters. Needed for the inverted persistence
+     * in case there are null values in the collection that need to be
+     * persisted.
+     *
+     * @since 2.1
+     */
+    public static final String NULL_REPLACEMENT = "°null°"; //$NON-NLS-1$
+
+    /**
+     * Replacement for an empty String value that is used for persisting
+     * collection values in case of combobox filters. Needed for the inverted
+     * persistence in case there are empty String values in the collection that
+     * need to be persisted.
+     *
+     * @since 2.1
+     */
+    public static final String EMPTY_REPLACEMENT = "°empty°"; //$NON-NLS-1$
+
+    /**
      * The prefix String that will be used to mark that the following filter
      * value in the persisted state is a collection.
      */
@@ -105,6 +129,29 @@
     private Map<Integer, Object> filterIndexToObjectMap = new HashMap<>();
 
     /**
+     * Flag to configure how filter collections are persisted. By default the
+     * values in the collection are persisted as is. In case of Excel like
+     * filters, it can be more feasible to store which values are NOT selected,
+     * to be able to load the filter even for different values in the filter
+     * list.
+     *
+     * @see #filterRowComboBoxDataProvider
+     *
+     * @since 2.1
+     */
+    private boolean invertCollectionPersistence = false;
+
+    /**
+     * The FilterRowComboBoxDataProvider that is needed to be able to support
+     * inverted persistence of filter collections.
+     *
+     * @see FilterRowDataProvider#invertCollectionPersistence
+     *
+     * @since 2.1
+     */
+    private FilterRowComboBoxDataProvider<T> filterRowComboBoxDataProvider;
+
+    /**
      *
      * @param filterStrategy
      *            The {@link IFilterStrategy} to which the set filter value
@@ -285,16 +332,43 @@
             StringBuilder builder = new StringBuilder(collectionSpec);
             builder.append("["); //$NON-NLS-1$
             Collection<?> filterCollection = (Collection<?>) filterValue;
-            for (Iterator<?> iterator = filterCollection.iterator(); iterator.hasNext();) {
-                Object filterObject = iterator.next();
-                String displayValue = (String) converter.canonicalToDisplayValue(
-                        new LayerCell(null, columnIndex, 0),
-                        this.configRegistry,
-                        filterObject);
-                displayValue = displayValue.replace(IPersistable.VALUE_SEPARATOR, COMMA_REPLACEMENT);
-                builder.append(displayValue);
-                if (iterator.hasNext()) {
-                    builder.append(IPersistable.VALUE_SEPARATOR);
+
+            if (!this.invertCollectionPersistence) {
+                for (Iterator<?> iterator = filterCollection.iterator(); iterator.hasNext();) {
+                    Object filterObject = iterator.next();
+                    String displayValue = (String) converter.canonicalToDisplayValue(
+                            new LayerCell(null, columnIndex, 0),
+                            this.configRegistry,
+                            filterObject);
+                    displayValue = displayValue.replace(IPersistable.VALUE_SEPARATOR, COMMA_REPLACEMENT);
+                    builder.append(displayValue);
+                    if (iterator.hasNext()) {
+                        builder.append(IPersistable.VALUE_SEPARATOR);
+                    }
+                }
+            } else {
+                List<?> allValues = new ArrayList<>(this.filterRowComboBoxDataProvider.getAllValues(columnIndex));
+                allValues.removeAll(filterCollection);
+
+                for (Iterator<?> iterator = allValues.iterator(); iterator.hasNext();) {
+                    Object filterObject = iterator.next();
+                    if (filterObject == null) {
+                        builder.append(NULL_REPLACEMENT);
+                    } else {
+                        String displayValue = (String) converter.canonicalToDisplayValue(
+                                new LayerCell(null, columnIndex, 0),
+                                this.configRegistry,
+                                filterObject);
+                        displayValue = displayValue.replace(IPersistable.VALUE_SEPARATOR, COMMA_REPLACEMENT);
+                        if (displayValue.isEmpty()) {
+                            builder.append(EMPTY_REPLACEMENT);
+                        } else {
+                            builder.append(displayValue);
+                        }
+                    }
+                    if (iterator.hasNext()) {
+                        builder.append(IPersistable.VALUE_SEPARATOR);
+                    }
                 }
             }
 
@@ -345,13 +419,31 @@
 
             // also get rid of the collection marks
             filterText = filterText.substring(indexEndCollSpec + 2, filterText.length() - 1);
-            String[] filterSplit = filterText.split(IPersistable.VALUE_SEPARATOR);
-            for (String filterString : filterSplit) {
-                filterString = filterString.replace(COMMA_REPLACEMENT, IPersistable.VALUE_SEPARATOR);
-                filterCollection.add(converter.displayToCanonicalValue(
-                        new LayerCell(null, columnIndex, 0),
-                        this.configRegistry,
-                        filterString));
+            if (!filterText.isEmpty()) {
+                String[] filterSplit = filterText.split(IPersistable.VALUE_SEPARATOR);
+                for (String filterString : filterSplit) {
+                    filterString = filterString.replace(COMMA_REPLACEMENT, IPersistable.VALUE_SEPARATOR);
+                    if (NULL_REPLACEMENT.equals(filterString)) {
+                        filterCollection.add(null);
+                    } else if (EMPTY_REPLACEMENT.equals(filterString)) {
+                        filterCollection.add(""); //$NON-NLS-1$
+                    } else {
+                        filterCollection.add(converter.displayToCanonicalValue(
+                                new LayerCell(null, columnIndex, 0),
+                                this.configRegistry,
+                                filterString));
+                    }
+                }
+            }
+
+            if (this.invertCollectionPersistence) {
+                if (filterCollection.isEmpty()) {
+                    return EditConstants.SELECT_ALL_ITEMS_VALUE;
+                }
+
+                List<?> allValues = new ArrayList<>(this.filterRowComboBoxDataProvider.getAllValues(columnIndex));
+                allValues.removeAll(filterCollection);
+                return allValues;
             }
 
             return filterCollection;
@@ -383,4 +475,35 @@
         return this.filterStrategy;
     }
 
+    /**
+     *
+     * @return <code>true</code> if filter collections are persisted in an
+     *         inverted way, which means the values that are <b>NOT</b> selected
+     *         in the combo are persisted. By default this configuration is set
+     *         to <code>false</code> which means values in the collection are
+     *         persisted as is.
+     *
+     * @since 2.1
+     */
+    public boolean isInvertCollectionPersistence() {
+        return this.invertCollectionPersistence;
+    }
+
+    /**
+     *
+     * @param invertCollectionPersistence
+     *            <code>true</code> if filter collections should be persisted in
+     *            an inverted way, which means the values that are <b>NOT</b>
+     *            selected in the combo are persisted.
+     *
+     * @since 2.1
+     */
+    public void setInvertCollectionPersistence(boolean invertCollectionPersistence, FilterRowComboBoxDataProvider<T> comboBoxDataProvider) {
+        if (invertCollectionPersistence && comboBoxDataProvider == null) {
+            throw new IllegalArgumentException("Can only invert the collection persistence if the FilterRowComboBoxDataProvider is provided"); //$NON-NLS-1$
+        }
+        this.invertCollectionPersistence = invertCollectionPersistence;
+        this.filterRowComboBoxDataProvider = comboBoxDataProvider;
+    }
+
 }
diff --git a/org.eclipse.nebula.widgets.nattable.extension.glazedlists.test/src/org/eclipse/nebula/widgets/nattable/extension/glazedlists/filterrow/FilterRowDataProviderTest.java b/org.eclipse.nebula.widgets.nattable.extension.glazedlists.test/src/org/eclipse/nebula/widgets/nattable/extension/glazedlists/filterrow/FilterRowDataProviderTest.java
index bc82a8d..0dcc01d 100644
--- a/org.eclipse.nebula.widgets.nattable.extension.glazedlists.test/src/org/eclipse/nebula/widgets/nattable/extension/glazedlists/filterrow/FilterRowDataProviderTest.java
+++ b/org.eclipse.nebula.widgets.nattable.extension.glazedlists.test/src/org/eclipse/nebula/widgets/nattable/extension/glazedlists/filterrow/FilterRowDataProviderTest.java
@@ -25,25 +25,30 @@
 import org.eclipse.nebula.widgets.nattable.config.ConfigRegistry;
 import org.eclipse.nebula.widgets.nattable.config.DefaultNatTableStyleConfiguration;
 import org.eclipse.nebula.widgets.nattable.config.IConfigRegistry;
+import org.eclipse.nebula.widgets.nattable.data.ListDataProvider;
 import org.eclipse.nebula.widgets.nattable.data.ReflectiveColumnPropertyAccessor;
 import org.eclipse.nebula.widgets.nattable.data.convert.ContextualDisplayConverter;
 import org.eclipse.nebula.widgets.nattable.data.convert.DefaultDoubleDisplayConverter;
 import org.eclipse.nebula.widgets.nattable.dataset.fixture.data.RowDataFixture;
 import org.eclipse.nebula.widgets.nattable.dataset.fixture.data.RowDataListFixture;
+import org.eclipse.nebula.widgets.nattable.edit.EditConstants;
 import org.eclipse.nebula.widgets.nattable.extension.glazedlists.fixture.DataLayerFixture;
 import org.eclipse.nebula.widgets.nattable.extension.glazedlists.fixture.LayerListenerFixture;
 import org.eclipse.nebula.widgets.nattable.filterrow.FilterRowDataLayer;
 import org.eclipse.nebula.widgets.nattable.filterrow.FilterRowDataProvider;
 import org.eclipse.nebula.widgets.nattable.filterrow.TextMatchingMode;
+import org.eclipse.nebula.widgets.nattable.filterrow.combobox.FilterRowComboBoxDataProvider;
 import org.eclipse.nebula.widgets.nattable.filterrow.config.DefaultFilterRowConfiguration;
 import org.eclipse.nebula.widgets.nattable.filterrow.config.FilterRowConfigAttributes;
 import org.eclipse.nebula.widgets.nattable.filterrow.event.FilterAppliedEvent;
+import org.eclipse.nebula.widgets.nattable.layer.DataLayer;
 import org.eclipse.nebula.widgets.nattable.layer.cell.ILayerCell;
 import org.eclipse.nebula.widgets.nattable.persistence.IPersistable;
 import org.eclipse.nebula.widgets.nattable.style.DisplayMode;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+import ca.odell.glazedlists.EventList;
 import ca.odell.glazedlists.FilterList;
 import ca.odell.glazedlists.GlazedLists;
 
@@ -51,9 +56,13 @@
 
     private FilterRowDataProvider<RowDataFixture> dataProvider;
     private DataLayerFixture columnHeaderLayer;
+    private EventList<RowDataFixture> baseList;
     private FilterList<RowDataFixture> filterList;
     private ConfigRegistry configRegistry;
 
+    private ReflectiveColumnPropertyAccessor<RowDataFixture> columnAccessor =
+            new ReflectiveColumnPropertyAccessor<>(RowDataListFixture.getPropertyNames());
+
     @BeforeEach
     public void setup() {
         this.columnHeaderLayer = new DataLayerFixture(10, 2, 100, 50);
@@ -62,12 +71,13 @@
         new DefaultNatTableStyleConfiguration().configureRegistry(this.configRegistry);
         new DefaultFilterRowConfiguration().configureRegistry(this.configRegistry);
 
-        this.filterList = new FilterList<>(GlazedLists.eventList(RowDataListFixture.getList()));
+        this.baseList = GlazedLists.eventList(RowDataListFixture.getList());
+        this.filterList = new FilterList<>(this.baseList);
 
         this.dataProvider = new FilterRowDataProvider<>(
                 new DefaultGlazedListsFilterStrategy<>(
                         this.filterList,
-                        new ReflectiveColumnPropertyAccessor<RowDataFixture>(RowDataListFixture.getPropertyNames()),
+                        this.columnAccessor,
                         this.configRegistry),
                 this.columnHeaderLayer,
                 this.columnHeaderLayer.getDataProvider(),
@@ -412,4 +422,151 @@
         assertEquals("foo", this.dataProvider.getDataValue(1, 1));
         assertEquals("testValue", this.dataProvider.getDataValue(2, 1));
     }
+
+    @Test
+    public void shouldInvertFilterCollectionPersistence() {
+        // enable inverted combobox filter persistence
+        FilterRowComboBoxDataProvider<RowDataFixture> cbdp =
+                new FilterRowComboBoxDataProvider<>(
+                        new DataLayer(new ListDataProvider<>(this.filterList, this.columnAccessor)),
+                        this.baseList,
+                        this.columnAccessor);
+        this.dataProvider.setInvertCollectionPersistence(true, cbdp);
+
+        // set filter to filter out AAA and aaa
+        this.dataProvider.setDataValue(2, 1, new ArrayList<>(Arrays.asList("A-", "AA", "B", "B-", "BB", "C", "a", "aa")));
+
+        // save state
+        Properties properties = new Properties();
+        this.dataProvider.saveState("prefix", properties);
+        String persistedProperty = properties.getProperty("prefix" + FilterRowDataLayer.PERSISTENCE_KEY_FILTER_ROW_TOKENS);
+
+        String expectedPersistedCollection = "2:" + FilterRowDataProvider.FILTER_COLLECTION_PREFIX + ArrayList.class.getName() + ")["
+                + "AAA" + IPersistable.VALUE_SEPARATOR
+                + "aaa"
+                + "]";
+
+        assertEquals(expectedPersistedCollection + "|", persistedProperty);
+
+        // reset state
+        this.dataProvider.clearAllFilters();
+
+        assertNull(this.dataProvider.getDataValue(2, 1));
+
+        // load state
+        this.dataProvider.loadState("prefix", properties);
+
+        assertEquals(new ArrayList<>(Arrays.asList("A-", "AA", "B", "B-", "BB", "C", "a", "aa")), this.dataProvider.getDataValue(2, 1));
+    }
+
+    @Test
+    public void shouldInvertAllSelectedFilterCollectionPersistence() {
+        // enable inverted combobox filter persistence
+        FilterRowComboBoxDataProvider<RowDataFixture> cbdp =
+                new FilterRowComboBoxDataProvider<>(
+                        new DataLayer(new ListDataProvider<>(this.filterList, this.columnAccessor)),
+                        this.baseList,
+                        this.columnAccessor);
+        this.dataProvider.setInvertCollectionPersistence(true, cbdp);
+
+        // set filter to select all, which means nothing is filtered
+        this.dataProvider.setDataValue(2, 1, new ArrayList<>(Arrays.asList("A-", "AA", "AAA", "B", "B-", "BB", "C", "a", "aa", "aaa")));
+
+        // save state
+        Properties properties = new Properties();
+        this.dataProvider.saveState("prefix", properties);
+        String persistedProperty = properties.getProperty("prefix" + FilterRowDataLayer.PERSISTENCE_KEY_FILTER_ROW_TOKENS);
+
+        String expectedPersistedCollection = "2:" + FilterRowDataProvider.FILTER_COLLECTION_PREFIX + ArrayList.class.getName() + ")[]";
+
+        assertEquals(expectedPersistedCollection + "|", persistedProperty);
+
+        // reset state
+        this.dataProvider.clearAllFilters();
+
+        assertNull(this.dataProvider.getDataValue(2, 1));
+
+        // load state
+        this.dataProvider.loadState("prefix", properties);
+
+        assertEquals(EditConstants.SELECT_ALL_ITEMS_VALUE, this.dataProvider.getDataValue(2, 1));
+    }
+
+    @Test
+    public void shouldInvertFilterCollectionNullValuePersistence() {
+        // enable inverted combobox filter persistence
+        FilterRowComboBoxDataProvider<RowDataFixture> cbdp =
+                new FilterRowComboBoxDataProvider<>(
+                        new DataLayer(new ListDataProvider<>(this.filterList, this.columnAccessor)),
+                        this.baseList,
+                        this.columnAccessor);
+        this.dataProvider.setInvertCollectionPersistence(true, cbdp);
+
+        this.filterList.get(0).setRating(null);
+
+        assertEquals(Arrays.asList(null, "A-", "AA", "AAA", "B", "B-", "BB", "C", "aa", "aaa"), cbdp.getAllValues(2));
+
+        // set filter to filter out null values
+        this.dataProvider.setDataValue(2, 1, new ArrayList<>(Arrays.asList("A-", "AA", "AAA", "B", "B-", "BB", "C", "aa", "aaa")));
+
+        // save state
+        Properties properties = new Properties();
+        this.dataProvider.saveState("prefix", properties);
+        String persistedProperty = properties.getProperty("prefix" + FilterRowDataLayer.PERSISTENCE_KEY_FILTER_ROW_TOKENS);
+
+        String expectedPersistedCollection = "2:" + FilterRowDataProvider.FILTER_COLLECTION_PREFIX + ArrayList.class.getName() + ")["
+                + FilterRowDataProvider.NULL_REPLACEMENT
+                + "]";
+
+        assertEquals(expectedPersistedCollection + "|", persistedProperty);
+
+        // reset state
+        this.dataProvider.clearAllFilters();
+
+        assertNull(this.dataProvider.getDataValue(2, 1));
+
+        // load state
+        this.dataProvider.loadState("prefix", properties);
+
+        assertEquals(new ArrayList<>(Arrays.asList("A-", "AA", "AAA", "B", "B-", "BB", "C", "aa", "aaa")), this.dataProvider.getDataValue(2, 1));
+    }
+
+    @Test
+    public void shouldInvertFilterCollectionEmptyValuePersistence() {
+        // enable inverted combobox filter persistence
+        FilterRowComboBoxDataProvider<RowDataFixture> cbdp =
+                new FilterRowComboBoxDataProvider<>(
+                        new DataLayer(new ListDataProvider<>(this.filterList, this.columnAccessor)),
+                        this.baseList,
+                        this.columnAccessor);
+        this.dataProvider.setInvertCollectionPersistence(true, cbdp);
+
+        this.filterList.get(0).setRating("");
+
+        assertEquals(Arrays.asList("", "A-", "AA", "AAA", "B", "B-", "BB", "C", "aa", "aaa"), cbdp.getAllValues(2));
+
+        // set filter to filter out null values
+        this.dataProvider.setDataValue(2, 1, new ArrayList<>(Arrays.asList("A-", "AA", "AAA", "B", "B-", "BB", "C", "aa", "aaa")));
+
+        // save state
+        Properties properties = new Properties();
+        this.dataProvider.saveState("prefix", properties);
+        String persistedProperty = properties.getProperty("prefix" + FilterRowDataLayer.PERSISTENCE_KEY_FILTER_ROW_TOKENS);
+
+        String expectedPersistedCollection = "2:" + FilterRowDataProvider.FILTER_COLLECTION_PREFIX + ArrayList.class.getName() + ")["
+                + FilterRowDataProvider.EMPTY_REPLACEMENT
+                + "]";
+
+        assertEquals(expectedPersistedCollection + "|", persistedProperty);
+
+        // reset state
+        this.dataProvider.clearAllFilters();
+
+        assertNull(this.dataProvider.getDataValue(2, 1));
+
+        // load state
+        this.dataProvider.loadState("prefix", properties);
+
+        assertEquals(new ArrayList<>(Arrays.asList("A-", "AA", "AAA", "B", "B-", "BB", "C", "aa", "aaa")), this.dataProvider.getDataValue(2, 1));
+    }
 }
\ No newline at end of file