Skip to content

Commit d6d5ff9

Browse files
[AutoComplete] Add OptionComparer to prevent duplicate items from different object instances (#3881) (#3882)
* Add check for internal list to determine if an item has already been added * Update code comment * Add demo for autocomplete with different object instances * Add unit test * Clear IssueTester * Use IEqualityComparer<TOption> instead of Func<TOption, TOption, bool> * Update demo description for OptionComparer * Update documentation * Add unit test for FluentSelect * Add unit test for FluentListBox * Code review fixes --------- Co-authored-by: Vincent Baaij <vnbaaij@outlook.com>
1 parent b82e5af commit d6d5ff9

File tree

8 files changed

+235
-25
lines changed

8 files changed

+235
-25
lines changed

examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6405,6 +6405,12 @@
64056405
Gets or sets the function used to determine if an option is initially selected.
64066406
</summary>
64076407
</member>
6408+
<member name="P:Microsoft.FluentUI.AspNetCore.Components.ListComponentBase`1.OptionComparer">
6409+
<summary>
6410+
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.
6411+
⚠️ Only available when Multiple = true.
6412+
</summary>
6413+
</member>
64086414
<member name="P:Microsoft.FluentUI.AspNetCore.Components.ListComponentBase`1.Items">
64096415
<summary>
64106416
Gets or sets the content source of all items to display in this list.

examples/Demo/Shared/Pages/List/Autocomplete/AutocompletePage.razor

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@
3636
</Description>
3737
</DemoSection>
3838

39+
<DemoSection Title="Different object instances from search results" Component="@typeof(AutocompleteDifferentObjectInstances)">
40+
<Description>
41+
<p>
42+
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.
43+
</p>
44+
</Description>
45+
</DemoSection>
46+
3947
<h2 id="documentation">Documentation</h2>
4048

4149
<ApiDocumentation Component="typeof(FluentAutocomplete<>)" GenericLabel="TOption" />
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<FluentStack>
2+
<div>
3+
Without <code>OptionComparer</code>:
4+
<FluentAutocomplete TOption="SimplePerson"
5+
Label="Users"
6+
Class="w-100"
7+
Placeholder="Name"
8+
OnOptionsSearch="@OnSearchUserAsync"
9+
OptionText="@(item => $"{item.Firstname} {item.Lastname}" )"
10+
@bind-SelectedOptions="Users1" />
11+
</div>
12+
<div>
13+
With <code>OptionComparer</code>:
14+
<FluentAutocomplete TOption="SimplePerson"
15+
Label="Users"
16+
Class="w-100"
17+
Placeholder="Name"
18+
OnOptionsSearch="@OnSearchUserAsync"
19+
OptionComparer="MyComparer.Instance"
20+
OptionText="@(item => $"{item.Firstname} {item.Lastname}" )"
21+
@bind-SelectedOptions="Users2" />
22+
</div>
23+
</FluentStack>
24+
@code {
25+
26+
public IEnumerable<SimplePerson> Users1 { get; set; } = [new SimplePerson { Firstname = "Marvin", Lastname = "Klein", Age = 28 }];
27+
public IEnumerable<SimplePerson> Users2 { get; set; } = [new SimplePerson { Firstname = "Marvin", Lastname = "Klein", Age = 28 }];
28+
29+
private Task OnSearchUserAsync(OptionsSearchEventArgs<SimplePerson> e)
30+
{
31+
// Simulate new instances for every search. Typically you would retrieve these from a database or an API.
32+
var results = new List<SimplePerson>
33+
{
34+
new SimplePerson { Firstname = "Alice", Lastname = "Wonder", Age = 31 },
35+
new SimplePerson { Firstname = "Marvin", Lastname = "Klein", Age = 28 },
36+
new SimplePerson { Firstname = "Vincent", Lastname = "Baaji", Age = 38 },
37+
};
38+
39+
e.Items = results;
40+
41+
return Task.CompletedTask;
42+
}
43+
44+
public class MyComparer : IEqualityComparer<SimplePerson>
45+
{
46+
public static readonly MyComparer Instance = new();
47+
48+
public bool Equals(SimplePerson? x, SimplePerson? y)
49+
{
50+
if (ReferenceEquals(x, y))
51+
{
52+
return true;
53+
}
54+
55+
if (x is null || y is null)
56+
{
57+
return false;
58+
}
59+
60+
return x.Firstname == y.Firstname &&
61+
x.Lastname == y.Lastname &&
62+
x.Age == y.Age;
63+
}
64+
65+
public int GetHashCode(SimplePerson obj) => HashCode.Combine(obj.Firstname, obj.Lastname, obj.Age);
66+
}
67+
}

src/Core/Components/List/ListComponentBase.razor.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,13 @@ protected string? InternalValue
129129
[Parameter]
130130
public virtual Func<TOption, bool>? OptionSelected { get; set; }
131131

132+
/// <summary>
133+
/// Gets or sets the <see cref="IEqualityComparer{T}"/> used to determine if an option is already added to the internal list.
134+
/// ⚠️ Only available when Multiple = true.
135+
/// </summary>
136+
[Parameter]
137+
public virtual IEqualityComparer<TOption>? OptionComparer { get; set; }
138+
132139
/// <summary>
133140
/// Gets or sets the content source of all items to display in this list.
134141
/// Each item must be instantiated (cannot be null).
@@ -533,11 +540,16 @@ protected virtual async Task OnSelectedItemChangedHandlerAsync(TOption? item)
533540

534541
if (Multiple)
535542
{
536-
if (_selectedOptions.Contains(item))
543+
if (OptionComparer is null && _selectedOptions.Contains(item))
537544
{
538545
RemoveSelectedItem(item);
539546
await RaiseChangedEventsAsync();
540547
}
548+
else if (OptionComparer is not null && _selectedOptions.Find(x => OptionComparer.Equals(x, item)) is TOption addedItem)
549+
{
550+
RemoveSelectedItem(addedItem);
551+
await RaiseChangedEventsAsync();
552+
}
541553
else
542554
{
543555
AddSelectedItem(item);

tests/Core/Extensions/Customer.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,25 @@ public static IEnumerable<Customer> Get()
2121
}
2222
}
2323

24+
public class CustomerComparer : IEqualityComparer<Customer>
25+
{
26+
public static readonly CustomerComparer Instance = new();
27+
28+
public bool Equals(Customer? x, Customer? y)
29+
{
30+
if (ReferenceEquals(x, y))
31+
{
32+
return true;
33+
}
34+
35+
if (x is null || y is null)
36+
{
37+
return false;
38+
}
39+
40+
return x.Id == y.Id &&
41+
x.Name == y.Name;
42+
}
43+
44+
public int GetHashCode(Customer obj) => HashCode.Combine(obj.Id, obj.Name);
45+
}

tests/Core/List/FluentAutocompleteTests.razor

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,32 @@
495495
cut.Verify();
496496
}
497497

