Building Custom Filter Controls in WpfDataGridFilter

In this example we'll take a look at writing a custom Filter for WpfDataGridFilter, which is a small library to simplify sever-side filtering in a WPF application. We will see how to use it for the WideWorldImporters backend, which is my current playground to base tutorials on.

All code can be found in a Git repository at:

What we are going to build

The WpfDataGridFilter library comes with few Filtering Controls built-in, such as Filters for String, Integer or DateTime properties. But what, if we need to filter some special type not provided built-in?

We are going to write our own, of course!

The WideWorldImporters uses the Microsoft.Spatial library to define spatial types. In previous articles I have shown how to use these types in a Backend, when confronted with EntityFramework Core.

In this article we are going to take a look at providing a filter control to filter the WideWorldImporters database the Delivery Location of a Customer. The Property Customer.DeliveryLocation has been generated as a GeographyPoint:

public partial class Customer : global::Microsoft.OData.Client.BaseEntityType, global::System.ComponentModel.INotifyPropertyChanged
{
    // ...

    public virtual Microsoft.Spatial.GeographyPoint DeliveryLocation
    {
        get
        {
            return this._DeliveryLocation;
        }
        set
        {
            this.OnDeliveryLocationChanging(value);
            this._DeliveryLocation = value;
            this.OnDeliveryLocationChanged();
            this.OnPropertyChanged("deliveryLocation");
        }
    }

    // ...
}

The final result is a Custom Control, which is shown in the Column Filter Popup:

WPF OData Example

Getting it up and running

It's useful to actually see, what we are going to build. It also sets you up for local development.

Running the Backend

You start by cloning the WideWorldImporters Git repository:

git clone https://github.com/bytefish/WideWorldImporters.git

Then start the Backend and SQL Server 2022 by using Docker:

docker compose --profile backend up

The Backend consists of two services:

  • SQL Server 2022 Database
    • Restores and provides the WideWorldImporters database.
  • ASP.NET Core Backend
    • Exposes OData Endpoints for querying the data.

The Backend exposes the ASP.NET Core endpoints using the following URL:

  • https://localhost:5000

And that's it for the Backend.

Running the WPF Application

You can use dotnet run to start the WPF application, by running:

dotnet run --project .\src\WideWorldImporters.Desktop.Client\WideWorldImporters.Desktop.Client.csproj

That's it. Congratulations! You will now see the WPF application, where you can see the sorting, pagination and filtering for the WPF DataGrid in action.

Implementation

Let's take a look at how a Custom Filter for Spatial Types is built.

Defining Constants for our Filter Controls

Our Filter has something to do with "Geography", so I start by adding a class GeographyFilter, which is going to hold all important constants to be used:

public static class GeographyFilter
{
    // TODO
}

In WpfDataGridFilter all Filter Controls are resolved by a FilterType. We want to Filter for a Distance to a given GeographyPoint, so let's add the FilterType:

public static class GeographyFilter
{
    // Filter Type
    public static FilterType GeoDistanceFilterType = new FilterType { Name = "GeoDistanceFilter" };
}

The library comes with a set of FilterOperator, say a IsNull or IsNotNull. But we don't have Filter Operators for Distances, let's add them:

public static class GeographyFilter
{
    // Filter Type
    public static FilterType GeoDistanceFilterType = new FilterType { Name = "GeoDistanceFilter" };

    // Filter Operators
    public static class FilterOperators
    {
        public static FilterOperator DistanceLessThan = new FilterOperator { Name = nameof(DistanceLessThan) };
        public static FilterOperator DistanceLessEqualThan = new FilterOperator { Name = nameof(DistanceLessEqualThan) };
        public static FilterOperator DistanceGreaterThan = new FilterOperator { Name = nameof(DistanceGreaterThan) };
        public static FilterOperator DistanceGreaterEqualThan = new FilterOperator { Name = nameof(DistanceGreaterEqualThan) };
    }
}

But what about displaying the Operators in a UI?

