diff --git a/src/Components/Components/src/NavigationManagerExtensions.cs b/src/Components/Components/src/NavigationManagerExtensions.cs new file mode 100644 index 000000000000..c8a04d28878c --- /dev/null +++ b/src/Components/Components/src/NavigationManagerExtensions.cs @@ -0,0 +1,940 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections; +using System.Globalization; +using System.Text; +using Microsoft.AspNetCore.Components.Routing; +using Microsoft.AspNetCore.Internal; + +namespace Microsoft.AspNetCore.Components +{ + /// + /// Provides extension methods for the type. + /// + public static class NavigationManagerExtensions + { + private const string EmptyQueryParameterNameExceptionMessage = "Cannot have empty query parameter names."; + + private delegate string? QueryParameterFormatter(TValue value); + + // We don't include mappings for Nullable types because we explicitly check for null values + // to see if the parameter should be excluded from the querystring. Therefore, we will only + // invoke these formatters for non-null values. We also get the underlying type of any Nullable + // types before performing lookups in this dictionary. + private static readonly Dictionary> _queryParameterFormatters = new() + { + [typeof(string)] = value => Format((string)value)!, + [typeof(bool)] = value => Format((bool)value), + [typeof(DateTime)] = value => Format((DateTime)value), + [typeof(decimal)] = value => Format((decimal)value), + [typeof(double)] = value => Format((double)value), + [typeof(float)] = value => Format((float)value), + [typeof(Guid)] = value => Format((Guid)value), + [typeof(int)] = value => Format((int)value), + [typeof(long)] = value => Format((long)value), + }; + + private static string? Format(string? value) + => value; + + private static string Format(bool value) + => value.ToString(CultureInfo.InvariantCulture); + + private static string? Format(bool? value) + => value?.ToString(CultureInfo.InvariantCulture); + + private static string Format(DateTime value) + => value.ToString(CultureInfo.InvariantCulture); + + private static string? Format(DateTime? value) + => value?.ToString(CultureInfo.InvariantCulture); + + private static string Format(decimal value) + => value.ToString(CultureInfo.InvariantCulture); + + private static string? Format(decimal? value) + => value?.ToString(CultureInfo.InvariantCulture); + + private static string Format(double value) + => value.ToString(CultureInfo.InvariantCulture); + + private static string? Format(double? value) + => value?.ToString(CultureInfo.InvariantCulture); + + private static string Format(float value) + => value.ToString(CultureInfo.InvariantCulture); + + private static string? Format(float? value) + => value?.ToString(CultureInfo.InvariantCulture); + + private static string Format(Guid value) + => value.ToString(null, CultureInfo.InvariantCulture); + + private static string? Format(Guid? value) + => value?.ToString(null, CultureInfo.InvariantCulture); + + private static string Format(int value) + => value.ToString(CultureInfo.InvariantCulture); + + private static string? Format(int? value) + => value?.ToString(CultureInfo.InvariantCulture); + + private static string Format(long value) + => value.ToString(CultureInfo.InvariantCulture); + + private static string? Format(long? value) + => value?.ToString(CultureInfo.InvariantCulture); + + // Used for constructing a URI with a new querystring from an existing URI. + private struct QueryStringBuilder + { + private readonly StringBuilder _builder; + + private bool _hasNewParameters; + + public string UriWithQueryString => _builder.ToString(); + + public QueryStringBuilder(ReadOnlySpan uriWithoutQueryString, int additionalCapacity = 0) + { + _builder = new(uriWithoutQueryString.Length + additionalCapacity); + _builder.Append(uriWithoutQueryString); + + _hasNewParameters = false; + } + + public void AppendParameter(ReadOnlySpan encodedName, ReadOnlySpan encodedValue) + { + if (!_hasNewParameters) + { + _hasNewParameters = true; + _builder.Append('?'); + } + else + { + _builder.Append('&'); + } + + _builder.Append(encodedName); + _builder.Append('='); + _builder.Append(encodedValue); + } + } + + // A utility for feeding a collection of parameter values into a QueryStringBuilder. + // This is used when generating a querystring with a query parameter that has multiple values. + private readonly struct QueryParameterSource + { + private readonly IEnumerator? _enumerator; + private readonly QueryParameterFormatter? _formatter; + + public string EncodedName { get; } + + // Creates an empty instance to simulate a source without any elements. + public QueryParameterSource(string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new InvalidOperationException(EmptyQueryParameterNameExceptionMessage); + } + + EncodedName = Uri.EscapeDataString(name); + + _enumerator = default; + _formatter = default; + } + + public QueryParameterSource(string name, IEnumerable values, QueryParameterFormatter formatter) + : this(name) + { + _enumerator = values.GetEnumerator(); + _formatter = formatter; + } + + public bool TryAppendNextParameter(ref QueryStringBuilder builder) + { + if (_enumerator is null || !_enumerator.MoveNext()) + { + return false; + } + + var currentValue = _enumerator.Current; + + if (currentValue is null) + { + // No-op to simulate appending a null parameter. + return true; + } + + var formattedValue = _formatter!(currentValue); + var encodedValue = Uri.EscapeDataString(formattedValue!); + builder.AppendParameter(EncodedName, encodedValue); + return true; + } + } + + // A utility for feeding an object of unknown type as one or more parameter values into + // a QueryStringBuilder. + private struct QueryParameterSource + { + private readonly QueryParameterSource _source; + private string? _encodedValue; + + public string EncodedName => _source.EncodedName; + + public QueryParameterSource(string name, object? value) + { + if (value is null) + { + _source = new(name); + _encodedValue = default; + return; + } + + var valueType = value.GetType(); + + if (valueType != typeof(string) && typeof(IEnumerable).IsAssignableFrom(valueType)) + { + // The provided value was of enumerable type, so we populate the underlying source. + var elementType = valueType.GetElementType()!; + var formatter = GetFormatterFromParameterValueType(elementType); + + // This cast is inevitable; the values have to be boxed anyway to be formatted. + var values = ((IEnumerable)value).Cast(); + + _source = new(name, values, formatter); + _encodedValue = default; + } + else + { + // The provided value was not of enumerable type, so we leave the underlying source + // empty and instead cache the encoded value to be appended later. + var formatter = GetFormatterFromParameterValueType(valueType); + var formattedValue = formatter(value); + _source = new(name); + _encodedValue = Uri.EscapeDataString(formattedValue!); + } + } + + public bool TryAppendNextParameter(ref QueryStringBuilder builder) + { + if (_source.TryAppendNextParameter(ref builder)) + { + // The underlying source of values had elements, so there is no more work to do here. + return true; + } + + // Either we've run out of elements to append or the given value was not of enumerable + // type in the first place. + + // If the value was not of enumerable type and has not been appended, append it + // and set it to null so we don't provide the value more than once. + if (_encodedValue is not null) + { + builder.AppendParameter(_source.EncodedName, _encodedValue); + _encodedValue = null; + return true; + } + + return false; + } + } + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// added or updated. + /// + /// The . + /// The name of the parameter to add or update. + /// The value of the parameter to add or update. + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, bool value) + => UriWithQueryParameter(navigationManager, name, Format(value)); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// added, updated, or removed. + /// + /// The . + /// The name of the parameter to add or update. + /// The value of the parameter to add or update. + /// + /// If is null, the parameter will be removed if it exists in the URI. + /// Otherwise, it will be added or updated. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, bool? value) + => UriWithQueryParameter(navigationManager, name, Format(value)); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// added or updated. + /// + /// The . + /// The name of the parameter to add or update. + /// The value of the parameter to add or update. + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, DateTime value) + => UriWithQueryParameter(navigationManager, name, Format(value)); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// added, updated, or removed. + /// + /// The . + /// The name of the parameter to add or update. + /// The value of the parameter to add or update. + /// + /// If is null, the parameter will be removed if it exists in the URI. + /// Otherwise, it will be added or updated. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, DateTime? value) + => UriWithQueryParameter(navigationManager, name, Format(value)); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// added or updated. + /// + /// The . + /// The name of the parameter to add or update. + /// The value of the parameter to add or update. + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, decimal value) + => UriWithQueryParameter(navigationManager, name, Format(value)); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// added, updated, or removed. + /// + /// The . + /// The name of the parameter to add or update. + /// The value of the parameter to add or update. + /// + /// If is null, the parameter will be removed if it exists in the URI. + /// Otherwise, it will be added or updated. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, decimal? value) + => UriWithQueryParameter(navigationManager, name, Format(value)); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// added or updated. + /// + /// The . + /// The name of the parameter to add or update. + /// The value of the parameter to add or update. + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, double value) + => UriWithQueryParameter(navigationManager, name, Format(value)); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// added, updated, or removed. + /// + /// The . + /// The name of the parameter to add or update. + /// The value of the parameter to add or update. + /// + /// If is null, the parameter will be removed if it exists in the URI. + /// Otherwise, it will be added or updated. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, double? value) + => UriWithQueryParameter(navigationManager, name, Format(value)); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// added or updated. + /// + /// The . + /// The name of the parameter to add or update. + /// The value of the parameter to add or update. + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, float value) + => UriWithQueryParameter(navigationManager, name, Format(value)); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// added, updated, or removed. + /// + /// The . + /// The name of the parameter to add or update. + /// The value of the parameter to add or update. + /// + /// If is null, the parameter will be removed if it exists in the URI. + /// Otherwise, it will be added or updated. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, float? value) + => UriWithQueryParameter(navigationManager, name, Format(value)); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// added or updated. + /// + /// The . + /// The name of the parameter to add or update. + /// The value of the parameter to add or update. + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, Guid value) + => UriWithQueryParameter(navigationManager, name, Format(value)); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// added, updated, or removed. + /// + /// The . + /// The name of the parameter to add or update. + /// The value of the parameter to add or update. + /// + /// If is null, the parameter will be removed if it exists in the URI. + /// Otherwise, it will be added or updated. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, Guid? value) + => UriWithQueryParameter(navigationManager, name, Format(value)); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// added or updated. + /// + /// The . + /// The name of the parameter to add or update. + /// The value of the parameter to add or update. + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, int value) + => UriWithQueryParameter(navigationManager, name, Format(value)); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// added, updated, or removed. + /// + /// The . + /// The name of the parameter to add or update. + /// The value of the parameter to add or update. + /// + /// If is null, the parameter will be removed if it exists in the URI. + /// Otherwise, it will be added or updated. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, int? value) + => UriWithQueryParameter(navigationManager, name, Format(value)); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// added or updated. + /// + /// The . + /// The name of the parameter to add or update. + /// The value of the parameter to add or update. + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, long value) + => UriWithQueryParameter(navigationManager, name, Format(value)); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// added, updated, or removed. + /// + /// The . + /// The name of the parameter to add or update. + /// The value of the parameter to add or update. + /// + /// If is null, the parameter will be removed if it exists in the URI. + /// Otherwise, it will be added or updated. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, long? value) + => UriWithQueryParameter(navigationManager, name, Format(value)); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// updated with the provided . + /// + /// The . + /// The name of the parameter to add or update. + /// The parameter values to add or update. + /// + /// Any null entries in will be skipped. Existing querystring parameters not in + /// will be removed from the querystring in the returned URI. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable values) + => GetUriWithUpdatedQueryParameter(navigationManager, name, values, Format); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// updated with the provided . + /// + /// The . + /// The name of the parameter to add or update. + /// The parameter values to add or update. + /// + /// Any null entries in will be skipped. Existing querystring parameters not in + /// will be removed from the querystring in the returned URI. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable values) + => GetUriWithUpdatedQueryParameter(navigationManager, name, values, Format); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// updated with the provided . + /// + /// The . + /// The name of the parameter to add or update. + /// The parameter values to add or update. + /// + /// Any null entries in will be skipped. Existing querystring parameters not in + /// will be removed from the querystring in the returned URI. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable values) + => GetUriWithUpdatedQueryParameter(navigationManager, name, values, Format); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// updated with the provided . + /// + /// The . + /// The name of the parameter to add or update. + /// The parameter values to add or update. + /// + /// Any null entries in will be skipped. Existing querystring parameters not in + /// will be removed from the querystring in the returned URI. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable values) + => GetUriWithUpdatedQueryParameter(navigationManager, name, values, Format); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// updated with the provided . + /// + /// The . + /// The name of the parameter to add or update. + /// The parameter values to add or update. + /// + /// Any null entries in will be skipped. Existing querystring parameters not in + /// will be removed from the querystring in the returned URI. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable values) + => GetUriWithUpdatedQueryParameter(navigationManager, name, values, Format); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// updated with the provided . + /// + /// The . + /// The name of the parameter to add or update. + /// The parameter values to add or update. + /// + /// Any null entries in will be skipped. Existing querystring parameters not in + /// will be removed from the querystring in the returned URI. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable values) + => GetUriWithUpdatedQueryParameter(navigationManager, name, values, Format); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// updated with the provided . + /// + /// The . + /// The name of the parameter to add or update. + /// The parameter values to add or update. + /// + /// Any null entries in will be skipped. Existing querystring parameters not in + /// will be removed from the querystring in the returned URI. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable values) + => GetUriWithUpdatedQueryParameter(navigationManager, name, values, Format); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// updated with the provided . + /// + /// The . + /// The name of the parameter to add or update. + /// The parameter values to add or update. + /// + /// Any null entries in will be skipped. Existing querystring parameters not in + /// will be removed from the querystring in the returned URI. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable values) + => GetUriWithUpdatedQueryParameter(navigationManager, name, values, Format); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// updated with the provided . + /// + /// The . + /// The name of the parameter to add or update. + /// The parameter values to add or update. + /// + /// Any null entries in will be skipped. Existing querystring parameters not in + /// will be removed from the querystring in the returned URI. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable values) + => GetUriWithUpdatedQueryParameter(navigationManager, name, values, Format); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// updated with the provided . + /// + /// The . + /// The name of the parameter to add or update. + /// The parameter values to add or update. + /// + /// Any null entries in will be skipped. Existing querystring parameters not in + /// will be removed from the querystring in the returned URI. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable values) + => GetUriWithUpdatedQueryParameter(navigationManager, name, values, Format); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// updated with the provided . + /// + /// The . + /// The name of the parameter to add or update. + /// The parameter values to add or update. + /// + /// Any null entries in will be skipped. Existing querystring parameters not in + /// will be removed from the querystring in the returned URI. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable values) + => GetUriWithUpdatedQueryParameter(navigationManager, name, values, Format); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// updated with the provided . + /// + /// The . + /// The name of the parameter to add or update. + /// The parameter values to add or update. + /// + /// Any null entries in will be skipped. Existing querystring parameters not in + /// will be removed from the querystring in the returned URI. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable values) + => GetUriWithUpdatedQueryParameter(navigationManager, name, values, Format); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// updated with the provided . + /// + /// The . + /// The name of the parameter to add or update. + /// The parameter values to add or update. + /// + /// Any null entries in will be skipped. Existing querystring parameters not in + /// will be removed from the querystring in the returned URI. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable values) + => GetUriWithUpdatedQueryParameter(navigationManager, name, values, Format); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// updated with the provided . + /// + /// The . + /// The name of the parameter to add or update. + /// The parameter values to add or update. + /// + /// Any null entries in will be skipped. Existing querystring parameters not in + /// will be removed from the querystring in the returned URI. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable values) + => GetUriWithUpdatedQueryParameter(navigationManager, name, values, Format); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// updated with the provided . + /// + /// The . + /// The name of the parameter to add or update. + /// The parameter values to add or update. + /// + /// Any null entries in will be skipped. Existing querystring parameters not in + /// will be removed from the querystring in the returned URI. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable values) + => GetUriWithUpdatedQueryParameter(navigationManager, name, values, Format); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// updated with the provided . + /// + /// The . + /// The name of the parameter to add or update. + /// The parameter values to add or update. + /// + /// Any null entries in will be skipped. Existing querystring parameters not in + /// will be removed from the querystring in the returned URI. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable values) + => GetUriWithUpdatedQueryParameter(navigationManager, name, values, Format); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// updated with the provided . + /// + /// The . + /// The name of the parameter to add or update. + /// The parameter values to add or update. + /// + /// Any null entries in will be skipped. Existing querystring parameters not in + /// will be removed from the querystring in the returned URI. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, IEnumerable values) + => GetUriWithUpdatedQueryParameter(navigationManager, name, values, Format); + + /// + /// Returns a URI that is constructed by updating with a single parameter + /// added, updated, or removed. + /// + /// The . + /// The name of the parameter to add or update. + /// The value of the parameter to add or update. + /// + /// If is null, the parameter will be removed if it exists in the URI. + /// Otherwise, it will be added or updated. + /// + public static string UriWithQueryParameter(this NavigationManager navigationManager, string name, string? value) + { + if (navigationManager is null) + { + throw new ArgumentNullException(nameof(navigationManager)); + } + + if (string.IsNullOrEmpty(name)) + { + throw new InvalidOperationException(EmptyQueryParameterNameExceptionMessage); + } + + var uri = navigationManager.Uri; + + return value is null + ? GetUriWithRemovedQueryParameter(uri, name) + : GetUriWithUpdatedQueryParameter(uri, name, value); + } + + /// + /// Returns a URI constructed from with multiple parameters + /// added, updated, or removed. + /// + /// The . + /// The values to add, update, or remove. + public static string UriWithQueryParameters( + this NavigationManager navigationManager, + IReadOnlyDictionary parameters) + => UriWithQueryParameters(navigationManager, navigationManager.Uri, parameters); + + /// + /// Returns a URI constructed from except with multiple parameters + /// added, updated, or removed. + /// + /// The . + /// The URI with the query to modify. + /// The values to add, update, or remove. + public static string UriWithQueryParameters( + this NavigationManager navigationManager, + string uri, + IReadOnlyDictionary parameters) + { + if (navigationManager is null) + { + throw new ArgumentNullException(nameof(navigationManager)); + } + + if (uri is null) + { + throw new ArgumentNullException(nameof(uri)); + } + + if (!TryRebuildExistingQueryFromUri(uri, out var existingQueryStringEnumerable, out var newQueryStringBuilder)) + { + // There was no existing query, so there is no need to allocate a new dictionary to cache + // encoded parameter values and track which parameters have been added. + return GetUriWithAppendedQueryParameters(uri, parameters); + } + + var parameterSources = CreateParameterSourceDictionary(parameters); + + // Rebuild the query, updating or removing parameters. + foreach (var pair in existingQueryStringEnumerable) + { + if (parameterSources.TryGetValue(pair.EncodedName, out var source)) + { + if (source.TryAppendNextParameter(ref newQueryStringBuilder)) + { + // We have just mutated the struct value so we need to overwrite the copy in the dictionary. + parameterSources[pair.EncodedName] = source; + } + } + else + { + newQueryStringBuilder.AppendParameter(pair.EncodedName.Span, pair.EncodedValue.Span); + } + } + + // Append any parameters with non-null values that did not replace existing parameters. + foreach (var source in parameterSources.Values) + { + while (source.TryAppendNextParameter(ref newQueryStringBuilder)) ; + } + + return newQueryStringBuilder.UriWithQueryString; + } + + private static string GetUriWithUpdatedQueryParameter(string uri, string name, string value) + { + var encodedName = Uri.EscapeDataString(name); + var encodedValue = Uri.EscapeDataString(value); + + if (!TryRebuildExistingQueryFromUri(uri, out var existingQueryStringEnumerable, out var newQueryStringBuilder)) + { + // There was no existing query, so we can generate the new URI immediately. + return $"{uri}?{encodedName}={encodedValue}"; + } + + var didReplace = false; + foreach (var pair in existingQueryStringEnumerable) + { + if (pair.EncodedName.Span.Equals(encodedName, StringComparison.OrdinalIgnoreCase)) + { + didReplace = true; + newQueryStringBuilder.AppendParameter(encodedName, encodedValue); + } + else + { + newQueryStringBuilder.AppendParameter(pair.EncodedName.Span, pair.EncodedValue.Span); + } + } + + // If there was no matching parameter, add it to the end of the query. + if (!didReplace) + { + newQueryStringBuilder.AppendParameter(encodedName, encodedValue); + } + + return newQueryStringBuilder.UriWithQueryString; + } + + private static string GetUriWithRemovedQueryParameter(string uri, string name) + { + if (!TryRebuildExistingQueryFromUri(uri, out var existingQueryStringEnumerable, out var newQueryStringBuilder)) + { + // There was no existing query, so the URI remains unchanged. + return uri; + } + + var encodedName = Uri.EscapeDataString(name); + + // Rebuild the query omitting parameters with a matching name. + foreach (var pair in existingQueryStringEnumerable) + { + if (!pair.EncodedName.Span.Equals(encodedName, StringComparison.OrdinalIgnoreCase)) + { + newQueryStringBuilder.AppendParameter(pair.EncodedName.Span, pair.EncodedValue.Span); + } + } + + return newQueryStringBuilder.UriWithQueryString; + } + + private static string GetUriWithUpdatedQueryParameter( + this NavigationManager navigationManager, + string name, + IEnumerable values, + QueryParameterFormatter formatter) + { + if (navigationManager is null) + { + throw new ArgumentNullException(nameof(navigationManager)); + } + + var uri = navigationManager.Uri; + var source = new QueryParameterSource(name, values, formatter); + + if (!TryRebuildExistingQueryFromUri(uri, out var existingQueryStringEnumerable, out var newQueryStringBuilder)) + { + return GetUriWithAppendedQueryParameter(uri, ref source); + } + + foreach (var pair in existingQueryStringEnumerable) + { + if (pair.EncodedName.Span.Equals(source.EncodedName, StringComparison.OrdinalIgnoreCase)) + { + // This will no-op if all parameter values have been appended. + source.TryAppendNextParameter(ref newQueryStringBuilder); + } + else + { + newQueryStringBuilder.AppendParameter(pair.EncodedName.Span, pair.EncodedValue.Span); + } + } + + while (source.TryAppendNextParameter(ref newQueryStringBuilder)) ; + + return newQueryStringBuilder.UriWithQueryString; + } + + private static string GetUriWithAppendedQueryParameter( + string uriWithoutQueryString, + ref QueryParameterSource queryParameterSource) + { + var builder = new QueryStringBuilder(uriWithoutQueryString); + + while (queryParameterSource.TryAppendNextParameter(ref builder)) ; + + return builder.UriWithQueryString; + } + + private static string GetUriWithAppendedQueryParameters( + string uriWithoutQueryString, + IReadOnlyDictionary parameters) + { + var builder = new QueryStringBuilder(uriWithoutQueryString); + + foreach (var (name, value) in parameters) + { + var source = new QueryParameterSource(name, value); + while (source.TryAppendNextParameter(ref builder)) ; + } + + return builder.UriWithQueryString; + } + + private static Dictionary, QueryParameterSource> CreateParameterSourceDictionary( + IReadOnlyDictionary parameters) + { + var parameterSources = new Dictionary, QueryParameterSource>(QueryParameterNameComparer.Instance); + + foreach (var (name, value) in parameters) + { + var parameterSource = new QueryParameterSource(name, value); + parameterSources.Add(parameterSource.EncodedName.AsMemory(), parameterSource); + } + + return parameterSources; + } + + private static QueryParameterFormatter GetFormatterFromParameterValueType(Type parameterValueType) + { + var underlyingParameterValueType = Nullable.GetUnderlyingType(parameterValueType) ?? parameterValueType; + + if (!_queryParameterFormatters.TryGetValue(underlyingParameterValueType, out var formatter)) + { + throw new InvalidOperationException( + $"Cannot format query parameters with values of type '{underlyingParameterValueType}'."); + } + + return formatter; + } + + private static bool TryRebuildExistingQueryFromUri( + string uri, + out QueryStringEnumerable existingQueryStringEnumerable, + out QueryStringBuilder newQueryStringBuilder) + { + var queryStartIndex = uri.IndexOf('?'); + + if (queryStartIndex < 0) + { + existingQueryStringEnumerable = default; + newQueryStringBuilder = default; + return false; + } + + var query = uri.AsMemory(queryStartIndex); + existingQueryStringEnumerable = new(query); + + var uriWithoutQueryString = uri.AsSpan(0, queryStartIndex); + newQueryStringBuilder = new(uriWithoutQueryString, query.Length); + + return true; + } + } +} diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 9af925b903db..243a78ee7302 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -34,6 +34,7 @@ Microsoft.AspNetCore.Components.Lifetime.IComponentApplicationStateStore.Persist Microsoft.AspNetCore.Components.NavigationManager.NavigateTo(string! uri, Microsoft.AspNetCore.Components.NavigationOptions options) -> void Microsoft.AspNetCore.Components.NavigationManager.NavigateTo(string! uri, bool forceLoad = false, bool replace = false) -> void Microsoft.AspNetCore.Components.NavigationManager.NavigateTo(string! uri, bool forceLoad) -> void +Microsoft.AspNetCore.Components.NavigationManagerExtensions Microsoft.AspNetCore.Components.NavigationOptions Microsoft.AspNetCore.Components.NavigationOptions.ForceLoad.get -> bool Microsoft.AspNetCore.Components.NavigationOptions.ForceLoad.init -> void @@ -88,6 +89,42 @@ static Microsoft.AspNetCore.Components.EventCallbackFactoryBinderExtensions.Crea static Microsoft.AspNetCore.Components.EventCallbackFactoryBinderExtensions.CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory! factory, object! receiver, System.Action! setter, System.TimeOnly existingValue, string! format, System.Globalization.CultureInfo? culture = null) -> Microsoft.AspNetCore.Components.EventCallback static Microsoft.AspNetCore.Components.EventCallbackFactoryBinderExtensions.CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory! factory, object! receiver, System.Action! setter, System.TimeOnly? existingValue, System.Globalization.CultureInfo? culture = null) -> Microsoft.AspNetCore.Components.EventCallback static Microsoft.AspNetCore.Components.EventCallbackFactoryBinderExtensions.CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory! factory, object! receiver, System.Action! setter, System.TimeOnly? existingValue, string! format, System.Globalization.CultureInfo? culture = null) -> Microsoft.AspNetCore.Components.EventCallback +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, System.Collections.Generic.IEnumerable! values) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, System.Collections.Generic.IEnumerable! values) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, System.Collections.Generic.IEnumerable! values) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, System.Collections.Generic.IEnumerable! values) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, System.Collections.Generic.IEnumerable! values) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, System.Collections.Generic.IEnumerable! values) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, System.Collections.Generic.IEnumerable! values) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, System.Collections.Generic.IEnumerable! values) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, System.Collections.Generic.IEnumerable! values) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, System.Collections.Generic.IEnumerable! values) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, System.Collections.Generic.IEnumerable! values) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, System.Collections.Generic.IEnumerable! values) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, System.Collections.Generic.IEnumerable! values) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, System.Collections.Generic.IEnumerable! values) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, System.Collections.Generic.IEnumerable! values) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, System.Collections.Generic.IEnumerable! values) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, System.Collections.Generic.IEnumerable! values) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, System.DateTime value) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, System.DateTime? value) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, System.Guid value) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, System.Guid? value) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, bool value) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, bool? value) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, decimal value) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, decimal? value) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, double value) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, double? value) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, float value) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, float? value) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, int value) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, int? value) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, long value) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, long? value) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameter(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! name, string? value) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameters(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, System.Collections.Generic.IReadOnlyDictionary! parameters) -> string! +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.UriWithQueryParameters(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! uri, System.Collections.Generic.IReadOnlyDictionary! parameters) -> string! static Microsoft.AspNetCore.Components.ParameterView.FromDictionary(System.Collections.Generic.IDictionary! parameters) -> Microsoft.AspNetCore.Components.ParameterView virtual Microsoft.AspNetCore.Components.NavigationManager.NavigateToCore(string! uri, Microsoft.AspNetCore.Components.NavigationOptions options) -> void virtual Microsoft.AspNetCore.Components.NavigationManager.NavigateToCore(string! uri, bool forceLoad) -> void diff --git a/src/Components/Components/src/Routing/QueryParameterNameComparer.cs b/src/Components/Components/src/Routing/QueryParameterNameComparer.cs new file mode 100644 index 000000000000..ccb6b5bb0076 --- /dev/null +++ b/src/Components/Components/src/Routing/QueryParameterNameComparer.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Components.Routing +{ + internal sealed class QueryParameterNameComparer : IComparer>, IEqualityComparer> + { + public static readonly QueryParameterNameComparer Instance = new(); + + public int Compare(ReadOnlyMemory x, ReadOnlyMemory y) + => x.Span.CompareTo(y.Span, StringComparison.OrdinalIgnoreCase); + + public bool Equals(ReadOnlyMemory x, ReadOnlyMemory y) + => x.Span.Equals(y.Span, StringComparison.OrdinalIgnoreCase); + + public int GetHashCode([DisallowNull] ReadOnlyMemory obj) + => string.GetHashCode(obj.Span, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Components/Components/src/Routing/QueryParameterValueSupplier.cs b/src/Components/Components/src/Routing/QueryParameterValueSupplier.cs index 49b4e196922b..3243c41618d9 100644 --- a/src/Components/Components/src/Routing/QueryParameterValueSupplier.cs +++ b/src/Components/Components/src/Routing/QueryParameterValueSupplier.cs @@ -187,19 +187,5 @@ public QueryParameterDestination(string componentParameterName, UrlValueConstrai IsArray = isArray; } } - - private class QueryParameterNameComparer : IComparer>, IEqualityComparer> - { - public static readonly QueryParameterNameComparer Instance = new(); - - public int Compare(ReadOnlyMemory x, ReadOnlyMemory y) - => x.Span.CompareTo(y.Span, StringComparison.OrdinalIgnoreCase); - - public bool Equals(ReadOnlyMemory x, ReadOnlyMemory y) - => x.Span.Equals(y.Span, StringComparison.OrdinalIgnoreCase); - - public int GetHashCode([DisallowNull] ReadOnlyMemory obj) - => string.GetHashCode(obj.Span, StringComparison.OrdinalIgnoreCase); - } } } diff --git a/src/Components/Components/test/NavigationManagerTest.cs b/src/Components/Components/test/NavigationManagerTest.cs index da506f429dfc..f6e39a97e452 100644 --- a/src/Components/Components/test/NavigationManagerTest.cs +++ b/src/Components/Components/test/NavigationManagerTest.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Globalization; using Xunit; namespace Microsoft.AspNetCore.Components @@ -92,6 +93,173 @@ public void ToBaseRelativePath_ThrowsForInvalidBaseRelativePaths(string baseUri, ex.Message); } + [Theory] + [InlineData("scheme://host/?full%20name=Bob%20Joe&age=42", "scheme://host/?full%20name=John%20Doe&age=42")] + [InlineData("scheme://host/?fUlL%20nAmE=Bob%20Joe&AgE=42", "scheme://host/?full%20name=John%20Doe&AgE=42")] + [InlineData("scheme://host/?full%20name=Sally%20Smith&age=42&full%20name=Emily", "scheme://host/?full%20name=John%20Doe&age=42&full%20name=John%20Doe")] + [InlineData("scheme://host/?full%20name=&age=42", "scheme://host/?full%20name=John%20Doe&age=42")] + [InlineData("scheme://host/?full%20name=", "scheme://host/?full%20name=John%20Doe")] + public void UriWithQueryParameter_ReplacesWhenParameterExists(string baseUri, string expectedUri) + { + var navigationManager = new TestNavigationManager(baseUri); + var actualUri = navigationManager.UriWithQueryParameter("full name", "John Doe"); + + Assert.Equal(expectedUri, actualUri); + } + + [Theory] + [InlineData("scheme://host/?age=42", "scheme://host/?age=42&name=John%20Doe")] + [InlineData("scheme://host/", "scheme://host/?name=John%20Doe")] + [InlineData("scheme://host/?", "scheme://host/?name=John%20Doe")] + public void UriWithQueryParameter_AppendsWhenParameterDoesNotExist(string baseUri, string expectedUri) + { + var navigationManager = new TestNavigationManager(baseUri); + var actualUri = navigationManager.UriWithQueryParameter("name", "John Doe"); + + Assert.Equal(expectedUri, actualUri); + } + + [Theory] + [InlineData("scheme://host/?full%20name=Bob%20Joe&age=42", "scheme://host/?age=42")] + [InlineData("scheme://host/?full%20name=Sally%Smith&age=42&full%20name=Emily%20Karlsen", "scheme://host/?age=42")] + [InlineData("scheme://host/?full%20name=Sally%Smith&age=42&FuLl%20NaMe=Emily%20Karlsen", "scheme://host/?age=42")] + [InlineData("scheme://host/?full%20name=&age=42", "scheme://host/?age=42")] + [InlineData("scheme://host/?full%20name=", "scheme://host/")] + [InlineData("scheme://host/", "scheme://host/")] + public void UriWithQueryParameter_RemovesWhenParameterValueIsNull(string baseUri, string expectedUri) + { + var navigationManager = new TestNavigationManager(baseUri); + var actualUri = navigationManager.UriWithQueryParameter("full name", (string)null); + + Assert.Equal(expectedUri, actualUri); + } + + [Theory] + [InlineData("")] + [InlineData((string)null)] + public void UriWithQueryParameter_ThrowsWhenNameIsNullOrEmpty(string name) + { + var baseUri = "scheme://host/"; + var navigationManager = new TestNavigationManager(baseUri); + + var exception = Assert.Throws(() => navigationManager.UriWithQueryParameter(name, "test")); + Assert.StartsWith("Cannot have empty query parameter names.", exception.Message); + } + + [Theory] + [InlineData("scheme://host/?search=rugs&item%20filter=price%3Ahigh", "scheme://host/?search=rugs&item%20filter=price%3Alow&item%20filter=shipping%3Afree&item%20filter=category%3Arug")] + [InlineData("scheme://host/?item%20filter=price%3Ahigh&search=rugs&item%20filter=shipping%3A2day", "scheme://host/?item%20filter=price%3Alow&search=rugs&item%20filter=shipping%3Afree&item%20filter=category%3Arug")] + [InlineData("scheme://host/?item%20filter=price&item%20filter=shipping%3A2day&item%20filter=category%3Arug&item%20filter=availability%3Atoday", "scheme://host/?item%20filter=price%3Alow&item%20filter=shipping%3Afree&item%20filter=category%3Arug")] + [InlineData("scheme://host/?item%20filter=price&iTeM%20fIlTeR=shipping%3A2day&item%20filter=category%3Arug&ItEm%20FiLtEr=availability%3Atoday", "scheme://host/?item%20filter=price%3Alow&item%20filter=shipping%3Afree&item%20filter=category%3Arug")] + [InlineData("scheme://host/", "scheme://host/?item%20filter=price%3Alow&item%20filter=shipping%3Afree&item%20filter=category%3Arug")] + public void UriWithQueryParameterOfTValue_ReplacesExistingQueryParameters(string baseUri, string expectedUri) + { + var navigationManager = new TestNavigationManager(baseUri); + var actualUri = navigationManager.UriWithQueryParameter("item filter", new string[] + { + "price:low", + "shipping:free", + "category:rug", + }); + + Assert.Equal(expectedUri, actualUri); + } + + [Theory] + [InlineData("scheme://host/?search=rugs&items=8&items=42", "scheme://host/?search=rugs&items=5&items=13")] + [InlineData("scheme://host/", "scheme://host/?items=5&items=13")] + public void UriWithQueryParameterOfTValue_SkipsNullValues(string baseUri, string expectedUri) + { + var navigationManager = new TestNavigationManager(baseUri); + var actualUri = navigationManager.UriWithQueryParameter("items", new int?[] + { + 5, + null, + 13, + }); + + Assert.Equal(expectedUri, actualUri); + } + + [Theory] + [InlineData("")] + [InlineData((string)null)] + public void UriWithQueryParameterOfTValue_ThrowsWhenNameIsNullOrEmpty(string name) + { + var baseUri = "scheme://host/"; + var navigationManager = new TestNavigationManager(baseUri); + var values = new string[] { "test" }; + + var exception = Assert.Throws(() => navigationManager.UriWithQueryParameter(name, values)); + Assert.StartsWith("Cannot have empty query parameter names.", exception.Message); + } + + [Theory] + [InlineData("scheme://host/?name=Bob%20Joe&age=42", "scheme://host/?age=25&eye%20color=green")] + [InlineData("scheme://host/?NaMe=Bob%20Joe&AgE=42", "scheme://host/?age=25&eye%20color=green")] + [InlineData("scheme://host/?name=Bob%20Joe&age=42&keepme=true", "scheme://host/?age=25&keepme=true&eye%20color=green")] + [InlineData("scheme://host/?age=42&eye%20color=87", "scheme://host/?age=25&eye%20color=green")] + [InlineData("scheme://host/?", "scheme://host/?age=25&eye%20color=green")] + [InlineData("scheme://host/", "scheme://host/?age=25&eye%20color=green")] + public void UriWithQueryParameters_CanAddUpdateAndRemove(string baseUri, string expectedUri) + { + var navigationManager = new TestNavigationManager(baseUri); + var actualUri = navigationManager.UriWithQueryParameters(new Dictionary + { + ["name"] = null, // Remove + ["age"] = (int?)25, // Add/update + ["eye color"] = "green",// Add/update + }); + + Assert.Equal(expectedUri, actualUri); + } + + [Theory] + [InlineData("scheme://host/?full%20name=Bob%20Joe&ping=8&ping=300", "scheme://host/?full%20name=John%20Doe&ping=35&ping=16&ping=87&ping=240")] + [InlineData("scheme://host/?ping=8&full%20name=Bob%20Joe&ping=300", "scheme://host/?ping=35&full%20name=John%20Doe&ping=16&ping=87&ping=240")] + [InlineData("scheme://host/?ping=8&ping=300&ping=50&ping=68&ping=42", "scheme://host/?ping=35&ping=16&ping=87&ping=240&full%20name=John%20Doe")] + public void UriWithQueryParameters_SupportsEnumerableValues(string baseUri, string expectedUri) + { + var navigationManager = new TestNavigationManager(baseUri); + var actualUri = navigationManager.UriWithQueryParameters(new Dictionary + { + ["full name"] = "John Doe", // Single value + ["ping"] = new int?[] { 35, 16, null, 87, 240 } + }); + + Assert.Equal(expectedUri, actualUri); + } + + [Fact] + public void UriWithQueryParameters_ThrowsWhenParameterValueTypeIsUnsupported() + { + var baseUri = "scheme://host/"; + var navigationManager = new TestNavigationManager(baseUri); + var unsupportedParameterValues = new Dictionary + { + ["value"] = new { Value = 3 } + }; + + var exception = Assert.Throws(() => navigationManager.UriWithQueryParameters(unsupportedParameterValues)); + Assert.StartsWith("Cannot format query parameters with values of type", exception.Message); + } + + [Theory] + [InlineData("scheme://host/")] + [InlineData("scheme://host/?existing-param=test")] + public void UriWithQueryParameters_ThrowsWhenAnyParameterNameIsEmpty(string baseUri) + { + var navigationManager = new TestNavigationManager(baseUri); + var values = new Dictionary + { + ["name1"] = "value1", + [string.Empty] = "value2", + }; + + var exception = Assert.Throws(() => navigationManager.UriWithQueryParameters(values)); + Assert.StartsWith("Cannot have empty query parameter names.", exception.Message); + } + private class TestNavigationManager : NavigationManager { public TestNavigationManager()