Fluent UI Blazorを用いてExcelのようなフィルタリング機能・ソート機能を持つデータグリッドを作成する

# 1. はじめに

`Fluent UI Blazor`を用いてExcelのようなフィルタリング機能・ソート機能を持つデータグリッドの実装を行う方法をご紹介します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2840516/a3821bdf-2b78-4884-bf14-fb2e3346edce.png)

# 2. Fluent UI Blazorとは

`Fluent UI Blazor`は、Microsoftの`Fluent UI Web Components`に基づいたBlazor向けのUIコンポーネントライブラリです。
高いアクセシビリティ、一貫性、レスポンシブデザイン、カスタマイズ性を提供し、効率的なWebアプリケーション開発をサポートします。

`Fluent UI Blazor`の導入方法に関しては以下の記事をご覧ください。

https://learn.microsoft.com/en-us/fluent-ui/web-components/integrations/blazor

# 3. ソースコード

本ソースコードは、プロジェクトテンプレート`Blazor Web App`に含まれる`ServerProject\Components\Pages\Weather.razor`の実装を元にしています。

```html
@page "/weather"
@rendermode CustomRenderingMode.InteractiveServerWithoutPrerendering
@using Microsoft.Fast.Components.FluentUI

<PageTitle>Weather</PageTitle>

<h1>Weather</h1>

<FluentStack Orientation="Orientation.Horizontal" Width="1600px">
    <div style="overflow-x: auto;">
        <FluentDataGrid Style="height: 700px;width:1200px;overflow:auto;" Items="@FilteredItems?.OrderBy(x => x.Date).AsQueryable()" GenerateHeader="GenerateHeaderOption.Sticky">
            <ChildContent>
                <PropertyColumn Title="Date" Sortable="true" Property="@(c => DateTimeToString(c.Date.ToLocalTime()))" Align="Align.Start" Class="multiline-text">
                    <ColumnOptions>
                        <div class="search-box">
                            <FluentStack Orientation="Orientation.Vertical">
                                <FluentLabel Style="margin-top:5px;">
                                    Date
                                </FluentLabel>
                                <FluentDatePicker @bind-Value="FilterDateFrom" />
                                <FluentLabel Style="margin-top:5px;">
                                    ～
                                </FluentLabel>
                                <FluentDatePicker @bind-Value="@FilterDateTo" />
                            </FluentStack>
                        </div>
                    </ColumnOptions>
                </PropertyColumn>
                <PropertyColumn Title="℃" Property="@(c => c.TemperatureC)" Sortable="true" Align="Align.Start" Filtered="!string.IsNullOrWhiteSpace(temperatureCFilter)" Class="multiline-text">
                    <ColumnOptions>
                        <div class="search-box">
                            <FluentSearch type="search" Autofocus=true @bind-Value="temperatureCFilter" @oninput=@(x => HandleFilter(x, out temperatureCFilter)) @bind-Value:after=@(() => HandleClear(out temperatureCFilter)) Maxlength="255" />
                        </div>
                    </ColumnOptions>
                </PropertyColumn>
                <PropertyColumn Title="℉" Property="@(c => c.TemperatureF)" SortBy="@temperatureFSort" Align="Align.Start" Filtered="!string.IsNullOrWhiteSpace(temperatureFFilter)" Class="multiline-text">
                    <ColumnOptions>
                        <div class="search-box">
                            <FluentSearch type="search" Autofocus=true @bind-Value="temperatureFFilter" @oninput=@(x => HandleFilter(x, out temperatureFFilter)) @bind-Value:after=@(() => HandleClear(out temperatureFFilter)) Maxlength="10" />
                        </div>
                    </ColumnOptions>
                </PropertyColumn>
                <PropertyColumn Title="Summary" Property="@(c => GetSummariesStr(c.Summary))" SortBy="@summarySort" Align="Align.Start" Class="multiline-text">
                    <ColumnOptions>
                        <div class="search-box" style="height:325px;">
                            <FluentSelect TOption="string"
                                          Items="@SummarieItems"
                                          Multiple="true"
                                          OptionValue="@(p => p)"
                                          OptionText="@(p => p)"
                                          OptionSelected="@(p => true)"
                                          @bind-SelectedOptions="@SelectedSummary"
                                          Style="position:relative; overflow: visible;" />
                        </div>
                    </ColumnOptions>
                </PropertyColumn>
            </ChildContent>
            <EmptyContent>
                <FluentLabel>&nbsp; 該当データがありません。</FluentLabel>
            </EmptyContent>
            <LoadingContent>
                <FluentStack Orientation="Orientation.Vertical" HorizontalAlignment="HorizontalAlignment.Center">
                    <FluentProgressRing Width="120px" />
                </FluentStack>
            </LoadingContent>
        </FluentDataGrid>
    </div>
</FluentStack>
```
```c#
@code {
    public class WeatherForecast
    {
        public DateTime Date { get; set; }
        public int TemperatureC { get; set; }
        public string TemperatureF => (32 + (int)(TemperatureC / 0.5556)).ToString();
        public int Summary => GetSummaries(TemperatureC);
    }

    public enum summariesEnum : uint
    {
        Freezing = 1,
        Bracing,
        Chilly,
        Cool,
        Mild,
        Warm,
        Balmy,
        Hot,
        Sweltering,
        Scorching,
        Unknown = 100,
    }

    private List<WeatherForecast>? forecasts = new List<WeatherForecast>();

    //表示用データを生成する
    protected override async Task OnInitializedAsync()
    {
        if (!forecasts.Any())
        {
            for (int i = 0; i < 10; i++)
            {
                WeatherForecast temp = new WeatherForecast()
                    {
                        Date = DateTime.UtcNow.AddDays(i),
                        TemperatureC = Random.Shared.Next(-20, 55),
                    };
                forecasts.Add(temp);
            }
        }
    }

    //Summary判定用関数
    public static int GetSummaries(int temperatureC)
    {
        if (-20 <= temperatureC && temperatureC <= -1)
        {
            //Freezing
            return 1; 
        }

        if (0 <= temperatureC && temperatureC <= 5)
        {
            //Bracing
            return 2;
        }

        if (6 <= temperatureC && temperatureC <= 10)
        {
            //Chilly
            return 3;
        }

        if (11 <= temperatureC && temperatureC <= 15)
        {
            //Cool
            return 4;
        }

        if (16 <= temperatureC && temperatureC <= 20)
        {
            //Mild
            return 5;
        }

        if (21 <= temperatureC && temperatureC <= 25)
        {
            //Warm
            return 6;
        }

        if (26 <= temperatureC && temperatureC <= 30)
        {
            //Balmy
            return 7;
        }

        if (31 <= temperatureC && temperatureC <= 35)
        {
            //Hot
            return 8;
        }

        if (36 <= temperatureC && temperatureC <= 45)
        {
            //Sweltering
            return 9;
        }

        if (46 <= temperatureC && temperatureC <= 55)
        {
            //Scorching
            return 10;
        }
        //Unknown
        return 100;
    }

    //Summary表示用関数
    string GetSummariesStr(int summary)
    {
        switch (summary)
        {
            case 1:
                return "Freezing";
            case 2:
                return "Bracing";
            case 3:
                return "Chilly";
            case 4:
                return "Cool";
            case 5:
                return "Mild";
            case 6:
                return "Warm";
            case 7:
                return "Balmy";
            case 8:
                return "Hot";
            case 9:
                return "Sweltering";
            case 10:
                return "Scorching";
            default:
                return "Unknown";
        }
    }

    //フィルタリング用定義
    //各フィルタリング処理を行いリストにする
    List<WeatherForecast>? FilteredItems =>
    forecasts?.Where(x => x.TemperatureC.ToString().Contains(temperatureCFilter, StringComparison.OrdinalIgnoreCase))
    .Where(x => x.TemperatureF.ToString().Contains(temperatureFFilter, StringComparison.OrdinalIgnoreCase))
    .Where(x => SummaryFilter(GetSummariesStr((int)x.Summary)))
    .Where(x => DateFromFilter(FilterDateFrom) <= x.Date && x.Date < DateToFilter(FilterDateTo)).ToList();

    DateTime? FilterDateTo;
    DateTime? FilterDateFrom;

    //日時のフィルタリング条件値のバリデーション処理
    DateTime? DateFromFilter(DateTime? from)
    {
        if (from is null)
        {
            from = DateTime.MinValue.ToLocalTime();
        }
        return from;
    }

    DateTime? DateToFilter(DateTime? to)
    {
        if (to is null)
        {
            to = DateTime.MaxValue.ToLocalTime();
        }
        return to;
    }
    
    //Dateのフォーマット用関数
    string DateTimeToString(DateTime? dateTime)
    {
        if (dateTime == null || dateTime == DateTime.MinValue || dateTime == DateTime.MinValue.ToLocalTime())
        {
            return string.Empty;
        }
        return dateTime?.ToString("yyyy-MM-dd") ?? string.Empty;
    }

    //文字列のフィルタリング処理
    string temperatureCFilter = string.Empty;
    string temperatureFFilter = string.Empty;

    //フィルタリング条件文字列取得関数
    void HandleFilter(ChangeEventArgs args, out string target)
    {
        if (args.Value is string value)
        {
            target = value;
        }
        else
        {
            target = string.Empty;
        }
    }
    //フィルタリング条件文字列クリア関数
    void HandleClear(out string target)
    {
        target = string.Empty;
    }

    //Summaryフィルタリング用コンボボックスの項目の定義
    string[] SummarieItems = new string[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
    IEnumerable<string>? SelectedSummary;

    //Summaryフィルタリング用関数
    bool SummaryFilter(string summary)
    {
        return SelectedSummary?.Where(x => x.Equals(summary, StringComparison.OrdinalIgnoreCase)).Any() ?? true;
    }

    //カスタムソート
    //文字列を数値としてソートする
    GridSort<WeatherForecast> temperatureFSort = GridSort<WeatherForecast>.ByAscending(x => Convert.ToInt32(x.TemperatureF));

    //int(Enumの定義順)でソートする
    GridSort<WeatherForecast> summarySort = GridSort<WeatherForecast>.ByAscending(x => x.Summary);
}
```