We need to add translations, which is done by the Translations<T> class of the WpfDataGridFilter library.

public static class GeographyFilter
{
    // Filter Type
    public static FilterType GeoDistanceFilterType = new FilterType { Name = "GeoDistanceFilter" };

    // Filter Operators
    public static class FilterOperators
    {
        public static FilterOperator DistanceLessThan = new FilterOperator { Name = nameof(DistanceLessThan) };
        public static FilterOperator DistanceLessEqualThan = new FilterOperator { Name = nameof(DistanceLessEqualThan) };
        public static FilterOperator DistanceGreaterThan = new FilterOperator { Name = nameof(DistanceGreaterThan) };
        public static FilterOperator DistanceGreaterEqualThan = new FilterOperator { Name = nameof(DistanceGreaterEqualThan) };
    }

    // Translations
    public static class Translations
    {
        public static List<Translation<FilterOperator>> FilterOperatorTranslations =
        [
            new Translation<FilterOperator>() { Value = FilterOperators.DistanceLessThan, Text = "Distance Less Than"},
            new Translation<FilterOperator>() { Value = FilterOperators.DistanceLessEqualThan, Text = "Distance Less Equal Than"},
            new Translation<FilterOperator>() { Value = FilterOperators.DistanceGreaterThan, Text = "Distance Greater Than"},
            new Translation<FilterOperator>() { Value = FilterOperators.DistanceGreaterEqualThan, Text = "Distance Greater Equal Than"},
        ];
    }
}

Adding a Custom FilterDescriptor

Filters in the library are always a FilterDescriptor, so let's add one for the Geo Distance we are calculating:

/// <summary>
/// The FilterDescriptor for Geography Distances.
/// </summary>
public record GeoDistanceFilterDescriptor : FilterDescriptor
{
    public override FilterType FilterType => GeographyFilter.GeoDistanceFilterType;

    /// <summary>
    /// Gets or sets the Latitude.
    /// </summary>
    public double? Latitude { get; set; }

    /// <summary>
    /// Gets or sets the Longitude.
    /// </summary>
    public double? Longitude { get; set; }

    /// <summary>
    /// Gets or sets the Distance.
    /// </summary>
    public double? Distance { get; set; }
}

Adding an IFilterTranslator

We are going to use WpfDataGridFilter.DynamicLinq for filtering on an IQueryable<T>. This means, we'll also need to convert the FilterDescriptor into a Predicate, given the FilterOperator and Properties of the FilterDescriptor.

Dynamic LINQ doesn't allow you to "just use" any C# function you want, due to sane security reasons. So we'll add a custom ParsingConfig, that allows to use the Custom Distance function for the Microsoft.Spatial.Point.

// ...

namespace WideWorldImporters.Desktop.Client.Controls
{
    /// <summary>
    /// Used to apply the Geo Distance Queries on an <see cref="IQueryable{T}">.
    /// </summary>
    public class GeoDistanceFilterTranslator : IFilterTranslator
    {
        public FilterType FilterType => GeographyFilter.GeoDistanceFilterType;

        private readonly ParsingConfig _parsingConfig;

        public GeoDistanceFilterTranslator()
        {
            _parsingConfig = GetParsingConfig();
        }

