From ea1c199110fb5313c0d2eb820d68290580c8884e Mon Sep 17 00:00:00 2001 From: Yaser Moradi Date: Tue, 15 Oct 2024 14:00:06 +0200 Subject: [PATCH 1/5] feat(templates): improve Boilerplate dutch resx files #8898 (#8899) --- .../Components/Pages/HomePage.razor | 4 +- .../Platforms/Android/MainActivity.cs | 4 +- .../Program.Middlewares.cs | 2 +- .../src/Shared/Resources/AppStrings.fa.resx | 3 + .../src/Shared/Resources/AppStrings.nl.resx | 71 ++++++++++--------- .../src/Shared/Resources/AppStrings.resx | 3 + 6 files changed, 48 insertions(+), 39 deletions(-) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/HomePage.razor b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/HomePage.razor index 2f800a19fb..422358cca6 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/HomePage.razor +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/HomePage.razor @@ -47,7 +47,7 @@ @Localizer[nameof(AppStrings.BitBlazorUIMessage)] - Watch video + @Localizer[nameof(AppStrings.WatchVideo)] @@ -65,7 +65,7 @@ @Localizer[nameof(AppStrings.BitBoilerplateMessage)] - Watch video + @Localizer[nameof(AppStrings.WatchVideo)] diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Maui/Platforms/Android/MainActivity.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Maui/Platforms/Android/MainActivity.cs index 685e31ecce..0b3238482e 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Maui/Platforms/Android/MainActivity.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Maui/Platforms/Android/MainActivity.cs @@ -16,9 +16,9 @@ namespace Boilerplate.Client.Maui.Platforms.Android; DataSchemes = ["https", "http"], DataHosts = ["use-your-server-url-here.com"], // the following app links will be opened in app instead of browser if the app is installed on Android device. - DataPaths = ["/"], + DataPaths = [Urls.HomePage], DataPathPrefixes = [ - "/en-US", "en-GB", "/fa-IR", "fr-FR", + "/en-US", "/en-GB", "/fa-IR", "/nl-NL", Urls.ConfirmPage, Urls.ForgotPasswordPage, Urls.SettingsPage, Urls.ResetPasswordPage, Urls.SignInPage, Urls.SignUpPage, Urls.NotAuthorizedPage, Urls.NotFoundPage, Urls.TermsPage, Urls.AboutPage, //#if (sample == "Admin") Urls.AddOrEditCategoryPage, Urls.CategoriesPage, Urls.DashboardPage, Urls.ProductsPage, diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/Program.Middlewares.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/Program.Middlewares.cs index 130221398b..3b4b18dd8b 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/Program.Middlewares.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/Program.Middlewares.cs @@ -41,7 +41,7 @@ public static void ConfiureMiddlewares(this WebApplication app) app.Use(async (context, next) => { // HomePage.razor is routed with the optional {culture?} parameter, so URLs like https://localhost:5030/en-US/ will correctly open the home page. - // For static file requests located in the root of wwwroot (e.g., https://localhost:5030/service-worker.js/), we need to check if the first segment of the URL matches a supported culture (e.g., fr-FR, en-US). + // For static file requests located in the root of wwwroot (e.g., https://localhost:5030/service-worker.js/), we need to check if the first segment of the URL matches a supported culture (e.g., nl-NL, en-US). // If no match is found, we must disable endpoint routing to prevent ASP.NET Core 8 from incorrectly rendering HomePage.razor instead of serving the requested static file. if (context.GetEndpoint() is RouteEndpoint routeEndpoint && routeEndpoint.RoutePattern?.RawText is "{culture?}/") diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx index 5940930f6d..ddc2969c1d 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx @@ -554,6 +554,9 @@ با استفاده چیزی که میدانید و دوستش دارید + + + مشاهده ویدئو بیشتر بدانید diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.nl.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.nl.resx index f68b424df2..0171f22063 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.nl.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.nl.resx @@ -163,7 +163,7 @@ Er is een onbekende fout opgetreden - De record is gewijzigd door een andere gebruiker nadat u de originele gegevens hebt ontvangen. De operatie is geannuleerd. + De record is gewijzigd door een Anderse gebruiker nadat u de originele gegevens hebt ontvangen. De operatie is geannuleerd. Bron niet gevonden @@ -242,7 +242,7 @@ Huidige sessie - Andere sessies + Anderse sessies Sessie verwijderen @@ -379,7 +379,7 @@ Weet je zeker dat je {0} wilt verwijderen? - Thuis + Home Opfrissen @@ -493,7 +493,7 @@ U kunt nu inloggen met uw account. - Rekening + Account Het e-mailadres of telefoonnummer van je account wijzigen @@ -529,13 +529,13 @@ Wachtwoord vergeten - Voer uw e-mailadres of telefoonnummer in, zodat we u een token voor het opnieuw instellen van uw wachtwoord kunnen sturen. + Voer je e-mailadres of telefoonnummer in, zodat we je een token voor het opnieuw instellen van je wachtwoord kunnen sturen. - Mannelijk + Man - Ander + Anders GitHub-opslagplaats @@ -544,22 +544,25 @@ Ga naar vandaag - Creëer uw multi-mode (WASM, Server, Hybrid, pre-rendering) Blazor-app eenvoudig in de kortste tijd ooit! + Creëer eenvoudig jouw multi-mode (WASM, Server, Hybrid, pre-rendering) Blazor app op de snelste manier ooit! Bouw al uw apps gebruik makend van wat je al kent en leuk vindt + + + Bekijk video Meer informatie - Een set tools voor .NET-ontwikkelaars op meerdere platforms + Handige tools voor .NET-ontwikkelaars op meerdere platforms - Een set krachtige, eenvoudig aan te passen en prachtige Blazor-componenten + Krachtige, eenvoudig aan te passen en mooie Blazor-componenten Een veelzijdige .NET-projectsjabloon die overal naadloos werkt @@ -601,7 +604,7 @@ We hebben een token naar je telefoon of e-mail gestuurd. - Dien het token samen met uw nieuwe wachtwoord hier in. + Geef hier het token samen met je nieuwe wachtwoord hier door. Wachtwoord instellen @@ -616,22 +619,22 @@ Heb je al een token voor het opnieuw instellen van je wachtwoord? - Redden + Opslaan - Aanmelden + Inloggen - Setup voortzetten + Welkom terug! - Meld u aan bij uw account met + Log in bij jouw account met Aangemeld bij social media - Voortzetten + Verzenden De sociale aanmelding is voltooid @@ -643,16 +646,16 @@ Inloggen met Google - Aanmelden met GitHub + Inloggen met GitHub Inloggen met Twitter (X) - Aanmelden met Google + Inloggen met Google - Aanmelden met GitHub + Inloggen met GitHub Meld je aan met Twitter (X) @@ -661,13 +664,13 @@ Afmelden - Weet u zeker dat u zich wilt afmelden? + Weet je zeker dat je je wilt afmelden? - Inschrijven + Inloggen - Inschrijven + Inloggen Opslaan @@ -712,13 +715,13 @@ Legitimatiebewijs - Aanmelden + Inloggen Bevestigen - Aanmelden als andere gebruiker + Inloggen als Anderse gebruiker U meldt zich aan als @@ -727,10 +730,10 @@ Wachtwoord vergeten? - Vrouwelijk + Vrouw - Herinner je me nog? + Onthoud mij. Kopiëren @@ -757,10 +760,10 @@ Haal een nieuwe code op via je authenticator-app of gebruik je herstelcode. - Probeer een andere manier + Probeer een Anderse manier - U kunt een nieuwe code krijgen via uw e-mail of telefoon. + Je kunt een nieuwe code krijgen via je e-mail of telefoon.. Code ophalen @@ -871,7 +874,7 @@ Met deze actie wordt alleen 2FA uitgeschakeld. - Het uitschakelen van 2FA verandert niets aan de sleutels die worden gebruikt in authenticator-apps. Als u de sleutel wilt wijzigen die in een authenticator-app wordt gebruikt, moet u uw authenticatorsleutels in het vorige tabblad opnieuw instellen. + Het uitschakelen van 2FA verAnderst niets aan de sleutels die worden gebruikt in authenticator-apps. Als u de sleutel wilt wijzigen die in een authenticator-app wordt gebruikt, moet u uw authenticatorsleutels in het vorige tabblad opnieuw instellen. Schakel 2FA uit @@ -958,7 +961,7 @@ Onlangs - Je kunt andere sessies op dit moment niet intrekken. Probeer het opnieuw in {0} + Je kunt Anderse sessies op dit moment niet intrekken. Probeer het opnieuw in {0} Update @@ -974,7 +977,7 @@ - Een taak toevoegen + Een To do toevoegen Nog geen todo's @@ -986,7 +989,7 @@ Todo-item kan niet worden gevonden - Taak + To do Takenitem verwijderen @@ -1032,7 +1035,7 @@ Verkoop van producten - Deze grafiek toont het verkoopnummer van elk product. + Deze grafiek toont het aantal verkochte items van elk product.. Zoeken op naam @@ -1095,7 +1098,7 @@ Dashboard - Dit zijn uw analytische gegevens + Dit zijn jouw analytische gegevens diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx index 5ea02aa217..f06c068494 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx @@ -551,6 +551,9 @@ using what you already know and love + + + Watch video Learn more From 60812f76efefd0238ae1e5e30d527a68e581fb94 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Tue, 15 Oct 2024 16:51:45 +0330 Subject: [PATCH 2/5] feat(templates): implement new design of Todo page in Boilerplate #8901 (#8906) --- .../Components/Layout/Main/NavMenu.razor | 16 +- .../Components/Pages/Todo/TodoPage.razor | 197 +++++++++--------- .../Components/Pages/Todo/TodoPage.razor.cs | 55 +++-- .../Components/Pages/Todo/TodoPage.razor.scss | 183 +++------------- .../src/Shared/Resources/AppStrings.fa.resx | 4 +- .../src/Shared/Resources/AppStrings.nl.resx | 4 +- .../src/Shared/Resources/AppStrings.resx | 4 +- 7 files changed, 174 insertions(+), 289 deletions(-) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/Main/NavMenu.razor b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/Main/NavMenu.razor index ae592b1fd8..30c3dc8e9b 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/Main/NavMenu.razor +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/Main/NavMenu.razor @@ -2,7 +2,6 @@ @inherits AppComponentBase;
- @*#if (sample == "Todo")*@ @@ -10,19 +9,13 @@ + @*#if (sample == "Todo")*@ @Localizer[nameof(AppStrings.TodoTitle)] - - - - - @Localizer[nameof(AppStrings.TermsTitle)] - - @*#endif*@ @*#if (sample == "Admin")*@ @@ -47,4 +40,11 @@ @*#endif*@ + + + + + @Localizer[nameof(AppStrings.TermsTitle)] + +
\ No newline at end of file diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Todo/TodoPage.razor b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Todo/TodoPage.razor index 60b6e1ae7d..efd3122370 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Todo/TodoPage.razor +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Todo/TodoPage.razor @@ -4,126 +4,129 @@ @Localizer[nameof(AppStrings.TodoTitle)] -
-
- -
- -
-

