Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Querystring building APIs for Blazor #34115

Closed
SteveSandersonMS opened this issue Jul 6, 2021 · 16 comments
Closed

Querystring building APIs for Blazor #34115

SteveSandersonMS opened this issue Jul 6, 2021 · 16 comments
Assignees
Labels
api-approved API was approved in API review, it can be implemented area-blazor Includes: Blazor, Razor Components Done This issue has been fixed enhancement This issue represents an ask for new feature or an enhancement to an existing one
Milestone

Comments

@SteveSandersonMS
Copy link
Member

SteveSandersonMS commented Jul 6, 2021

As part of #33338, I'm considering what (if anything) we should do to help people generate URLs with particular querystrings from Blazor components.

Scenarios

  1. During rendering, emitting (a lot) of links to different combinations of querystring parameters. For example, pagination links, or links in every cell/row of a grid.
  2. During event handling, navigating to a URL with a specific combination of querystring parameters

In both cases, it's worth thinking of these two sub-cases:

a. If you're navigating to a different @page component, or if your component completely owns and controls the whole URL, then you want to discard any other query parameters.

b. If you're just updating query parameter(s) while staying on the same page, and unrelated components are also storing state in the querystring, you need to retain the unrelated parameters.

In other words, are you also retaining the path part of the URL, and are you retaining unrelated query params?

Sample for case 1:

