Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
Original file line number Diff line number Diff line change
Expand Up @@ -6405,6 +6405,11 @@
Gets or sets the function used to determine if an option is initially selected.
</summary>
</member>
<member name="P:Microsoft.FluentUI.AspNetCore.Components.ListComponentBase`1.OptionComparer">
<summary>
Gets or sets the <see cref="T:System.Collections.Generic.IEqualityComparer`1"/> used to determine if an option is already added to the internal list.
</summary>
</member>
<member name="P:Microsoft.FluentUI.AspNetCore.Components.ListComponentBase`1.Items">
<summary>
Gets or sets the content source of all items to display in this list.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@
</Description>
</DemoSection>

<DemoSection Title="Different object instances from search results" Component="@typeof(AutocompleteDifferentObjectInstances)">
<Description>
<p>
By default the <code>FluentAutocomplete</code> component compares the search results by instance with it's internal selected items. You can control that behaviour by providing the <code>OptionComparer</code> parameter.
</p>
</Description>
</DemoSection>

<h2 id="documentation">Documentation</h2>

<ApiDocumentation Component="typeof(FluentAutocomplete<>)" GenericLabel="TOption" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<FluentStack>
<div>
Without <code>OptionComparer</code>:
<FluentAutocomplete TOption="SimplePerson"
Label="Users"
Class="w-100"
Placeholder="Name"
OnOptionsSearch="@OnSearchUserAsync"
OptionText="@(item => $"{item.Firstname} {item.Lastname}" )"
@bind-SelectedOptions="Users1" />
</div>
<div>
With <code>OptionComparer</code>:
<FluentAutocomplete TOption="SimplePerson"
Label="Users"
Class="w-100"
Placeholder="Name"
OnOptionsSearch="@OnSearchUserAsync"
OptionComparer="MyComparer.Instance"
OptionText="@(item => $"{item.Firstname} {item.Lastname}" )"
@bind-SelectedOptions="Users2" />
</div>
</FluentStack>
@code {

public IEnumerable<SimplePerson> Users1 { get; set; } = [new SimplePerson { Firstname = "Marvin", Lastname = "Klein", Age = 28 }];
public IEnumerable<SimplePerson> Users2 { get; set; } = [new SimplePerson { Firstname = "Marvin", Lastname = "Klein", Age = 28 }];

private Task OnSearchUserAsync(OptionsSearchEventArgs<SimplePerson> e)
{
// Simulate new instances for every search. Typically you would retrieve these from a database or an API.
var results = new List<SimplePerson>
{
new SimplePerson { Firstname = "Alice", Lastname = "Wonder", Age = 31 },
new SimplePerson { Firstname = "Marvin", Lastname = "Klein", Age = 28 },
new SimplePerson { Firstname = "Vincent", Lastname = "Baaji", Age = 38 },
};

e.Items = results;

return Task.CompletedTask;
}

public class MyComparer : IEqualityComparer<SimplePerson>
{
public static readonly MyComparer Instance = new();

public bool Equals(SimplePerson? x, SimplePerson? y)
{
if (ReferenceEquals(x, y))
{
return true;
}

if (x is null || y is null)
{
return false;
}

return x.Firstname == y.Firstname &&
x.Lastname == y.Lastname &&
x.Age == y.Age;
}

public int GetHashCode(SimplePerson obj) => HashCode.Combine(obj.Firstname, obj.Lastname, obj.Age);
}
}
10 changes: 10 additions & 0 deletions src/Core/Components/List/ListComponentBase.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ protected string? InternalValue
/// </summary>
[Parameter]
public virtual Func<TOption, bool>? OptionSelected { get; set; }
/// <summary>
/// Gets or sets the <see cref="IEqualityComparer{T}"/> used to determine if an option is already added to the internal list.
/// </summary>
[Parameter]
public virtual IEqualityComparer<TOption>? OptionComparer { get; set; }

