Browse Source

Merge pull request #25359 from abpframework/auto-merge/rel-10-2/4535

Merge branch rel-10.3 with rel-10.2
pull/25360/head
Volosoft Agent 3 weeks ago
committed by GitHub
parent
commit
87a5cee65d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 37
      framework/src/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap/TagHelpers/Extensions/TagHelperOutputExtensions.cs
  2. 17
      framework/src/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap/TagHelpers/Form/AbpInputTagHelperService.cs
  3. 25
      framework/src/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap/TagHelpers/Form/AbpSelectTagHelperService.cs
  4. 194
      framework/test/Volo.Abp.AspNetCore.Mvc.UI.Tests/Volo/Abp/AspNetCore/Mvc/UI/Bootstrap/TagHelpers/Form/AbpInputTagHelperService_Tests.cs
  5. 246
      framework/test/Volo.Abp.AspNetCore.Mvc.UI.Tests/Volo/Abp/AspNetCore/Mvc/UI/Bootstrap/TagHelpers/Form/AbpSelectTagHelperService_Tests.cs

37
framework/src/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap/TagHelpers/Extensions/TagHelperOutputExtensions.cs

@ -1,11 +1,17 @@
using Microsoft.AspNetCore.Razor.TagHelpers;
using System;
using System.IO;
using System.Linq;
using System.Text.Encodings.Web;
namespace Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Extensions;
public static class TagHelperOutputExtensions
{
// ASCII whitespace per the HTML5 spec, used to tokenize space-separated
// attribute values such as aria-describedby.
private static readonly char[] HtmlAsciiWhitespace = { ' ', '\t', '\n', '\r', '\f' };
public static string Render(this TagHelperOutput output, HtmlEncoder htmlEncoder)
{
using (var writer = new StringWriter())
@ -14,4 +20,35 @@ public static class TagHelperOutputExtensions
return writer.ToString();
}
}
/// <summary>
/// Appends an id token to the space-separated <c>aria-describedby</c> attribute,
/// preserving any tokens that were already present (e.g. provided by the consumer)
/// and skipping the token when it is already in the list.
/// </summary>
public static void AppendAriaDescribedby(this TagHelperOutput output, string token)
{
if (string.IsNullOrEmpty(token))
{
return;
}
var existing = output.Attributes
.FirstOrDefault(a => string.Equals(a.Name, "aria-describedby", StringComparison.OrdinalIgnoreCase))
?.Value?.ToString();
if (string.IsNullOrWhiteSpace(existing))
{
output.Attributes.SetAttribute("aria-describedby", token);
return;
}
var tokens = existing.Split(HtmlAsciiWhitespace, StringSplitOptions.RemoveEmptyEntries);
if (tokens.Any(t => t == token))
{
return;
}
output.Attributes.SetAttribute("aria-describedby", existing + " " + token);
}
}

17
framework/src/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap/TagHelpers/Form/AbpInputTagHelperService.cs