        public IQueryable<TEntity> Convert<TEntity>(IQueryable<TEntity> source, FilterDescriptor filterDescriptor)
        {
            if (filterDescriptor is not GeoDistanceFilterDescriptor f)
            {
                return source;
            }

            // Convert to a Microsoft Spatial Point, we could use in an OData Query
            GeographyPoint point = GeographyPoint.Create(f.Latitude ?? 0, f.Longitude ?? 0);

            switch (f.FilterOperator)
            {
                case var _ when f.FilterOperator == GeographyFilter.FilterOperators.DistanceLessThan:
                    return source.Where(_parsingConfig, $"(GeographyOperationsExtensions.Distance({f.PropertyName}, @0) lt @1)", point, f.Distance);
                case var _ when f.FilterOperator == GeographyFilter.FilterOperators.DistanceLessThan:
                    return source.Where(_parsingConfig, $"({f.PropertyName}.Distance{f.PropertyName}, (@0) le @1)", point, f.Distance);
                case var _ when f.FilterOperator == GeographyFilter.FilterOperators.DistanceLessThan:
                    return source.Where(_parsingConfig, $"({f.PropertyName}.Distance({f.PropertyName}, @0) gt @1)", point, f.Distance);
                case var _ when f.FilterOperator == GeographyFilter.FilterOperators.DistanceLessThan:
                    return source.Where(_parsingConfig, $"({f.PropertyName}.Distance({f.PropertyName}, @0) gt @1)", point, f.Distance);
                default:
                    throw new InvalidOperationException($"The Filter Operator '{f.FilterOperator.Name}' is not supported");
            }
        }

        public ParsingConfig GetParsingConfig()
        {
            ParsingConfig parsingConfigWithSpatial = new ParsingConfig();

            parsingConfigWithSpatial.CustomTypeProvider = new DefaultDynamicLinqCustomTypeProvider(parsingConfigWithSpatial, [typeof(GeographyOperationsExtensions)], false);

            return parsingConfigWithSpatial;

        }
    }
}

Adding the FilterControl

<ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:controls="clr-namespace:WideWorldImporters.Desktop.Client.Controls">

    <Style TargetType="{x:Type controls:GeoDistanceFilterControl}">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type controls:GeoDistanceFilterControl}">
                    <Grid>
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition Width="150"/>
                            <ColumnDefinition Width="Auto"/>
                        </Grid.ColumnDefinitions>
                        <Grid.RowDefinitions>
                            <RowDefinition Height="40" />
                            <RowDefinition Height="40" />
                            <RowDefinition Height="40" />
                            <RowDefinition Height="40" />
                            <RowDefinition Height="40" />
                            <RowDefinition Height="*" />
                        </Grid.RowDefinitions>

                        <TextBlock Grid.Row="0" Grid.Column="0" Style="{DynamicResource FilterLabelStyle}">Filter Operator:</TextBlock>

                        <ComboBox x:Name="PART_FilterOperators"
                                  Grid.Row="0" Grid.Column="1"
                                  Style="{DynamicResource FilterComboBoxStyle}" />

                        <TextBlock Grid.Row="1" Grid.Column="0" Style="{DynamicResource FilterLabelStyle}">Latitude:</TextBlock>

                        <TextBox x:Name="PART_LatitudeTextBox"
                                 Grid.Row="1" Grid.Column="1"
                                 Style="{DynamicResource FilterTextBoxStyle}"/>

                        <TextBlock Grid.Row="2" Grid.Column="0" Style="{DynamicResource FilterLabelStyle}">Longitude:</TextBlock>

                        <TextBox x:Name="PART_LongitudeTextBox"
                            Grid.Row="2" Grid.Column="1"
                            Style="{DynamicResource FilterTextBoxStyle}" />

                        <TextBlock Grid.Row="3" Grid.Column="0" Style="{DynamicResource FilterLabelStyle}">Distance (in Meters):</TextBlock>

                        <TextBox x:Name="PART_DistanceTextBox"
                            Grid.Row="3" Grid.Column="1"
                            Style="{DynamicResource FilterTextBoxStyle}" />

                        <StackPanel Grid.Row="4" Grid.Column="0" Grid.ColumnSpan="2" Orientation="Horizontal" HorizontalAlignment="Right">
                            <Button x:Name="PART_ResetButton" 
                                    Style="{DynamicResource FilterButtonStyle}" />
                            <Button x:Name="PART_ApplyButton" 
                                    Style="{DynamicResource FilterButtonStyle}" />
                        </StackPanel>
                    </Grid>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</ResourceDictionary>