@Localizer[nameof(AppStrings.TodoTitle)]

-
- + +
+ + + Placeholder="@Localizer[nameof(AppStrings.TodoAddPlaceholder)]" + OnKeyDown="WrapHandled(async (KeyboardEventArgs args) => await OnInputKeyDown(args))" /> + @Localizer[nameof(AppStrings.Add)] -
- -
-
- + +
+ + + -
- + + + + OnSelectItem="(BitDropdownOption item) => SortTodoItems(item.Value)" + Styles="@(new() { Container="height:32px;background-color:var(--bit-clr-bg-sec)" })"> - + -
-
+ + + -
- @if (isLoading) - { -
- -
- } - else - { - if (viewTodoItems?.Any() is false or null) - { -
- + @if (isLoading) + { + + + + } + else + { + + + + @Localizer[nameof(AppStrings.NoTodos)] -
- } - else - { - - -
- @if (todo.IsInEditMode) - { - -
- - @Localizer[nameof(AppStrings.Save)] - - - @Localizer[nameof(AppStrings.Cancel)] - -
- } - else - { -
- + + + + + + @if (todo.IsInEditMode is false) + { + + + @todo.Date.ToLocalTime().ToString("yyyy MMMM dd, HH:mm:ss") + -
- @todo.Date.ToLocalTime().ToString("yyyy MMMM dd, HH:mm:ss") -
-
+ + -
- + + + } + else + { + - -
- } -
-
-
- } - } -
-
-
-
+ + @Localizer[nameof(AppStrings.Cancel)] + + + @Localizer[nameof(AppStrings.Save)] + + } +
+ +
+ + + } + + + - \ No newline at end of file + \ No newline at end of file diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Todo/TodoPage.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Todo/TodoPage.razor.cs index b3e169c3ec..7870829035 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Todo/TodoPage.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Todo/TodoPage.razor.cs @@ -1,5 +1,6 @@ using Boilerplate.Shared.Controllers.Todo; using Boilerplate.Shared.Dtos.Todo; +using Microsoft.AspNetCore.Components.Web; namespace Boilerplate.Client.Core.Components.Pages.Todo; @@ -15,13 +16,16 @@ public partial class TodoPage private bool isLoading; private string? searchText; private string? selectedSort; + private bool isDescendingSort; private string? selectedFilter; + private bool isDeleteDialogOpen; + private TodoItemDto? deletingTodoItem; private string? underEditTodoItemTitle; private string newTodoTitle = string.Empty; - private ConfirmMessageBox confirmMessageBox = default!; private IList allTodoItems = []; private IList viewTodoItems = default!; private BitSearchBox searchBox = default!; + private BitTextField newTodoInput = default!; protected override async Task OnInitAsync() { @@ -53,11 +57,18 @@ private async Task LoadTodoItems() private void FilterViewTodoItems() { - viewTodoItems = allTodoItems - .Where(t => TodoItemIsVisible(t)) - .OrderByIf(selectedSort == nameof(AppStrings.Alphabetical), t => t.Title!) - .OrderByIf(selectedSort == nameof(AppStrings.Date), t => t.Date!) - .ToList(); + var items = allTodoItems.Where(TodoItemIsVisible); + if (isDescendingSort) + { + items = items.OrderByDescendingIf(selectedSort == nameof(AppStrings.Alphabetical), t => t.Title!) + .OrderByDescendingIf(selectedSort == nameof(AppStrings.Date), t => t.Date!); + } + else + { + items = items.OrderByIf(selectedSort == nameof(AppStrings.Alphabetical), t => t.Title!) + .OrderByIf(selectedSort == nameof(AppStrings.Date), t => t.Date!); + } + viewTodoItems = items.ToList(); } private bool TodoItemIsVisible(TodoItemDto todoItem) @@ -107,6 +118,8 @@ private void ToggleEditMode(TodoItemDto todoItem) private async Task AddTodoItem() { + if (string.IsNullOrWhiteSpace(newTodoTitle)) return; + var addedTodoItem = await todoItemController.Create(new() { Title = newTodoTitle }, CurrentCancellationToken); allTodoItems.Add(addedTodoItem!); @@ -117,29 +130,29 @@ private async Task AddTodoItem() } newTodoTitle = ""; + await newTodoInput.FocusAsync(); } - private async Task DeleteTodoItem(TodoItemDto todoItem) + private async Task OnInputKeyDown(KeyboardEventArgs args) { - if (isLoading) return; - - try + if (args.Key == "Enter") { - var confirmed = await confirmMessageBox.Show(Localizer.GetString(nameof(AppStrings.AreYouSureWannaDelete), todoItem.Title!), - Localizer[nameof(AppStrings.DeleteTodoItem)]); - - if (confirmed) - { - isLoading = true; + await AddTodoItem(); + } + } - StateHasChanged(); + private async Task DeleteTodoItem() + { + if (isLoading || deletingTodoItem is null) return; - await todoItemController.Delete(todoItem.Id, CurrentCancellationToken); + isLoading = true; - allTodoItems.Remove(todoItem); + try + { + await todoItemController.Delete(deletingTodoItem.Id, CurrentCancellationToken); - viewTodoItems.Remove(todoItem); - } + allTodoItems.Remove(deletingTodoItem); + viewTodoItems.Remove(deletingTodoItem); } finally { diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Todo/TodoPage.razor.scss b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Todo/TodoPage.razor.scss index 622c044a30..b8d7dc997f 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Todo/TodoPage.razor.scss +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Todo/TodoPage.razor.scss @@ -2,180 +2,49 @@ @import '../../../Styles/abstracts/_media-queries.scss'; @import '../../../Styles/abstracts/_bit-css-variables.scss'; -.page-container { +section { width: 100%; height: 100%; - flex-grow: 1; display: flex; - align-items: center; justify-content: center; - flex-flow: column nowrap; } -.search-box-container { - width: 100%; - display: flex; - align-items: center; - justify-content: center; - padding: rem2(8px) rem2(14px) rem2(24px); - border-bottom: rem2(1px) solid $bit-color-border-secondary; -} - -.todo-content { - width: 100%; - flex-grow: 1; - display: flex; - padding-top: 1rem; - position: relative; - max-width: rem2(608px); - align-items: flex-start; - flex-flow: column nowrap; - justify-content: flex-start; -} - -.main-title { - margin: 0; - font-weight: 600; - font-size: rem2(28px); - line-height: rem2(36px); - margin-bottom: rem2(20px); -} - -.add-todo-container { - width: 100%; - display: flex; - gap: rem2(16px); - align-items: center; - flex-flow: row nowrap; - margin-bottom: rem2(24px); - justify-content: flex-start; -} - -.todo-list-container { - width: 100%; -} - -.filter-container { - width: 100%; - display: flex; - align-items: center; - flex-flow: row nowrap; - justify-content: space-between; -} - -.sort-drp-container { - @media all and (max-width: #{rem2(430px)}) { - position: absolute; - inset-inline-end: 0; - inset-block-start: rem2(25px); - } -} - -.todo-list--empty-state { - height: 100%; - display: flex; - align-items: center; - justify-content: center; - flex-flow: column nowrap; -} - -.todo-list { - width: 100%; - display: flex; - height: rem2(322px); - align-items: center; - margin-top: rem2(4px); - flex-flow: column nowrap; - justify-content: flex-start; - background-color: $bit-color-background-primary; - border: rem2(1px) solid $bit-color-border-secondary; -} - -.todo-item { - width: 100%; - display: flex; - padding: rem2(16px); - align-items: center; - flex-flow: row nowrap; - min-height: rem2(80px); - min-width: fit-content; - justify-content: space-between; - border-bottom: rem2(1px) solid $bit-color-border-secondary; - - &:nth-last-child(-n + 2) { - border-bottom: none; +::deep { + .todo-stack { + max-width: 45rem; } - &.edit-mode { - gap: rem2(16px); - - @media all and (max-width: #{rem2(430px)}) { - flex-flow: column; - - ::deep .todo-input { - width: 100%; - } + .todo-header { + @include lt-sm { + gap: 0 !important; + position: relative; + align-items: flex-start !important; + flex-direction: column-reverse !important; } } -} - -.todo-info { - display: flex; - align-items: flex-start; - justify-content: center; - flex-flow: column nowrap; - - &.done ::deep .bit-chb-txt { - text-decoration: line-through; - } -} - -.todo-item-date { - white-space: nowrap; - font-size: rem2(11px); - line-height: rem2(20px); - margin-block-start: rem2(4px); - margin-inline-start: rem2(28px); - color: $bit-color-foreground-secondary; -} - -.todo-btn-group { - display: flex; - flex-flow: row nowrap; - justify-content: center; -} -.todo-list-spinner { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; -} + .todo-search { + width: 192px; -::deep { - .add-todo-input { - flex-grow: 1; + @include lt-sm { + width: 100%; + } } - .sort-todo-drp { - width: rem2(136px); - height: rem2(32px); - - .sort-todo-icn { - color: $bit-color-primary; + .todo-sort { + @include lt-sm { + bottom: 0; + position: absolute; + inset-inline-end: 0; } + } - .bit-drp-iwp .bit-drp-rsp-lbl-ctn { - margin-top: rem2(24px); + .todo-list { + width: 100%; + background-color: var(--bit-clr-bg-sec); - @supports (-webkit-touch-callout: none) { - margin-top: calc(env(safe-area-inset-top) - rem2(24px)); - } + @include lt-md { + height: 300px; } } - - .todo-input { - flex-grow: 1; - } } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx index ddc2969c1d..30bf2c2c07 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx @@ -182,7 +182,7 @@ همه - الفبایی + ا-ی تمام شده @@ -983,7 +983,7 @@ هنوز کاری ثبت نشده - جستجو در کارها... + جستجوی کارها... کار پیدا نشد diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.nl.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.nl.resx index 0171f22063..4977287890 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.nl.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.nl.resx @@ -182,7 +182,7 @@ Alle - Alfabetisch + A-Z Volbracht @@ -983,7 +983,7 @@ Nog geen todo's - Zoek een todo... + Zoek todo... Todo-item kan niet worden gevonden diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx index f06c068494..ed11bda4cb 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx @@ -182,7 +182,7 @@ All - Alphabetical + A-Z Completed @@ -980,7 +980,7 @@ No todos yet - Search some todo... + Search todo... Todo item could not be found From e12e4a0fb837af0f42664e3d604c8cf957f2110b Mon Sep 17 00:00:00 2001 From: Mohammad Javad Ebrahimi Date: Tue, 15 Oct 2024 19:08:26 +0330 Subject: [PATCH 3/5] feat(templates): improve page models of Boilerplate tests #8908 (#8911) --- .../src/Tests/PageTests/IdentityPagesTests.cs | 103 ++++++++---------- .../PageModels/Email/ConfirmationEmail.cs | 47 ++++++++ .../PageModels/Identity/ConfirmPage.cs | 48 ++++++++ .../PageModels/Identity/SignInPage.cs | 17 ++- .../PageModels/Identity/SignUpPage.cs | 88 ++++----------- .../PageModels/Layout/IdentityLayout.cs | 13 ++- .../PageTests/PageModels/Layout/MainLayout.cs | 7 +- .../src/Tests/PageTests/PageTestBase.cs | 19 +++- .../src/Tests/Services/EmailReaderService.cs | 23 ++++ .../src/Tests/Services/UserService.cs | 27 +++++ 10 files changed, 250 insertions(+), 142 deletions(-) create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Email/ConfirmationEmail.cs create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Identity/ConfirmPage.cs create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Services/EmailReaderService.cs create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Services/UserService.cs diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/IdentityPagesTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/IdentityPagesTests.cs index 3a034e4a7c..c9ad30cab9 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/IdentityPagesTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/IdentityPagesTests.cs @@ -1,7 +1,8 @@ //+:cnd:noEmit using Boilerplate.Tests.PageTests.PageModels.Identity; -using Boilerplate.Server.Api.Models.Identity; using Boilerplate.Server.Api.Data; +using Boilerplate.Tests.PageTests.PageModels.Layout; +using Boilerplate.Tests.Services; namespace Boilerplate.Tests.PageTests; @@ -20,51 +21,40 @@ public async Task UnauthorizedUser_Should_RedirectToSignInPage() } [TestMethod] - public async Task SignIn_Should_Work_With_ValidCredentials() + [DataRow("ValidCredentials")] + [DataRow("InvalidCredentials")] + public async Task SignIn(string mode) { var signInPage = new SignInPage(Page, WebAppServerAddress); await signInPage.Open(); await signInPage.AssertOpen(); - var signedInPage = await signInPage.SignIn(); - await signedInPage.AssertSignInSuccess(); - } - - [TestMethod] - public async Task SignIn_Should_Fail_With_InvalidCredentials() - { - var signInPage = new SignInPage(Page, WebAppServerAddress); - - await signInPage.Open(); - await signInPage.AssertOpen(); - - await signInPage.SignIn(email: "invalid@bitplatform.dev", password: "invalid"); - await signInPage.AssetNotSignedIn(); + switch (mode) + { + case "ValidCredentials": + var signedInPage = await signInPage.SignIn(); + await signedInPage.AssertSignInSuccess(); + break; + case "InvalidCredentials": + await signInPage.SignIn(email: "invalid@bitplatform.dev", password: "invalid"); + await signInPage.AssertSignInFailed(); + break; + default: + throw new NotSupportedException(); + } } [TestMethod] - public async Task SignOut_Should_WorkAsExpected() + public async Task SignOut() { await using var scope = TestServer.WebApp.Services.CreateAsyncScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); + var userService = new UserService(dbContext); var email = $"{Guid.NewGuid()}@gmail.com"; - var user = new User - { - EmailConfirmed = true, - UserName = email, - NormalizedUserName = email.ToUpperInvariant(), - Email = email, - NormalizedEmail = email.ToUpperInvariant(), - SecurityStamp = "959ff4a9-4b07-4cc1-8141-c5fc033daf83", - ConcurrencyStamp = "315e1a26-5b3a-4544-8e91-2760cd28e231", - PasswordHash = "AQAAAAIAAYagAAAAEP0v3wxkdWtMkHA3Pp5/JfS+42/Qto9G05p2mta6dncSK37hPxEHa3PGE4aqN30Aag==", // 123456 - }; - - dbContext.Users.Add(user); - await dbContext.SaveChangesAsync(); + var user = await userService.AddUser(email); var signInPage = new SignInPage(Page, WebAppServerAddress); @@ -72,7 +62,7 @@ public async Task SignOut_Should_WorkAsExpected() await signInPage.AssertOpen(); var signedInPage = await signInPage.SignIn(email); - await signedInPage.AssertSignInSuccess(email); + await signedInPage.AssertSignInSuccess(email, null); await dbContext.Entry(user).ReloadAsync(); Assert.AreEqual(1, user.Sessions.Count); @@ -85,38 +75,37 @@ public async Task SignOut_Should_WorkAsExpected() } [TestMethod] - public async Task SignUp_Should_Work_With_MagicLink() - { - var signupPage = new SignUpPage(Page, WebAppServerAddress); - - await signupPage.Open(); - await signupPage.AssertOpen(); - - await signupPage.SignUp(); - await signupPage.AssertSignUp(); - - await signupPage.OpenEmail(); - await signupPage.AssertConfirmationEmailContent(); - - await signupPage.ConfirmByMagicLink(); - await signupPage.AssertConfirm(); - } - - [TestMethod] - public async Task SignUp_Should_Work_With_OtpCode() + [DataRow("Token")] + [DataRow("MagicLink")] + public async Task SignUp(string mode) { var signupPage = new SignUpPage(Page, WebAppServerAddress); await signupPage.Open(); await signupPage.AssertOpen(); - await signupPage.SignUp(); - await signupPage.AssertSignUp(); + var email = $"{Guid.NewGuid()}@gmail.com"; + var confirmPage = await signupPage.SignUp(email); + await confirmPage.AssertOpen(); - await signupPage.OpenEmail(); - await signupPage.AssertConfirmationEmailContent(); + var confirmationEmail = await signupPage.OpenConfirmationEmail(); + await confirmationEmail.AssertContent(); - await signupPage.ConfirmByOtp(); - await signupPage.AssertConfirm(); + IdentityLayout signedInPage; + switch (mode) + { + case "Token": + var token = await confirmationEmail.GetToken(); + signedInPage = await confirmPage.ConfirmByToken(email: null, token); + break; + case "MagicLink": + signedInPage = await confirmationEmail.OpenMagicLink(); + break; + default: + throw new NotSupportedException(); + } + + await signedInPage.AssertOpen(); + await signedInPage.AssertSignInSuccess(email, userFullName: null); } } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Email/ConfirmationEmail.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Email/ConfirmationEmail.cs new file mode 100644 index 0000000000..13fb1eb90e --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Email/ConfirmationEmail.cs @@ -0,0 +1,47 @@ +using System.Text.RegularExpressions; +using Boilerplate.Server.Api.Resources; +using Boilerplate.Tests.PageTests.PageModels.Layout; +using Boilerplate.Tests.Services; + +namespace Boilerplate.Tests.PageTests.PageModels.Email; + +public partial class ConfirmationEmail(IBrowserContext context, Uri serverAddress) +{ + private IPage page; + private const string OpenEmailFirstMessage = $"You must call {nameof(Open)} method first"; + + public async Task Open(string emailAddress) + { + var html = EmailReaderService.GetLastEmailFor(emailAddress, EmailStrings.ConfirmationEmailSubject.Replace("{0}", "\\d{6}")); + page = await context.NewPageAsync(); + await page.SetContentAsync(html); + } + + public async Task AssertContent() + { + Assert.IsNotNull(page, OpenEmailFirstMessage); + + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(EmailStrings.WelcomeToApp); + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(new Regex(EmailStrings.EmailConfirmationMessageSubtitle.Replace("{0}", ".*"))); + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(EmailStrings.EmailConfirmationMessageBodyToken); + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(EmailStrings.EmailConfirmationMessageBodyLink); + await Assertions.Expect(page.GetByRole(AriaRole.Link, new() { Name = new Uri(serverAddress, Urls.ConfirmPage).ToString() })).ToBeVisibleAsync(); + } + + public async Task GetToken() + { + Assert.IsNotNull(page, OpenEmailFirstMessage); + + var token = await page.GetByText(new Regex("^\\d{6}$")).TextContentAsync(); + Assert.IsNotNull(token, "Confirmation token not found in email"); + return token; + } + + public async Task OpenMagicLink() + { + Assert.IsNotNull(page, OpenEmailFirstMessage); + + await page.GetByRole(AriaRole.Link, new() { Name = new Uri(serverAddress, Urls.ConfirmPage).ToString() }).ClickAsync(); + return new(page, serverAddress, Urls.HomePage, AppStrings.HomeTitle); + } +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Identity/ConfirmPage.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Identity/ConfirmPage.cs new file mode 100644 index 0000000000..fe7e23af5f --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Identity/ConfirmPage.cs @@ -0,0 +1,48 @@ +using Boilerplate.Tests.PageTests.PageModels.Layout; + +namespace Boilerplate.Tests.PageTests.PageModels.Identity; + +public partial class ConfirmPage(IPage page, Uri serverAddress, string? emailAddress) + : MainLayout(page, serverAddress, Urls.ConfirmPage, AppStrings.ConfirmTitle) +{ + public override async Task AssertOpen() + { + await base.AssertOpen(); + + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(AppStrings.ConfirmTitle); + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(AppStrings.ConfirmEmailSubtitle); + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(AppStrings.ConfirmEmailMessage); + var emailInput = page.GetByPlaceholder(AppStrings.EmailPlaceholder); + if (emailAddress is null) + { + await Assertions.Expect(emailInput).ToBeVisibleAsync(); + await Assertions.Expect(emailInput).ToBeEnabledAsync(); + await Assertions.Expect(emailInput).ToBeEditableAsync(); + } + else + { + await Assertions.Expect(emailInput).ToBeVisibleAsync(); + await Assertions.Expect(emailInput).ToBeDisabledAsync(); + await Assertions.Expect(emailInput).ToBeEditableAsync(new() { Editable = false }); + } + await Assertions.Expect(page.GetByPlaceholder(AppStrings.EmailTokenPlaceholder)).ToBeVisibleAsync(); + await Assertions.Expect(page.GetByRole(AriaRole.Button, new() { Name = AppStrings.EmailTokenConfirmButtonText })).ToBeVisibleAsync(); + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(AppStrings.NotReceivedConfirmationEmailMessage); + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(AppStrings.CheckSpamMailMessage); + await Assertions.Expect(page.GetByRole(AriaRole.Button, new() { Name = AppStrings.ResendEmailTokenButtonText })).ToBeVisibleAsync(); + } + + public async Task ConfirmByToken(string? email, string token) + { + Assert.IsTrue(emailAddress is not null || email is not null, "Either email address from query string or email input is required"); + Assert.IsTrue(emailAddress is null || email is null, "Both email address from query string and email input cannot be used at the same time"); + + if (email is not null) + await page.GetByPlaceholder(AppStrings.EmailPlaceholder).FillAsync(email); + + await page.GetByPlaceholder(AppStrings.EmailTokenPlaceholder).FillAsync(token); + await page.GetByRole(AriaRole.Button, new() { Name = AppStrings.EmailTokenConfirmButtonText }).ClickAsync(); + + return new(page, serverAddress, Urls.HomePage, AppStrings.HomeTitle); + } +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Identity/SignInPage.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Identity/SignInPage.cs index 62ec3bc7a4..90723c461e 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Identity/SignInPage.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Identity/SignInPage.cs @@ -5,11 +5,22 @@ namespace Boilerplate.Tests.PageTests.PageModels.Identity; public partial class SignInPage(IPage page, Uri serverAddress) : MainLayout(page, serverAddress, Urls.SignInPage, AppStrings.SignInTitle) { - public async Task SignIn(string email = "test@bitplatform.dev", string password = "123456") + public override async Task AssertOpen() { - //Ensure the page is completely loaded + await base.AssertOpen(); + + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(AppStrings.SignInPanelSubtitle); await Assertions.Expect(page.GetByPlaceholder(AppStrings.EmailPlaceholder)).ToBeVisibleAsync(); + await Assertions.Expect(page.GetByPlaceholder(AppStrings.PasswordPlaceholder)).ToBeVisibleAsync(); + await Assertions.Expect(page.GetByRole(AriaRole.Button, new() { Name = AppStrings.SignIn })).ToBeVisibleAsync(); + var forgotPassswordLink = page.GetByRole(AriaRole.Link, new() { Name = AppStrings.ForgotPasswordLink }); + await Assertions.Expect(forgotPassswordLink).ToBeVisibleAsync(); + await Assertions.Expect(forgotPassswordLink).ToHaveAttributeAsync("href", Urls.ForgotPasswordPage); + await Assertions.Expect(page.GetByLabel(AppStrings.RememberMe)).ToBeCheckedAsync(); + } + public async Task SignIn(string email = "test@bitplatform.dev", string password = "123456") + { await page.GetByPlaceholder(AppStrings.EmailPlaceholder).FillAsync(email); await page.GetByPlaceholder(AppStrings.PasswordPlaceholder).FillAsync(password); await page.GetByRole(AriaRole.Button, new() { Name = AppStrings.SignIn }).ClickAsync(); @@ -17,7 +28,7 @@ public async Task SignIn(string email = "test@bitplatform.dev", return new(page, serverAddress, Urls.HomePage, AppStrings.HomeTitle); } - public async Task AssetNotSignedIn() + public async Task AssertSignInFailed() { await Assertions.Expect(page.GetByText(AppStrings.InvalidUserCredentials)).ToBeVisibleAsync(); await Assertions.Expect(page.Locator(".bit-prs")).ToBeHiddenAsync(); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Identity/SignUpPage.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Identity/SignUpPage.cs index f8c7a3717f..3249db2639 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Identity/SignUpPage.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Identity/SignUpPage.cs @@ -1,16 +1,13 @@ //+:cnd:noEmit -using System.Text.RegularExpressions; -using Boilerplate.Server.Api.Resources; +using Boilerplate.Tests.PageTests.PageModels.Email; using Boilerplate.Tests.PageTests.PageModels.Layout; -using MsgReader.Mime; namespace Boilerplate.Tests.PageTests.PageModels.Identity; public partial class SignUpPage(IPage page, Uri serverAddress) : MainLayout(page, serverAddress, Urls.SignUpPage, AppStrings.SingUpTitle) { - private string email; - private IPage emailPage; + private string emailAddress; public override async Task Open() { @@ -23,78 +20,31 @@ public override async Task Open() //#endif } - public async Task SignUp() + public override async Task AssertOpen() { - email = $"{Guid.NewGuid()}@gmail.com"; + await base.AssertOpen(); - await page.GetByPlaceholder(AppStrings.EmailPlaceholder).FillAsync(email); - await page.GetByPlaceholder(AppStrings.PasswordPlaceholder).FillAsync("123456"); - await page.GetByRole(AriaRole.Button, new() { Name = AppStrings.SignUp, Exact = true }).ClickAsync(); - } - - public async Task AssertSignUp() - { - await Assertions.Expect(page.GetByRole(AriaRole.Button, new() { Name = AppStrings.EmailTokenConfirmButtonText })).ToBeVisibleAsync(); - } - - public async Task OpenEmail() - { - var html = ReadEmlAndGetHtmlBody(email); - emailPage = await page.Context.NewPageAsync(); - await emailPage.SetContentAsync(html); - } - - public async Task AssertConfirmationEmailContent() - { - await Assertions.Expect(emailPage.GetByRole(AriaRole.Main)).ToContainTextAsync(EmailStrings.WelcomeToApp); - await Assertions.Expect(emailPage.GetByRole(AriaRole.Main)).ToContainTextAsync(new Regex(EmailStrings.EmailConfirmationMessageSubtitle.Replace("{0}", ".*"))); - await Assertions.Expect(emailPage.GetByRole(AriaRole.Main)).ToContainTextAsync(EmailStrings.EmailConfirmationMessageBodyToken); - await Assertions.Expect(emailPage.GetByRole(AriaRole.Main)).ToContainTextAsync(EmailStrings.EmailConfirmationMessageBodyLink); + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(AppStrings.SignUp); + await Assertions.Expect(page.GetByPlaceholder(AppStrings.EmailPlaceholder)).ToBeVisibleAsync(); + await Assertions.Expect(page.GetByPlaceholder(AppStrings.PasswordPlaceholder)).ToBeVisibleAsync(); + await Assertions.Expect(page.GetByRole(AriaRole.Button, new() { Name = AppStrings.SignUp, Exact = true })).ToBeVisibleAsync(); } - public async Task ConfirmByOtp() + /// The email is optional, if not provided, a random email will be generated + public async Task SignUp(string email, string password = "123456") { - var optCode = await emailPage.GetByText(new Regex("^\\d{6}$")).TextContentAsync(); - await page.GetByPlaceholder(AppStrings.EmailTokenPlaceholder).FillAsync(optCode!); - await page.GetByRole(AriaRole.Button, new() { Name = AppStrings.EmailTokenConfirmButtonText }).ClickAsync(); - } - - public async Task ConfirmByMagicLink() - { - var optLink = emailPage.GetByRole(AriaRole.Link, new() { Name = new Uri(serverAddress, Urls.ConfirmPage).ToString() }); - await Assertions.Expect(optLink).ToBeVisibleAsync(); - await optLink.ClickAsync(); - page = emailPage; - } - - public async Task AssertConfirm() - { - await Assertions.Expect(page).ToHaveURLAsync(serverAddress.ToString()); - await Assertions.Expect(page.GetByRole(AriaRole.Button, new() { Name = email })).ToBeVisibleAsync(); - await Assertions.Expect(page.Locator(".bit-prs").First).ToContainTextAsync(email); - await Assertions.Expect(page.Locator(".bit-prs").Last).ToContainTextAsync(email); - await Assertions.Expect(page.GetByRole(AriaRole.Button, new() { Name = AppStrings.SignOut })).ToBeVisibleAsync(); - await Assertions.Expect(page.GetByRole(AriaRole.Button, new() { Name = AppStrings.SignIn })).ToBeHiddenAsync(); - } - - private static string ReadEmlAndGetHtmlBody(string toMailAddress) - { - var email = LoadLastEmailFor(toMailAddress); - - Assert.IsNotNull(email); - Assert.AreEqual("info@Boilerplate.com", email.Headers.From.Address); - Assert.AreEqual(EmailStrings.DefaultFromName, email.Headers.From.DisplayName); - Assert.IsTrue(Regex.IsMatch(email.Headers.Subject, EmailStrings.ConfirmationEmailSubject.Replace("{0}", "\\d{6}")), "Email subject does not match."); + emailAddress = email; + await page.GetByPlaceholder(AppStrings.EmailPlaceholder).FillAsync(email); + await page.GetByPlaceholder(AppStrings.PasswordPlaceholder).FillAsync(password); + await page.GetByRole(AriaRole.Button, new() { Name = AppStrings.SignUp, Exact = true }).ClickAsync(); - return email.HtmlBody.GetBodyAsText(); + return new(page, serverAddress, email); } - private static Message? LoadLastEmailFor(string toMailAddress) + public async Task OpenConfirmationEmail() { - var emailsDirectory = Path.Combine(AppContext.BaseDirectory, "App_Data", "sent-emails"); - var messages = new DirectoryInfo(emailsDirectory).GetFiles().Select(Message.Load); - return messages - .Where(m => m.Headers.To[0].Address == toMailAddress) - .MaxBy(m => m.Headers.DateSent); + var confirmationEmail = new ConfirmationEmail(page.Context, serverAddress); + await confirmationEmail.Open(emailAddress); + return confirmationEmail; } } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Layout/IdentityLayout.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Layout/IdentityLayout.cs index a615a4d059..d7760ba1bf 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Layout/IdentityLayout.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Layout/IdentityLayout.cs @@ -3,12 +3,15 @@ public partial class IdentityLayout(IPage page, Uri serverAddress, string pagePath, string pageTitle) : MainLayout(page, serverAddress, pagePath, pageTitle) { - public async Task AssertSignInSuccess(string userFullName = "Boilerplate test account") + public async Task AssertSignInSuccess(string userEmail = "test@bitplatform.dev", string? userFullName = "Boilerplate test account") { - await Assertions.Expect(page).ToHaveURLAsync(serverAddress.ToString()); - await Assertions.Expect(page.GetByRole(AriaRole.Button, new() { Name = userFullName })).ToBeVisibleAsync(); - await Assertions.Expect(page.Locator(".bit-prs").First).ToContainTextAsync(userFullName); - await Assertions.Expect(page.Locator(".bit-prs").Last).ToContainTextAsync(userFullName); + var displayName = string.IsNullOrWhiteSpace(userFullName) ? userEmail : userFullName; + + await Assertions.Expect(page).ToHaveURLAsync(new Uri(serverAddress, pagePath).ToString()); + await Assertions.Expect(page.GetByRole(AriaRole.Button, new() { Name = displayName })).ToBeVisibleAsync(); + await Assertions.Expect(page.Locator(".bit-prs").First).ToContainTextAsync(displayName); + await Assertions.Expect(page.Locator(".bit-prs").Last).ToContainTextAsync(displayName); + await Assertions.Expect(page.Locator(".bit-prs").Last).ToContainTextAsync(userEmail); await Assertions.Expect(page.GetByRole(AriaRole.Button, new() { Name = AppStrings.SignOut })).ToBeVisibleAsync(); await Assertions.Expect(page.GetByRole(AriaRole.Button, new() { Name = AppStrings.SignIn })).ToBeHiddenAsync(); } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Layout/MainLayout.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Layout/MainLayout.cs index 4ab0a09370..6e72255130 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Layout/MainLayout.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Layout/MainLayout.cs @@ -2,18 +2,13 @@ public partial class MainLayout(IPage page, Uri serverAddress, string pagePath, string pageTitle) { - private IResponse response; - public virtual async Task Open() { - response = (await page.GotoAsync(new Uri(serverAddress, pagePath).ToString()))!; + await page.GotoAsync(new Uri(serverAddress, pagePath).ToString()); } public virtual async Task AssertOpen() { - Assert.IsNotNull(response); - Assert.AreEqual(StatusCodes.Status200OK, response.Status); - await Assertions.Expect(page).ToHaveTitleAsync(pageTitle); } } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageTestBase.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageTestBase.cs index 3fbf4f82d0..954f81e785 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageTestBase.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageTestBase.cs @@ -70,8 +70,23 @@ public override BrowserNewContextOptions ContextOptions() private static string GetVideoDirectory(TestContext testContext) { - var testMethodFullName = $"{testContext.FullyQualifiedTestClassName}.{testContext.TestName}"; - return Path.Combine(testContext.TestResultsDirectory!, "..", "..", "Videos", testMethodFullName); + var testMethodDsiplayName = GetTestMethodDisplayName(testContext); + char[] invalidChars = [.. Path.GetInvalidPathChars(), .. Path.GetInvalidFileNameChars(), ')']; + testMethodDsiplayName = new string(testMethodDsiplayName.Where(ch => !invalidChars.Contains(ch)).Select(ch => ch is '(' or ',' ? '_' : ch).ToArray()); + var testMethodFullName = $"{testContext.FullyQualifiedTestClassName}.{testMethodDsiplayName}"; + var dir = Path.Combine(testContext.TestResultsDirectory!, "..", "..", "Videos", testMethodFullName); + return Path.GetFullPath(dir); + } + + private static string GetTestMethodDisplayName(object testContext) + { + var testContextType = testContext.GetType(); + var testMethodField = testContextType.GetField("_testMethod", BindingFlags.NonPublic | BindingFlags.Instance) ?? throw new InvalidOperationException("Field '_testMethod' not found."); + var testMethod = testMethodField.GetValue(testContext) ?? throw new InvalidOperationException("Field '_testMethod' is null."); + var testMethodType = testMethod.GetType(); + var displayNameProperty = testMethodType.GetProperty("DisplayName", BindingFlags.NonPublic | BindingFlags.Instance) ?? throw new InvalidOperationException("Property 'DisplayName' not found."); + var displayName = displayNameProperty.GetValue(testMethod) ?? throw new InvalidOperationException("Field 'DisplayName' is null."); + return (string)displayName; } } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Services/EmailReaderService.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Services/EmailReaderService.cs new file mode 100644 index 0000000000..e8b3290d79 --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Services/EmailReaderService.cs @@ -0,0 +1,23 @@ +using Boilerplate.Server.Api.Resources; +using System.Text.RegularExpressions; +using MsgReader.Mime; + +namespace Boilerplate.Tests.Services; +public static partial class EmailReaderService +{ + public static string GetLastEmailFor(string toMailAddress, string emailSubject) + { + var emailsDirectory = Path.Combine(AppContext.BaseDirectory, "App_Data", "sent-emails"); + var messages = new DirectoryInfo(emailsDirectory).GetFiles().Select(Message.Load); + var message = messages + .Where(m => m.Headers.To[0].Address == toMailAddress) + .MaxBy(m => m.Headers.DateSent); + + Assert.IsNotNull(message, "Email has not sent"); + Assert.AreEqual("info@Boilerplate.com", message.Headers.From.Address); //TODO: read from AppSettings.Email.DefaultFromEmail + Assert.AreEqual(EmailStrings.DefaultFromName, message.Headers.From.DisplayName); + Assert.IsTrue(Regex.IsMatch(message.Headers.Subject, emailSubject), "Email subject does not match."); + + return message.HtmlBody.GetBodyAsText(); + } +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Services/UserService.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Services/UserService.cs new file mode 100644 index 0000000000..fbabf243b3 --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Services/UserService.cs @@ -0,0 +1,27 @@ +using Boilerplate.Server.Api.Data; +using Boilerplate.Server.Api.Models.Identity; + +namespace Boilerplate.Tests.Services; + +public partial class UserService(AppDbContext dbContext) +{ + public async Task AddUser(string email) + { + var user = new User + { + EmailConfirmed = true, + UserName = email, + NormalizedUserName = email.ToUpperInvariant(), + Email = email, + NormalizedEmail = email.ToUpperInvariant(), + SecurityStamp = "959ff4a9-4b07-4cc1-8141-c5fc033daf83", + ConcurrencyStamp = "315e1a26-5b3a-4544-8e91-2760cd28e231", + PasswordHash = "AQAAAAIAAYagAAAAEP0v3wxkdWtMkHA3Pp5/JfS+42/Qto9G05p2mta6dncSK37hPxEHa3PGE4aqN30Aag==", // 123456 + }; + + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(); + + return user; + } +} From 209d019d78970df1d2f87c9d45ce937fafd4b2cc Mon Sep 17 00:00:00 2001 From: Mohammad Javad Ebrahimi Date: Tue, 15 Oct 2024 21:22:28 +0330 Subject: [PATCH 4/5] feat(templates): add tests for forget and reset password in Boilerplate #8909 (#8914) --- .../src/Tests/PageTests/IdentityPagesTests.cs | 57 +++++++++++++++ .../PageModels/Email/ResetPasswordEmail.cs | 48 ++++++++++++ .../PageModels/Identity/ForgotPasswordPage.cs | 42 +++++++++++ .../PageModels/Identity/ResetPasswordPage.cs | 73 +++++++++++++++++++ 4 files changed, 220 insertions(+) create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Email/ResetPasswordEmail.cs create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Identity/ForgotPasswordPage.cs create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Identity/ResetPasswordPage.cs diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/IdentityPagesTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/IdentityPagesTests.cs index c9ad30cab9..ff9b6ff1e4 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/IdentityPagesTests.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/IdentityPagesTests.cs @@ -108,4 +108,61 @@ public async Task SignUp(string mode) await signedInPage.AssertOpen(); await signedInPage.AssertSignInSuccess(email, userFullName: null); } + + [TestMethod] + [DataRow("Token")] + [DataRow("InvalidToken")] + [DataRow("MagicLink")] + public async Task ForgotPassword(string mode) + { + await using var scope = TestServer.WebApp.Services.CreateAsyncScope(); + + var dbContext = scope.ServiceProvider.GetRequiredService(); + var userService = new UserService(dbContext); + var email = $"{Guid.NewGuid()}@gmail.com"; + await userService.AddUser(email); + + var forgotPasswordPage = new ForgotPasswordPage(Page, WebAppServerAddress); + + await forgotPasswordPage.Open(); + await forgotPasswordPage.AssertOpen(); + + var resetPasswordPage = await forgotPasswordPage.ForgotPassword(email); + await resetPasswordPage.AssertOpen(); + + var resetPasswordEmail = await forgotPasswordPage.OpenResetPasswordEmail(); + await resetPasswordEmail.AssertContent(); + + const string newPassword = "new_password"; + switch (mode) + { + case "Token": + var token = await resetPasswordEmail.GetToken(); + await resetPasswordPage.ContinueByToken(email: null, token); + break; + case "InvalidToken": + await resetPasswordPage.ContinueByToken(email: null, "111111"); + await resetPasswordPage.SetPassword(newPassword); + await resetPasswordPage.AssertInvalidToken(); + return; + case "MagicLink": + resetPasswordPage = await resetPasswordEmail.OpenMagicLink(); + await resetPasswordPage.Continue(); + break; + default: + throw new NotSupportedException(); + } + await resetPasswordPage.AssertValidToken(); + + await resetPasswordPage.SetPassword(newPassword); + await resetPasswordPage.AssertSetPassword(); + + var signInPage = new SignInPage(Page, WebAppServerAddress); + + await signInPage.Open(); + await signInPage.AssertOpen(); + + var signedInPage = await signInPage.SignIn(email, newPassword); + await signedInPage.AssertSignInSuccess(email, userFullName: null); + } } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Email/ResetPasswordEmail.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Email/ResetPasswordEmail.cs new file mode 100644 index 0000000000..2036147234 --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Email/ResetPasswordEmail.cs @@ -0,0 +1,48 @@ +using System.Text.RegularExpressions; +using Boilerplate.Server.Api.Resources; +using Boilerplate.Tests.PageTests.PageModels.Identity; +using Boilerplate.Tests.Services; + +namespace Boilerplate.Tests.PageTests.PageModels.Email; + +public partial class ResetPasswordEmail(IBrowserContext context, Uri serverAddress) +{ + private IPage page; + private const string OpenEmailFirstMessage = $"You must call {nameof(Open)} method first"; + + public async Task Open(string emailAddress) + { + var html = EmailReaderService.GetLastEmailFor(emailAddress, EmailStrings.ResetPasswordEmailSubject.Replace("{0}", "\\d{6}")); + page = await context.NewPageAsync(); + await page.SetContentAsync(html); + } + + public async Task AssertContent() + { + Assert.IsNotNull(page, OpenEmailFirstMessage); + + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(new Regex(EmailStrings.ResetPasswordTitle.Replace("{0}", ".*"))); + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(EmailStrings.ResetPasswordSubtitle); + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(EmailStrings.ResetPasswordBody); + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(EmailStrings.ResetPasswordTokenMessage); + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(EmailStrings.ResetPasswordLinkMessage); + await Assertions.Expect(page.GetByRole(AriaRole.Link, new() { Name = new Uri(serverAddress, Urls.ResetPasswordPage).ToString() })).ToBeVisibleAsync(); + } + + public async Task GetToken() + { + Assert.IsNotNull(page, OpenEmailFirstMessage); + + var token = await page.GetByText(new Regex("^\\d{6}$")).TextContentAsync(); + Assert.IsNotNull(token, "Confirmation token not found in email"); + return token; + } + + public async Task OpenMagicLink() + { + Assert.IsNotNull(page, OpenEmailFirstMessage); + + await page.GetByRole(AriaRole.Link, new() { Name = new Uri(serverAddress, Urls.ResetPasswordPage).ToString() }).ClickAsync(); + return new(page, serverAddress, null); + } +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Identity/ForgotPasswordPage.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Identity/ForgotPasswordPage.cs new file mode 100644 index 0000000000..be69f90383 --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Identity/ForgotPasswordPage.cs @@ -0,0 +1,42 @@ +//+:cnd:noEmit +using Boilerplate.Tests.PageTests.PageModels.Email; +using Boilerplate.Tests.PageTests.PageModels.Layout; + +namespace Boilerplate.Tests.PageTests.PageModels.Identity; + +public partial class ForgotPasswordPage(IPage page, Uri serverAddress) + : MainLayout(page, serverAddress, Urls.ForgotPasswordPage, AppStrings.ForgotPasswordTitle) +{ + private string emailAddress; + + public override async Task AssertOpen() + { + await base.AssertOpen(); + + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(AppStrings.ForgotPassword); + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(AppStrings.ForgotPasswordMessage); + await Assertions.Expect(page.GetByPlaceholder(AppStrings.EmailPlaceholder)).ToBeVisibleAsync(); + await Assertions.Expect(page.GetByRole(AriaRole.Button, new() { Name = AppStrings.Submit })).ToBeVisibleAsync(); + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(AppStrings.ResetPasswordMessageInForgot); + var resetPasswordLink = page.GetByRole(AriaRole.Link, new() { Name = AppStrings.ResetPassword }); + await Assertions.Expect(resetPasswordLink).ToBeVisibleAsync(); + await Assertions.Expect(resetPasswordLink).ToHaveAttributeAsync("href", Urls.ResetPasswordPage); + } + + public async Task ForgotPassword(string email = "test@bitplatform.dev") + { + emailAddress = email; + + await page.GetByPlaceholder(AppStrings.EmailPlaceholder).FillAsync(emailAddress); + await page.GetByRole(AriaRole.Button, new() { Name = AppStrings.Submit }).ClickAsync(); + + return new(page, serverAddress, emailAddress); + } + + public async Task OpenResetPasswordEmail() + { + var resetPasswordEmail = new ResetPasswordEmail(page.Context, serverAddress); + await resetPasswordEmail.Open(emailAddress); + return resetPasswordEmail; + } +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Identity/ResetPasswordPage.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Identity/ResetPasswordPage.cs new file mode 100644 index 0000000000..82aaf2840e --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/PageTests/PageModels/Identity/ResetPasswordPage.cs @@ -0,0 +1,73 @@ +using Boilerplate.Tests.PageTests.PageModels.Layout; + +namespace Boilerplate.Tests.PageTests.PageModels.Identity; + +public partial class ResetPasswordPage(IPage page, Uri serverAddress, string? emailAddress) + : MainLayout(page, serverAddress, Urls.ResetPasswordPage, AppStrings.ResetPasswordTitle) +{ + public override async Task AssertOpen() + { + await base.AssertOpen(); + + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(AppStrings.ResetPassword); + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(AppStrings.ResetPasswordSubtitle); + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(AppStrings.ResetPasswordMessage); + var emailInput = page.GetByPlaceholder(AppStrings.EmailPlaceholder); + if (emailAddress is null) + { + await Assertions.Expect(emailInput).ToBeVisibleAsync(); + await Assertions.Expect(emailInput).ToBeEnabledAsync(); + await Assertions.Expect(emailInput).ToBeEditableAsync(); + } + else + { + await Assertions.Expect(emailInput).ToBeVisibleAsync(); + await Assertions.Expect(emailInput).ToBeDisabledAsync(); + await Assertions.Expect(emailInput).ToBeEditableAsync(new() { Editable = false }); + } + await Assertions.Expect(page.GetByPlaceholder(AppStrings.TokenPlaceholder)).ToBeVisibleAsync(); + await Assertions.Expect(page.GetByRole(AriaRole.Button, new() { Name = AppStrings.Continue })).ToBeVisibleAsync(); + } + + public async Task ContinueByToken(string? email, string token) + { + Assert.IsTrue(emailAddress is not null || email is not null, "Either email address from query string or email input is required"); + Assert.IsTrue(emailAddress is null || email is null, "Both email address from query string and email input cannot be used at the same time"); + + if (email is not null) + await page.GetByPlaceholder(AppStrings.EmailPlaceholder).FillAsync(email); + + await page.GetByPlaceholder(AppStrings.TokenPlaceholder).FillAsync(token); + await page.GetByRole(AriaRole.Button, new() { Name = AppStrings.Continue }).ClickAsync(); + } + + public async Task Continue() + { + await page.GetByRole(AriaRole.Button, new() { Name = AppStrings.Continue }).ClickAsync(); + } + + public async Task AssertInvalidToken() + { + await Assertions.Expect(page.GetByText(AppStrings.InvalidToken)).ToBeVisibleAsync(); + } + + public async Task AssertValidToken() + { + await Assertions.Expect(page.GetByPlaceholder(AppStrings.PasswordPlaceholder)).ToBeVisibleAsync(); + await Assertions.Expect(page.GetByPlaceholder(AppStrings.ConfirmPassword)).ToBeVisibleAsync(); + await Assertions.Expect(page.GetByRole(AriaRole.Button, new() { Name = AppStrings.ResetPasswordButtonText })).ToBeVisibleAsync(); + } + + public async Task SetPassword(string newPassword) + { + await page.GetByPlaceholder(AppStrings.PasswordPlaceholder).FillAsync(newPassword); + await page.GetByPlaceholder(AppStrings.ConfirmPassword).FillAsync(newPassword); + await page.GetByRole(AriaRole.Button, new() { Name = AppStrings.ResetPasswordButtonText }).ClickAsync(); + } + + public async Task AssertSetPassword() + { + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(AppStrings.ResetPasswordSuccessTitle); + await Assertions.Expect(page.GetByRole(AriaRole.Main)).ToContainTextAsync(AppStrings.ResetPasswordSuccessBody); + } +} \ No newline at end of file From eb2f2d9856c6febf41d361a08c2ede792e00d972 Mon Sep 17 00:00:00 2001 From: Yaser Moradi Date: Tue, 15 Oct 2024 20:00:57 +0200 Subject: [PATCH 5/5] fix(templates): resolve issues of universal link in MAUI client of Boilerplate #8903 (#8904) --- .../Components/AppInitializer.cs | 6 +- .../Components/Layout/RootLayout.razor.cs | 2 +- .../Extensions/NavigationManagerExtensions.cs | 68 ++----------------- .../Boilerplate.Client.Core/Routes.razor.cs | 8 +++ .../Services/CultureService.cs | 2 +- .../Client/Boilerplate.Client.Web/Program.cs | 2 +- .../src/Shared/Extensions/UriExtensions.cs | 68 +++++++++++++++++++ 7 files changed, 87 insertions(+), 69 deletions(-) create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Extensions/UriExtensions.cs diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppInitializer.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppInitializer.cs index 03bfd50edb..ccd19bfc3e 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppInitializer.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/AppInitializer.cs @@ -22,6 +22,7 @@ public partial class AppInitializer : AppComponentBase [AutoInject] private IStorageService storageService = default!; [AutoInject] private CultureInfoManager cultureInfoManager = default!; [AutoInject] private ILogger authLogger = default!; + [AutoInject] private NavigationManager navigationManager = default!; protected async override Task OnInitAsync() { @@ -33,8 +34,9 @@ protected async override Task OnInitAsync() { if (CultureInfoManager.MultilingualEnabled) { - cultureInfoManager.SetCurrentCulture(await storageService.GetItem("Culture") ?? // 1- User settings - CultureInfo.CurrentUICulture.Name); // 2- OS settings + cultureInfoManager.SetCurrentCulture(new Uri(navigationManager.Uri).GetCulture() ?? // 1- Culture query string OR Route data request culture + await storageService.GetItem("Culture") ?? // 2- User settings + CultureInfo.CurrentUICulture.Name); // 3- OS settings } await SetupBodyClasses(); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/RootLayout.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/RootLayout.razor.cs index bfa7fa7078..ae0c352dcf 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/RootLayout.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/RootLayout.razor.cs @@ -106,7 +106,7 @@ private void SetCurrentDir() private void SetCurrentUrl() { - var path = navigationManager.GetPath(); + var path = navigationManager.GetUriPath(); currentUrl = Urls.All.SingleOrDefault(pageUrl => { diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Extensions/NavigationManagerExtensions.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Extensions/NavigationManagerExtensions.cs index fd4704e55b..6b7e621f42 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Extensions/NavigationManagerExtensions.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Extensions/NavigationManagerExtensions.cs @@ -1,74 +1,14 @@ -using System.Web; - -namespace Microsoft.AspNetCore.Components; +namespace Microsoft.AspNetCore.Components; public static partial class NavigationManagerExtensions { public static string GetUriWithoutQueryParameter(this NavigationManager navigationManager, string key) { - var url = navigationManager.Uri; - - var uri = new Uri(url); - - // this gets all the query string key value pairs as a collection - var newQueryString = HttpUtility.ParseQueryString(uri.Query); - - // this removes the key if exists - newQueryString.Remove(key); - - // this gets the page path from root without QueryString - string pagePathWithoutQueryString = uri.GetLeftPart(UriPartial.Path); - - return newQueryString.Count > 0 - ? string.Format("{0}?{1}", pagePathWithoutQueryString, newQueryString) - : pagePathWithoutQueryString; - } - - /// - /// Reads culture from either route segment or query string. - /// https://adminpanel.bitpaltform.dev/en-US/categories - /// https://adminpanel.bitpaltform.dev/categories?culture=en-US - /// - public static string? GetCultureFromUri(this NavigationManager navigationManager) - { - var uri = new Uri(navigationManager.Uri); - - var culture = HttpUtility.ParseQueryString(uri.Query)["culture"]; - - if (string.IsNullOrEmpty(culture) is false) - return culture; - - foreach (var segment in uri.Segments.Take(2)) - { - var segmentValue = segment.Trim('/'); - if (CultureInfoManager.SupportedCultures.Any(sc => string.Equals(sc.Culture.Name, segmentValue, StringComparison.InvariantCultureIgnoreCase))) - { - return segmentValue; - } - } - - return null; - } - - public static string GetUriWithoutCulture(this NavigationManager navigationManager) - { - var uri = navigationManager.GetUriWithoutQueryParameter("culture"); - - var culture = navigationManager.GetCultureFromUri(); - - if (string.IsNullOrEmpty(culture) is false) - { - uri = uri - .Replace($"{culture}/", string.Empty) - .Replace(culture, string.Empty); - } - - return uri; + return new Uri(navigationManager.Uri).GetUrlWithoutQueryParameter(key); } - public static string GetPath(this NavigationManager navigationManager) + public static string GetUriPath(this NavigationManager navigationManager) { - var uriBuilder = new UriBuilder(navigationManager.GetUriWithoutCulture()) { Query = string.Empty, Fragment = string.Empty }; - return uriBuilder.Path; + return new Uri(navigationManager.Uri).GetPath(); } } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Routes.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Routes.razor.cs index e6cdbad4b5..3ace7d082d 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Routes.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Routes.razor.cs @@ -15,6 +15,14 @@ await Task.Run(async () => } }); + if (CultureInfoManager.MultilingualEnabled && forceLoad == false) + { + var currentCulture = CultureInfo.CurrentUICulture.Name; + var uri = new Uri(url); + var urlCulture = uri.GetCulture(); + forceLoad = urlCulture is not null && currentCulture != urlCulture; + } + universalLinksNavigationManager!.NavigateTo(url, forceLoad, replace); } } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Services/CultureService.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Services/CultureService.cs index 933d4ce65a..9950a80c62 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Services/CultureService.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Services/CultureService.cs @@ -27,6 +27,6 @@ await cookie.Set(new() }); } - navigationManager.NavigateTo(navigationManager.GetUriWithoutCulture(), forceLoad: true, replace: true); + navigationManager.NavigateTo(new Uri(navigationManager.Uri).GetUrlWithoutCulture(), forceLoad: true, replace: true); } } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Web/Program.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Web/Program.cs index 397c731009..d35f930b83 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Web/Program.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Web/Program.cs @@ -45,7 +45,7 @@ public static async Task Main(string[] args) var navigationManager = host.Services.GetRequiredService(); - var culture = navigationManager.GetCultureFromUri() ?? // 1- Culture query string OR Route data request culture + var culture = new Uri(navigationManager.Uri).GetCulture() ?? // 1- Culture query string OR Route data request culture cultureCookie ?? // 2- User settings CultureInfo.CurrentUICulture.Name; // 3- OS/Browser settings diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Extensions/UriExtensions.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Extensions/UriExtensions.cs new file mode 100644 index 0000000000..ff5e466448 --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Extensions/UriExtensions.cs @@ -0,0 +1,68 @@ +using System.Web; + +namespace System; + +public static partial class UriExtensions +{ + public static string GetUrlWithoutQueryParameter(this Uri uri, string key) + { + // this gets all the query string key value pairs as a collection + var newQueryString = HttpUtility.ParseQueryString(uri.Query); + + // this removes the key if exists + newQueryString.Remove(key); + + // this gets the page path from root without QueryString + string pagePathWithoutQueryString = uri.GetLeftPart(UriPartial.Path); + + return newQueryString.Count > 0 + ? string.Format("{0}?{1}", pagePathWithoutQueryString, newQueryString) + : pagePathWithoutQueryString; + } + + /// + /// Reads culture from either route segment or query string. + /// https://adminpanel.bitpaltform.dev/en-US/categories + /// https://adminpanel.bitpaltform.dev/categories?culture=en-US + /// + public static string? GetCulture(this Uri uri) + { + var culture = HttpUtility.ParseQueryString(uri.Query)["culture"]; + + if (string.IsNullOrEmpty(culture) is false) + return culture; + + foreach (var segment in uri.Segments.Take(2)) + { + var segmentValue = segment.Trim('/'); + if (CultureInfoManager.SupportedCultures.Any(sc => string.Equals(sc.Culture.Name, segmentValue, StringComparison.InvariantCultureIgnoreCase))) + { + return segmentValue; + } + } + + return null; + } + + public static string GetUrlWithoutCulture(this Uri uri) + { + uri = new Uri(uri.GetUrlWithoutQueryParameter("culture")); + + var culture = uri.GetCulture(); + + if (string.IsNullOrEmpty(culture) is false) + { + uri = new Uri(uri.ToString() + .Replace($"{culture}/", string.Empty) + .Replace(culture, string.Empty)); + } + + return uri.ToString(); + } + + public static string GetPath(this Uri uri) + { + var uriBuilder = new UriBuilder(uri.GetUrlWithoutCulture()) { Query = string.Empty, Fragment = string.Empty }; + return uriBuilder.Path; + } +}