diff --git a/.github/workflows/post-integration.yml b/.github/workflows/post-integration.yml index 9e1a079..c648fe1 100644 --- a/.github/workflows/post-integration.yml +++ b/.github/workflows/post-integration.yml @@ -77,4 +77,6 @@ jobs: run: dotnet pack -c Release --no-restore -o ${GITHUB_WORKSPACE}/packages -p:RepositoryBranch=$BRANCH_NAME - name: 📦 Push packages to GitHub Package Registry - run: dotnet nuget push ${GITHUB_WORKSPACE}/packages/'Atc.Blazor.ColorThemePreference.'${NBGV_NuGetPackageVersion}'.nupkg' -k ${{ secrets.GITHUB_TOKEN }} -s ${{ env.NUGET_REPO_URL }} --skip-duplicate + run: | + dotnet nuget push ${GITHUB_WORKSPACE}/packages/'Atc.Blazor.'${NBGV_NuGetPackageVersion}'.nupkg' -k ${{ secrets.GITHUB_TOKEN }} -s ${{ env.NUGET_REPO_URL }} --skip-duplicate + dotnet nuget push ${GITHUB_WORKSPACE}/packages/'Atc.Blazor.ColorThemePreference.'${NBGV_NuGetPackageVersion}'.nupkg' -k ${{ secrets.GITHUB_TOKEN }} -s ${{ env.NUGET_REPO_URL }} --skip-duplicate diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0756ef7..70e025b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,4 +53,6 @@ jobs: run: dotnet pack -c Release --no-restore -o ${GITHUB_WORKSPACE}/packages -p:RepositoryBranch=$BRANCH_NAME /p:PublicRelease=true - name: 📦 Push packages to NuGet - run: dotnet nuget push ${GITHUB_WORKSPACE}/packages/'Atc.Blazor.ColorThemePreference.'${NBGV_NuGetPackageVersion}'.nupkg' -k ${{ secrets.NUGET_KEY }} -s ${{ env.NUGET_REPO_URL }} --skip-duplicate --no-symbols + run: | + dotnet nuget push ${GITHUB_WORKSPACE}/packages/'Atc.Blazor.'${NBGV_NuGetPackageVersion}'.nupkg' -k ${{ secrets.NUGET_KEY }} -s ${{ env.NUGET_REPO_URL }} --skip-duplicate --no-symbols + dotnet nuget push ${GITHUB_WORKSPACE}/packages/'Atc.Blazor.ColorThemePreference.'${NBGV_NuGetPackageVersion}'.nupkg' -k ${{ secrets.NUGET_KEY }} -s ${{ env.NUGET_REPO_URL }} --skip-duplicate --no-symbols diff --git a/Atc.Blazor.sln b/Atc.Blazor.sln index f720b6d..f6e8d07 100644 --- a/Atc.Blazor.sln +++ b/Atc.Blazor.sln @@ -12,6 +12,16 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docs", "Docs", "{DCB86DDD-7 README.md = README.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atc.Blazor", "src\Atc.Blazor\Atc.Blazor.csproj", "{7CE1DD6F-7339-44E1-B037-1941A8387099}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Src", "Src", "{A0E0CD23-AD47-44BB-A056-A76EE3EA64BC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Sample", "Sample", "{5FDCB20D-0C42-4CBA-AA3A-317A6B9E2760}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Test", "Test", "{283A2EC4-BDB8-45BC-81A0-362B59D7A0AE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atc.Blazor.Tests", "test\Atc.Blazor.Tests\Atc.Blazor.Tests.csproj", "{B9EE0DDA-761C-401E-AC1D-B641D772FFA3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -26,10 +36,24 @@ Global {3875CEE1-DBED-4999-B1F6-F5D45E14BEF6}.Debug|Any CPU.Build.0 = Debug|Any CPU {3875CEE1-DBED-4999-B1F6-F5D45E14BEF6}.Release|Any CPU.ActiveCfg = Release|Any CPU {3875CEE1-DBED-4999-B1F6-F5D45E14BEF6}.Release|Any CPU.Build.0 = Release|Any CPU + {7CE1DD6F-7339-44E1-B037-1941A8387099}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7CE1DD6F-7339-44E1-B037-1941A8387099}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7CE1DD6F-7339-44E1-B037-1941A8387099}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7CE1DD6F-7339-44E1-B037-1941A8387099}.Release|Any CPU.Build.0 = Release|Any CPU + {B9EE0DDA-761C-401E-AC1D-B641D772FFA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B9EE0DDA-761C-401E-AC1D-B641D772FFA3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B9EE0DDA-761C-401E-AC1D-B641D772FFA3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B9EE0DDA-761C-401E-AC1D-B641D772FFA3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {97E12D4B-3492-4336-B851-22202BDD327B} = {A0E0CD23-AD47-44BB-A056-A76EE3EA64BC} + {3875CEE1-DBED-4999-B1F6-F5D45E14BEF6} = {5FDCB20D-0C42-4CBA-AA3A-317A6B9E2760} + {7CE1DD6F-7339-44E1-B037-1941A8387099} = {A0E0CD23-AD47-44BB-A056-A76EE3EA64BC} + {B9EE0DDA-761C-401E-AC1D-B641D772FFA3} = {283A2EC4-BDB8-45BC-81A0-362B59D7A0AE} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A21A02DE-E75C-4D9F-9B76-012E6FFC5EF0} EndGlobalSection diff --git a/README.md b/README.md index aaab8ca..a2f967d 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,90 @@ This repository contains packages with components for Blazor application: |---|---| | Atc.Blazor.ColorThemePreference | A library for detecting the user preferred color theme | -## Get started Atc.Blazor.ColorThemePreference - -### Requirements +## Requirements * [.NET 6 Runtime](https://dotnet.microsoft.com/en-us/download/dotnet/6.0) -### Installation +## Get started Atc.Blazor + +### Installation for Atc.Blazor + +```powershell +Install-Package Atc.Blazor +``` + +### How to Use `NavigationManager.TryGetQueryString` + +```csharp +int myInt = 0; + +NavigationManager.TryGetQueryString("myKey", out var myInt) +``` + +### How to Use `QueryStringParameterAttribute` and `SetPropertiesWithDecoratedQueryStringParameterFromQueryString` + +```csharp +@page "/" +@inject NavigationManager NavigationManager + +
My age is: @Age
+ +@code +{ + [QueryStringParameter] + public int Age { get; set; } + + public override Task SetParametersAsync(ParameterView parameters) // Overload from Blazor components lifecycle + { + this.SetPropertiesWithDecoratedQueryStringParameterFromQueryString(NavigationManager); // Bind from url-qyery-parameter 'age' to property 'Age' + return base.SetParametersAsync(parameters); + } +} +``` + +```csharp +@page "/" +@inject NavigationManager NavigationManager + +
My age is: @Age
+ +@code +{ + [QueryStringParameter("myAge")] + public int Age { get; set; } + + public override Task SetParametersAsync(ParameterView parameters) // Overload from Blazor components lifecycle + { + this.SetPropertiesWithDecoratedQueryStringParameterFromQueryString(NavigationManager); // Bind from url-qyery-parameter 'myAge' to property 'Age' + return base.SetParametersAsync(parameters); + } +} +``` + +### How to Use `QueryStringParameterAttribute` and `UpdateQueryStringFromPropertiesWithDecoratedQueryStringParameter` + +```csharp +@page "/" +@inject NavigationManager NavigationManager + + + +@code +{ + [QueryStringParameter] + public int Age { get; set; } + + public void UpdateQueryStringWithAge(int age) + { + this.Age = age; + this.UpdateQueryString(NavigationManager); + } +} +``` + +## Get started Atc.Blazor.ColorThemePreference + +### Installation for Atc.Blazor.ColorThemePreference ```powershell Install-Package Atc.Blazor.ColorThemePreference diff --git a/src/Atc.Blazor/Atc.Blazor.csproj b/src/Atc.Blazor/Atc.Blazor.csproj new file mode 100644 index 0000000..8601d0c --- /dev/null +++ b/src/Atc.Blazor/Atc.Blazor.csproj @@ -0,0 +1,16 @@ + + + + net6.0 + Atc.Blazor + blazor + Atc.Blazor is a collection of classes and extension methods for common functionality. + + + + + + + + + diff --git a/src/Atc.Blazor/Attributes/QueryStringParameterAttribute.cs b/src/Atc.Blazor/Attributes/QueryStringParameterAttribute.cs new file mode 100644 index 0000000..e8ac268 --- /dev/null +++ b/src/Atc.Blazor/Attributes/QueryStringParameterAttribute.cs @@ -0,0 +1,21 @@ +// ReSharper disable once CheckNamespace +namespace Atc; + +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] +public sealed class QueryStringParameterAttribute : Attribute +{ + public QueryStringParameterAttribute() + { + Name = string.Empty; + } + + public QueryStringParameterAttribute(string name) + { + Name = name; + } + + /// + /// Name of the query string parameter. It uses the property name by default. + /// + public string Name { get; } +} diff --git a/src/Atc.Blazor/Extensions/ComponentBaseExtensions.cs b/src/Atc.Blazor/Extensions/ComponentBaseExtensions.cs new file mode 100644 index 0000000..bf82c59 --- /dev/null +++ b/src/Atc.Blazor/Extensions/ComponentBaseExtensions.cs @@ -0,0 +1,96 @@ +// ReSharper disable once CheckNamespace +// ReSharper disable ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator +// ReSharper disable SuggestBaseTypeForParameter +namespace Atc; + +public static class ComponentBaseExtensions +{ + public static void SetPropertiesWithDecoratedQueryStringParameterFromQueryString(this T component, NavigationManager navigationManager) + where T : ComponentBase + { + ArgumentNullException.ThrowIfNull(navigationManager); + + if (!Uri.TryCreate(navigationManager.Uri, UriKind.RelativeOrAbsolute, out var uri)) + { + throw new InvalidOperationException("The current url is not a valid URI. Url: " + navigationManager.Uri); + } + + var queryString = QueryHelpers.ParseQuery(uri.Query); + foreach (var property in GetPublicAndNonPublicProperties()) + { + var parameterName = GetQueryStringParameterName(property); + if (parameterName is null) + { + continue; + } + + if (!queryString.TryGetValue(parameterName, out var value)) + { + continue; + } + + var convertedValue = Convert.ChangeType(value[0], property.PropertyType, CultureInfo.InvariantCulture); + property.SetValue(component, convertedValue); + } + } + + public static void UpdateQueryStringFromPropertiesWithDecoratedQueryStringParameter(this T component, NavigationManager navigationManager) + where T : ComponentBase + { + ArgumentNullException.ThrowIfNull(navigationManager); + + if (!Uri.TryCreate(navigationManager.Uri, UriKind.RelativeOrAbsolute, out var uri)) + { + throw new InvalidOperationException("The current url is not a valid URI. Url: " + navigationManager.Uri); + } + + var parameters = QueryHelpers.ParseQuery(uri.Query); + foreach (var property in GetPublicAndNonPublicProperties()) + { + var parameterName = GetQueryStringParameterName(property); + if (parameterName is null) + { + continue; + } + + var value = property.GetValue(component); + if (value is null) + { + parameters.Remove(parameterName); + } + else + { + var convertedValue = Convert.ToString(value, CultureInfo.InvariantCulture); + parameters[parameterName] = convertedValue; + } + } + + var newUri = uri.GetComponents(UriComponents.Scheme | UriComponents.Host | UriComponents.Port | UriComponents.Path, UriFormat.UriEscaped); + foreach (var (key, stringValues) in parameters) + { + foreach (var value in stringValues) + { + newUri = QueryHelpers.AddQueryString(newUri, key, value); + } + } + + navigationManager.NavigateTo(newUri); + } + + [SuppressMessage("Major Code Smell", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", Justification = "OK - By design.")] + private static IEnumerable GetPublicAndNonPublicProperties() + => typeof(T).GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + + private static string? GetQueryStringParameterName(PropertyInfo propertyInfo) + { + var attribute = propertyInfo.GetCustomAttribute(); + if (attribute is null) + { + return null; + } + + return !string.IsNullOrEmpty(attribute.Name) + ? attribute.Name + : propertyInfo.Name; + } +} \ No newline at end of file diff --git a/src/Atc.Blazor/Extensions/NavigationManagerExtendedExtensions.cs b/src/Atc.Blazor/Extensions/NavigationManagerExtendedExtensions.cs new file mode 100644 index 0000000..906306d --- /dev/null +++ b/src/Atc.Blazor/Extensions/NavigationManagerExtendedExtensions.cs @@ -0,0 +1,59 @@ +// ReSharper disable once CheckNamespace +// ReSharper disable ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator +namespace Atc; + +public static class NavigationManagerExtendedExtensions +{ + public static bool TryGetQueryString(this NavigationManager navigationManager, string key, out T value) + { + ArgumentNullException.ThrowIfNull(navigationManager); + + var uri = navigationManager.ToAbsoluteUri(navigationManager.Uri); + if (QueryHelpers.ParseQuery(uri.Query).TryGetValue(key, out var valueFromQueryString)) + { + if (typeof(T) == typeof(bool) && + bool.TryParse(valueFromQueryString, out var valueAsBool)) + { + value = (T)(object)valueAsBool; + return true; + } + + if (typeof(T) == typeof(decimal) && + decimal.TryParse(valueFromQueryString, NumberStyles.Any, CultureInfo.InvariantCulture, out var valueAsDecimal)) + { + value = (T)(object)valueAsDecimal; + return true; + } + + if (typeof(T) == typeof(double) && + double.TryParse(valueFromQueryString, NumberStyles.Any, CultureInfo.InvariantCulture, out var valueAsDouble)) + { + value = (T)(object)valueAsDouble; + return true; + } + + if (typeof(T) == typeof(int) && + int.TryParse(valueFromQueryString, NumberStyles.Any, CultureInfo.InvariantCulture, out var valueAsInt)) + { + value = (T)(object)valueAsInt; + return true; + } + + if (typeof(T) == typeof(Guid) && + Guid.TryParse(valueFromQueryString, out var valueAsGuid)) + { + value = (T)(object)valueAsGuid; + return true; + } + + if (typeof(T) == typeof(string)) + { + value = (T)(object)valueFromQueryString.ToString(); + return true; + } + } + + value = default!; + return false; + } +} \ No newline at end of file diff --git a/src/Atc.Blazor/GlobalUsings.cs b/src/Atc.Blazor/GlobalUsings.cs new file mode 100644 index 0000000..7ede183 --- /dev/null +++ b/src/Atc.Blazor/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using System.Diagnostics.CodeAnalysis; +global using System.Globalization; +global using System.Reflection; +global using Microsoft.AspNetCore.Components; +global using Microsoft.AspNetCore.WebUtilities; \ No newline at end of file diff --git a/test/Atc.Blazor.Tests/Atc.Blazor.Tests.csproj b/test/Atc.Blazor.Tests/Atc.Blazor.Tests.csproj new file mode 100644 index 0000000..b3c2837 --- /dev/null +++ b/test/Atc.Blazor.Tests/Atc.Blazor.Tests.csproj @@ -0,0 +1,28 @@ + + + + net6.0 + false + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/test/Atc.Blazor.Tests/Extensions/NavigationManagerExtensionsTests.cs b/test/Atc.Blazor.Tests/Extensions/NavigationManagerExtensionsTests.cs new file mode 100644 index 0000000..3724268 --- /dev/null +++ b/test/Atc.Blazor.Tests/Extensions/NavigationManagerExtensionsTests.cs @@ -0,0 +1,30 @@ +namespace Atc.Blazor.Tests.Extensions; + +public class NavigationManagerExtensionsTests +{ + [Theory] + [InlineData(false, 0, "", "hello")] + [InlineData(true, false, "?myBool=false", "myBool")] + [InlineData(true, true, "?myBool=true", "myBool")] + [InlineData(true, 2.5, "?myDouble=2.5", "myDouble")] + [InlineData(true, 2.6, "?myDouble=2.6", "MYDOUBLE")] + [InlineData(true, 5, "?myInt=5", "myInt")] + [InlineData(true, 6, "?myInt=6", "MYINT")] + [InlineData(true, 7, "?MYINT=7", "myInt")] + [InlineData(true, 21, "?myBool=true&myInt=21&myDouble=2.6", "myInT")] + public void TryGetQueryString(bool expectedResult, T expectedValue, string queryStringPart, string queryStringKey) + { + // Arrange + var navigationManager = new FakeNavigationManager("http://localhost/", "http://localhost/" + queryStringPart); + + // Act + var actualResult = navigationManager.TryGetQueryString(queryStringKey, out var actualValue); + + // Assert + Assert.Equal(expectedResult, actualResult); + if (actualResult) + { + Assert.Equal(expectedValue, actualValue); + } + } +} \ No newline at end of file diff --git a/test/Atc.Blazor.Tests/GlobalUsings.cs b/test/Atc.Blazor.Tests/GlobalUsings.cs new file mode 100644 index 0000000..1b05da6 --- /dev/null +++ b/test/Atc.Blazor.Tests/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using System.Diagnostics.CodeAnalysis; + +global using Atc.Blazor.Tests.XUnitTestTypes; \ No newline at end of file diff --git a/test/Atc.Blazor.Tests/XUnitTestTypes/FakeNavigationManager.cs b/test/Atc.Blazor.Tests/XUnitTestTypes/FakeNavigationManager.cs new file mode 100644 index 0000000..5aede35 --- /dev/null +++ b/test/Atc.Blazor.Tests/XUnitTestTypes/FakeNavigationManager.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Components; + +namespace Atc.Blazor.Tests.XUnitTestTypes; + +[SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "OK.")] +public class FakeNavigationManager : NavigationManager +{ + public FakeNavigationManager() + { + Initialize("http://localhost/", "http://localhost/"); + } + + public FakeNavigationManager(string baseUri, string uri) + { + Initialize(baseUri, uri); + } + + protected override void NavigateToCore(string uri, bool forceLoad) + { + Uri = ToAbsoluteUri(uri).ToString(); + } +}