Skip to content

Commit a2d717e

Browse files
authored
Merge pull request CactuseSecurity#4273 from NilsPur/develop
Fix: Dropdown behaviour on tab in / out
2 parents c7d5a54 + df73fff commit a2d717e

5 files changed

Lines changed: 338 additions & 129 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,4 @@ modules.xml
6767
cov.xml
6868
.coverage
6969
.DS_Store
70+
.dotnet
Lines changed: 125 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,142 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
13
using System.Reflection;
4+
using Bunit;
5+
using FWO.Ui.Services;
26
using FWO.Ui.Shared;
7+
using Microsoft.Extensions.DependencyInjection;
38
using NUnit.Framework;
49

510
namespace FWO.Test
611
{
712
[TestFixture]
8-
public class DropdownTest
13+
[FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
14+
public class DropdownTest : BunitContext
915
{
16+
private static MethodInfo GetInstanceMethod(string methodName, params Type[] parameterTypes)
17+
{
18+
MethodInfo? method = typeof(Dropdown<string>).GetMethod(
19+
methodName,
20+
BindingFlags.NonPublic | BindingFlags.Instance,
21+
null,
22+
parameterTypes,
23+
null);
24+
25+
Assert.That(method, Is.Not.Null);
26+
return method!;
27+
}
28+
29+
private static string GetSearchValue(Dropdown<string> dropdown)
30+
{
31+
FieldInfo? searchValueField = typeof(Dropdown<string>).GetField("searchValue", BindingFlags.NonPublic | BindingFlags.Instance);
32+
Assert.That(searchValueField, Is.Not.Null);
33+
return (string?)searchValueField!.GetValue(dropdown) ?? "";
34+
}
35+
36+
private static void SetComponentParameter<TValue>(Dropdown<string> dropdown, string parameterName, TValue value)
37+
{
38+
PropertyInfo? parameter = typeof(Dropdown<string>).GetProperty(parameterName, BindingFlags.Public | BindingFlags.Instance);
39+
Assert.That(parameter, Is.Not.Null);
40+
parameter!.SetValue(dropdown, value);
41+
}
42+
1043
/// <summary>
11-
/// Ensures that a global click callback tolerates a missing JS runtime after disposal.
12-
/// </summary>
44+
/// Ensures that a global focus callback tolerates a missing JS runtime after disposal.
45+
/// </summary>
1346
[Test]
14-
public void OnGlobalClick_DoesNotThrow_WhenJsRuntimeIsNull()
47+
public void OnFocusChanged_DoesNotThrow_WhenJsRuntimeIsNull()
1548
{
1649
Dropdown<string> dropdown = new();
17-
MethodInfo? method = typeof(Dropdown<string>).GetMethod("OnGlobalClick", BindingFlags.NonPublic | BindingFlags.Instance);
50+
MethodInfo? method = typeof(Dropdown<string>).GetMethod("OnFocusChanged", BindingFlags.NonPublic | BindingFlags.Instance);
1851

1952
Assert.That(method, Is.Not.Null);
20-
Assert.DoesNotThrow(() => method!.Invoke(dropdown, new object[] { "test-element" }));
53+
Assert.DoesNotThrow(() => method!.Invoke(dropdown, ["test-element"]));
54+
}
55+
56+
/// <summary>
57+
/// Verifies that filtering matches values without regard to input casing.
58+
/// </summary>
59+
[Test]
60+
public void Filter_IsCaseInsensitive()
61+
{
62+
Dropdown<string> dropdown = new();
63+
SetComponentParameter(dropdown, nameof(Dropdown<string>.Elements), new[] { "Alpha", "beta", "Gamma" });
64+
MethodInfo filterMethod = GetInstanceMethod("Filter", typeof(string));
65+
66+
filterMethod.Invoke(dropdown, ["AL"]);
67+
68+
Assert.That(dropdown.FilteredElements, Is.EqualTo(["Alpha"]));
69+
}
70+
71+
/// <summary>
72+
/// Verifies that the none-selected label is shown when there is no selection.
73+
/// </summary>
74+
[Test]
75+
public void DisplaySelection_UsesNoneSelectedText_WhenNoSelection()
76+
{
77+
Dropdown<string> dropdown = new();
78+
SetComponentParameter(dropdown, nameof(Dropdown<string>.NoneSelectedText), "none");
79+
MethodInfo displaySelectionMethod = GetInstanceMethod("DisplaySelection", typeof(IEnumerable<string>));
80+
81+
displaySelectionMethod.Invoke(dropdown, [Enumerable.Empty<string>()]);
82+
83+
Assert.That(GetSearchValue(dropdown), Is.EqualTo("none"));
84+
}
85+
86+
/// <summary>
87+
/// Verifies that multiple selected values are rendered as first item plus count summary.
88+
/// </summary>
89+
[Test]
90+
public void DisplaySelection_FormatsSummary_WhenMultipleElementsSelected()
91+
{
92+
Dropdown<string> dropdown = new();
93+
MethodInfo displaySelectionMethod = GetInstanceMethod("DisplaySelection", typeof(IEnumerable<string>));
94+
95+
displaySelectionMethod.Invoke(dropdown, [new[] { "first", "second", "third" }]);
96+
97+
Assert.That(GetSearchValue(dropdown), Is.EqualTo("first, ... (+ 2)"));
98+
}
99+
100+
/// <summary>
101+
/// Verifies that selecting the same element twice in multiselect mode keeps a single entry.
102+
/// </summary>
103+
[Test]
104+
public async Task SelectElement_MultiSelect_AddsElementOnlyOnce()
105+
{
106+
Services.AddScoped<DomEventService>();
107+
IRenderedComponent<Dropdown<string>> renderedDropdown = Render<Dropdown<string>>(parameters => parameters
108+
.Add(p => p.Multiselect, true)
109+
.Add(p => p.SelectedElements, []));
110+
Dropdown<string> dropdown = renderedDropdown.Instance;
111+
MethodInfo selectMethod = GetInstanceMethod("SelectElement", typeof(string));
112+
113+
Task firstSelection = (Task)selectMethod.Invoke(dropdown, ["one"])!;
114+
await firstSelection;
115+
Task secondSelection = (Task)selectMethod.Invoke(dropdown, ["one"])!;
116+
await secondSelection;
117+
118+
Assert.That(dropdown.SelectedElements, Is.EqualTo(["one"]));
119+
Assert.That(dropdown.Toggled, Is.False);
120+
}
121+
122+
/// <summary>
123+
/// Verifies that unselecting one item in multiselect mode removes only that item.
124+
/// </summary>
125+
[Test]
126+
public async Task UnselectElement_MultiSelect_RemovesElementFromSelection()
127+
{
128+
Services.AddScoped<DomEventService>();
129+
IRenderedComponent<Dropdown<string>> renderedDropdown = Render<Dropdown<string>>(parameters => parameters
130+
.Add(p => p.Multiselect, true)
131+
.Add(p => p.SelectedElements, ["one", "two"]));
132+
Dropdown<string> dropdown = renderedDropdown.Instance;
133+
MethodInfo unselectMethod = GetInstanceMethod("UnselectElement", typeof(string));
134+
135+
Task unselection = (Task)unselectMethod.Invoke(dropdown, ["one"])!;
136+
await unselection;
137+
138+
Assert.That(dropdown.SelectedElements, Is.EqualTo(["two"]));
139+
Assert.That(dropdown.Toggled, Is.False);
21140
}
22141
}
23142
}
Lines changed: 55 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1+
using FWO.Logging;
12
using Microsoft.JSInterop;
2-
using Newtonsoft.Json.Linq;
3-
using System.Diagnostics;
43

54
namespace FWO.Ui.Services
65
{
7-
public class DomEventService
6+
public class DomEventService : IAsyncDisposable
87
{
9-
public event Action<string>? OnGlobalScroll;
10-
public event Action<string>? OnGlobalClick;
11-
public event Action? OnGlobalResize;
8+
public delegate void OnDomEvent(string elementId);
9+
10+
public event OnDomEvent? OnGlobalScroll;
11+
public event OnDomEvent? OnGlobalClick;
12+
public event OnDomEvent? OnGlobalFocus;
13+
public event OnDomEvent? OnGlobalResize;
1214

1315
private Action<int>? _navbarHeightSubscribers;
1416
private int? _lastNavbarHeight;
@@ -27,24 +29,51 @@ public event Action<int>? OnNavbarHeightChanged
2729
remove => _navbarHeightSubscribers -= value;
2830
}
2931

30-
public bool Initialized { get; private set; } = false;
32+
private DotNetObjectReference<DomEventService>? _dotNetRef;
33+
private IJSRuntime? _runtime;
34+
35+
public bool Initialized { get; private set; }
36+
37+
public async Task Initialize(IJSRuntime runtime)
38+
{
39+
if (!Initialized)
40+
{
41+
try
42+
{
43+
_runtime = runtime;
44+
_dotNetRef ??= DotNetObjectReference.Create(this);
45+
await runtime.InvokeVoidAsync("initializeEventHandlers", _dotNetRef);
46+
Initialized = true;
47+
}
48+
catch (Exception exception)
49+
{
50+
Log.WriteError("DomEventService", $"Initialization failure", exception);
51+
}
52+
}
53+
}
3154

3255
[JSInvokable]
3356
public void InvokeOnGlobalScroll(string elementId)
3457
{
35-
OnGlobalScroll?.Invoke(elementId ?? "");
58+
OnGlobalScroll?.Invoke(elementId);
3659
}
3760

3861
[JSInvokable]
39-
public void InvokeOnGlobalResize()
62+
public void InvokeOnGlobalResize(string elementId)
4063
{
41-
OnGlobalResize?.Invoke();
64+
OnGlobalResize?.Invoke(elementId);
4265
}
4366

4467
[JSInvokable]
4568
public void InvokeOnGlobalClick(string elementId)
4669
{
47-
OnGlobalClick?.Invoke(elementId ?? "");
70+
OnGlobalClick?.Invoke(elementId);
71+
}
72+
73+
[JSInvokable]
74+
public void InvokeOnGlobalFocus(string elementId)
75+
{
76+
OnGlobalFocus?.Invoke(elementId);
4877
}
4978

5079
[JSInvokable]
@@ -54,23 +83,24 @@ public void InvokeNavbarHeightChanged(int height)
5483
_navbarHeightSubscribers?.Invoke(height);
5584
}
5685

57-
public async Task Initialize(IJSRuntime runtime)
86+
protected virtual async ValueTask DisposeAsyncCore()
5887
{
59-
if (!Initialized)
88+
try
6089
{
61-
try
62-
{
63-
await runtime.InvokeVoidAsync("globalScroll", DotNetObjectReference.Create(this));
64-
await runtime.InvokeVoidAsync("globalResize", DotNetObjectReference.Create(this));
65-
await runtime.InvokeVoidAsync("globalClick", DotNetObjectReference.Create(this));
66-
await runtime.InvokeVoidAsync("observeNavbarHeight", DotNetObjectReference.Create(this));
67-
Initialized = true;
68-
}
69-
catch (Exception ex)
70-
{
71-
Debug.WriteLine(ex.ToString());
72-
}
90+
if (Initialized && _runtime is not null)
91+
await _runtime.InvokeVoidAsync("disposeEventHandlers");
7392
}
93+
catch { /* ignore */ }
94+
95+
_dotNetRef?.Dispose();
96+
_dotNetRef = null;
97+
Initialized = false;
98+
}
99+
100+
public async ValueTask DisposeAsync()
101+
{
102+
await DisposeAsyncCore().ConfigureAwait(false);
103+
GC.SuppressFinalize(this);
74104
}
75105
}
76106
}

0 commit comments

Comments
 (0)