@ -88,10 +88,10 @@ public class AbpInputTagHelperService : AbpTagHelperService<AbpInputTagHelper>
var (inputTag, isCheckBox) = await GetInputTagHelperOutputAsync(context, output);
context.Items[nameof(IsOutputHidden)] = IsOutputHidden(inputTag);
var inputHtml = inputTag.Render(_encoder);
var label = await GetLabelAsHtmlAsync(context, output, inputTag, isCheckBox);
var info = GetInfoAsHtml(context, output, inputTag, isCheckBox);
var validation = isCheckBox ? "" : await GetValidationAsHtmlAsync(context, output, inputTag);
var inputHtml = inputTag.Render(_encoder);
return (GetContent(context, output, label, inputHtml, validation, info, isCheckBox), isCheckBox);
}
@ -259,15 +259,14 @@ public class AbpInputTagHelperService : AbpTagHelperService<AbpInputTagHelper>
}
var idAttr = inputTagHelperOutput.Attributes.FirstOrDefault(a => a.Name == "id");
var idValue = idAttr?.Value?.ToString();
if (idAttr == null)
if (string.IsNullOrEmpty(idValue))
{
return;
}
var infoText = _tagHelperLocalizer.GetLocalizedText(idAttr.Value + "InfoText", TagHelper.AspFor.ModelExplorer);
inputTagHelperOutput.Attributes.Add("aria-describedby", infoText);
inputTagHelperOutput.AppendAriaDescribedby(idValue + "InfoText");
}
protected virtual bool IsInputCheckbox(TagHelperContext context, TagHelperOutput output, TagHelperAttributeList attributes)
@ -356,14 +355,18 @@ public class AbpInputTagHelperService : AbpTagHelperService<AbpInputTagHelper>
}
var idAttr = inputTag.Attributes.FirstOrDefault(a => a.Name == "id");
var idValue = idAttr?.Value?.ToString();
var localizedText = _tagHelperLocalizer.GetLocalizedText(text, TagHelper.AspFor.ModelExplorer);
var div = new TagBuilder("div");
div.Attributes.Add("id", idAttr?.Value + "InfoText");
div.AddCssClass("form-text");
div.InnerHtml.Append(localizedText);
inputTag.Attributes.Add("aria-describedby", idAttr?.Value + "InfoText");
if (!string.IsNullOrEmpty(idValue))
{
div.Attributes.Add("id", idValue + "InfoText");
inputTag.AppendAriaDescribedby(idValue + "InfoText");
}
return div.ToHtmlString();
}

25
framework/src/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap/TagHelpers/Form/AbpSelectTagHelperService.cs

@ -73,10 +73,10 @@ public class AbpSelectTagHelperService : AbpTagHelperService<AbpSelectTagHelper>
protected virtual async Task<string> GetFormInputGroupAsHtmlAsync(TagHelperContext context, TagHelperOutput output, TagHelperContent childContent)
{
var selectTag = await GetSelectTagAsync(context, output, childContent);
var selectAsHtml = selectTag.Render(_encoder);
var label = await GetLabelAsHtmlAsync(context, output, selectTag);
var validation = await GetValidationAsHtmlAsync(context, output, selectTag);
var infoText = GetInfoAsHtml(context, output, selectTag);
var selectAsHtml = selectTag.Render(_encoder);
return TagHelper.FloatingLabel ? selectAsHtml + Environment.NewLine + label + Environment.NewLine + infoText + Environment.NewLine + validation :
label + Environment.NewLine + selectAsHtml + Environment.NewLine + infoText + Environment.NewLine + validation;
@ -216,15 +216,14 @@ public class AbpSelectTagHelperService : AbpTagHelperService<AbpSelectTagHelper>
}
var idAttr = inputTagHelperOutput.Attributes.FirstOrDefault(a => a.Name == "id");
var idValue = idAttr?.Value?.ToString();
if (idAttr == null)
if (string.IsNullOrEmpty(idValue))
{
return;
}
var infoText = _tagHelperLocalizer.GetLocalizedText(idAttr.Value + "InfoText", TagHelper.AspFor.ModelExplorer);
inputTagHelperOutput.Attributes.Add("aria-describedby", infoText);
inputTagHelperOutput.AppendAriaDescribedby(idValue + "InfoText");
}
protected virtual string GetInfoAsHtml(TagHelperContext context, TagHelperOutput output, TagHelperOutput inputTag)
@ -249,14 +248,20 @@ public class AbpSelectTagHelperService : AbpTagHelperService<AbpSelectTagHelper>
}
var idAttr = inputTag.Attributes.FirstOrDefault(a => a.Name == "id");
var idValue = idAttr?.Value?.ToString();
var localizedText = _tagHelperLocalizer.GetLocalizedText(text, TagHelper.AspFor.ModelExplorer);
var small = new TagBuilder("small");
small.Attributes.Add("id", idAttr?.Value?.ToString() + "InfoText");
small.AddCssClass("form-text");
small.InnerHtml.Append(localizedText);
var div = new TagBuilder("div");
div.AddCssClass("form-text");
div.InnerHtml.Append(localizedText);
if (!string.IsNullOrEmpty(idValue))
{
div.Attributes.Add("id", idValue + "InfoText");
inputTag.AppendAriaDescribedby(idValue + "InfoText");
}
return small.ToHtmlString();
return div.ToHtmlString();
}
protected virtual List<SelectListItem> GetSelectItemsFromEnum(TagHelperContext context, TagHelperOutput output, ModelExplorer explorer)

