Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="9.0.0" />
</ItemGroup>
<ItemGroup Label="Hotchocolate">
<PackageVersion Include="HotChocolate.Abstractions" Version="15.1.12" />
<PackageVersion Include="HotChocolate.Data" Version="15.1.12" />
<PackageVersion Include="HotChocolate.Data.EntityFramework" Version="15.1.12" />
<PackageVersion Include="HotChocolate.Types" Version="15.1.12" />
<PackageVersion Include="HotChocolate.Types.CursorPagination" Version="15.1.12" />
<PackageVersion Include="HotChocolate.AspNetCore" Version="15.1.12" />
<PackageVersion Include="HotChocolate.Abstractions" Version="15.1.14" />
<PackageVersion Include="HotChocolate.Data" Version="15.1.14" />
<PackageVersion Include="HotChocolate.Data.EntityFramework" Version="15.1.14" />
<PackageVersion Include="HotChocolate.Types" Version="15.1.14" />
<PackageVersion Include="HotChocolate.Types.CursorPagination" Version="15.1.14" />
<PackageVersion Include="HotChocolate.AspNetCore" Version="15.1.14" />
</ItemGroup>
<ItemGroup Label="Test Dependencies">
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,4 +244,17 @@ Not all MUI operators are supported for all default handlers.
getGridSingleSelectOperators().filter(({ value }) =>
['equals', 'isEmpty', 'isNotEmpty', 'isAnyOf'].includes(value)
);
```
```

### Enum collection (`DefaultEnumMultiSelectHandler`)

`isAnyOf` — matches rows where the entity's enum collection contains **any** of the filter values.

Register explicitly on the property; auto-detection is not performed:

```csharp
// Configure once for IEnumerable<TEnum>; this also covers IList<TEnum> and ICollection<TEnum>
builder.Property(u => u.Roles).SetEnumMultiSelectHandler();
```

Filter values are enum name strings normalised via Humanizer, e.g. `SUPER_USER`, `Super User`, and `SuperUser` all resolve to the same member. Unknown values throw `ArgumentException` immediately.
26 changes: 25 additions & 1 deletion src/Hotchocolate.MudDataGrid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,31 @@ Operators are matched **case-insensitively** after trimming. The table below sho
| `is not` / `not` | `not` | DateTime, SingleSelect |
| `empty` | `isEmpty` | String, Number, DateTime, Guid |
| `not empty` | `isNotEmpty` | String, Number, DateTime, Guid |
| `any of` / `is any of` | `isAnyOf` | String, Number, Guid, SingleSelect |
| `any of` / `is any of` | `isAnyOf` | String, Number, Guid, SingleSelect, **EnumMultiSelect** |

### Enum collection filtering (isAnyOf)

Use `SetEnumMultiSelectHandler` to filter entity properties that hold a collection of enum values. Configure it for `IEnumerable<TEnum>` and it will also apply to `IList<TEnum>` and `ICollection<TEnum>` because they implement `IEnumerable<TEnum>`. A row is returned when its collection contains **at least one** of the selected values.

**Data type configuration:**

```csharp
builder.Property(u => u.Roles).SetEnumMultiSelectHandler();
```

**Mud / MUI filter payload:**

```graphql
filterDefinitions: [{
field: "roles",
operator: "any of", // or "is any of"
value: ["ADMIN", "SUPER_USER"]
}]
```

