feat(module: mentions): Add dynamic async loading of mentions (#3503)

* Add dynamic async loading of mentions along with more tests.

* Make symbol a parameter
This commit is contained in:
Key Roche 2023-11-09 06:58:53 -05:00 committed by GitHub
parent c9d95e31a8
commit 161aef5507
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 299 additions and 59 deletions

View File

@ -10,12 +10,12 @@
InlineFlexMode=false>
<Overlay>
<CascadingValue Value=this>
@if (OriginalOptions.Count > 0)
@if (ShowOptions.Count > 0)
{
<ul class="ant-mentions-dropdown-menu ant-mentions-dropdown-menu-root ant-mentions-dropdown-menu-vertical">
@foreach (var item in ShowOptions)
{
<MentionsOption Value="@item.Value">@item.ChildContent</MentionsOption>
<MentionsOption Value="@item.Value">@item.Display</MentionsOption>
}
</ul>
}

View File

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AntDesign.Internal;
using Microsoft.AspNetCore.Components;
@ -11,34 +12,70 @@ namespace AntDesign
{
public partial class Mentions
{
[Parameter] public RenderFragment ChildContent { get; set; }
[Parameter] public bool Disable { get; set; }
[Parameter] public uint Rows { get; set; } = 3;
[Parameter] public bool Focused { get; set; }
[Parameter] public bool Readonly { get; set; }
[Parameter] public bool Loading { get; set; }
[Parameter]
public RenderFragment ChildContent { get; set; }
[Parameter] public Dictionary<string, object> Attributes { get; set; }
[Inject] public IJSRuntime JS { get; set; }
[Parameter] public string Placeholder { get; set; }
[Parameter] public string Value { get; set; } = String.Empty;
[Parameter] public EventCallback<string> ValueChanged { get; set; }
[Parameter]
public bool Disable { get; set; }
[Parameter] public RenderFragment<MentionsTextareaTemplateOptions> TextareaTemplate { get; set; }
[Parameter]
public uint Rows { get; set; } = 3;
[Parameter]
public bool Focused { get; set; }
[Parameter]
public bool Readonly { get; set; }
[Parameter]
public bool Loading { get; set; }
[Parameter]
public Dictionary<string, object> Attributes { get; set; }
[Inject]
public IJSRuntime JS { get; set; }
[Parameter]
public string Placeholder { get; set; }
[Parameter]
public string Value { get; set; } = string.Empty;
[Parameter]
public EventCallback<string> ValueChanged { get; set; }
/// <summary>
/// Dynamically load mention options for display when the user types a value after the @ symbol
/// </summary>
[Parameter]
public Func<string, CancellationToken, Task<IEnumerable<MentionsDynamicOption>>> LoadOptions { get; set; }
[Parameter]
public RenderFragment<MentionsTextareaTemplateOptions> TextareaTemplate { get; set; }
[Parameter]
public string Prefix { get; set; } = "@";
private CancellationTokenSource _cancellationTokenSource;
internal List<MentionsDynamicOption> OriginalOptions { get; set; } = new List<MentionsDynamicOption>();
internal List<MentionsDynamicOption> ShowOptions { get; } = new List<MentionsDynamicOption>();
internal List<MentionsOption> OriginalOptions { get; set; } = new List<MentionsOption>();
internal List<MentionsOption> ShowOptions { get; } = new List<MentionsOption>();
private OverlayTrigger _overlayTrigger;
internal string ActiveOptionValue { get; set; }
internal int ActiveOptionIndex => ShowOptions.FindIndex(x => x.Value == ActiveOptionValue);
private void SetClassMap()
{
var prefixCls = "ant-mentions";
this.ClassMapper.Clear()
ClassMapper.Clear()
.Add(prefixCls)
.If($"{prefixCls}-disable", () => this.Disable)
.If($"{prefixCls}-focused", () => this.Focused)
.If($"{prefixCls}-disable", () => Disable)
.If($"{prefixCls}-focused", () => Focused)
.If($"{prefixCls}-rtl", () => RTL)
;
}
@ -52,12 +89,18 @@ namespace AntDesign
internal void AddOption(MentionsOption option)
{
if (option == null) return;
var opt = OriginalOptions.Find(x => x.Value == option.Value);
if (opt == null)
{
OriginalOptions.Add(option);
OriginalOptions.Add(new MentionsDynamicOption
{
Value = option.Value,
Display = option.ChildContent
});
}
}
public List<string> GetMentionNames()
{
var r = new List<string>();
@ -69,6 +112,7 @@ namespace AntDesign
});
return r;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
@ -79,6 +123,7 @@ namespace AntDesign
}
await base.OnAfterRenderAsync(firstRender);
}
[JSInvokable]
public void PrevOption()
{
@ -86,6 +131,7 @@ namespace AntDesign
ActiveOptionValue = ShowOptions[index].Value;
StateHasChanged();
}
[JSInvokable]
public void NextOption()
{
@ -93,6 +139,7 @@ namespace AntDesign
ActiveOptionValue = ShowOptions[index].Value;
StateHasChanged();
}
[JSInvokable]
public async Task EnterOption()
{
@ -105,14 +152,9 @@ namespace AntDesign
await _overlayTrigger.Hide();
}
async Task ShowOverlay(bool resetOptions, bool reCalcPosition)
async Task ShowOverlay(bool reCalcPosition)
{
await JS.InvokeAsync<double[]>(JSInteropConstants.SetPopShowFlag, true);
if (resetOptions)
{
ShowOptions.Clear();
ShowOptions.AddRange(OriginalOptions);
}
ActiveOptionValue = ShowOptions.First().Value;
if (reCalcPosition)
{
@ -127,6 +169,7 @@ namespace AntDesign
}
await InvokeStateHasChangedAsync();
}
async void OnKeyDown(KeyboardEventArgs args)
{ //↑、↓、回车键只能放进js里判断不然在Sever异步模式下无法拦截原键功能
//开启浮窗的判断放在oninput里不然会有问题
@ -137,55 +180,82 @@ namespace AntDesign
{
Value = args.Value.ToString();
await ValueChanged.InvokeAsync(Value);
if (Value.EndsWith("@"))
if (Value.EndsWith(Prefix))
{
await ShowOverlay(true, true);
await LoadItems(string.Empty);
await ShowOrHideBasedOnAvailableShowOptions();
return;
}
var focusPosition = await JS.InvokeAsync<int>(JSInteropConstants.GetProp, _overlayTrigger.Ref, "selectionStart");
if (focusPosition == 0)
{
await HideOverlay();
return;
};
var showPop = false;
var v = Value.Substring(0, focusPosition); //从光标处切断,向前找匹配项
var lastIndex = v.LastIndexOf("@");
var lastIndex = v.LastIndexOf(Prefix);
if (lastIndex >= 0)
{
var lastOption = v.Substring(lastIndex + 1);
ShowOptions.Clear();
ShowOptions.AddRange(OriginalOptions.Where(x => x.Value.Contains(lastOption, StringComparison.OrdinalIgnoreCase)).ToList());
if (ShowOptions.Count > 0) showPop = true;
await LoadItems(lastOption);
}
if (showPop)
await ShowOrHideBasedOnAvailableShowOptions();
}
private async Task LoadItems(string searchValue)
{
if (LoadOptions is null)
{
await ShowOverlay(false, true);
var optionsToShow = OriginalOptions.Where(x => x.Value.Contains(searchValue, StringComparison.OrdinalIgnoreCase)).ToList();
SetOptionsToShow(optionsToShow);
}
else
{
await HideOverlay();
_cancellationTokenSource?.Cancel();
_cancellationTokenSource = new CancellationTokenSource();
var options = await LoadOptions.Invoke(searchValue, _cancellationTokenSource.Token).ConfigureAwait(false);
SetOptionsToShow(options);
}
}
private void SetOptionsToShow(IEnumerable<MentionsDynamicOption> optionsToShow)
{
ShowOptions.Clear();
ShowOptions.AddRange(optionsToShow);
}
private Task ShowOrHideBasedOnAvailableShowOptions()
{
return ShowOptions.Count > 0
? ShowOverlay(true)
: HideOverlay();
}
internal async Task ItemClick(string optionValue)
{
var focusPosition = await JS.InvokeAsync<int>(JSInteropConstants.GetProp, _overlayTrigger.Ref, "selectionStart");
var preText = Value.Substring(0, focusPosition);
preText = preText.LastIndexOf("@") >= 0 ? Value.Substring(0, preText.LastIndexOf("@")) : preText;
preText = preText.LastIndexOf(Prefix) >= 0 ? Value.Substring(0, preText.LastIndexOf(Prefix)) : preText;
if (preText.EndsWith(' ')) preText = preText.Substring(0, preText.Length - 1);
var nextText = Value.Substring(focusPosition);
if (nextText.StartsWith(' ')) nextText = nextText.Substring(1);
var option = " @" + optionValue + " ";
Value = preText + option + nextText;
await ValueChanged.InvokeAsync(Value);
var pos = preText.Length + option.Length;
var js = $"document.querySelector('[_bl_{_overlayTrigger.Ref.Id}]').selectionStart = {pos};";
js += $"document.querySelector('[_bl_{_overlayTrigger.Ref.Id}]').selectionEnd = {pos}";
await JS.InvokeVoidAsync("eval", js);
await HideOverlay();
await InvokeStateHasChangedAsync();
}

View File

@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Components;
namespace AntDesign
{
public class MentionsDynamicOption
{
public string Value { get; set; }
public RenderFragment Display { get; set; }
}
}

View File

@ -1,13 +1,35 @@
<div>
To do
@using System.Threading;
<div>
<Mentions Style="width:100%" Loading="true" LoadOptions="@LoadMentions">
</Mentions>
</div>
@*<div>
<Mentions Style="width:100%" Loading="true">
<MentionsOption Value="afc163">
<Icon Type="setting" Theme="fill" />
<span>afc163</span>
<span>zombieJ</span>
</MentionsOption>
</Mentions>
</div>*@
@code
{
private RenderFragment<string> OptionDisplay => value =>@<span>Display: @value</span>;
public async Task<IEnumerable<MentionsDynamicOption>> LoadMentions(string search, CancellationToken cancellationToken)
{
await Task.Delay(TimeSpan.FromSeconds(1));
if (cancellationToken.IsCancellationRequested)
{
return Enumerable.Empty<MentionsDynamicOption>();
}
var fakeAsyncResults = new List<string>()
{
search
};
for (var i = 0; i < 25; i++){
fakeAsyncResults.Add(search + i);
}
return fakeAsyncResults.Select(x => new MentionsDynamicOption
{
Value = x,
Display = OptionDisplay(x)
});
}
}

View File

@ -11,4 +11,4 @@ title:
## en-US
async
When the list of matched content is returned asyncronously.

View File

@ -1,21 +1,148 @@
@using AntDesign.Internal
@using System.Threading;
@using static AntDesign.TextArea;
@inherits AntDesignTestBase
@code {
private void SetupJavascript()
{
JSInterop.SetupVoid("AntDesign.interop.mentionsHelper.setEditorKeyHandler", _ => true);
JSInterop.Setup<int>("AntDesign.interop.mentionsHelper.getProp", _ => true).SetResult(1);
JSInterop.Setup<Double[]>("AntDesign.interop.mentionsHelper.setPopShowFlag", _ => true).SetResult(new[] { 1d, 1d });
JSInterop.Setup<Double[]>("AntDesign.interop.mentionsHelper.getCursorXY", _ => true).SetResult(new[] { 1d, 1d });
}
private IRenderedComponent<AntDesign.Mentions> GetStandardRenderSystemUnderTest()
{
return Render<AntDesign.Mentions>(
@<AntDesign.Mentions>
<AntDesign.MentionsOption Value="testUsername">Test User</AntDesign.MentionsOption>
</AntDesign.Mentions>
@<AntDesign.Mentions>
<AntDesign.MentionsOption Value="testUsername">Test User</AntDesign.MentionsOption>
<AntDesign.MentionsOption Value="anotherUsername">Another User</AntDesign.MentionsOption>
</AntDesign.Mentions>
);
}
private IRenderedComponent<AntDesign.Mentions> GetLoadOptionsRenderSystemUnderTest(Func<string, CancellationToken, Task<IEnumerable<MentionsDynamicOption>>> loadOptions)
{
return Render<AntDesign.Mentions>(
@<AntDesign.Mentions LoadOptions="@loadOptions" />
);
}
[Theory]
[InlineData("")]
[InlineData("username")]
public async Task ItShouldCallLoadOptionsWithSearchValueWhenUserEntersValueAfterPrefix(string testEntry)
{
SetupJavascript();
JSInterop.Setup<int>(
"AntDesign.interop.mentionsHelper.getProp",
invocation => (string?)invocation.Arguments[1] == "selectionStart")
.SetResult(testEntry.Length + 1);
string? valueSentToCallback = null;
var systemUnderTest = GetLoadOptionsRenderSystemUnderTest((value, _) =>
{
valueSentToCallback = value;
return Task.FromResult(Enumerable.Empty<MentionsDynamicOption>());
});
var textArea = systemUnderTest.Find("textarea");
await textArea.InputAsync(new ChangeEventArgs
{
Value = "@" + testEntry
});
valueSentToCallback.Should().BeEquivalentTo(testEntry);
}
[Fact]
public async Task ItShouldDisplayOptionsReturnedFromLoadOptionsCall()
{
var testEntry = "username";
SetupJavascript();
JSInterop.Setup<int>(
"AntDesign.interop.mentionsHelper.getProp",
invocation => (string?)invocation.Arguments[1] == "selectionStart")
.SetResult(testEntry.Length + 1);
var systemUnderTest = GetLoadOptionsRenderSystemUnderTest((value, _) =>
{
return Task.FromResult(new[]
{
new MentionsDynamicOption
{
Display = @<span>User Name 1</span>,
Value = "username1"
},
new MentionsDynamicOption
{
Display = @<span>User Name 2</span>,
Value = "username2"
}
}.AsEnumerable());
});
var textArea = systemUnderTest.Find("textarea");
await textArea.InputAsync(new ChangeEventArgs
{
Value = "@" + testEntry
});
systemUnderTest.MarkupMatches(@"
<div class=""ant-mentions"">
<textarea value="""" class=""rc-textarea"" rows=""3""></textarea>
</div>
<div class:ignore style:ignore>
<ul class:ignore>
<li class=""ant-mentions-dropdown-menu-item ant-mentions-dropdown-menu-item-active"">
<span>User Name 1</span>
</li>
<li class=""ant-mentions-dropdown-menu-item"">
<span>User Name 2</span>
</li>
</ul>
</div>");
}
[Fact]
public async Task ItShouldDisplayOptionsWithValueContainingSearchValueWhenUserTypesForStaticOptions()
{
var testEntry = "anoth";
SetupJavascript();
JSInterop.Setup<int>(
"AntDesign.interop.mentionsHelper.getProp",
invocation => (string?)invocation.Arguments[1] == "selectionStart")
.SetResult(testEntry.Length + 1);
var systemUnderTest = GetStandardRenderSystemUnderTest();
var textArea = systemUnderTest.Find("textarea");
await textArea.InputAsync(new ChangeEventArgs
{
Value = "@" + testEntry
});
systemUnderTest.MarkupMatches(@"
<div class=""ant-mentions"">
<textarea value="""" class=""rc-textarea"" rows=""3""></textarea>
</div>
<div class:ignore style:ignore>
<ul class:ignore>
<li class=""ant-mentions-dropdown-menu-item ant-mentions-dropdown-menu-item-active"">
Another User
</li>
</ul>
</div>");
}
[Fact]
public void ItShouldRenderByDefaultWithTextarea()
{
@ -31,11 +158,14 @@
<li class=""ant-mentions-dropdown-menu-item"">
Test User
</li>
<li class=""ant-mentions-dropdown-menu-item"">
Another User
</li>
</div>");
}
[Fact]
public void StandardRender_ShouldCallJavascriptCallbackToDisplayOverlayWhenTypingAtSymbol()
public void StandardRender_ShouldCallJavascriptCallbackToDisplayOverlayWhenTypingPrefix()
{
SetupJavascript();
var systemUnderTest = GetStandardRenderSystemUnderTest();
@ -48,14 +178,21 @@
}
[Fact]
public void TemplatedRender_ShouldCallJavascriptCallbackToDisplayOverlayWhenTypingAtSymbol()
public void ShouldCallJavascriptCallbackToDisplayOverlayWhenTypingProvidedPrefix()
{
const string prefix = "#";
SetupJavascript();
var systemUnderTest = GetStandardRenderSystemUnderTest();
var systemUnderTest = Render<AntDesign.Mentions>(
@<AntDesign.Mentions Prefix="@prefix">
<AntDesign.MentionsOption Value="testUsername">Test User</AntDesign.MentionsOption>
<AntDesign.MentionsOption Value="anotherUsername">Another User</AntDesign.MentionsOption>
</AntDesign.Mentions>
);
JSInterop.Setup<Double[]>("AntDesign.interop.mentionsHelper.setPopShowFlag", true);
systemUnderTest.Find("textarea").Input("@");
systemUnderTest.Find("textarea").Input(prefix);
JSInterop.VerifyInvoke("AntDesign.interop.mentionsHelper.setPopShowFlag", 1);
}