`FluentDataGrid`と`PropertyColumn`で簡単にデータグリッドの作成が可能です。
本記事では、`ColumnOptions`でデータグリッドを拡張していきます。

https://calm-sea-053fa6a03-archivesv3.westeurope.3.azurestaticapps.net/DataGrid

※本記事のソート機能紹介用に`TemperatureF(℉)`の型定義を`string`に変更しています。

# 4. 解説
## 4.1 フィルタリング
### 日付

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2840516/a3e4c7c8-b70f-45d1-9d2e-c5a56146b572.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2840516/c50d8248-0021-424d-8798-8412692cb211.png)

Dataは指定した日付の範囲に当てはまる要素を表示するようフィルタリングします。
空欄で指定することで、全要素表示、`2025/2/25～`の要素すべて、`～2025/2/25`の要素すべてといった指定も可能です。

```html
                <PropertyColumn Title="Date" Sortable="true" Property="@(c => DateTimeToString(c.Date.ToLocalTime()))" Align="Align.Start" Class="multiline-text">
                    <ColumnOptions>
                        <div class="search-box">
                            <FluentStack Orientation="Orientation.Vertical">
                                <FluentLabel Style="margin-top:5px;">
                                    Date
                                </FluentLabel>
                                <FluentDatePicker @bind-Value="FilterDateFrom" />
                                <FluentLabel Style="margin-top:5px;">
                                    ～
                                </FluentLabel>
                                <FluentDatePicker @bind-Value="@FilterDateTo" />
                            </FluentStack>
                        </div>
                    </ColumnOptions>
                </PropertyColumn>
```
```C#
    List<WeatherForecast>? FilteredItems =>
    forecasts?.Where(x => x.TemperatureC.ToString().Contains(temperatureCFilter, StringComparison.OrdinalIgnoreCase))
    .Where(x => x.TemperatureF.ToString().Contains(temperatureFFilter, StringComparison.OrdinalIgnoreCase))
    .Where(x => SummaryFilter(GetSummariesStr((int)x.Summary)))
    .Where(x => DateFromFilter(FilterDateFrom) <= x.Date && x.Date < DateToFilter(FilterDateTo)).ToList();

    DateTime? FilterDateTo;
    DateTime? FilterDateFrom;

    DateTime? DateFromFilter(DateTime? from)
    {
        if (from is null)
        {
            from = DateTime.MinValue.ToLocalTime();
        }
        return from;
    }

    DateTime? DateToFilter(DateTime? to)
    {
        if (to is null)
        {
            to = DateTime.MaxValue.ToLocalTime();
        }
        return to;
    }
```
* bind-ValueでFluentDatePickerで指定された時刻を取得
* 空欄指定されたかを判定し、空欄であれば`DateTime.MaxValue`または`DateTime.MinValue`に変更
* 指定された日時の範囲でフィルタリングする