498+
[Fact]
499+
public void FluentAutocomplete_SelectValueFromDifferentObjectInstances()
500+
{
501+
IEnumerable<Customer> SelectedItems = [new Customer(1, "Marvin Klein")];
502+
503+
// Arrange
504+
var cut = Render<FluentAutocomplete<Customer>>(
505+
@<FluentAutocomplete TOption="Customer"
506+
SelectValueOnTab="true"
507+
OptionComparer="CustomerComparer.Instance"
508+
@bind-SelectedOptions="@SelectedItems"
509+
OnOptionsSearch="@OnSearchNewInstance" />
510+
);
511+
512+
// Act: click to open -> KeyDow + Enter to select
513+
var input = cut.Find("fluent-text-field");
514+
input.Click();
515+
516+
// Click on the second FluentOption
517+
var marvin = SelectedItems.First(i => i.Id == 1);
518+
cut.Find($"fluent-option[value='{marvin}']").Click();
519+
520+
// Assert (no item selected)
521+
Assert.Empty(SelectedItems);
522+
}
523+
498524
// Send a key code
499525
private async Task PressKeyAsync(IRenderedComponent<FluentAutocomplete<Customer>> cut, KeyCode key, bool popoverOpened = false)
500526
{
@@ -511,4 +537,17 @@
511537
.OrderBy(i => i.Name);
512538
return Task.CompletedTask;
513539
}
540+
541+
private Task OnSearchNewInstance(OptionsSearchEventArgs<Customer> e)
542+
{
543+
var results = new List<Customer>
544+
{
545+
new Customer(1, "Marvin Klein"),
546+
new Customer(2, "Alice Wonder"),
547+
new Customer(3, "Vincent Baaji")
548+
};
549+
550+
e.Items = results;
551+
return Task.CompletedTask;
552+
}
514553
}

tests/Core/List/FluentListboxTests.razor

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,32 @@
108108
// Assert
109109
cut.Verify();
110110
}
111+
112+
[Fact]
113+
public void FluentListbox_SelectValueFromDifferentObjectInstances()
114+
{
115+
IEnumerable<Customer> SelectedItems = [new Customer(1, "Marvin Klein")];
116+
117+
List<Customer> Items = new List<Customer>
118+
{
119+
new Customer(1, "Marvin Klein"),
120+
new Customer(2, "Alice Wonder"),
121+
new Customer(3, "Vincent Baaji")
122+
};
123+
124+
// Arrange
125+
var cut = Render<FluentListbox<Customer>>(
126+
@<FluentListbox @bind-SelectedOptions="SelectedItems"
127+
Multiple="true"
128+
Items="Items"
129+
OptionComparer="CustomerComparer.Instance" />
130+
);
131+
132+
// Click on the second FluentOption
133+
var marvin = SelectedItems.First(i => i.Id == 1);
134+
cut.Find($"fluent-option[value='{marvin}']").Click();
135+
136+
// Assert (no item selected)
137+
Assert.Empty(SelectedItems);
138+
}
111139
}