And in the Code-Behind we'll derive from a BaseFilterControl<TFilterDescriptor> to implement the Filter.

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Windows.Controls;
using WpfDataGridFilter.Models;
using WpfDataGridFilter.Translations;
using WpfDataGridFilter.Controls;

namespace WideWorldImporters.Desktop.Client.Controls
{
    public class GeoDistanceFilterControl : BaseFilterControl<GeoDistanceFilterDescriptor>
    {
        public static FilterType GeoDistanceFilterType = new FilterType { Name = "GeoDistanceFilter" };

        /// <summary>
        /// Supported Filters for this Filter Control.
        /// </summary>
        public static readonly List<FilterOperator> SupportedFilterOperators =
        [
            GeographyFilter.FilterOperators.DistanceLessThan,
            GeographyFilter.FilterOperators.DistanceLessEqualThan,
            GeographyFilter.FilterOperators.DistanceGreaterThan,
            GeographyFilter.FilterOperators.DistanceGreaterEqualThan,
        ];

        #region Controls 

        ComboBox? FilterOperatorsComboBox;
        TextBox? LatitudeTextBox;
        TextBox? LongitudeTextBox;
        TextBox? DistanceTextBox;

        #endregion Controls

        public override string PropertyName { get; set; } = string.Empty;

        public List<Translation<FilterOperator>> FilterOperators { get; private set; } = [];

        public override void OnApplyTemplate()
        {
            base.OnApplyTemplate();

            FilterOperatorsComboBox = GetTemplateChild("PART_FilterOperators") as ComboBox;
            LatitudeTextBox = GetTemplateChild("PART_LatitudeTextBox") as TextBox;
            LongitudeTextBox = GetTemplateChild("PART_LongitudeTextBox") as TextBox;
            DistanceTextBox = GetTemplateChild("PART_DistanceTextBox") as TextBox;

            // Translations for the Control
            FilterOperators = GetFilterOperatorTranslations(Translations, SupportedFilterOperators);

            if (FilterOperatorsComboBox != null)
            {
                FilterOperatorsComboBox.DisplayMemberPath = nameof(Translation<FilterOperator>.Text);
                FilterOperatorsComboBox.SelectedValuePath = nameof(Translation<FilterOperator>.Value);
                FilterOperatorsComboBox.ItemsSource = FilterOperators;
            }

            if (DataGridState != null)
            {
                OnDataGridStateChanged();
            }
        }

        protected override void OnDataGridStateChanged()
        {
            GeoDistanceFilterDescriptor filterDescriptor = GetFilterDescriptor(DataGridState, PropertyName);

            if (FilterOperatorsComboBox != null)
            {
                FilterOperatorsComboBox.SelectedValue = filterDescriptor.FilterOperator;
            }

            if (LatitudeTextBox != null)
            {
                LatitudeTextBox.Text = filterDescriptor.Latitude?.ToString();
            }

            if (LongitudeTextBox != null)
            {
                LongitudeTextBox.Text = filterDescriptor.Longitude?.ToString();
            }
        }

        protected override void OnApplyFilter()
        {
            // Nothing to do...
        }

        protected override void OnResetFilter()
        {
            // Nothing to do...
        }

        protected override GeoDistanceFilterDescriptor GetDefaultFilterDescriptor()
        {
            return new GeoDistanceFilterDescriptor
            {
                PropertyName = PropertyName,
                FilterOperator = FilterOperator.None,
            };
        }

        protected override FilterDescriptor GetFilterDescriptor()
        {
            return new GeoDistanceFilterDescriptor
            {
                PropertyName = PropertyName,
                FilterOperator = GetCurrentFilterOperator(),
                Latitude = GetDoubleValue(LatitudeTextBox?.Text),
                Longitude = GetDoubleValue(LongitudeTextBox?.Text),
                Distance = GetDoubleValue(DistanceTextBox?.Text),
            };
        }

        private double? GetDoubleValue(string? value)
        {
            if (!double.TryParse(value, out double result))
            {
                return null;
            }

            return result;
        }