### 文字列

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2840516/6e2602cb-0a7a-4465-bf5a-115d817b6a22.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2840516/14950bb5-76a2-4903-b59f-4776db889ab5.png)

`TemperatureC(℃)`、`TemperatureF(℉)`は指定された文字列の部分一致でフィルタリングを行っています。
`FluentSearch`に入力された値に部分一致する要素のみを表示します。

```html
 <FluentSearch type="search" Autofocus=true @bind-Value="temperatureCFilter" @oninput=@(x => HandleFilter(x, out temperatureCFilter)) @bind-Value:after=@(() => HandleClear(out temperatureCFilter)) Maxlength="255" />
```
 
```c#

    List<WeatherForecast>? FilteredItems =>
    forecasts?.Where(x => x.TemperatureC.ToString().Contains(temperatureCFilter, StringComparison.OrdinalIgnoreCase))
    .Where(x => x.TemperatureF.ToString().Contains(temperatureFFilter, StringComparison.OrdinalIgnoreCase))
    .Where(x => SummaryFilter(GetSummariesStr((int)x.Summary)))
    .Where(x => DateFromFilter(FilterDateFrom) <= x.Date && x.Date < DateToFilter(FilterDateTo)).ToList();
    
    void HandleFilter(ChangeEventArgs args, out string target)
    {
        if (args.Value is string value)
        {
            target = value;
        }
        else
        {
            target = string.Empty;
        }
    }

    void HandleClear(out string target)
    {
        target = string.Empty;
    }
```