194
framework/test/Volo.Abp.AspNetCore.Mvc.UI.Tests/Volo/Abp/AspNetCore/Mvc/UI/Bootstrap/TagHelpers/Form/AbpInputTagHelperService_Tests.cs

@ -1,9 +1,14 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.Extensions.Localization;
using Shouldly;
using Xunit;
@ -45,6 +50,109 @@ public class AbpInputTagHelperService_Tests
service.LastGroupHtml.ShouldContain("mb-3");
}
[Fact]
public async Task Info_text_should_be_rendered_as_div_with_form_text_class()
{
var service = new TestAbpInputTagHelperServiceForInfo();
var tagHelper = new AbpInputTagHelper(service)
{
AspFor = CreateModelExpression(),
InfoText = "Description"
};
var output = CreateOutput();
await tagHelper.ProcessAsync(CreateContext(), output);
service.LastGroupHtml.ShouldContain("<div class=\"form-text\"");
service.LastGroupHtml.ShouldContain("id=\"TestInputInfoText\"");
service.LastGroupHtml.ShouldNotContain("<small");
}
[Fact]
public async Task Info_text_should_set_aria_describedby_on_input()
{
var service = new TestAbpInputTagHelperServiceForInfo();
var tagHelper = new AbpInputTagHelper(service)
{
AspFor = CreateModelExpression(),
InfoText = "Description"
};
var output = CreateOutput();
await tagHelper.ProcessAsync(CreateContext(), output);
service.LastInputTag.ShouldNotBeNull();
service.LastInputTag!.Attributes["aria-describedby"].Value.ToString().ShouldBe("TestInputInfoText");
service.LastGroupHtml.ShouldContain("aria-describedby=\"TestInputInfoText\"");
}
[Fact]
public async Task Info_text_should_skip_id_and_aria_describedby_when_input_has_no_id()
{
var service = new TestAbpInputTagHelperServiceForInfo(inputId: null);
var tagHelper = new AbpInputTagHelper(service)
{
AspFor = CreateModelExpression(),
InfoText = "Description"
};
var output = CreateOutput();
await tagHelper.ProcessAsync(CreateContext(), output);
service.LastGroupHtml.ShouldContain("<div class=\"form-text\"");
service.LastGroupHtml.ShouldContain("Description");
service.LastGroupHtml.ShouldNotContain("id=\"InfoText\"");
service.LastGroupHtml.ShouldNotContain("aria-describedby=\"InfoText\"");
service.LastInputTag.ShouldNotBeNull();
service.LastInputTag!.Attributes.ContainsName("aria-describedby").ShouldBeFalse();
}
[Fact]
public async Task Aria_describedby_should_preserve_existing_value_set_by_caller()
{
var service = new TestAbpInputTagHelperServiceForInfo(existingAriaDescribedby: "custom-id");
var tagHelper = new AbpInputTagHelper(service)
{
AspFor = CreateModelExpression(),
InfoText = "Description"
};
var output = CreateOutput();
await tagHelper.ProcessAsync(CreateContext(), output);
service.LastInputTag.ShouldNotBeNull();
service.LastInputTag!.Attributes["aria-describedby"].Value.ToString().ShouldBe("custom-id TestInputInfoText");
service.LastGroupHtml.ShouldContain("aria-describedby=\"custom-id TestInputInfoText\"");
}
[Fact]
public async Task InputInfoText_attribute_should_render_info_text_with_single_aria_describedby()
{
var service = new TestAbpInputTagHelperServiceForInfo();
var tagHelper = new AbpInputTagHelper(service)
{
AspFor = CreateModelExpressionWithInputInfoText()
};
var output = CreateOutput();
await tagHelper.ProcessAsync(CreateContext(), output);
service.LastGroupHtml.ShouldContain("<div class=\"form-text\"");
service.LastGroupHtml.ShouldContain("Description from attribute");
service.LastGroupHtml.ShouldNotContain("<small");
service.LastInputTag.ShouldNotBeNull();
var ariaDescribedby = service.LastInputTag!.Attributes.Where(a => a.Name == "aria-describedby").ToList();
ariaDescribedby.Count.ShouldBe(1);
ariaDescribedby[0].Value.ToString().ShouldBe("TestInputInfoText");
}
private static TagHelperContext CreateContext()
{
return new TagHelperContext(
@ -69,6 +177,21 @@ public class AbpInputTagHelperService_Tests
metadataProvider.GetModelExplorerForType(typeof(string), null));
}
private static ModelExpression CreateModelExpressionWithInputInfoText()
{
var metadataProvider = new EmptyModelMetadataProvider();
var modelExplorer = metadataProvider
.GetModelExplorerForType(typeof(TestModelWithInputInfoText), null)
.GetExplorerForProperty(nameof(TestModelWithInputInfoText.TestInput));
return new ModelExpression(nameof(TestModelWithInputInfoText.TestInput), modelExplorer);
}
private class TestModelWithInputInfoText
{
[InputInfoText("Description from attribute")]
public string TestInput { get; set; } = string.Empty;
}
private sealed class TestAbpInputTagHelperService : AbpInputTagHelperService
{
private readonly string _inputTypeName;
@ -119,4 +242,75 @@ public class AbpInputTagHelperService_Tests
suppress = false;
}
}
private sealed class TestAbpInputTagHelperServiceForInfo : AbpInputTagHelperService
{
private readonly string? _inputId;
private readonly string? _existingAriaDescribedby;
public string LastGroupHtml { get; private set; } = string.Empty;
public TagHelperOutput? LastInputTag { get; private set; }
public TestAbpInputTagHelperServiceForInfo(string? inputId = "TestInput", string? existingAriaDescribedby = null)
: base(null!, HtmlEncoder.Default, new FakeTagHelperLocalizer())
{
_inputId = inputId;
_existingAriaDescribedby = existingAriaDescribedby;
}
protected override Task<(TagHelperOutput, bool)> GetInputTagHelperOutputAsync(TagHelperContext context, TagHelperOutput output)
{
var attributes = new TagHelperAttributeList
{
{ "type", "text" },
{ "class", "form-control" }
};
if (!string.IsNullOrEmpty(_inputId))
{
attributes.Add("id", _inputId);
}
if (!string.IsNullOrEmpty(_existingAriaDescribedby))
{
attributes.Add("aria-describedby", _existingAriaDescribedby);
}
LastInputTag = new TagHelperOutput(
"input",
attributes,
(_, _) => Task.FromResult<TagHelperContent>(new DefaultTagHelperContent()))
{
TagMode = TagMode.SelfClosing
};
AddInfoTextId(LastInputTag);
return Task.FromResult((LastInputTag, false));
}
protected override Task<string> GetLabelAsHtmlAsync(TagHelperContext context, TagHelperOutput output, TagHelperOutput inputTag, bool isCheckbox)
{
return Task.FromResult(string.Empty);
}
protected override Task<string> GetValidationAsHtmlAsync(TagHelperContext context, TagHelperOutput output, TagHelperOutput inputTag)
{
return Task.FromResult(string.Empty);
}
protected override void AddGroupToFormGroupContents(TagHelperContext context, string propertyName, string html, int order, out bool suppress)
{
LastGroupHtml = html;
suppress = false;
}
}
private sealed class FakeTagHelperLocalizer : IAbpTagHelperLocalizer
{
public string GetLocalizedText(string text, ModelExplorer explorer) => text;
public IStringLocalizer? GetLocalizerOrNull(ModelExplorer explorer) => null;
public IStringLocalizer? GetLocalizerOrNull(Assembly assembly) => null;
}
}