/// <summary>
/// Gets or sets the content source of all items to display in this list.
Expand Down Expand Up @@ -538,6 +543,11 @@ protected virtual async Task OnSelectedItemChangedHandlerAsync(TOption? item)
RemoveSelectedItem(item);
await RaiseChangedEventsAsync();
}
else if (OptionComparer is not null && _selectedOptions.FirstOrDefault(x => OptionComparer.Equals(x, item)) is TOption addedItem)
{
RemoveSelectedItem(addedItem);
await RaiseChangedEventsAsync();
}
else
{
AddSelectedItem(item);
Expand Down
22 changes: 22 additions & 0 deletions tests/Core/Extensions/Customer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,25 @@ public static IEnumerable<Customer> Get()
}
}

public class CustomerComparer : IEqualityComparer<Customer>
{
public static readonly CustomerComparer Instance = new();

public bool Equals(Customer? x, Customer? y)
{
if (ReferenceEquals(x, y))
{
return true;
}

if (x is null || y is null)
{
return false;
}

return x.Id == y.Id &&
x.Name == y.Name;
}

public int GetHashCode(Customer obj) => HashCode.Combine(obj.Id, obj.Name);
}
39 changes: 39 additions & 0 deletions tests/Core/List/FluentAutocompleteTests.razor
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,32 @@
cut.Verify();
}

[Fact]
public void FluentAutocomplete_SelectValueFromDifferentObjectInstances()
{
IEnumerable<Customer> SelectedItems = [new Customer(1, "Marvin Klein")];

// Arrange
var cut = Render<FluentAutocomplete<Customer>>(
@<FluentAutocomplete TOption="Customer"
SelectValueOnTab="true"
OptionComparer="CustomerComparer.Instance"
@bind-SelectedOptions="@SelectedItems"
OnOptionsSearch="@OnSearchNewInstance" />
);

// Act: click to open -> KeyDow + Enter to select
var input = cut.Find("fluent-text-field");
input.Click();

// Click on the second FluentOption
var marvin = SelectedItems.First(i => i.Id == 1);
cut.Find($"fluent-option[value='{marvin}']").Click();

// Assert (no item selected)
Assert.Empty(SelectedItems);
}

// Send a key code
private async Task PressKeyAsync(IRenderedComponent<FluentAutocomplete<Customer>> cut, KeyCode key, bool popoverOpened = false)
{
Expand All @@ -511,4 +537,17 @@
.OrderBy(i => i.Name);
return Task.CompletedTask;
}

private Task OnSearchNewInstance(OptionsSearchEventArgs<Customer> e)
{
var results = new List<Customer>
{
new Customer(1, "Marvin Klein"),
new Customer(2, "Alice Wonder"),
new Customer(3, "Vincent Baaji")
};

e.Items = results;
return Task.CompletedTask;
}
}
28 changes: 28 additions & 0 deletions tests/Core/List/FluentListboxTests.razor
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,32 @@
// Assert
cut.Verify();
}

