Building dynamic filters: Getting an Observable to use on Filter() when any subproperty of an collection change #1028
-
This lib is extremely powerful and flexible, but every time I try to use it I also find it very hard to find documentation and/or basic example of use cases. I have a SourceCache on which I get a real time transformed list via For the "Filter()" method, you can easily create an Observable for simple cases, like using a .WhenAny() on a string property so when the searched text changes the filter refresh. What I'm trying to achieve is having the user build dynamic filters. Some sort of "query builder". This will be based on a collection of filter conditions that can be added and removed, and on which each one can apply different operations. And I will have a global "ApplyFilter" method that will compute everything to see if the conditions passes. My issue is how to get the Observable to pass to Filter() so that it refreshes when the user add/remove or modify a new filter condition. How to get an observable that triggers when a collection or any of its sub objects are modified? Here's what I came up with:
As you can see, this is already getting exhaustive code and I'm sure it can be shortened to something simpler. But, it doesn't work. I used ToObservableChangeSet/WhenAnyPropertyChanged to trigger an RaisePropertyChanged() when any of the sub properties of the FilterCondition model change, and I see it triggering, but somehow the WhenAny() observable doesn't, and the filter doesn't refresh?!? Why? I also used IPropagateCollectionChanges to listen to the ObservableCollection add/remove events and trigger the same event.
Is that a good method, is there a better method? It was taken from another thread here. I'm using Blazor, but I don't think it matters here. |
Beta Was this translation helpful? Give feedback.
Replies: 1 comment 2 replies
-
There's a bit too much missing context here for me to diagnose exactly what you're missing, but it's likely something in you public static class EntryPoint
{
public static void Main()
{
using var devicesList = new DevicesListViewModel();
using var subscription = devicesList.FilteredDevices
.ObserveCollectionChanges()
.Subscribe(_ =>
{
Console.WriteLine("\tFiltered Devices:");
if (devicesList.FilteredDevices.Count is 0)
Console.WriteLine("\t\t<Empty>");
else
foreach (var device in devicesList.FilteredDevices)
Console.WriteLine($"\t\t\"{device.Name}\"");
Console.WriteLine();
});
Console.WriteLine("Adding Device #1...");
devicesList.Devices.AddOrUpdate(new DeviceViewModel()
{
Id = 1,
Name = "Device #1: Audio",
Created = DateTimeOffset.Parse("2025-01-01"),
CreatedBy = "Jake"
});
Console.WriteLine("Adding Device #2...");
devicesList.Devices.AddOrUpdate(new DeviceViewModel()
{
Id = 2,
Name = "Device #2: Video",
Created = DateTimeOffset.Parse("2025-01-02"),
CreatedBy = "Darrin"
});
Console.WriteLine("Adding Device #3...");
devicesList.Devices.AddOrUpdate(new DeviceViewModel()
{
Id = 3,
Name = "Device #3: Audio",
Created = DateTimeOffset.Parse("2025-01-03"),
CreatedBy = "Roland"
});
Console.WriteLine("Adding Device #4...");
devicesList.Devices.AddOrUpdate(new DeviceViewModel()
{
Id = 4,
Name = "Device #4: Video",
Created = DateTimeOffset.Parse("2025-01-04"),
CreatedBy = "Dunge"
});
Console.WriteLine("Adding Name Filter...");
var nameFilter = new DeviceFilterViewModel()
{
Field = DeviceField.Name,
MatchingPattern = "Audio"
};
devicesList.Filters.Add(nameFilter);
Console.WriteLine("Adding Created By Filter...");
var createdByFilter = new DeviceFilterViewModel()
{
Field = DeviceField.CreatedBy,
MatchingPattern = "Jake"
};
devicesList.Filters.Add(createdByFilter);
Console.WriteLine("Adding Device #5...");
devicesList.Devices.AddOrUpdate(new DeviceViewModel()
{
Id = 5,
Name = "Device #5: Audio",
Created = DateTimeOffset.Parse("2025-01-05"),
CreatedBy = "Jake"
});
Console.WriteLine("Removing Created By Filter...");
devicesList.Filters.Remove(createdByFilter);
Console.WriteLine("Changing Device #4 Name...");
devicesList.Devices.Lookup(4).Value.Name = "Device #4: Audio";
Console.WriteLine("Changing Name Filter Pattern...");
nameFilter.MatchingPattern = "Video";
}
} public sealed class DeviceViewModel
: INotifyPropertyChanged
{
public DeviceViewModel()
{
_createdBy = string.Empty;
_name = string.Empty;
}
public required long Id
{
get => _id;
init => _id = value;
}
public required string Name
{
get => _name;
set
{
if (_name == value)
return;
_name = value;
PropertyChanged?.Invoke(this, new(nameof(Name)));
}
}
public required DateTimeOffset Created
{
get => _created;
set
{
if (_created == value)
return;
_created = value;
PropertyChanged?.Invoke(this, new(nameof(Created)));
}
}
public required string CreatedBy
{
get => _createdBy;
set
{
if (_createdBy == value)
return;
_createdBy = value;
PropertyChanged?.Invoke(this, new(nameof(CreatedBy)));
}
}
public event PropertyChangedEventHandler? PropertyChanged;
private readonly long _id;
private DateTimeOffset _created;
private string _createdBy;
private string _name;
} public enum DeviceField
{
Id,
Name,
Created,
CreatedBy
} public sealed class DeviceFilterViewModel
: INotifyPropertyChanged
{
public DeviceFilterViewModel()
=> _matchingPattern = string.Empty;
public required DeviceField Field
{
get => _field;
init => _field = value;
}
public string MatchingPattern
{
get => _matchingPattern;
set
{
if (_matchingPattern == value)
return;
_matchingPattern = value;
PropertyChanged?.Invoke(this, new(nameof(MatchingPattern)));
}
}
public event PropertyChangedEventHandler? PropertyChanged;
private readonly DeviceField _field;
private string _matchingPattern;
} public sealed class DevicesListViewModel
: IDisposable
{
public DevicesListViewModel()
{
_devices = new(static device => device.Id);
_filters = new();
// DynamicData equivalent of .Publish().RefCount() or .Share(), cause we're going to need to reference this stream twice.
var filtersChanged = _filters
.ToObservableChangeSet()
.AsObservableList();
var predicate = new Func<DeviceViewModel, bool>(EvaluateFilters);
_subscription = _devices
.Connect()
.AutoRefresh()
.Filter(Observable.Merge(
// Evaluate the filter on startup
Observable.Return(Unit.Default),
// Re-evaluate filtering upon changes to the collection of filters (E.G. filters added or removed)
filtersChanged
.Connect()
.Select(static _ => Unit.Default),
// Re-evaluate filtering upon changes to individual filters themselves
filtersChanged
.Connect()
.MergeMany(filter => filter.WhenAnyPropertyChanged())
.Select(static _ => Unit.Default))
.Select(_ => predicate))
.SortAndBind(out _filteredDevices, SortExpressionComparer<DeviceViewModel>.Ascending(device => device.Name))
.Subscribe();
}
public ISourceCache<DeviceViewModel, long> Devices
=> _devices;
public ReadOnlyObservableCollection<DeviceViewModel> FilteredDevices
=> _filteredDevices;
public ObservableCollection<DeviceFilterViewModel> Filters
=> _filters;
public void Dispose()
{
_devices.Dispose();
_subscription.Dispose();
}
private bool EvaluateFilters(DeviceViewModel device)
=> (_filters.Count is 0)
|| _filters.All(filter =>
{
var subject = filter.Field switch
{
DeviceField.Id => device.Id.ToString(),
DeviceField.Name => device.Name,
DeviceField.Created => device.Created.ToString(),
DeviceField.CreatedBy => device.CreatedBy.ToString(),
_ => throw new InvalidOperationException()
};
return Regex.IsMatch(subject, filter.MatchingPattern);
});
private readonly SourceCache<DeviceViewModel, long> _devices;
private readonly ReadOnlyObservableCollection<DeviceViewModel> _filteredDevices;
private readonly ObservableCollection<DeviceFilterViewModel> _filters;
private readonly IDisposable _subscription;
} Output ends up as...
So yeah, no significant issue with triggering re-filtering whenever your set of filters changes, you just need to cover all the scenarios. I've commented that portion of a stream to indicate the thought process. |
Beta Was this translation helpful? Give feedback.
There's a bit too much missing context here for me to diagnose exactly what you're missing, but it's likely something in you
filter
stream logic. I.E. it's not emitting notifications for certain filter changes that you're interested in. I can definitely give you a working example of how I would do this...