Here we generate page links to the current component, retaining the sort parameter. We may or may not have to retain unrelated parameters (that depends on whether we're in subcase a or b).

@page "/products/{categoryId:int}"

@for(var pageIndex = 1; pageIndex < MaxPage; pageIndex++)
{
    <a href="@(GetUrl(pageIndex, Sort))">Page @pageIndex</a> |
}

@code {
    [Parameter] public int CategoryId { get; set; }
    [Parameter, SupplyParameterFromQueryString] public int? Page { get; set; }
    [Parameter, SupplyParameterFromQueryString] public string Sort { get; set; }

    private string GetUrl(int pageIndex, string sort)
        => // TODO: implement - see below
}

Sample for case 2:

Consider a <Wizard> component that has a step querystring parameter. It's not a @page component as you embed it in a routable page.

It doesn't own the whole URL, so need to just trigger navigations to "current URL except with a modified parameter".

@inject NavigationManager Nav

<button @onclick="MovePrev">Next</button>
<button @onclick="MoveNext">Next</button>

@code {
    [Parameter] public string[] StepIds { get; set; }
    [Parameter, SupplyParameterFromQueryString(Name = "step")] public string CurrentStepId { get; set; }

    private async Task MoveNext()
    {
        // Ignoring details of error handling, etc.
        var currentStepIndex = StepIds.IndexOf(CurrentStepId);
        var nextStepId = StepIds[currentStepIndex + 1];

        // Imagine this API retains all the query params except the one you're updating
        return Nav.UpdateQueryParameterAsync("step", nextStepId);
    }
}

Possible designs

1. Do nothing

We could leave developers to figure something out on their own. In subcases (a), this isn't too hard, as developers can create a utility method that takes whatever combination of params they want and formats a string:

private string GetUrl(int pageIndex, string sort)
    => $"products?page={pageIndex}&sort={Uri.EscapeDataString(sort)}";

// Or maybe, if you want to omit default values:
private string GetUrl(int? pageIndex, string sort)
{
    var query = new StringBuilder();
    if (pageIndex.HasValue && pageIndex.Value > 1)
    {
        var separator = query.Length > 0 ? "&" : "?";
        query.AppendFormat("{0}page={1}", separator, page.Value);
    }

    if (!string.IsNullOrEmpty(sort))
    {
        var separator = query.Length > 0 ? "&" : "?";
        query.AppendFormat("{0}sort={1}", separator, Uri.EscapeDataString(sort));
    }

    return $"products{query}";
}

However it's harder if you need to retain unrelated parameters. You end up needing to parse the existing querystring.

If you're on Blazor Server, you can use WebUtilities:

private string GetUrl(int? pageIndex, string sort)
{
    var newParams = new Dictionary<string, string>();
    if (pageIndex.HasValue && pageIndex.Value > 1)
    {
        newParams["page"] = pageIndex.Value;
    }
    if (!string.IsNullOrEmpty(sort))
    {
        newParams["sort"] = sort;
    }

    return QueryHelpers.AddQueryString(
        NavigationManager.Url,
        newParams);
}

Or you can use QueryBuilder from HttpExtensions:

private string GetUrl(int? pageIndex, string sort)
{
    var existingValues = QueryHelpers.ParseQuery(Nav.Uri);
    var qb = new QueryBuilder(existingValues);
    qb.Add("page", page.GetValueOrDefault(1));
    qb.Add("sort", sort);
    return Nav.UrlWithQuery(qb); // This doesn't exist, but we could add it
}

If you're on Blazor WebAssembly, it's more messy because you don't have a reference to WebUtilities, and you can't even reference the current version as it's not a package. You'll probably end up referencing the 2.2.0 version, which is on NuGet.

Pros:

  • Cheapest option
  • Nothing new for developers to learn

Cons:

  • You have to know how to format each type of parameter correctly (e.g., always use culture-invariant format), or you'll get nonfunctional URLs
  • If you're emitting your own string directly,
    • It's easy to forget escaping
    • You can't retain unrelated params without complex code
  • If you're using WebUtilities/HttpExtensions:
    • Hard to reference this on WebAssembly
    • Perf cost of building a new dictionary for each combination (actually, two dictionaries). Overall really quite allocatey.

Overall, I think this might be OK as a first step. We could wait to see what level of customer demand emerges for making this more built-in.

2. Dictionary-based API

We could expose a dictionary-based API, just like QueryHelpers:

private string GetUrl(int? pageIndex, string sort)
    => BlazorQueryHelpers.AddQueryString(NavigationManager.Url, new
    {
        { "page", pageIndex.GetValueOrDefault(1) },
        { "sort", sort },
    });

// Or retain existing values:
private string GetUrl(int? pageIndex, string sort)
{
    var dict = BlazorQueryHelpers.ParseQuery(NavigationManager.Url);
    dict["page"] = pageIndex.GetValueOrDefault(1);
    dict["sort"] = sort;
    return BlazorQueryHelpers.AddQueryString(NavigationManager.Url, dict);
}

// Or see above for an example of omitting default values

Pros:

  • No need to define different APIs for "add", "update", "remove", since that's all inside Dictionary already
  • Covers retaining existing values as well as creating whole new URLs

Cons:

  • If you're on Server, it's weird that both QueryHelpers and this new thing are both available, when they basically do the same thing
  • Allocates a lot and is computationally inefficient to build these dictionaries
  • Still doesn't deal with formatting values. Developer still has to know to call the culture-invariant formatters.
    • Or we could have Dictionary<string, object> and do the formatting for them, but that's even more allocatey

Generally I think this is fine for "navigating during an event handler" (scenario 2), but would be pretty bad for "emitting a lot of links during rendering" (scenario 1).

3. Fluent builder API

Thanks to various improvements in modern .NET, we could create an efficient fluent API for constructing or modifying querystrings that doesn't allocate at all until it writes out the final string instance:

// Creating a new URL, discarding existing values:
private string GetUrl(int? pageIndex, string sort)
    => Nav.CreateUrl("/products")
        .WithQuery("page", pageIndex.GetValueOrDefault(1))
        .WithQuery("sort", sort);

// Or retain existing URL and overwrite/add specific params:
private string GetUrl(int? pageIndex, string sort)
    => Nav.CreateUrl()
        .WithQuery("page", pageIndex.GetValueOrDefault(1))
        .WithQuery("sort", sort);

Or, since it's so compact, use it directly during rendering logic:

@for (var pageIndex = 1; pageIndex <= MaxPages; pageIndex++)
{
    <a href="@Nav.UrlWithQuery("page", pageIndex)">Page @pageIndex</a> |
}

Pros:

  • Doesn't clash with existing APIs. Naturally sits on NavigationManager to let you update the existing URL.
  • No allocations except producing the final string (see notes below). Also no work to construct dictionaries.
  • Can automatically format each supported parameter type in correct culture-invariant format. Fully strongly-typed via overloads.

Cons:

  • Largest implementation cost
  • Should this go into ASP.NET Core too? Doesn't seem Blazor-specific.
  • Needs a separate method to remove items (e.g., .Remove(name))

Overall, this seems like the gold-standard solution but it's not yet 100% clear that it warrants the implementation effort.

Implementation notes: I made a prototype of this. It avoids allocations by using a fixed buffer on a mutable struct and String.Create, plus the allocation-free QueryStringEnumerable added recently. It's fairly involved, but customers don't need to see or care how it works internally. In the prototype, you can add/overwrite/remove up to 3 values before it has to allocate an expandable buffer (which is also transparent to callers).

@SteveSandersonMS SteveSandersonMS added the area-blazor Includes: Blazor, Razor Components label Jul 6, 2021
@SteveSandersonMS
Copy link
Member Author

Tagging @dotnet/aspnet-blazor-eng @davidfowl for opinions.

@SteveSandersonMS
Copy link
Member Author

Just noticed @MackinnonBuck isn't in the group I tagged above, so mentioning him for any opinions too.

@pranavkm pranavkm added the enhancement This issue represents an ask for new feature or an enhancement to an existing one label Jul 6, 2021
@pranavkm pranavkm added this to the 6.0-preview7 milestone Jul 6, 2021
@javiercn
Copy link
Member

javiercn commented Jul 7, 2021

@SteveSandersonMS I would separate things in three cases:

  • Adding more parameters
  • Removing parameters
  • Updating parameters

Adding a parameter is the "simplest" and can "relatively easy" be done without any special primitive (just raw string manipulation). (I'm not suggesting we do this).

Removing and updating parameters is trickier because you need to know the query string structure. One potential way to go about this (I don't know if we already do) is to expose our QueryParameterEnumerator thing and iterate over the values skipping a given key/value to remove it and accumulating the results in, for example, a stringbuilder. Update could be done in the same way, but replacing the existing value with the provided value.

One thing to note here, is that I don't think we have a problem when you are generating lots of links. ASP.NET Core link generation isn't allocation free and we don't think there's a compelling need to optimize it further.

Its nice if we can have more "optimal" APIs, but I don't necessarily think we need to jump into it.

I would suggest we avoid this problem at all or provide simple APIs with a basic implementation over ultra-optimizing things from the beginning.

@SteveSandersonMS
Copy link
Member Author

I'm open to the idea that we do nothing (that's what I wrote up as design 1 above) but there are some real and specific problems there for WebAssembly developers. We will need to have some specific recommendations about what APIs people should use, and how they should use them.

ASP.NET Core link generation isn't allocation free and we don't think there's a compelling need to optimize it further

Understood, but I wouldn't generally take ASP.NET Core as a guide to the perf characteristics and requirements for Blazor. An interactive UI that may refresh tens of times per second and that has a degraded UX if GC happens can involve making some different tradeoffs to an HTTP server. I'm not disagreeing with your view that we may be able to avoid implementing something new here, but I think we should find justifications based on its own scenarios. Hope that sounds reasonable!

@javiercn
Copy link
Member

javiercn commented Jul 7, 2021

An interactive UI that may refresh tens of times per second and that has a degraded UX if GC happens can involve making some different tradeoffs to an HTTP server

Agree, at the same time, a traditional server handles hundreds of thousands of requests per second. I understand the potential concerns about UX experience in extreme cases, I'm suggesting that they are "extreme" and likely not something we necessarily need to solve ourselves.

People with these extreme needs can come up with solutions that work for their needs on their own in the same way they do in other areas. For most people, an API that uses a StringBuilder or the ArrayPool internally to build the query will likely do the trick.

@SteveSandersonMS
Copy link
Member Author

a traditional server handles hundreds of thousands of requests per second.

I know - perf is important everywhere! I'm not disputing your conclusion, but just trying to indicate why I think there are grounds for making different tradeoffs sometimes. In this case it's to do with Blazor being more focused on lag, and ASP.NET Core being more focused on throughput.

Anyway, that aside, we can't completely do nothing here. We have to provide some APIs (or recommendations) that are valid for WebAssembly. I'd love to get some perspectives from others on the team too, but @javiercn if you want to chat about options here just ping me whenever you want.

@SteveSandersonMS
Copy link
Member Author

SteveSandersonMS commented Jul 19, 2021

API proposal

I've boiled this down to what I think are the minimum reasonable scenarios:

  • Adding/changing/removing a single query parameter on the current URL
  • Adding/changing/removing multiple query parameters on the current URL
  • Constructing a whole new URL with any number of query parameters

These three cases naturally lead to three different method overloads, and since they are mainly focused on updating the current URL, they make perfectly good sense to hang on NavigationManager. This avoids confusing anyone with it seeming to duplicate any other query-related APIs in ASP.NET Core.

Rather than optimize excessively for perf at this stage (e.g., as @javiercn argues above) with the whole "builder" pattern to avoid allocations, I propose the following overloads. Note that there's already navigationManager.Uri to get the current URL, so these naturally fit alongside it in intellisense:

Public APIs

navigationManager.UriWithQueryParameter<T>(string name, T value)
  • Returns a string equal to the current URL except with a single parameter added or updated, or removed if the type is nullable and the value is null
  • Automatically uses the correct culture-invariant formatting for the given type, and of course URL-encodes the name and value
  • Won't really be generic - I just wrote it like that here as shorthand. In reality there will be actual overloads for all the supported primitive types, and no other types, so you can't get confused about what's supported.
  • Replaces all of the values with the matching name (if there were multiple of them before). If the value you pass is an IEnumerable<T> for one of the supported types T, then we emit multiple name/value pairs.
navigationManager.UriWithQueryParameters(IReadOnlyDictionary<string, object> parameters)
  • As above, except with multiple parameters being added/updated/removed.
  • For each value, uses value?.GetType() to determine the runtime type and picks the correct culture-invariant formatting for each, or throws for unsupported types
    • Open question: do we want to support multiple values for a given parameter name? If the value type is IEnumerable<T> for a supported T, we can certainly add multiple values for the parameter, but we'd need to replace all existing ones with that name otherwise there'd be no way to remove items from a collection.
  • Clearly this will involve a bunch of boxing for most parameter types, plus needs to allocate storage to track which of the existing/new parameters have been used. Possible future alternative: We could introduce a more allocation-free builder variant like I prototyped before, but there's no immediate need for that.
navigationManager.UriWithQueryParameters(string uri, IReadOnlyDictionary<string, object> parameters)
  • As above, except instead of being based on the current URI, it uses some other uri you pass in. This covers the "build a whole new URL with parameters" case.

For clarity, these can actually be extension methods that live in Microsoft.AspNetCore.Components.Web or even Microsoft.AspNetCore.Components.

Non-goals

I no longer thing it's necessary or valuable to:

  • ... have APIs like navigationManager.SetQueryParameter(name, value, replaceFlag) because that's equivalent to navigationManager.NavigateTo(navigationManager.UriWithQuery(name, value), replaceFlag)
  • ... have any standalone "query string parser" API because:
    • If you need to receive and process existing query parameters, you'd do that with [SupplyParameterFromQuery]
    • If you want to ignore and leave unrelated parameters in place, the above APIs already do that
    • It would require a more complicated API anyway. We can't give the developer a dictionary whose values are object, because we don't know how they want each value to be parsed. We'd need a dictionary of string->QueryParameterValue, where QueryParameterValue is some type on which you can call GetBoolean, GetInt, GetIntArray, etc. It's not good enough to just supply string because they also need access to the standard culture-invariant parsers. So although we could do all this, it's a whole bunch more complex and warrants ending up in a separate package.

@SteveSandersonMS
Copy link
Member Author

@pranavkm Hey API review boss, it it valid to "API review" a proposal for forthcoming API, or do we only review things after they are implemented?

@campersau
Copy link
Contributor

campersau commented Jul 19, 2021

or removed if value == default

Is it then possible to set a query parameter to (int)0 or should they be nullable instead?


navigationManager.UriWithQueryParameters(string name, IReadOnlyDictionary<string, object> parameters)

What exactly is the name here? Or what is the string Key of the dictionary? Is one of them unused?


Would it be possible to add / update multiple values for the same query parameter name?

@SteveSandersonMS
Copy link
Member Author

@campersau - thanks for commenting! I've edited to clarify that default really was only meant to mean "null for nullable types", and regarding name on the dictionary overloads, that was a typo and is now removed.

@SteveSandersonMS
Copy link
Member Author

SteveSandersonMS commented Jul 19, 2021

Would it possible to add / update multiple values for the same query parameter name?

We certainly could support IEnumerable<T> for each of the supported primitive types. It might be that, to avoid the overload explosion, we only do it for the IDictionary<string, object> overloads. It's not a goal here to provide shorthands for all possible usage patterns, as the dictionary form allows you ultimately to do anything if writing your own code.

@SteveSandersonMS
Copy link
Member Author

SteveSandersonMS commented Jul 26, 2021

@MackinnonBuck Just in case you get to this before we next talk, the infrastructure I was mentioning that will be helpful here is Microsoft.AspNetCore.Internal.QueryStringEnumerable which should be reachable from either the Components or Components.Web projects. This will provide an efficient way to step through the entries in the existing querystring, and can be fed into some kind of string builder where you emit the resulting querystring with entries added/removed/replaced (or maybe there's some fancy way to use string.Create - not sure, and suspect it would get complicated if the input already had multiple matches to be replaced/removed/etc).

@MackinnonBuck
Copy link
Member

Done in #34813.

@ghost ghost added Done This issue has been fixed and removed Working labels Aug 3, 2021
@MackinnonBuck
Copy link
Member

MackinnonBuck commented Aug 4, 2021

For API review: Here is the API surface diff after the changes in #34813

namespace Microsoft.AspNetCore.Components
{
+    public static class NavigationManagerExtensions
+    {
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, string? value)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, bool value)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, bool? value)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, DateTime value)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, DateTime? value)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, decimal value)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, decimal? value)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, double value)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, double? value)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, float value)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, float? value)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, Guid value)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, Guid? value)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, int value)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, int? value)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, long value)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, long? value)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<string?> values)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<bool> values)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<bool?> values)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<DateTime> values)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<DateTime?> values)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<decimal> values)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<decimal?> values)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<double> values)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<double?> values)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<float> values)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<float?> values)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<Guid> values)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<Guid?> values)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<int> values)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<int?> values)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<long> values)
+        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<long?> values)
+        public static string UriWithQueryParameters(this NavigationManager navigationManager, IReadOnlyDictionary<string, object?> parameters)
+        public static string UriWithQueryParameters(this NavigationManager navigationManager, string uri, IReadOnlyDictionary<string, object?> parameters)
+    }
}