* `FluentSearch`での検索を検知しoninputイベントが発生した場合は、`HandleFilter`でフィルタリング条件を取得し適用
* `FluentSearch`で指定してたフィルタリング条件の削除を検知した場合は、`HandleClear`でフィルタリング条件を削除

### コンボボックス

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2840516/c59571dd-3977-482a-ae8b-33caf18607e2.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2840516/24854919-fcec-4410-a37a-448b4c23895b.png)

Summaryはコンボボックスで選択した項目に一致する要素のみを表示するようフィルタリングします。

```html
                <PropertyColumn Title="Summary" Property="@(c => GetSummariesStr(c.Summary))" SortBy="@summarySort" Align="Align.Start" Class="multiline-text">
                    <ColumnOptions>
                        <div class="search-box" style="height:325px;">
                            <FluentSelect TOption="string"
                                          Items="@SummarieItems"
                                          Multiple="true"
                                          OptionValue="@(p => p)"
                                          OptionText="@(p => p)"
                                          OptionSelected="@(p => true)"
                                          @bind-SelectedOptions="@SelectedSummary"
                                          Style="position:relative; overflow: visible;" />
                        </div>
                    </ColumnOptions>
                </PropertyColumn>
```

```C#
    List<WeatherForecast>? FilteredItems =>
    forecasts?.Where(x => x.TemperatureC.ToString().Contains(temperatureCFilter, StringComparison.OrdinalIgnoreCase))
    .Where(x => x.TemperatureF.ToString().Contains(temperatureFFilter, StringComparison.OrdinalIgnoreCase))
    .Where(x => SummaryFilter(GetSummariesStr((int)x.Summary)))
    .Where(x => DateFromFilter(FilterDateFrom) <= x.Date && x.Date < DateToFilter(FilterDateTo)).ToList();
    
    string[] SummarieItems = new string[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
    IEnumerable<string>? SelectedSummary;

    bool SummaryFilter(string summary)
    {
        return SelectedSummary?.Where(x => x.Equals(summary, StringComparison.OrdinalIgnoreCase)).Any() ?? true;
    }
```

* `bind-SelectedOptions`でコンボボックス選択中の`Summary`のリストを取得
* リストに含まれている`Summary`の表示文字列に一致かで判定しフィルタリングを行う

## 4.2 ソート
### Sortable - 規定のソート機能

* 昇順 
        ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2840516/34cc6fba-91d8-4850-99eb-91f2254edcdd.png)
* 降順
        ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2840516/8c294cc2-c74c-466e-ab02-8bc9bbb508f7.png)

規定のソート機能です。
`Sortable="true"`と実装することで表示対象の型の仕様に合わせた昇順・降順のソート機能が実現できます。

```html
<PropertyColumn Title="Date" Sortable="true" Property="@(c => DateTimeToString(c.Date.ToLocalTime()))" Align="Align.Start" Class="multiline-text">
```

### SortBy - カスタムソート

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2840516/66deda9b-18ed-4cc7-b147-7a74e1672af3.png)

`SortBy`を用いてソート処理を記述することで、カスタマイズされたソート処理を実現できます。
* `TemperatureF(℉)`は文字列を数値としてソート
* `Summary`は表示用に文字列に加工する前のintでソート

```html
<PropertyColumn Title="℉" Property="@(c => c.TemperatureF)" SortBy="@temperatureFSort" Align="Align.Start" Filtered="!string.IsNullOrWhiteSpace(temperatureFFilter)" Class="multiline-text">
```
```C#
GridSort<WeatherForecast> temperatureFSort = GridSort<WeatherForecast>.ByAscending(x => Convert.ToInt32(x.TemperatureF));
```

# 5. おわりに

Fluent UI Blazorを用いることで、見やすく高機能なデータグリッドを簡単に作成することができました。
今後もFluentUIを用いてよりよいUIの実装を行っていきたいと思います。

# 6. 参考
https://calm-sea-053fa6a03-archivesv3.westeurope.3.azurestaticapps.net/

https://demos.blazorbootstrap.com/

https://techblog.sakurug.co.jp/article/fluentid-advocate/


https://zenn.dev/microsoft/articles/i-dont-want-to-use-html-to-create-ui