blob: 6c812a18419b7fb21aeb0c846c97649d0281c751 [file] [log] [blame]
/*******************************************************************************
* Copyright (c) 2013, 2020 Dirk Fauth and others.
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Dirk Fauth <dirk.fauth@googlemail.com> - initial API and implementation
* Dirk Fauth <dirk.fauth@googlemail.com> - Bug 454505
*******************************************************************************/
package org.eclipse.nebula.widgets.nattable.filterrow.combobox;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.stream.Collectors;
import org.eclipse.nebula.widgets.nattable.data.IColumnAccessor;
import org.eclipse.nebula.widgets.nattable.edit.editor.IComboBoxDataProvider;
import org.eclipse.nebula.widgets.nattable.layer.ILayer;
import org.eclipse.nebula.widgets.nattable.layer.ILayerListener;
import org.eclipse.nebula.widgets.nattable.layer.event.CellVisualChangeEvent;
import org.eclipse.nebula.widgets.nattable.layer.event.ILayerEvent;
import org.eclipse.nebula.widgets.nattable.layer.event.IStructuralChangeEvent;
/**
* IComboBoxDataProvider that provides items for a combobox in the filter row.
* These items are calculated dynamically based on the content contained in the
* column it is connected to.
* <p>
* On creating this IComboBoxDataProvider, the possible values for all columns
* will be calculated taking the whole data provided by the body IDataProvider
* into account. Therefore you shouldn't use this one if you show huge datasets
* at once.
* <p>
* As the values are cached in here, this IComboBoxDataProvider registers itself
* as ILayerListener to the body DataLayer. If values are updated or rows get
* added/deleted, it will update the cache accordingly.
*
* @param <T>
* The type of the objects shown within the NatTable. Needed to
* access the data columnwise.
*/
public class FilterRowComboBoxDataProvider<T> implements IComboBoxDataProvider, ILayerListener {
/**
* The base collection used to collect the unique values from. This need to
* be a collection that is not filtered, otherwise after modifications the
* content of the filter row combo boxes will only contain the current
* visible (not filtered) elements.
*/
private Collection<T> baseCollection;
/**
* The IColumnAccessor to be able to read the values out of the base
* collection objects.
*/
private IColumnAccessor<T> columnAccessor;
/**
* The local cache for the values to show in the filter row combobox. This
* is needed because otherwise the calculation of the necessary values would
* happen everytime the combobox is opened and if a filter is applied using
* GlazedLists for example, the combobox would only contain the value which
* is currently used for filtering.
*/
private final Map<Integer, List<?>> valueCache = new HashMap<>();
/**
* List of listeners that get informed if the value cache gets updated.
*/
private List<IFilterRowComboUpdateListener> cacheUpdateListener = new ArrayList<>();
/**
* Flag to indicate whether the combo box content should be loaded lazily.
*
* @since 1.4
*/
protected final boolean lazyLoading;
/**
* Flag for enabling/disabling caching of filter combo box values.
*
* @since 1.4
*/
protected boolean cachingEnabled = true;
/**
* Flag for enabling/disabling firing a {@link FilterRowComboUpdateEvent} if
* the filter value cache is updated. Important for use cases where the
* cache is not build up yet and the filter is restored from properties,
* e.g. on opening a table with stored properties.
*
* @since 1.6
*/
private boolean updateEventsEnabled = true;
/**
* Lock used for accessing the value cache.
*
* @since 1.6
*/
private final ReadWriteLock valueCacheLock = new ReentrantReadWriteLock();
/**
* @param bodyLayer
* A layer in the body region. Usually the DataLayer or a layer
* that is responsible for list event handling. Needed to
* register ourself as listener for data changes.
* @param baseCollection
* The base collection used to collect the unique values from.
* This need to be a collection that is not filtered, otherwise
* after modifications the content of the filter row combo boxes
* will only contain the current visible (not filtered) elements.
* @param columnAccessor
* The IColumnAccessor to be able to read the values out of the
* base collection objects.
*/
public FilterRowComboBoxDataProvider(
ILayer bodyLayer, Collection<T> baseCollection, IColumnAccessor<T> columnAccessor) {
this(bodyLayer, baseCollection, columnAccessor, true);
}
/**
* @param bodyLayer
* A layer in the body region. Usually the DataLayer or a layer
* that is responsible for list event handling. Needed to
* register ourself as listener for data changes.
* @param baseCollection
* The base collection used to collect the unique values from.
* This need to be a collection that is not filtered, otherwise
* after modifications the content of the filter row combo boxes
* will only contain the current visible (not filtered) elements.
* @param columnAccessor
* The IColumnAccessor to be able to read the values out of the
* base collection objects.
* @param lazy
* <code>true</code> to configure this
* {@link FilterRowComboBoxDataProvider} should load the combobox
* values lazily, <code>false</code> to pre-build the value
* cache.
* @since 1.4
*/
public FilterRowComboBoxDataProvider(
ILayer bodyLayer,
Collection<T> baseCollection,
IColumnAccessor<T> columnAccessor,
boolean lazy) {
this.baseCollection = baseCollection;
this.columnAccessor = columnAccessor;
this.lazyLoading = lazy;
if (!this.lazyLoading) {
// build the cache
this.valueCacheLock.writeLock().lock();
try {
buildValueCache();
} finally {
this.valueCacheLock.writeLock().unlock();
}
}
bodyLayer.addLayerListener(this);
}
@Override
public List<?> getValues(int columnIndex, int rowIndex) {
if (this.cachingEnabled) {
this.valueCacheLock.readLock().lock();
List<?> result = null;
try {
result = this.valueCache.get(columnIndex);
} finally {
this.valueCacheLock.readLock().unlock();
}
if (result == null) {
this.valueCacheLock.writeLock().lock();
try {
result = collectValues(columnIndex);
this.valueCache.put(columnIndex, result);
} finally {
this.valueCacheLock.writeLock().unlock();
}
if (isUpdateEventsEnabled()) {
fireCacheUpdateEvent(buildUpdateEvent(columnIndex, null, result));
}
}
return result;
} else {
return collectValues(columnIndex);
}
}
/**
* Builds the local value cache for all columns.
*/
protected void buildValueCache() {
for (int i = 0; i < this.columnAccessor.getColumnCount(); i++) {
this.valueCache.put(i, collectValues(i));
}
}
/**
* This method returns the column indexes of the columns for which values
* was cached. Usually it will return all column indexes that are available
* in the table.
*
* @return The column indexes of the columns for which values was cached.
*/
public Collection<Integer> getCachedColumnIndexes() {
this.valueCacheLock.readLock().lock();
try {
return this.valueCache.keySet();
} finally {
this.valueCacheLock.readLock().unlock();
}
}
/**
* Iterates over all rows of the local body IDataProvider and collects the
* unique values for the given column index.
*
* @param columnIndex
* The column index for which the values should be collected
* @return List of all unique values that are contained in the body
* IDataProvider for the given column.
*/
@SuppressWarnings({ "rawtypes", "unchecked" })
protected List<?> collectValues(int columnIndex) {
List result = this.baseCollection.stream()
.unordered()
.parallel()
.map(x -> this.columnAccessor.getDataValue(x, columnIndex))
.distinct()
.collect(Collectors.toList());
Object firstNonNull = result.stream()
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
if (firstNonNull instanceof Comparable) {
result.sort(Comparator.nullsFirst(Comparator.naturalOrder()));
} else {
// always ensure that null is at the first position
int index = result.indexOf(null);
if (index >= 0) {
result.remove(index);
result.add(0, null);
}
}
return result;
}
@Override
public void handleLayerEvent(ILayerEvent event) {
// we only need to perform event handling if caching is enabled
if (this.cachingEnabled) {
if (event instanceof CellVisualChangeEvent) {
// usually this is fired for data updates
// so we need to update the value cache for the updated column
this.valueCacheLock.writeLock().lock();
try {
int column = ((CellVisualChangeEvent) event).getColumnPosition();
List<?> cacheBefore = this.valueCache.get(column);
if (!this.lazyLoading || cacheBefore != null) {
this.valueCache.put(column, collectValues(column));
}
if (isUpdateEventsEnabled()) {
// get the diff and fire the event
fireCacheUpdateEvent(buildUpdateEvent(column, cacheBefore, this.valueCache.get(column)));
}
} finally {
this.valueCacheLock.writeLock().unlock();
}
} else if (event instanceof IStructuralChangeEvent
&& ((IStructuralChangeEvent) event).isVerticalStructureChanged()) {
// a new row was added or a row was deleted
this.valueCacheLock.writeLock().lock();
try {
// remember the cache before updating
Map<Integer, List<?>> cacheBefore = new HashMap<>(this.valueCache);
// perform a refresh of the whole cache
this.valueCache.clear();
if (!this.lazyLoading) {
buildValueCache();
}
if (isUpdateEventsEnabled()) {
// fire events for every column
for (Map.Entry<Integer, List<?>> entry : cacheBefore.entrySet()) {
fireCacheUpdateEvent(buildUpdateEvent(entry.getKey(), entry.getValue(), this.valueCache.get(entry.getKey())));
}
}
} finally {
this.valueCacheLock.writeLock().unlock();
}
}
}
}
/**
* Creates a FilterRowComboUpdateEvent for the given column index.
* Calculates the diffs of the value cache for that column based on the
* given lists.
*
* @param columnIndex
* The column index for which the value cache was updated.
* @param cacheBefore
* The value cache for the column before the change. Needed to
* determine which values where removed by the update.
* @param cacheAfter
* The value cache for the column after the change. Needed to
* determine which values where added by the update.
* @return Event to tell about value cache updates for the given column or
* <code>null</code> if nothing has changed.
*/
protected FilterRowComboUpdateEvent buildUpdateEvent(int columnIndex, List<?> cacheBefore, List<?> cacheAfter) {
Set<Object> addedValues = new HashSet<>();
Set<Object> removedValues = new HashSet<>();
// find the added values
if (cacheAfter != null && cacheBefore != null) {
for (Object after : cacheAfter) {
if (!cacheBefore.contains(after)) {
addedValues.add(after);
}
}
// find the removed values
for (Object before : cacheBefore) {
if (!cacheAfter.contains(before)) {
removedValues.add(before);
}
}
} else if ((cacheBefore == null || cacheBefore.isEmpty()) && cacheAfter != null) {
addedValues.addAll(cacheAfter);
} else if (cacheBefore != null && (cacheAfter == null || cacheAfter.isEmpty())) {
removedValues.addAll(cacheBefore);
}
// only create a new update event if there has something changed
if (!addedValues.isEmpty() || !removedValues.isEmpty()) {
return new FilterRowComboUpdateEvent(columnIndex, addedValues, removedValues);
}
// nothing has changed so nothing to update
return null;
}
/**
* Fire the given event to all registered listeners.
*
* @param event
* The event to handle.
*/
protected void fireCacheUpdateEvent(FilterRowComboUpdateEvent event) {
if (event != null) {
for (IFilterRowComboUpdateListener listener : this.cacheUpdateListener) {
listener.handleEvent(event);
}
}
}
/**
* Adds the given listener to the list of listeners for value cache updates.
*
* @param listener
* The listener to add.
*/
public void addCacheUpdateListener(IFilterRowComboUpdateListener listener) {
this.cacheUpdateListener.add(listener);
}
/**
* Removes the given listener from the list of listeners for value cache
* updates.
*
* @param listener
* The listener to remove.
*
* @since 1.6
*/
public void removeCacheUpdateListener(IFilterRowComboUpdateListener listener) {
this.cacheUpdateListener.remove(listener);
}
/**
* @return The local cache for the values to show in the filter row
* combobox. This is needed because otherwise the calculation of the
* necessary values would happen everytime the combobox is opened
* and if a filter is applied using GlazedLists for example, the
* combobox would only contain the value which is currently used for
* filtering.
*/
protected Map<Integer, List<?>> getValueCache() {
return this.valueCache;
}
/**
*
* @return <code>true</code> if caching of filterrow combobox values is
* enabled, <code>false</code> if the combobox values should be
* calculated on request.
* @since 1.4
*/
public boolean isCachingEnabled() {
return this.cachingEnabled;
}
/**
* Enable/disable the caching of filterrow combobox values. By default the
* caching is enabled.
* <p>
* You should disable caching if the base collection that is used to
* determine the filterrow combobox values changes its contents dynamically,
* e.g. if the base collection is a GlazedLists FilterList that returns only
* the current non-filtered items.
* </p>
*
* @param cachingEnabled
* <code>true</code> to enable caching of filter row combobox
* values, <code>false</code> if the combobox values should be
* calculated on request.
* @since 1.4
*/
public void setCachingEnabled(boolean cachingEnabled) {
this.cachingEnabled = cachingEnabled;
}
/**
* Cleanup acquired resources.
*
* @since 1.5
*/
public void dispose() {
// nothing to do here
}
/**
*
* @return <code>true</code> if a {@link FilterRowComboUpdateEvent} is fired
* in case of filter value cache updates, <code>false</code> if not.
*
* @since 1.6
*/
public boolean isUpdateEventsEnabled() {
return this.updateEventsEnabled;
}
/**
* Enable firing of {@link FilterRowComboUpdateEvent} if the filter value
* cache is updated.
*
* <p>
* By default it should be enabled to automatically update applied filters
* in case new values are added, otherwise the row containing the new value
* will be filtered directly.
* </p>
* <p>
* <b>Note:</b> It is important to disable firing the events in use cases
* where the cache is not build up yet and the filter is restored from
* properties, e.g. on opening a table with stored properties.
* </p>
*
* @since 1.6
*/
public void enableUpdateEvents() {
this.updateEventsEnabled = true;
}
/**
* Disable firing of {@link FilterRowComboUpdateEvent} if the filter value
* cache is updated.
*
* <p>
* By default it should be enabled to automatically update applied filters
* in case new values are added, otherwise the row containing the new value
* will be filtered directly.
* </p>
* <p>
* <b>Note:</b> It is important to disable firing the events in use cases
* where the cache is not build up yet and the filter is restored from
* properties, e.g. on opening a table with stored properties.
* </p>
*
* @since 1.6
*/
public void disableUpdateEvents() {
this.updateEventsEnabled = false;
}
/**
*
* @return The {@link ReadWriteLock} that should be used for locking on
* accessing the {@link #valueCache}.
*
* @since 1.6
*/
public ReadWriteLock getValueCacheLock() {
return this.valueCacheLock;
}
}