tests/Core/List/FluentSelectTests.razor

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -114,19 +114,19 @@
114114
{
115115
// Arrange && Act
116116
var cut = Render(@<FluentSelect TOption="string">
117-
<FluentOption Value="1">
118-
Search
119-
<FluentIcon Value="@(new SampleIcons.Samples.MyCircle())" Color="@Color.Neutral" Slot="start" />
120-
</FluentOption>
121-
<FluentOption Value="2" Selected="true">
122-
Show
123-
<FluentIcon Value="@(new SampleIcons.Samples.MyCircle())" Color="@Color.Neutral" Slot="start" />
124-
</FluentOption>
125-
<FluentOption Value="3">
126-
Generate
127-
<FluentIcon Value="@(new SampleIcons.Samples.MyCircle())" Color="@Color.Neutral" Slot="start" />
128-
</FluentOption>
129-
</FluentSelect>);
117+
<FluentOption Value="1">
118+
Search
119+
<FluentIcon Value="@(new SampleIcons.Samples.MyCircle())" Color="@Color.Neutral" Slot="start" />
120+
</FluentOption>
121+
<FluentOption Value="2" Selected="true">
122+
Show
123+
<FluentIcon Value="@(new SampleIcons.Samples.MyCircle())" Color="@Color.Neutral" Slot="start" />
124+
</FluentOption>
125+
<FluentOption Value="3">
126+
Generate
127+
<FluentIcon Value="@(new SampleIcons.Samples.MyCircle())" Color="@Color.Neutral" Slot="start" />
128+
</FluentOption>
129+
</FluentSelect>);
130130

131131
// Assert
132132
cut.Verify();
@@ -138,9 +138,9 @@
138138
{
139139
// Arrange && Act
140140
var cut = Render(@<FluentSelect Position="SelectPosition.Above" TOption="string">
141-
<FluentOption>Position forced above</FluentOption>
142-
<FluentOption>Option Two</FluentOption>
143-
</FluentSelect>);
141+
<FluentOption>Position forced above</FluentOption>
142+
<FluentOption>Option Two</FluentOption>
143+
</FluentSelect>);
144144

145145
// Assert
146146
cut.Verify();
@@ -151,9 +151,9 @@
151151
{
152152
// Arrange && Act
153153
var cut = Render(@<FluentSelect Position="SelectPosition.Below" TOption="string">
154-
<FluentOption>Position forced above</FluentOption>
155-
<FluentOption>Option Two</FluentOption>
156-
</FluentSelect>);
154+
<FluentOption>Position forced above</FluentOption>
155+
<FluentOption>Option Two</FluentOption>
156+
</FluentSelect>);
157157

158158
// Assert
159159
cut.Verify();
@@ -165,11 +165,11 @@
165165
{
166166
// Arrange && Act
167167
var cut = Render(@<FluentSelect Items="@(Customers.Get())" OptionValue="@(context => context.Id.ToString())">
168-
<OptionTemplate>
169-
<FluentIcon Value="@(new SampleIcons.Samples.MyCircle())" Slot="end" />
170-
@(context.Name)
171-
</OptionTemplate>
172-
</FluentSelect>
168+
<OptionTemplate>
169+
<FluentIcon Value="@(new SampleIcons.Samples.MyCircle())" Slot="end" />
170+
@(context.Name)
171+
</OptionTemplate>
172+
</FluentSelect>
173173
);
174174

175175
// Assert
@@ -191,4 +191,32 @@
191191
Assert.Equal("Make a selection...", cut.Find("fluent-option").InnerHtml);
192192
cut.Verify();
193193
}
194+
195+
[Fact]
196+
public void FluentSelect_SelectValueFromDifferentObjectInstances()
197+
{
198+
IEnumerable<Customer> SelectedItems = [new Customer(1, "Marvin Klein")];
199+
200+
List<Customer> Items = new List<Customer>
201+
{
202+
new Customer(1, "Marvin Klein"),
203+
new Customer(2, "Alice Wonder"),
204+
new Customer(3, "Vincent Baaji")
205+
};
206+
207+
// Arrange
208+
var cut = Render<FluentSelect<Customer>>(
209+
@<FluentSelect @bind-SelectedOptions="SelectedItems"
210+
Multiple="true"
211+
Items="Items"
212+
OptionComparer="CustomerComparer.Instance" />
213+
);
214+
215+
// Click on the second FluentOption
216+
var marvin = SelectedItems.First(i => i.Id == 1);
217+
cut.Find($"fluent-option[value='{marvin}']").Click();
218+
219+
// Assert (no item selected)
220+
Assert.Empty(SelectedItems);
221+
}
194222
}

0 commit comments

Comments
 (0)