246
framework/test/Volo.Abp.AspNetCore.Mvc.UI.Tests/Volo/Abp/AspNetCore/Mvc/UI/Bootstrap/TagHelpers/Form/AbpSelectTagHelperService_Tests.cs

@ -0,0 +1,246 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.Extensions.Localization;
using Shouldly;
using Xunit;
namespace Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Form;
public class AbpSelectTagHelperService_Tests
{
[Fact]
public async Task Info_text_should_be_rendered_as_div_with_form_text_class()
{
var service = new TestAbpSelectTagHelperService();
var tagHelper = new AbpSelectTagHelper(service)
{
AspFor = CreateModelExpression(),
InfoText = "Description"
};
var output = CreateOutput();
await tagHelper.ProcessAsync(CreateContext(), output);
service.LastGroupHtml.ShouldContain("<div class=\"form-text\"");
service.LastGroupHtml.ShouldContain("id=\"TestSelectInfoText\"");
service.LastGroupHtml.ShouldNotContain("<small");
}
[Fact]
public async Task Info_text_should_set_aria_describedby_on_select()
{
var service = new TestAbpSelectTagHelperService();
var tagHelper = new AbpSelectTagHelper(service)
{
AspFor = CreateModelExpression(),
InfoText = "Description"
};
var output = CreateOutput();
await tagHelper.ProcessAsync(CreateContext(), output);
service.LastSelectTag.ShouldNotBeNull();
service.LastSelectTag!.Attributes.ContainsName("aria-describedby").ShouldBeTrue();
service.LastSelectTag.Attributes["aria-describedby"].Value.ToString().ShouldBe("TestSelectInfoText");
service.LastGroupHtml.ShouldContain("aria-describedby=\"TestSelectInfoText\"");
}
[Fact]
public async Task Info_text_should_skip_id_and_aria_describedby_when_select_has_no_id()
{
var service = new TestAbpSelectTagHelperService(selectId: null);
var tagHelper = new AbpSelectTagHelper(service)
{
AspFor = CreateModelExpression(),
InfoText = "Description"
};
var output = CreateOutput();
await tagHelper.ProcessAsync(CreateContext(), output);
service.LastGroupHtml.ShouldContain("<div class=\"form-text\"");
service.LastGroupHtml.ShouldContain("Description");
service.LastGroupHtml.ShouldNotContain("id=\"InfoText\"");
service.LastGroupHtml.ShouldNotContain("aria-describedby=\"InfoText\"");
service.LastSelectTag.ShouldNotBeNull();
service.LastSelectTag!.Attributes.ContainsName("aria-describedby").ShouldBeFalse();
}
[Fact]
public async Task Aria_describedby_should_preserve_existing_value_set_by_caller()
{
var service = new TestAbpSelectTagHelperService(existingAriaDescribedby: "custom-id");
var tagHelper = new AbpSelectTagHelper(service)
{
AspFor = CreateModelExpression(),
InfoText = "Description"
};
var output = CreateOutput();
await tagHelper.ProcessAsync(CreateContext(), output);
service.LastSelectTag.ShouldNotBeNull();
service.LastSelectTag!.Attributes["aria-describedby"].Value.ToString().ShouldBe("custom-id TestSelectInfoText");
service.LastGroupHtml.ShouldContain("aria-describedby=\"custom-id TestSelectInfoText\"");
}
[Fact]
public async Task Aria_describedby_should_split_on_html_whitespace_separators()
{
var service = new TestAbpSelectTagHelperService(existingAriaDescribedby: "id1\tid2");
var tagHelper = new AbpSelectTagHelper(service)
{
AspFor = CreateModelExpression(),
InfoText = "Description"
};
var output = CreateOutput();
await tagHelper.ProcessAsync(CreateContext(), output);
service.LastSelectTag.ShouldNotBeNull();
service.LastSelectTag!.Attributes["aria-describedby"].Value.ToString().ShouldBe("id1\tid2 TestSelectInfoText");
}
[Fact]
public async Task InputInfoText_attribute_should_render_info_text_with_single_aria_describedby()
{
var service = new TestAbpSelectTagHelperService();
var tagHelper = new AbpSelectTagHelper(service)
{
AspFor = CreateModelExpressionWithInputInfoText()
};
var output = CreateOutput();
await tagHelper.ProcessAsync(CreateContext(), output);
service.LastGroupHtml.ShouldContain("<div class=\"form-text\"");
service.LastGroupHtml.ShouldContain("Description from attribute");
service.LastGroupHtml.ShouldNotContain("<small");
service.LastSelectTag.ShouldNotBeNull();
var ariaDescribedby = service.LastSelectTag!.Attributes.Where(a => a.Name == "aria-describedby").ToList();
ariaDescribedby.Count.ShouldBe(1);
ariaDescribedby[0].Value.ToString().ShouldBe("TestSelectInfoText");
}
private static TagHelperContext CreateContext()
{
return new TagHelperContext(
new TagHelperAttributeList(),
new Dictionary<object, object>(),
"test");
}
private static TagHelperOutput CreateOutput()
{
return new TagHelperOutput(
"abp-select",
new TagHelperAttributeList(),
(_, _) => Task.FromResult<TagHelperContent>(new DefaultTagHelperContent()));
}
private static ModelExpression CreateModelExpression()
{
var metadataProvider = new EmptyModelMetadataProvider();
return new ModelExpression(
"TestSelect",
metadataProvider.GetModelExplorerForType(typeof(string), null));
}
private static ModelExpression CreateModelExpressionWithInputInfoText()
{
var metadataProvider = new EmptyModelMetadataProvider();
var modelExplorer = metadataProvider
.GetModelExplorerForType(typeof(TestModelWithInputInfoText), null)
.GetExplorerForProperty(nameof(TestModelWithInputInfoText.TestSelect));
return new ModelExpression(nameof(TestModelWithInputInfoText.TestSelect), modelExplorer);
}
private class TestModelWithInputInfoText
{
[InputInfoText("Description from attribute")]
public string TestSelect { get; set; } = string.Empty;
}
private sealed class TestAbpSelectTagHelperService : AbpSelectTagHelperService
{
private readonly string? _selectId;
private readonly string? _existingAriaDescribedby;
public string LastGroupHtml { get; private set; } = string.Empty;
public TagHelperOutput? LastSelectTag { get; private set; }
public TestAbpSelectTagHelperService(string? selectId = "TestSelect", string? existingAriaDescribedby = null)
: base(null!, HtmlEncoder.Default, new FakeTagHelperLocalizer(), null!, null!)
{
_selectId = selectId;
_existingAriaDescribedby = existingAriaDescribedby;
}
protected override Task<TagHelperOutput> GetSelectTagAsync(TagHelperContext context, TagHelperOutput output, TagHelperContent childContent)
{
var attributes = new TagHelperAttributeList();
if (!string.IsNullOrEmpty(_selectId))
{
attributes.Add("id", _selectId);
}
if (!string.IsNullOrEmpty(_existingAriaDescribedby))
{
attributes.Add("aria-describedby", _existingAriaDescribedby);
}
LastSelectTag = new TagHelperOutput(
"select",
attributes,
(_, _) => Task.FromResult<TagHelperContent>(new DefaultTagHelperContent()))
{
TagMode = TagMode.StartTagAndEndTag
};
AddInfoTextId(LastSelectTag);
return Task.FromResult(LastSelectTag);
}
protected override Task<string> GetLabelAsHtmlAsync(TagHelperContext context, TagHelperOutput output, TagHelperOutput selectTag)
{
return Task.FromResult(string.Empty);
}
protected override Task<string> GetValidationAsHtmlAsync(TagHelperContext context, TagHelperOutput output, TagHelperOutput selectTag)
{
return Task.FromResult(string.Empty);
}
protected override void AddGroupToFormGroupContents(TagHelperContext context, string propertyName, string html, int order, out bool suppress)
{
LastGroupHtml = html;
suppress = false;
}
}
private sealed class FakeTagHelperLocalizer : IAbpTagHelperLocalizer
{
public string GetLocalizedText(string text, ModelExplorer explorer) => text;
public IStringLocalizer? GetLocalizerOrNull(ModelExplorer explorer) => null;
public IStringLocalizer? GetLocalizerOrNull(Assembly assembly) => null;
}
}
Loading…
Cancel
Save