        private FilterOperator GetCurrentFilterOperator()
        {
            if (FilterOperatorsComboBox == null)
            {
                return FilterOperator.None;
            }

            if (FilterOperatorsComboBox.SelectedValue == null)
            {
                return FilterOperator.None;
            }

            FilterOperator currentFilterOperator = (FilterOperator)FilterOperatorsComboBox.SelectedValue;

            return currentFilterOperator;
        }

        protected override List<Translation<FilterOperator>> GetAdditionalTranslations()
        {
            return GeographyFilter.Translations.FilterOperatorTranslations;
        }
    }
}

Connecting all things in the ViewModel

Filter Controls are created by an IFilterControlProvider by the WpfDataGridFilter library. The WpfDataGridFilter.DynamicLinq plugin resolves the IFilterTranslator by using a IFilterTranslatorProvider. So we add a Method MainWindowViewModel#GetCustomProviders(), that creates the two providers.

public partial class MainWindowViewModel : ObservableObject
{
    [ObservableProperty]
    private IFilterControlProvider _filterControlProvider;

    [ObservableProperty]
    private IFilterTranslatorProvider _filterTranslatorProvider;

    // ...

    public MainWindowViewModel(DataGridState dataGridState)
    {
        // ...

        // Create a Custom Filter Provider, which is able to resolve 
        (FilterControlProvider, FilterTranslatorProvider) = GetCustomProviders();

        // ...
    }

    public static (IFilterControlProvider, IFilterTranslatorProvider)  GetCustomProviders()
    {
        // Build default Providers
        (FilterControlProvider filterControlProvider, FilterTranslatorProvider filterTranslatorProvider) = 
            (new FilterControlProvider(), new FilterTranslatorProvider());

        // Register custom hooks
        filterControlProvider
            .AddOrReplace(GeographyFilter.GeoDistanceFilterType, () => new GeoDistanceFilterControl());

        filterTranslatorProvider
            .AddOrReplace(new GeoDistanceFilterTranslator());

        return (filterControlProvider, filterTranslatorProvider);
    }
}

In the MainWindow.xaml we pass the custom IFilterControlProvider to the FilterableColumnHeader by using the FilterControlProvider property:

<Window x:Name="MainWindowRoot">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>
        <DataGrid ItemsSource="{Binding ViewModel.Customers}" AutoGenerateColumns="False" CanUserSortColumns="False" MinColumnWidth="150">
            <DataGrid.Columns>
                <DataGridTextColumn Binding="{Binding DeliveryLocation, Converter={StaticResource GeographyPointConverter}}">
                    <DataGridTextColumn.HeaderTemplate>
                        <DataTemplate>
                            <wpfdatagridfilter:FilterableColumnHeader DataGridState="..." FilterControlProvider="{Binding ViewModel.FilterControlProvider, ElementName=MainWindowRoot}" ... />
                        </DataTemplate>
                    </DataGridTextColumn.HeaderTemplate>
                </DataGridTextColumn>
            </DataGrid.Columns>
        </DataGrid>
    </Grid>
</Window>

What's left is passing the custom FilterTranslatorProvider to the WpfDataGridFilter.DynamicLinq libraries IQueryable#ApplyDataGridState extension method.

public partial class MainWindowViewModel : ObservableObject
{
    // ...

    public async Task RefreshAsync()
    {
        // ...

        DataServiceQuery<Customer> dataServiceQuery = (DataServiceQuery<Customer>)Container.Customers
            .IncludeCount()
            .Expand(x => x.LastEditedByNavigation)
            .ApplyDataGridState(DataGridState, FilterTranslatorProvider);

        // ...
    }

And we are done!

Conclusion

I think we're off to a good start, because we are able to add our custom Filter Controls.

As of now the API requires quite a lot in-depth knowledge about the library. The XAML Styling should also be a lot simpler and not require us to use the exact PART names to apply the Button actions.

Anyways. It's important to start with something, instead of starting with nothing.