[Fact]
public void FluentListbox_SelectValueFromDifferentObjectInstances()
{
IEnumerable<Customer> SelectedItems = [new Customer(1, "Marvin Klein")];

List<Customer> Items = new List<Customer>
{
new Customer(1, "Marvin Klein"),
new Customer(2, "Alice Wonder"),
new Customer(3, "Vincent Baaji")
};

// Arrange
var cut = Render<FluentListbox<Customer>>(
@<FluentListbox @bind-SelectedOptions="SelectedItems"
Multiple="true"
Items="Items"
OptionComparer="CustomerComparer.Instance" />
);

// Click on the second FluentOption
var marvin = SelectedItems.First(i => i.Id == 1);
cut.Find($"fluent-option[value='{marvin}']").Click();

// Assert (no item selected)
Assert.Empty(SelectedItems);
}
}
76 changes: 52 additions & 24 deletions tests/Core/List/FluentSelectTests.razor
Original file line number Diff line number Diff line change
Expand Up @@ -114,19 +114,19 @@
{
// Arrange && Act
var cut = Render(@<FluentSelect TOption="string">
<FluentOption Value="1">
Search
<FluentIcon Value="@(new SampleIcons.Samples.MyCircle())" Color="@Color.Neutral" Slot="start" />
</FluentOption>
<FluentOption Value="2" Selected="true">
Show
<FluentIcon Value="@(new SampleIcons.Samples.MyCircle())" Color="@Color.Neutral" Slot="start" />
</FluentOption>
<FluentOption Value="3">
Generate
<FluentIcon Value="@(new SampleIcons.Samples.MyCircle())" Color="@Color.Neutral" Slot="start" />
</FluentOption>
</FluentSelect>);
<FluentOption Value="1">
Search
<FluentIcon Value="@(new SampleIcons.Samples.MyCircle())" Color="@Color.Neutral" Slot="start" />
</FluentOption>
<FluentOption Value="2" Selected="true">
Show
<FluentIcon Value="@(new SampleIcons.Samples.MyCircle())" Color="@Color.Neutral" Slot="start" />
</FluentOption>
<FluentOption Value="3">
Generate
<FluentIcon Value="@(new SampleIcons.Samples.MyCircle())" Color="@Color.Neutral" Slot="start" />
</FluentOption>
</FluentSelect>);

// Assert
cut.Verify();
Expand All @@ -138,9 +138,9 @@
{
// Arrange && Act
var cut = Render(@<FluentSelect Position="SelectPosition.Above" TOption="string">
<FluentOption>Position forced above</FluentOption>
<FluentOption>Option Two</FluentOption>
</FluentSelect>);
<FluentOption>Position forced above</FluentOption>
<FluentOption>Option Two</FluentOption>
</FluentSelect>);

// Assert
cut.Verify();
Expand All @@ -151,9 +151,9 @@
{
// Arrange && Act
var cut = Render(@<FluentSelect Position="SelectPosition.Below" TOption="string">
<FluentOption>Position forced above</FluentOption>
<FluentOption>Option Two</FluentOption>
</FluentSelect>);
<FluentOption>Position forced above</FluentOption>
<FluentOption>Option Two</FluentOption>
</FluentSelect>);

// Assert
cut.Verify();
Expand All @@ -165,11 +165,11 @@
{
// Arrange && Act
var cut = Render(@<FluentSelect Items="@(Customers.Get())" OptionValue="@(context => context.Id.ToString())">
<OptionTemplate>
<FluentIcon Value="@(new SampleIcons.Samples.MyCircle())" Slot="end" />
@(context.Name)
</OptionTemplate>
</FluentSelect>
<OptionTemplate>
<FluentIcon Value="@(new SampleIcons.Samples.MyCircle())" Slot="end" />
@(context.Name)
</OptionTemplate>
</FluentSelect>
);

// Assert
Expand All @@ -191,4 +191,32 @@
Assert.Equal("Make a selection...", cut.Find("fluent-option").InnerHtml);
cut.Verify();
}

[Fact]
public void FluentSelect_SelectValueFromDifferentObjectInstances()
{
IEnumerable<Customer> SelectedItems = [new Customer(1, "Marvin Klein")];

List<Customer> Items = new List<Customer>
{
new Customer(1, "Marvin Klein"),
new Customer(2, "Alice Wonder"),
new Customer(3, "Vincent Baaji")
};

// Arrange
var cut = Render<FluentSelect<Customer>>(
@<FluentSelect @bind-SelectedOptions="SelectedItems"
Multiple="true"
Items="Items"
OptionComparer="CustomerComparer.Instance" />
);

// Click on the second FluentOption
var marvin = SelectedItems.First(i => i.Id == 1);
cut.Find($"fluent-option[value='{marvin}']").Click();

// Assert (no item selected)
Assert.Empty(SelectedItems);
}
}
Loading