@MackinnonBuck MackinnonBuck added the api-ready-for-review API is ready for formal API review - https://github.com/dotnet/apireviews label Aug 4, 2021
@ghost
Copy link

ghost commented Aug 4, 2021

Thank you for submitting this for API review. This will be reviewed by @dotnet/aspnet-api-review at the next meeting of the ASP.NET Core API Review group. Please ensure you take a look at the API review process documentation and ensure that:

  • The PR contains changes to the reference-assembly that describe the API change. Or, you have included a snippet of reference-assembly-style code that illustrates the API change.
  • The PR describes the impact to users, both positive (useful new APIs) and negative (breaking changes).
  • Someone is assigned to "champion" this change in the meeting, and they understand the impact and design of the change.

@pranavkm
Copy link
Contributor

pranavkm commented Aug 9, 2021

API review:

  • UriWithQueryParameter -> GetUriWithQueryParameter
  • UriWithQueryParameters -> GetUriWithQueryParameters

We also decided to remove all the IEnumerable<{DataType}> overloads.

-        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<string?> values)
-        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<bool> values)
-        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<bool?> values)
-        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<DateTime> values)
-        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<DateTime?> values)
-        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<decimal> values)
-        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<decimal?> values)
-        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<double> values)
-        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<double?> values)
-        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<float> values)
-        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<float?> values)
-        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<Guid> values)
-        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<Guid?> values)
-        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<int> values)
-        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<int?> values)
-        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<long> values)
-        public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable<long?> values)

@pranavkm pranavkm added api-approved API was approved in API review, it can be implemented and removed api-ready-for-review API is ready for formal API review - https://github.com/dotnet/apireviews labels Aug 9, 2021
@ghost ghost locked as resolved and limited conversation to collaborators Sep 8, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
api-approved API was approved in API review, it can be implemented area-blazor Includes: Blazor, Razor Components Done This issue has been fixed enhancement This issue represents an ask for new feature or an enhancement to an existing one
Projects
None yet
Development

No branches or pull requests

8 participants