Enum string values are normalised with [Humanizer](https://github.com/Humanizr/Humanizer) before parsing, so `SUPER_USER`, `Super User`, and `SuperUser` all resolve to the same enum member. Unknown enum strings throw an `ArgumentException` immediately (fail-fast, consistent with `DefaultEnumSingleSelectHandler`).

`SetEnumMultiSelectHandler` is defined for `IEnumerable<TEnum>`. This also covers `IList<TEnum>`, `ICollection<TEnum>`, and `List<TEnum>` implementations. It **does not auto-register** — you must call it explicitly on the relevant property.

Any operator not listed above throws a `GraphQLException` immediately (fail-fast).

Expand Down
68 changes: 68 additions & 0 deletions src/Hotchocolate.MuiDataGrid/DataTypePropertyBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,74 @@ public static DataTypePropertyBuilder<T, TEnum> SetEnumHandler<T, TEnum>(
return builder.SetHandler(new DefaultEnumSingleSelectHandler<T, TEnum>());
}

/// <summary>
/// Registers <see cref="DefaultEnumMultiSelectHandler{T,TEnum}"/> for an
/// <see cref="IEnumerable{TEnum}"/> property. Only the <c>isAnyOf</c>
/// operator is supported: rows are returned when the collection contains any
/// of the selected enum values.
/// </summary>
/// <typeparam name="T">The entity type.</typeparam>
/// <typeparam name="TEnum">The enum type stored in the collection.</typeparam>
/// <param name="builder">The property builder to configure.</param>
/// <returns>The same builder instance for chaining.</returns>
public static DataTypePropertyBuilder<T, IEnumerable<TEnum>> SetEnumMultiSelectHandler<T, TEnum>(
this DataTypePropertyBuilder<T, IEnumerable<TEnum>> builder)
where TEnum : struct, Enum
{
return builder.SetHandler(new DefaultEnumMultiSelectHandler<T, TEnum>());
}

/// <summary>
/// Registers <see cref="DefaultEnumMultiSelectHandler{T,TEnum}"/> for an
/// <see cref="ICollection{TEnum}"/> property. Only the <c>isAnyOf</c>
/// operator is supported: rows are returned when the collection contains any
/// of the selected enum values.
/// </summary>
/// <typeparam name="T">The entity type.</typeparam>
/// <typeparam name="TEnum">The enum type stored in the collection.</typeparam>
/// <param name="builder">The property builder to configure.</param>
/// <returns>The same builder instance for chaining.</returns>
public static DataTypePropertyBuilder<T, ICollection<TEnum>> SetEnumMultiSelectHandler<T, TEnum>(
this DataTypePropertyBuilder<T, ICollection<TEnum>> builder)
where TEnum : struct, Enum
{
return builder.SetHandler(new DefaultEnumMultiSelectHandler<T, TEnum>());
}

/// <summary>
/// Registers <see cref="DefaultEnumMultiSelectHandler{T,TEnum}"/> for an
/// <see cref="IList{TEnum}"/> property. Only the <c>isAnyOf</c>
/// operator is supported: rows are returned when the collection contains any
/// of the selected enum values.
/// </summary>
/// <typeparam name="T">The entity type.</typeparam>
/// <typeparam name="TEnum">The enum type stored in the collection.</typeparam>
/// <param name="builder">The property builder to configure.</param>
/// <returns>The same builder instance for chaining.</returns>
public static DataTypePropertyBuilder<T, IList<TEnum>> SetEnumMultiSelectHandler<T, TEnum>(
this DataTypePropertyBuilder<T, IList<TEnum>> builder)
where TEnum : struct, Enum
{
return builder.SetHandler(new DefaultEnumMultiSelectHandler<T, TEnum>());
}

/// <summary>
/// Registers <see cref="DefaultEnumMultiSelectHandler{T,TEnum}"/> for a
/// <see cref="List{TEnum}"/> property. Only the <c>isAnyOf</c>
/// operator is supported: rows are returned when the collection contains any
/// of the selected enum values.
/// </summary>
/// <typeparam name="T">The entity type.</typeparam>
/// <typeparam name="TEnum">The enum type stored in the collection.</typeparam>
/// <param name="builder">The property builder to configure.</param>
/// <returns>The same builder instance for chaining.</returns>
public static DataTypePropertyBuilder<T, List<TEnum>> SetEnumMultiSelectHandler<T, TEnum>(
this DataTypePropertyBuilder<T, List<TEnum>> builder)
where TEnum : struct, Enum
{
return builder.SetHandler(new DefaultEnumMultiSelectHandler<T, TEnum>());
}

public static DataTypePropertyBuilder<T, TProperty> SetNodeIdHandler<T, TProperty>(
this DataTypePropertyBuilder<T, TProperty> builder,
INodeIdSerializer idSerializer,
Expand Down
60 changes: 60 additions & 0 deletions src/Hotchocolate.MuiDataGrid/DefaultEnumMultiSelectHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
namespace Stackworx.Hotchocolate.MuiDataGrid;

using Humanizer;

/// <summary>
/// Handles filtering for entity properties that are collections of enum values.
/// Only the <c>isAnyOf</c> operator is supported: a row matches when the entity's
/// enum collection contains at least one of the selected filter values.
/// </summary>
/// <typeparam name="T">The entity type.</typeparam>
/// <typeparam name="TEnum">The enum type stored in the collection.</typeparam>
public class DefaultEnumMultiSelectHandler<T, TEnum> : ExpressionBuilderHandler<T>
where TEnum : struct, Enum
{
protected override Expression InternalHandle(ColumnLookupMember member, ExpressionBuilderFlavour flavour, MuiDataGridFilterItemInput filter)
{
return filter.Operator switch
{
"isAnyOf" => this.BuildIsAnyOf(member, filter),
_ => throw new ArgumentException($"Unknown operator: {filter.Operator}"),
};
}

protected override dynamic ParseValue(ColumnLookupMember member, MuiValue value)
{
var v = value.AsString();
v = v.Humanize(LetterCasing.Title).Transform(To.LowerCase, To.TitleCase).Dehumanize();
if (Enum.TryParse<TEnum>(v, out var result))
{
return result;
}

throw new ArgumentException($"Failed to Parse {typeof(TEnum).Name}: {v}");
}

// Builds: entity.Collection.Any(e => filterValues.Contains(e))
private Expression BuildIsAnyOf(ColumnLookupMember member, MuiDataGridFilterItemInput filter)
{
filter.Value.AssertNotNull(filter.Operator);

var enumValues = filter.Value
.AsArray()
.Select(v => (TEnum)this.ParseValue(member, v))
.ToList();

var filterConstant = Expression.Constant(enumValues, typeof(List<TEnum>));

var enumParam = Expression.Parameter(typeof(TEnum), "e");
var containsMethod = typeof(List<TEnum>).GetMethod("Contains", new[] { typeof(TEnum) })!;
var containsCall = Expression.Call(filterConstant, containsMethod, enumParam);
var predicate = Expression.Lambda<Func<TEnum, bool>>(containsCall, enumParam);

var anyMethod = typeof(Enumerable)
.GetMethods()
.First(m => m.Name == "Any" && m.GetParameters().Length == 2)
.MakeGenericMethod(typeof(TEnum));

return Expression.Call(anyMethod, member.Expression, predicate);
}
}
71 changes: 71 additions & 0 deletions tests/Hotchocolate.MuiDataGrid.Test/MudDataGridAdapterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,77 @@ public void Map_ShouldPreserveSortDefinitionOrder()
result.Sorting!.Select(x => x.Field).Should().Equal("first", "second", "third");
}

[Fact]
public void Map_ShouldMapEnumListWithAnyOf_ToIsAnyOf()
{
// Verifies that a MudDataGrid "any of" filter carrying a list of enum string
// values is forwarded untouched as an isAnyOf MUI filter item.
var enumValues = new MuiValue(new List<string> { "ADMIN", "SUPER_USER" });

var input = new MudDataGridFilterInput
{
FilterDefinitions =
[
new MudDataGridFilterDefinitionInput("roles", "any of", enumValues),
],
};

var result = MudToMuiDataGridAdapter.Map(input);

result.Filters.Should().NotBeNull();
result.Filters!.Items.Should().HaveCount(1);

var item = result.Filters.Items[0];
item.Field.Should().Be("roles");
item.Operator.Should().Be("isAnyOf");
item.Value.Should().Be(enumValues);
item.FromInput.Should().Be("mud");
}

[Fact]
public void Map_ShouldMapEnumListWithIsAnyOf_ToIsAnyOf()
{
var enumValues = new MuiValue(new List<string> { "READ_ONLY" });

var input = new MudDataGridFilterInput
{
FilterDefinitions =
[
new MudDataGridFilterDefinitionInput("roles", "is any of", enumValues),
],
};

var result = MudToMuiDataGridAdapter.Map(input);

result.Filters!.Items.Single().Operator.Should().Be("isAnyOf");
result.Filters.Items.Single().Value.Should().Be(enumValues);
}

[Fact]
public void Map_ShouldPreserveEnumArrayValuesAcrossMapping()
{
// Ensures the MuiValue array holding the enum strings is identical by ref
// so no data is lost during operator normalisation.
var enumValues = new MuiValue(new List<string> { "ADMIN", "SUPER_USER", "READ_ONLY" });

var input = new MudDataGridFilterInput
{
FilterDefinitions =
[
new MudDataGridFilterDefinitionInput("permissions", "any of", enumValues),
],
};

var result = MudToMuiDataGridAdapter.Map(input);

var mappedItem = result.Filters!.Items.Single();
mappedItem.Value.Should().BeSameAs(enumValues, "the original MuiValue array must not be cloned or altered");

// Verify the string values are accessible and intact
var arrayValues = mappedItem.Value!.AsArray().Select(v => v.AsString()).ToList();
arrayValues.Should().Equal("ADMIN", "SUPER_USER", "READ_ONLY");
}

[Fact]
public void Map_ShouldFailFastWithGraphqlError_WhenOperatorIsUnsupported()
{
Expand Down
Loading
Loading