From d2c060764f1998fe8fcac9ff0574c5cd63de3cdf Mon Sep 17 00:00:00 2001 From: gumbarros Date: Wed, 21 Oct 2026 11:18:03 -0300 Subject: [PATCH 1/2] Added IGridPreferenceStore.cs --- .../articles/developer-guide/extensions.md | 1 + .../developer-guide/grid-preference-store.md | 80 +++++++ doc/MasterData.Docs/articles/toc.yml | 2 + doc/MasterData.Docs/llms.txt | 1 + .../MasterDataServiceBuilderExtensions.cs | 24 ++ .../ServiceCollectionExtensions.cs | 11 + .../DataDictionary/Models/GridUI.cs | 4 +- .../Extensions/CookieExtensions.cs | 151 ------------ .../GridPreferenceStoreExtensions.cs | 62 +++++ .../MasterData.Core.csproj.DotSettings | 1 + .../UI/Components/GridView/GridFilter.cs | 14 +- .../UI/Components/GridView/GridViewFactory.cs | 30 +-- .../UI/Components/GridView/JJGridView.cs | 29 ++- .../Preferences/CookieGridPreferenceStore.cs | 101 ++++++++ .../GridView/Preferences/GridPreferences.cs | 12 + .../Preferences/IGridPreferenceStore.cs | 11 + .../Preferences/SessionGridPreferenceStore.cs | 57 +++++ .../GridPreferenceStoreBuilderTests.cs | 131 +++++++++++ .../Extensions/CookieExtensionsTests.cs | 123 ---------- .../CookieGridPreferenceStoreTests.cs | 219 ++++++++++++++++++ .../UI/Components/GridPreferencesTests.cs | 149 ++++++++++++ .../SessionGridPreferenceStoreTests.cs | 207 +++++++++++++++++ 22 files changed, 1110 insertions(+), 310 deletions(-) create mode 100644 doc/MasterData.Docs/articles/developer-guide/grid-preference-store.md delete mode 100644 src/MasterData.Core/Extensions/CookieExtensions.cs create mode 100644 src/MasterData.Core/Extensions/GridPreferenceStoreExtensions.cs create mode 100644 src/MasterData.Core/UI/Components/GridView/Preferences/CookieGridPreferenceStore.cs create mode 100644 src/MasterData.Core/UI/Components/GridView/Preferences/GridPreferences.cs create mode 100644 src/MasterData.Core/UI/Components/GridView/Preferences/IGridPreferenceStore.cs create mode 100644 src/MasterData.Core/UI/Components/GridView/Preferences/SessionGridPreferenceStore.cs create mode 100644 test/MasterData.Core.Test/Configuration/GridPreferenceStoreBuilderTests.cs delete mode 100644 test/MasterData.Core.Test/Extensions/CookieExtensionsTests.cs create mode 100644 test/MasterData.Core.Test/UI/Components/CookieGridPreferenceStoreTests.cs create mode 100644 test/MasterData.Core.Test/UI/Components/GridPreferencesTests.cs create mode 100644 test/MasterData.Core.Test/UI/Components/SessionGridPreferenceStoreTests.cs diff --git a/doc/MasterData.Docs/articles/developer-guide/extensions.md b/doc/MasterData.Docs/articles/developer-guide/extensions.md index b2be02a38..a88b2311b 100644 --- a/doc/MasterData.Docs/articles/developer-guide/extensions.md +++ b/doc/MasterData.Docs/articles/developer-guide/extensions.md @@ -6,6 +6,7 @@ Choose an extension according to the behavior being changed: | --- | --- | --- | | Validate or modify submitted values; react after persistence | `IFormEventHandler` | [Form events](form-events.md) | | Customize grid filters, data and rendered content | `IGridEventHandler` | [Grid events](grid-events.md) | +| Choose how grid preferences are stored | `WithGridPreferenceStore()` / `WithGridSessionPreferenceStore()` | [Grid Preference Store](grid-preference-store.md) | | Add a downloadable file format and its options | `IExportFormat` | [Custom export formats](export-formats.md) | | Add an expression provider | `WithExpressionProvider()` | [Expressions](../concepts/expressions.md) | | Add an action plugin | `WithActionPlugin()` | [Actions](../concepts/actions.md) | diff --git a/doc/MasterData.Docs/articles/developer-guide/grid-preference-store.md b/doc/MasterData.Docs/articles/developer-guide/grid-preference-store.md new file mode 100644 index 000000000..c73c8a7a5 --- /dev/null +++ b/doc/MasterData.Docs/articles/developer-guide/grid-preference-store.md @@ -0,0 +1,80 @@ +# Grid Preference Store + +`IGridPreferenceStore` reads and saves one `GridPreferences` object per grid in the store's current scope. It contains display settings, filters, sort order and the current page. Enable +`FormElement.Options.Grid.MaintainValuesOnLoad` ("Save User Preferences" in the dictionary editor) to save and restore +them. + +The stores and interface are in `JJMasterData.Core.UI.Components`. Builder methods are in +`JJMasterData.Core.Configuration`. + +Consume `IGridPreferenceStore` through constructor injection. The GridView and its factory receive the store directly. + +| Store | Storage | Lifetime | +|---------------------------------------|---------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `CookieGridPreferenceStore` | Protected browser cookies | All preferences use browser session cookies without an explicit expiration. | +| `SessionGridPreferenceStore` | ASP.NET Core session | All preferences last until the session expires or the host clears them. | +| Custom `IGridPreferenceStore` | Chosen by the host | Chosen by the implementation. | + +By default, JJMasterData selects `SessionGridPreferenceStore` when the current request has a session provided by `UseSession()`. Otherwise, it uses `CookieGridPreferenceStore`. The choice is made when the scoped `IGridPreferenceStore` is resolved, so the order of `AddSession()` and `AddJJMasterDataWeb()` does not matter. `AddSession()` alone does not activate the session middleware. + +An explicit `WithGridPreferenceStore()`, factory registration or `WithGridSessionPreferenceStore()` overrides automatic selection. + +The cookie store isolates preferences by `IMasterDataUser.Id`, application path and grid name. The session store uses the grid name within the current ASP.NET Core session. Both ignore grid-name casing and skip storage when the user ID is missing. `MasterDataCoreOptions.UserIdClaimType` controls the default user ID claim. + +## Current session only + +Configure the host's session services and middleware. JJMasterData will then use the session store automatically: + +```csharp +using JJMasterData.Core.Configuration; +using JJMasterData.Web.Configuration; + +builder.Services.AddDistributedMemoryCache(); // Or use the host's distributed cache. +builder.Services.AddSession(); +builder.Services.AddJJMasterDataWeb(builder.Configuration); + +var app = builder.Build(); +app.UseRouting(); +app.UseSession(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); +app.MapDefaultControllerRoute(); +``` + +To require session storage explicitly, use `AddJJMasterDataWeb(...).WithGridSessionPreferenceStore()`. +This requires `AddSession()` and `UseSession()` and does not fall back to cookies. To force cookies even when a session exists, use `.WithGridPreferenceStore()`. + +ASP.NET Core session is independent of authentication. Signing out does not automatically clear it. To discard the +current session's data on logout, the host can call `HttpContext.Session.Clear()`; this clears all session entries, +including data unrelated to the grid. + +## Custom store + +Implement `IGridPreferenceStore`, then register it through the builder: + +```csharp +builder.Services.AddJJMasterDataWeb(builder.Configuration) + .WithGridPreferenceStore(); +``` + +A factory overload is also available: + +```csharp +builder.Services.AddJJMasterDataWeb(builder.Configuration) + .WithGridPreferenceStore(services => new ApplicationGridPreferenceStore( + services.GetRequiredService())); +``` + +Both overloads replace the current store with a scoped registration and return the builder. The last store selection +wins. `WithGridSessionPreferenceStore()` is shorthand for `WithGridPreferenceStore()`. + +Implement only two methods: + +```csharp +public interface IGridPreferenceStore +{ + GridPreferences? Get(string gridName); + void Set(string gridName, GridPreferences? preferences); +} +``` \ No newline at end of file diff --git a/doc/MasterData.Docs/articles/toc.yml b/doc/MasterData.Docs/articles/toc.yml index ac10af662..bf53b2f00 100644 --- a/doc/MasterData.Docs/articles/toc.yml +++ b/doc/MasterData.Docs/articles/toc.yml @@ -63,6 +63,8 @@ href: developer-guide/form-events.md - name: Grid events href: developer-guide/grid-events.md + - name: Grid Preference Store + href: developer-guide/grid-preference-store.md - name: Custom export formats href: developer-guide/export-formats.md - name: Authorization diff --git a/doc/MasterData.Docs/llms.txt b/doc/MasterData.Docs/llms.txt index 756ba19a1..0732ae14a 100644 --- a/doc/MasterData.Docs/llms.txt +++ b/doc/MasterData.Docs/llms.txt @@ -28,6 +28,7 @@ Dictionary metadata and business records use separate repositories. Saving metad - [Routes and embedding](articles/developer-guide/routes-and-embedding.html): Embed components in controllers and Razor views; preserve callback responses. - [Data panels and application-owned saves](articles/developer-guide/data-panels.html): Complete MVC controller/view example using ViewData, explicit validation and saving. - [UI customization](articles/developer-guide/ui-customization.html): Runtime metadata changes and shared host assets. +- [Grid Preference Store](articles/developer-guide/grid-preference-store.html): Cookie, session and custom storage for grid preferences. - [Form events](articles/developer-guide/form-events.html): Registration, insert/update/delete/import hooks and transaction boundaries. - [Grid events](articles/developer-guide/grid-events.html): Custom data loading, pagination, rendering, selection and editable values. - [Authorization](articles/developer-guide/authorization.html): Protect administration, runtime routes and record access. diff --git a/src/MasterData.Core/Configuration/MasterDataServiceBuilderExtensions.cs b/src/MasterData.Core/Configuration/MasterDataServiceBuilderExtensions.cs index a12b12ecd..2ab6a754a 100644 --- a/src/MasterData.Core/Configuration/MasterDataServiceBuilderExtensions.cs +++ b/src/MasterData.Core/Configuration/MasterDataServiceBuilderExtensions.cs @@ -9,6 +9,7 @@ using JJMasterData.Core.DataManager.Exportation.Abstractions; using JJMasterData.Core.DataManager.Expressions.Abstractions; using JJMasterData.Core.Events.Abstractions; +using JJMasterData.Core.UI.Components; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -21,6 +22,29 @@ public static class MasterDataServiceBuilderExtensions { extension(MasterDataServiceBuilder builder) { + /// Replaces the scoped store used for GridView preferences. + public MasterDataServiceBuilder WithGridPreferenceStore() where T : class, IGridPreferenceStore + { + builder.Services.Replace(ServiceDescriptor.Scoped()); + return builder; + } + + /// Replaces the scoped store used for GridView preferences. + public MasterDataServiceBuilder WithGridPreferenceStore(Func implementationFactory) + { + builder.Services.Replace(ServiceDescriptor.Scoped(implementationFactory)); + return builder; + } + + /// + /// Stores all grid preferences in the current ASP.NET Core session. + /// The host must configure AddSession and UseSession. + /// + public MasterDataServiceBuilder WithGridSessionPreferenceStore() + { + return builder.WithGridPreferenceStore(); + } + public MasterDataServiceBuilder WithExportFormat() where TFormat : class, IExportFormat { builder.Services.TryAddEnumerable(ServiceDescriptor.Scoped()); diff --git a/src/MasterData.Core/Configuration/ServiceCollectionExtensions.cs b/src/MasterData.Core/Configuration/ServiceCollectionExtensions.cs index 97490744e..d31061015 100644 --- a/src/MasterData.Core/Configuration/ServiceCollectionExtensions.cs +++ b/src/MasterData.Core/Configuration/ServiceCollectionExtensions.cs @@ -9,6 +9,8 @@ using JJMasterData.Core.DataManager.Exportation.Formats; using JJMasterData.Core.DataManager.Importation.Background; using JJMasterData.Core.Html.Templates; +using JJMasterData.Core.UI.Components; +using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -50,6 +52,15 @@ private void AddMasterDataCoreServices() services.AddOptions().BindConfiguration("JJMasterData"); services.AddHttpServices(); + services.AddDataProtection(); + services.TryAddScoped(provider => + { + var context = provider.GetRequiredService().HttpContext; + if (context?.Features.Get()?.Session != null) + return ActivatorUtilities.CreateInstance(provider); + + return ActivatorUtilities.CreateInstance(provider); + }); services.AddDataDictionaryServices(); services.AddDataManagerServices(); services.AddEventHandlers(); diff --git a/src/MasterData.Core/DataDictionary/Models/GridUI.cs b/src/MasterData.Core/DataDictionary/Models/GridUI.cs index acd9776f0..381209af8 100644 --- a/src/MasterData.Core/DataDictionary/Models/GridUI.cs +++ b/src/MasterData.Core/DataDictionary/Models/GridUI.cs @@ -94,12 +94,12 @@ public class GridUI public bool EnableMultiSelect { get; set; } /// - /// Maintains filters, order and grid pagination in cookies, + /// Maintains grid display settings and navigation state in the configured preference store, /// and recovers on the first page load. (Default = false) /// /// /// When using this property, we recommend changing the object's [Name] parameter. - /// The [Name] property is used to compose the cookie names. + /// The [Name] property identifies the grid in the preference store. /// [JsonPropertyName("maintainValuesOnLoad")] [Display(Name = "Save User Preferences")] diff --git a/src/MasterData.Core/Extensions/CookieExtensions.cs b/src/MasterData.Core/Extensions/CookieExtensions.cs deleted file mode 100644 index 3a36e1950..000000000 --- a/src/MasterData.Core/Extensions/CookieExtensions.cs +++ /dev/null @@ -1,151 +0,0 @@ -#nullable enable annotations -#nullable disable warnings -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Security.Cryptography; -using System.Text.Json; -using JJMasterData.Commons.Serialization; -using JJMasterData.Core.UI.Components; -using Microsoft.AspNetCore.DataProtection; -using Microsoft.Extensions.DependencyInjection; - -namespace JJMasterData.Core.Extensions; - -public static class CookieExtensions -{ - private const string Prefix = ".JJMasterData."; - private const string GridCookiePrefix = "Grid."; - private const string FilterCookieSuffix = ".Filter"; - private const string OrderCookieSuffix = ".Order"; - private const string PageCookieSuffix = ".Page"; - private const string SettingsCookieSuffix = ".Settings"; - - extension(HttpContext httpContext) - { - public Dictionary? GetGridFilterCookie(string gridName) - { - var cookieValue = httpContext.GetDecodedCookieValue(GetGridCookieKey(gridName, FilterCookieSuffix)); - if (string.IsNullOrEmpty(cookieValue)) - return null; - - try - { - var unprotectedValue = httpContext.GetGridFilterCookieProtector().Unprotect(cookieValue); - var filter = JsonSerializer.Deserialize>(unprotectedValue, MasterDataJsonSerializerOptions.Default); - - if (filter == null || filter.Count == 0) - return null; - - return filter; - } - catch (CryptographicException) - { - return null; - } - catch (JsonException) - { - return null; - } - } - - public void SetGridFilterCookie(string gridName, Dictionary filterValues) - { - var serializedValue = filterValues is { Count: > 0 } - ? JsonSerializer.Serialize(filterValues, MasterDataJsonSerializerOptions.Default) - : null; - - serializedValue = serializedValue == null - ? null - : httpContext.GetGridFilterCookieProtector().Protect(serializedValue); - - httpContext.SetCookieValue(GetGridCookieKey(gridName, FilterCookieSuffix), serializedValue); - } - - public string GetGridOrderCookie(string gridName) - { - return httpContext.GetDecodedCookieValue(GetGridCookieKey(gridName, OrderCookieSuffix)); - } - - public void SetGridOrderCookie(string gridName, string orderBy) - { - httpContext.SetCookieValue(GetGridCookieKey(gridName, OrderCookieSuffix), string.IsNullOrEmpty(orderBy) ? null : orderBy); - } - - public int? GetGridCurrentPageCookie(string gridName) - { - var cookieValue = httpContext.GetDecodedCookieValue(GetGridCookieKey(gridName, PageCookieSuffix)); - if (string.IsNullOrEmpty(cookieValue)) - return null; - - return int.TryParse(cookieValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var page) - ? page - : null; - } - - public void SetGridCurrentPageCookie(string gridName, int page) - { - httpContext.SetCookieValue(GetGridCookieKey(gridName, PageCookieSuffix), page.ToString(CultureInfo.InvariantCulture)); - } - - public GridSettings GetGridSettingsCookie(string gridName) - { - var cookieValue = httpContext.GetDecodedCookieValue(GetGridCookieKey(gridName, SettingsCookieSuffix)); - if (string.IsNullOrEmpty(cookieValue)) - return null; - - return JsonSerializer.Deserialize(cookieValue, MasterDataJsonSerializerOptions.Default); - } - - public void SetGridSettingsCookie(string gridName, GridSettings settings) - { - var serializedValue = settings == null - ? null - : JsonSerializer.Serialize(settings, MasterDataJsonSerializerOptions.Default); - - httpContext.SetCookieValue(GetGridCookieKey(gridName, SettingsCookieSuffix), serializedValue); - } - - private void SetCookieValue(string key, string value) - { - if (string.IsNullOrEmpty(value)) - { - httpContext.Response.Cookies.Delete(key, httpContext.CreateCookieOptions()); - return; - } - - httpContext.Response.Cookies.Append(key, Uri.EscapeDataString(value), httpContext.CreateCookieOptions()); - } - - private CookieOptions CreateCookieOptions() - { - return new CookieOptions - { - HttpOnly = true, - IsEssential = true, - Path = httpContext.Request.PathBase - .Add(httpContext.Request.Path) - .ToUriComponent(), - SameSite = SameSiteMode.Lax, - Secure = httpContext.Request.IsHttps - }; - } - - private string GetDecodedCookieValue(string key) - { - var value = httpContext.Request.Cookies[key]; - return string.IsNullOrEmpty(value) ? value : Uri.UnescapeDataString(value); - } - - private IDataProtector GetGridFilterCookieProtector() - { - var provider = httpContext.RequestServices.GetRequiredService(); - return provider.CreateProtector("JJMasterData GridView filter cookie."); - } - } - - private static string GetGridCookieKey(string gridName, string suffix = null) - { - return $"{Prefix}{GridCookiePrefix}{gridName.ToLowerInvariant()}{suffix}"; - } -} diff --git a/src/MasterData.Core/Extensions/GridPreferenceStoreExtensions.cs b/src/MasterData.Core/Extensions/GridPreferenceStoreExtensions.cs new file mode 100644 index 000000000..3ccf54c02 --- /dev/null +++ b/src/MasterData.Core/Extensions/GridPreferenceStoreExtensions.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using JJMasterData.Core.UI.Components; + +namespace JJMasterData.Core.Extensions; + +internal static class GridPreferenceStoreExtensions +{ + extension(IGridPreferenceStore store) + { + internal GridSettings? GetSettings(string gridName) + { + var settings = store.Get(gridName)?.Settings; + return settings is { RecordsPerPage: >= 0, TotalPaginationButtons: > 0 } ? settings : null; + } + + internal void SetSettings(string gridName, GridSettings? settings) + { + store.Update(gridName, preferences => preferences.Settings = settings); + } + + internal Dictionary? GetFilter(string gridName) + { + return store.Get(gridName)?.Filter; + } + + internal void SetFilter(string gridName, Dictionary? filter) + { + store.Update(gridName, preferences => preferences.Filter = filter is { Count: > 0 } ? filter : null); + } + + internal string? GetOrder(string gridName) + { + return store.Get(gridName)?.Order; + } + + internal void SetOrder(string gridName, string? order) + { + store.Update(gridName, preferences => preferences.Order = string.IsNullOrEmpty(order) ? null : order); + } + + internal int? GetCurrentPage(string gridName) + { + var page = store.Get(gridName)?.CurrentPage; + return page is > 0 ? page : null; + } + + internal void SetCurrentPage(string gridName, int? page) + { + store.Update(gridName, preferences => preferences.CurrentPage = page is > 0 ? page : null); + } + + private void Update(string gridName, Action update) + { + var preferences = store.Get(gridName) ?? new GridPreferences(); + update(preferences); + store.Set(gridName, preferences is { Settings: null, Filter: null, Order: null, CurrentPage: null } + ? null + : preferences); + } + } +} diff --git a/src/MasterData.Core/MasterData.Core.csproj.DotSettings b/src/MasterData.Core/MasterData.Core.csproj.DotSettings index 17f003150..9b2408fd2 100644 --- a/src/MasterData.Core/MasterData.Core.csproj.DotSettings +++ b/src/MasterData.Core/MasterData.Core.csproj.DotSettings @@ -28,6 +28,7 @@ True True True + True True True True diff --git a/src/MasterData.Core/UI/Components/GridView/GridFilter.cs b/src/MasterData.Core/UI/Components/GridView/GridFilter.cs index 0f410a027..7b2bce052 100644 --- a/src/MasterData.Core/UI/Components/GridView/GridFilter.cs +++ b/src/MasterData.Core/UI/Components/GridView/GridFilter.cs @@ -10,8 +10,6 @@ using JJConsulting.Html.Bootstrap.Extensions; using JJConsulting.Html.Bootstrap.Models; using JJConsulting.Html.Extensions; -using JJMasterData.Commons; -using JJMasterData.Commons.Background; using JJMasterData.Commons.Data.Entity.Models; using JJMasterData.Commons.Serialization; using JJMasterData.Commons.Tasks; @@ -96,17 +94,17 @@ internal async ValueTask GetFilterHtml() return _currentFilter; } - var cookieFilter = _currentContext.HttpContext!.GetGridFilterCookie(gridView.Name); + var savedFilter = gridView.PreferenceStore.GetFilter(gridView.Name); - if (cookieFilter != null && gridView.MaintainValuesOnLoad) + if (savedFilter != null && gridView.MaintainValuesOnLoad) { - DataHelper.CopyIntoDictionary(_currentFilter, cookieFilter); + DataHelper.CopyIntoDictionary(_currentFilter, savedFilter); return _currentFilter; } - if (cookieFilter != null && (_currentContext.HttpContext!.Request.HasFormContentType || IsDynamicPost())) + if (savedFilter != null && (_currentContext.HttpContext!.Request.HasFormContentType || IsDynamicPost())) { - DataHelper.CopyIntoDictionary(_currentFilter, cookieFilter); + DataHelper.CopyIntoDictionary(_currentFilter, savedFilter); return _currentFilter; } @@ -170,7 +168,7 @@ await gridView.FieldValuesService.MergeWithDefaultValuesAsync(gridView.FormEleme DataHelper.CopyIntoDictionary(_currentFilter, values); if(gridView.MaintainValuesOnLoad) - _currentContext.HttpContext!.SetGridFilterCookie(gridView.Name, _currentFilter); + gridView.PreferenceStore.SetFilter(gridView.Name, _currentFilter); } private async ValueTask GetDefaultFilter() diff --git a/src/MasterData.Core/UI/Components/GridView/GridViewFactory.cs b/src/MasterData.Core/UI/Components/GridView/GridViewFactory.cs index 32c8f58f8..4f0148dd4 100644 --- a/src/MasterData.Core/UI/Components/GridView/GridViewFactory.cs +++ b/src/MasterData.Core/UI/Components/GridView/GridViewFactory.cs @@ -31,7 +31,8 @@ internal sealed class GridViewFactory(IHttpContextAccessor currentContext, UrlRedirectService urlRedirectService, HtmlTemplateRenderer htmlTemplateRenderer, ILoggerFactory loggerFactory, - IComponentFactory componentFactory) + IComponentFactory componentFactory, + IGridPreferenceStore preferenceStore) : IFormElementComponentFactory { public JJGridView Create(FormElement formElement) @@ -51,7 +52,8 @@ public JJGridView Create(FormElement formElement) urlRedirectService, htmlTemplateRenderer, loggerFactory.CreateLogger(), - componentFactory); + componentFactory, + preferenceStore); var eventHandler = gridEventHandlerResolver.GetGridEventHandler(formElement.Name); @@ -103,6 +105,7 @@ internal void SetGridOptions(JJGridView grid, FormElementOptions options) SetGridUiOptions(grid, gridOptions); } + internal void SetGridUiOptions(JJGridView grid, GridUI gridOptions) { grid.EnableSorting = gridOptions.EnableSorting; @@ -115,21 +118,20 @@ internal void SetGridUiOptions(JJGridView grid, GridUI gridOptions) { GridSettings settings = null; if (grid.MaintainValuesOnLoad) - settings = currentContext.HttpContext!.GetGridSettingsCookie(grid.FormElement.Name); + settings = preferenceStore.GetSettings(grid.Name); - if (settings == null) + settings ??= new GridSettings { - settings = grid.CurrentSettings; - settings.ShowRowHover = gridOptions.ShowRowHover; - settings.ShowRowStriped = gridOptions.ShowRowStriped; - settings.ShowBorder = gridOptions.ShowBorder; - settings.RecordsPerPage = gridOptions.RecordsPerPage; - settings.TotalPaginationButtons = gridOptions.TotalPaggingButton; - settings.IsHeaderFixed = gridOptions.HeaderFixed; - settings.IsCompact = gridOptions.IsCompact; - } + ShowRowHover = gridOptions.ShowRowHover, + ShowRowStriped = gridOptions.ShowRowStriped, + ShowBorder = gridOptions.ShowBorder, + RecordsPerPage = gridOptions.RecordsPerPage, + TotalPaginationButtons = gridOptions.TotalPaggingButton, + IsHeaderFixed = gridOptions.HeaderFixed, + IsCompact = gridOptions.IsCompact + }; - grid.CurrentSettings = settings; + grid.SetInitialSettings(settings); } grid.ShowHeaderWhenEmpty = gridOptions.ShowHeaderWhenEmpty; diff --git a/src/MasterData.Core/UI/Components/GridView/JJGridView.cs b/src/MasterData.Core/UI/Components/GridView/JJGridView.cs index 5cede187b..a32b70a27 100644 --- a/src/MasterData.Core/UI/Components/GridView/JJGridView.cs +++ b/src/MasterData.Core/UI/Components/GridView/JJGridView.cs @@ -102,6 +102,8 @@ public event AsyncEventHandler? OnFilterLoadAsync private JJDataExportation? _dataExportation; private GridScripts? _gridScripts; + internal IGridPreferenceStore PreferenceStore { get; } + private readonly FieldValidationService _fieldValidationService; private readonly UrlRedirectService _urlRedirectService; @@ -234,7 +236,7 @@ public OrderByData CurrentOrder { if (MaintainValuesOnLoad) { - var tableOrder = CurrentContext.HttpContext!.GetGridOrderCookie(Name); + var tableOrder = PreferenceStore.GetOrder(Name); if (tableOrder != null) { _currentOrder = OrderByData.FromString(tableOrder); @@ -247,7 +249,7 @@ public OrderByData CurrentOrder _currentOrder = OrderByData.FromString(order); if (_currentOrder == null) { - var tableOrder = CurrentContext.HttpContext!.GetGridOrderCookie(Name); + var tableOrder = PreferenceStore.GetOrder(Name); if (tableOrder != null) { _currentOrder = OrderByData.FromString(tableOrder); @@ -265,7 +267,7 @@ public OrderByData CurrentOrder set { if (MaintainValuesOnLoad) - CurrentContext.HttpContext!.SetGridOrderCookie(Name, value.ToQueryParameter()); + PreferenceStore.SetOrder(Name, value.ToQueryParameter()); _currentOrder = value; } @@ -312,13 +314,13 @@ public int CurrentPage set { if (MaintainValuesOnLoad && PaginationType is GridPaginationType.Buttons) - CurrentContext.HttpContext!.SetGridCurrentPageCookie(Name, value); + PreferenceStore.SetCurrentPage(Name, value); _currentPage = value; } } - private int GetPageFromPreferences() => CurrentContext.HttpContext!.GetGridCurrentPageCookie(Name) ?? 1; + private int GetPageFromPreferences() => PreferenceStore.GetCurrentPage(Name) ?? 1; public int TotalOfPages { @@ -353,22 +355,23 @@ public GridSettings CurrentSettings } if (MaintainValuesOnLoad) - CurrentSettings = CurrentContext.HttpContext!.GetGridSettingsCookie(FormElement.Name); + _currentSettings = PreferenceStore.GetSettings(Name); - if (_currentSettings == null) - CurrentSettings = GridSettingsForm.LoadFromForm(); + _currentSettings ??= GridSettingsForm.LoadFromForm(); return _currentSettings!; } set { if (MaintainValuesOnLoad) - CurrentContext.HttpContext!.SetGridSettingsCookie(FormElement.Name, value); + PreferenceStore.SetSettings(Name, value); _currentSettings = value; } } + internal void SetInitialSettings(GridSettings settings) => _currentSettings = settings; + internal GridFilter Filter { get @@ -410,12 +413,12 @@ public ExportOptions CurrentExportConfig public bool EnableMultiSelect { get; set; } /// - /// Keep the grid filters, order and pagination in cookies, + /// Keep grid display settings and navigation state in the configured preference store, /// and recover on the first page load. (Default = false) /// /// /// When using this property, we recommend changing the object's [Name] parameter. - /// The [Name] property is used to compose the cookie names. + /// The [Name] property identifies the grid in the preference store. /// public bool MaintainValuesOnLoad { get; set; } @@ -576,8 +579,10 @@ internal JJGridView( UrlRedirectService urlRedirectService, HtmlTemplateRenderer htmlTemplateRenderer, ILogger logger, - IComponentFactory componentFactory) + IComponentFactory componentFactory, + IGridPreferenceStore preferenceStore) { + PreferenceStore = preferenceStore; Name = formElement.Name.ToLowerInvariant(); FormElement = formElement; ShowTitle = formElement.Options.Grid.ShowTitle; diff --git a/src/MasterData.Core/UI/Components/GridView/Preferences/CookieGridPreferenceStore.cs b/src/MasterData.Core/UI/Components/GridView/Preferences/CookieGridPreferenceStore.cs new file mode 100644 index 000000000..6a5b40e0c --- /dev/null +++ b/src/MasterData.Core/UI/Components/GridView/Preferences/CookieGridPreferenceStore.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text.Json; +using JJMasterData.Commons.Serialization; +using JJMasterData.Core.DataManager; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Authentication.Cookies; + +namespace JJMasterData.Core.UI.Components; + +/// +/// Browser-local preferences protected and isolated by user, application path and grid. +/// All preferences use browser session cookies without an explicit expiration. +/// +public sealed class CookieGridPreferenceStore( + IHttpContextAccessor contextAccessor, + IMasterDataUser user, + IDataProtectionProvider protectionProvider) : IGridPreferenceStore +{ + private readonly ChunkingCookieManager _cookies = new(); + private readonly Dictionary _values = new(); + + public GridPreferences? Get(string gridName) + { + var context = contextAccessor.HttpContext; + var scope = user.Id; + if (context == null || string.IsNullOrWhiteSpace(scope)) + return null; + + var key = GetKey(scope, gridName); + if (!_values.TryGetValue(key, out var serialized)) + { + var value = _cookies.GetRequestCookie(context, key); + if (string.IsNullOrEmpty(value)) + return null; + + try + { + serialized = GetProtector(key).Unprotect(value); + } + catch (CryptographicException) + { + return null; + } + } + + if (serialized == null) + return null; + + try + { + return JsonSerializer.Deserialize(serialized, MasterDataJsonSerializerOptions.Default); + } + catch (JsonException) + { + return null; + } + } + + public void Set(string gridName, GridPreferences? preferences) + { + var context = contextAccessor.HttpContext; + var scope = user.Id; + if (context == null || string.IsNullOrWhiteSpace(scope)) + return; + + var key = GetKey(scope, gridName); + var cookieOptions = new CookieOptions + { + HttpOnly = true, + IsEssential = true, + Path = context.Request.PathBase.Add(context.Request.Path).ToUriComponent(), + SameSite = SameSiteMode.Lax, + Secure = context.Request.IsHttps + }; + + if (preferences == null) + { + _values[key] = null; + _cookies.DeleteCookie(context, key, cookieOptions); + return; + } + + var serialized = JsonSerializer.Serialize(preferences, MasterDataJsonSerializerOptions.Default); + _values[key] = serialized; + _cookies.AppendResponseCookie(context, key, GetProtector(key).Protect(serialized), cookieOptions); + } + + private static string GetKey(string user, string gridName) + { + return $".JJMasterData.Grid." + + $"{Uri.EscapeDataString(user)}." + + $"{Uri.EscapeDataString(gridName.ToLowerInvariant())}"; + } + + private IDataProtector GetProtector(string key) + { + return protectionProvider.CreateProtector("JJMasterData.GridPreferences.v2", key); + } +} diff --git a/src/MasterData.Core/UI/Components/GridView/Preferences/GridPreferences.cs b/src/MasterData.Core/UI/Components/GridView/Preferences/GridPreferences.cs new file mode 100644 index 000000000..8b82bdb3c --- /dev/null +++ b/src/MasterData.Core/UI/Components/GridView/Preferences/GridPreferences.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace JJMasterData.Core.UI.Components; + +/// All saved preferences for one grid. +public sealed class GridPreferences +{ + public GridSettings? Settings { get; set; } + public Dictionary? Filter { get; set; } + public string? Order { get; set; } + public int? CurrentPage { get; set; } +} diff --git a/src/MasterData.Core/UI/Components/GridView/Preferences/IGridPreferenceStore.cs b/src/MasterData.Core/UI/Components/GridView/Preferences/IGridPreferenceStore.cs new file mode 100644 index 000000000..f30e9aa36 --- /dev/null +++ b/src/MasterData.Core/UI/Components/GridView/Preferences/IGridPreferenceStore.cs @@ -0,0 +1,11 @@ +namespace JJMasterData.Core.UI.Components; + +/// Stores all preferences for a grid in the implementation's current scope. +public interface IGridPreferenceStore +{ + /// Returns saved preferences, or null when none exist. Grid names are case-insensitive. + GridPreferences? Get(string gridName); + + /// Saves the complete preferences, or removes them when null. Writes must be visible immediately. + void Set(string gridName, GridPreferences? preferences); +} diff --git a/src/MasterData.Core/UI/Components/GridView/Preferences/SessionGridPreferenceStore.cs b/src/MasterData.Core/UI/Components/GridView/Preferences/SessionGridPreferenceStore.cs new file mode 100644 index 000000000..9d8e21e49 --- /dev/null +++ b/src/MasterData.Core/UI/Components/GridView/Preferences/SessionGridPreferenceStore.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.Text.Json; +using JJMasterData.Commons.Serialization; +using JJMasterData.Core.DataManager; + +namespace JJMasterData.Core.UI.Components; + +/// Stores one preference document per grid in the current ASP.NET Core session. +public sealed class SessionGridPreferenceStore(IHttpContextAccessor contextAccessor, IMasterDataUser user) : IGridPreferenceStore +{ + private readonly Dictionary _values = new(); + + public GridPreferences? Get(string gridName) + { + if (contextAccessor.HttpContext == null || string.IsNullOrWhiteSpace(user.Id)) + return null; + + var key = GetKey(gridName); + if (_values.TryGetValue(key, out var cached)) + return cached; + + var serialized = contextAccessor.HttpContext.Session.GetString(key); + GridPreferences? preferences = null; + try + { + if (serialized != null) + preferences = JsonSerializer.Deserialize(serialized, MasterDataJsonSerializerOptions.Default); + } + catch (JsonException) + { + preferences = null; + } + + _values[key] = preferences; + return preferences; + } + + public void Set(string gridName, GridPreferences? preferences) + { + if (contextAccessor.HttpContext == null || string.IsNullOrWhiteSpace(user.Id)) + return; + + var session = contextAccessor.HttpContext.Session; + var key = GetKey(gridName); + if (preferences == null) + { + session.Remove(key); + _values[key] = null; + return; + } + + session.SetString(key, JsonSerializer.Serialize(preferences, MasterDataJsonSerializerOptions.Default)); + _values[key] = preferences; + } + + private static string GetKey(string gridName) => ".JJMasterData.Grid.v2." + gridName.ToLowerInvariant(); +} diff --git a/test/MasterData.Core.Test/Configuration/GridPreferenceStoreBuilderTests.cs b/test/MasterData.Core.Test/Configuration/GridPreferenceStoreBuilderTests.cs new file mode 100644 index 000000000..51a489b87 --- /dev/null +++ b/test/MasterData.Core.Test/Configuration/GridPreferenceStoreBuilderTests.cs @@ -0,0 +1,131 @@ +using JJMasterData.Core.Configuration; +using JJMasterData.Core.DataManager; +using JJMasterData.Core.UI.Components; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace JJMasterData.Core.Test.Configuration; + +public class GridPreferenceStoreBuilderTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DefaultStore_UsesAvailableSessionRegardlessOfRegistrationOrder(bool registerSessionFirst) + { + var services = new ServiceCollection(); + services.AddSingleton(Mock.Of()); + services.AddSingleton(new HttpContextAccessor + { + HttpContext = new DefaultHttpContext { Session = Mock.Of() } + }); + if (registerSessionFirst) + services.AddSession(); + services.AddJJMasterDataCore(); + if (!registerSessionFirst) + services.AddSession(); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService(); + Assert.IsType(store); + Assert.Same(store, scope.ServiceProvider.GetRequiredService()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DefaultStore_UsesCookiesWithoutAnAvailableSession(bool registerSession) + { + var services = new ServiceCollection(); + services.AddSingleton(Mock.Of()); + services.AddSingleton(new HttpContextAccessor { HttpContext = new DefaultHttpContext() }); + services.AddJJMasterDataCore(); + if (registerSession) + services.AddSession(); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + } + + [Fact] + public void ExplicitCookieStore_OverridesAutomaticSessionSelection() + { + var services = new ServiceCollection(); + services.AddSingleton(Mock.Of()); + services.AddSingleton(new HttpContextAccessor + { + HttpContext = new DefaultHttpContext { Session = Mock.Of() } + }); + services.AddJJMasterDataCore().WithGridPreferenceStore(); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + } + + [Fact] + public void WithGridSessionPreferenceStore_ReplacesDefaultAndReturnsBuilder() + { + var services = new ServiceCollection(); + services.AddSingleton(Mock.Of()); + var builder = services.AddJJMasterDataCore(); + + Assert.Same(builder, builder.WithGridSessionPreferenceStore()); + var descriptor = Assert.Single(services, s => s.ServiceType == typeof(IGridPreferenceStore)); + Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime); + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + } + + [Fact] + public void WithGridPreferenceStore_UsesTypeAndRespectsRequestScopes() + { + var services = new ServiceCollection(); + services.AddSingleton(Mock.Of()); + services.AddSingleton(new HttpContextAccessor()); + var builder = services.AddJJMasterDataCore(); + Assert.Same(builder, builder.WithGridPreferenceStore()); + + using var provider = services.BuildServiceProvider(); + using var first = provider.CreateScope(); + using var second = provider.CreateScope(); + var firstStore = first.ServiceProvider.GetRequiredService(); + Assert.IsType(firstStore); + Assert.Same(firstStore, first.ServiceProvider.GetRequiredService()); + Assert.NotSame(firstStore, second.ServiceProvider.GetRequiredService()); + } + + [Fact] + public void WithGridPreferenceStore_FactoryRunsOncePerScopeAndLastSelectionWins() + { + var services = new ServiceCollection(); + services.AddSingleton(new HttpContextAccessor + { + HttpContext = new DefaultHttpContext { Session = Mock.Of() } + }); + services.AddScoped(_ => Mock.Of()); + var builder = services.AddJJMasterDataCore(); + builder.WithGridSessionPreferenceStore(); + var expected = Mock.Of(); + var calls = 0; + Assert.Same(builder, builder.WithGridPreferenceStore(_ => + { + calls++; + return expected; + })); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + Assert.Same(expected, scope.ServiceProvider.GetRequiredService()); + Assert.Same(expected, scope.ServiceProvider.GetRequiredService()); + Assert.Equal(1, calls); + using var second = provider.CreateScope(); + Assert.Same(expected, second.ServiceProvider.GetRequiredService()); + Assert.Equal(2, calls); + Assert.Single(services, s => s.ServiceType == typeof(IGridPreferenceStore)); + } +} diff --git a/test/MasterData.Core.Test/Extensions/CookieExtensionsTests.cs b/test/MasterData.Core.Test/Extensions/CookieExtensionsTests.cs deleted file mode 100644 index 7fc25500f..000000000 --- a/test/MasterData.Core.Test/Extensions/CookieExtensionsTests.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System.Security.Claims; -using JJMasterData.Core.Extensions; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; - -namespace JJMasterData.Core.Test.Extensions; - -public class CookieExtensionsTests -{ - [Fact] - public void SetGridOrderCookie_SameUserOnNextRequest_ReadsCookieValue() - { - var writeContext = CreateHttpContext("user-1"); - - writeContext.SetGridOrderCookie("Customers", "Name asc"); - - var readContext = CreateHttpContext("user-1", GetRequestCookieHeader(writeContext)); - - Assert.Equal("Name asc", readContext.GetGridOrderCookie("Customers")); - } - - [Fact] - public void GetGridOrderCookie_DifferentUserOnNextRequest_ReadsCookieValue() - { - var writeContext = CreateHttpContext("user-1"); - - writeContext.SetGridOrderCookie("Customers", "Name asc"); - - var readContext = CreateHttpContext("user-2", GetRequestCookieHeader(writeContext)); - - Assert.Equal("Name asc", readContext.GetGridOrderCookie("Customers")); - } - - [Fact] - public void SetGridFilterCookie_SameUserOnNextRequest_ReadsCookieValue() - { - var writeContext = CreateHttpContext("user-1"); - - writeContext.SetGridFilterCookie("Customers", new Dictionary - { - ["Name"] = "Alice" - }); - - var readContext = CreateHttpContext("user-1", GetRequestCookieHeader(writeContext)); - var filter = readContext.GetGridFilterCookie("Customers"); - - Assert.NotNull(filter); - Assert.Equal("Alice", filter["Name"]?.ToString()); - } - - [Fact] - public void SetGridFilterCookie_UsesApplicationPath() - { - var context = CreateHttpContext("user-1"); - context.Request.PathBase = "/app"; - context.Request.Path = "/customers/filter"; - - context.SetGridFilterCookie("Customers", new Dictionary - { - ["Name"] = "Alice" - }); - - Assert.Contains("path=/app", context.Response.Headers.SetCookie.ToString(), - StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void SetGridFilterCookie_WithoutApplicationPath_UsesRootPath() - { - var context = CreateHttpContext("user-1"); - context.Request.Path = "/customers/filter"; - - context.SetGridFilterCookie("Customers", new Dictionary - { - ["Name"] = "Alice" - }); - - Assert.Contains("path=/", context.Response.Headers.SetCookie.ToString(), - StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void GetGridFilterCookie_TamperedPayload_ReturnsNull() - { - var writeContext = CreateHttpContext("user-1"); - - writeContext.SetGridFilterCookie("Customers", new Dictionary - { - ["Name"] = "Alice" - }); - - var cookieHeader = GetRequestCookieHeader(writeContext); - var tamperedCookieHeader = $"{cookieHeader[..^1]}X"; - var readContext = CreateHttpContext("user-1", tamperedCookieHeader); - - Assert.Null(readContext.GetGridFilterCookie("Customers")); - } - - private static DefaultHttpContext CreateHttpContext(string userId, string? cookieHeader = null) - { - var context = new DefaultHttpContext(); - var services = new ServiceCollection(); - services.AddDataProtection(); - - context.Request.Path = "/customers"; - context.RequestServices = services.BuildServiceProvider(); - context.User = new ClaimsPrincipal( - new ClaimsIdentity( - [new Claim(ClaimTypes.NameIdentifier, userId)], - authenticationType: "test")); - - if (!string.IsNullOrEmpty(cookieHeader)) - context.Request.Headers.Cookie = cookieHeader; - - return context; - } - private static string GetRequestCookieHeader(DefaultHttpContext context) - { - var setCookie = context.Response.Headers.SetCookie.ToString(); - var separatorIndex = setCookie.IndexOf(';'); - return separatorIndex >= 0 ? setCookie[..separatorIndex] : setCookie; - } -} diff --git a/test/MasterData.Core.Test/UI/Components/CookieGridPreferenceStoreTests.cs b/test/MasterData.Core.Test/UI/Components/CookieGridPreferenceStoreTests.cs new file mode 100644 index 000000000..20c38aa4d --- /dev/null +++ b/test/MasterData.Core.Test/UI/Components/CookieGridPreferenceStoreTests.cs @@ -0,0 +1,219 @@ +using JJMasterData.Core.Extensions; +using JJMasterData.Core.Configuration; +using JJMasterData.Core.DataManager; +using JJMasterData.Core.UI.Components; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Net.Http.Headers; +using Moq; + +namespace JJMasterData.Core.Test.UI.Components; + +public class CookieGridPreferenceStoreTests +{ + private readonly EphemeralDataProtectionProvider _protection = new(); + + [Fact] + public void Preferences_RoundTripInASingleSessionCookie() + { + var (context, store) = CreateStore(); + store.Set("Customers", new GridPreferences + { + Settings = new GridSettings { RecordsPerPage = 25, IsCompact = false }, + Filter = new() { ["Name"] = "Alice" }, + Order = "Name asc", + CurrentPage = 3 + }); + + var cookie = Assert.Single(GetCookies(context)); + Assert.Null(cookie.Expires); + Assert.Null(cookie.MaxAge); + Assert.True(cookie.HttpOnly); + Assert.Equal("/app/customers", cookie.Path.Value); + + var (_, next) = CreateStore(cookieHeader: GetRequestCookies(context)); + var preferences = next.Get("CUSTOMERS"); + Assert.NotNull(preferences); + Assert.Equal("Alice", preferences.Filter!["Name"]?.ToString()); + Assert.Equal("Name asc", preferences.Order); + Assert.Equal(3, preferences.CurrentPage); + var settings = preferences.Settings; + Assert.NotNull(settings); + Assert.Equal(25, settings.RecordsPerPage); + Assert.False(settings.IsCompact); + } + + [Fact] + public void NavigationState_RoundTripsWithSessionCookies() + { + var (context, store) = CreateStore(); + store.SetFilter("Customers", new() { ["Name"] = "Alice" }); + store.SetOrder("Customers", "Name asc"); + store.SetCurrentPage("Customers", 3); + + Assert.All(GetCookies(context), cookie => Assert.Null(cookie.Expires)); + Assert.Single(GetCookies(context).Select(cookie => cookie.Name.ToString()).Distinct()); + var (_, next) = CreateStore(cookieHeader: GetRequestCookies(context)); + Assert.Equal("Alice", next.GetFilter("Customers")!["Name"]?.ToString()); + Assert.Equal("Name asc", next.GetOrder("Customers")); + Assert.Equal(3, next.GetCurrentPage("Customers")); + } + + [Theory] + [InlineData("other-user", "/app", "Customers")] + [InlineData("user-1", "/app", "Orders")] + public void Preferences_AreIsolated(string userId, string pathBase, string gridName) + { + var (context, store) = CreateStore(); + store.SetSettings("Customers", new GridSettings()); + store.SetFilter("Customers", new() { ["Name"] = "Alice" }); + store.SetOrder("Customers", "Name asc"); + store.SetCurrentPage("Customers", 3); + + var (_, other) = CreateStore(userId, GetRequestCookies(context), pathBase); + Assert.Null(other.GetSettings(gridName)); + Assert.Null(other.GetFilter(gridName)); + Assert.Null(other.GetOrder(gridName)); + Assert.Null(other.GetCurrentPage(gridName)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void MissingUser_DisablesPersistence(string? userId) + { + var (context, store) = CreateStore(userId); + store.SetSettings("Customers", new GridSettings()); + store.SetFilter("Customers", new() { ["Name"] = "Alice" }); + store.SetOrder("Customers", "Name asc"); + store.SetCurrentPage("Customers", 2); + Assert.Empty(GetCookies(context)); + Assert.Null(store.GetSettings("Customers")); + Assert.Null(store.GetOrder("Customers")); + Assert.Null(store.GetFilter("Customers")); + Assert.Null(store.GetCurrentPage("Customers")); + } + + [Fact] + public void WritesAndDeletes_AreVisibleWithinTheRequest() + { + var (context, store) = CreateStore(); + store.SetSettings("Customers", new GridSettings { RecordsPerPage = 25 }); + store.SetFilter("Customers", new() { ["Name"] = "Alice" }); + store.SetOrder("Customers", "Name asc"); + store.SetCurrentPage("Customers", 2); + + var (_, next) = CreateStore(cookieHeader: GetRequestCookies(context)); + next.SetSettings("Customers", new GridSettings { RecordsPerPage = 50 }); + Assert.Equal(50, next.GetSettings("Customers")!.RecordsPerPage); + next.SetSettings("Customers", null); + next.SetFilter("Customers", new()); + next.SetOrder("Customers", null); + next.SetCurrentPage("Customers", null); + Assert.Null(next.GetSettings("Customers")); + Assert.Null(next.GetFilter("Customers")); + Assert.Null(next.GetOrder("Customers")); + Assert.Null(next.GetCurrentPage("Customers")); + } + + [Fact] + public void TamperedAndLegacyCookies_AreIgnored() + { + var (context, store) = CreateStore(); + store.SetSettings("Customers", new GridSettings()); + var cookie = Assert.Single(GetCookies(context)); + var (_, tampered) = CreateStore(cookieHeader: $"{cookie.Name}=invalid; .JJMasterData.Grid.customers.Settings={{}}"); + Assert.Null(tampered.GetSettings("Customers")); + } + + [Theory] + [InlineData("not-json")] + [InlineData("{\"Settings\":{\"recordsPerPage\":-1}}")] + [InlineData("{\"Settings\":{\"totalPaginationButtons\":0}}")] + public void InvalidSettings_AreIgnored(string serialized) + { + var (context, store) = CreateStore(); + store.SetSettings("Customers", new GridSettings()); + var cookie = Assert.Single(GetCookies(context)); + var payload = _protection.CreateProtector("JJMasterData.GridPreferences.v2", cookie.Name.ToString()).Protect(serialized); + var (_, next) = CreateStore(cookieHeader: $"{cookie.Name}={payload}"); + Assert.Null(next.GetSettings("Customers")); + } + + [Fact] + public void LargeFilter_IsChunkedAndRestored() + { + var (context, store) = CreateStore(); + var value = new string('x', 6000); + store.SetFilter("Customers", new() { ["Name"] = value }); + Assert.True(GetCookies(context).Count > 1); + var (_, next) = CreateStore(cookieHeader: GetRequestCookies(context)); + Assert.Equal(value, next.GetFilter("Customers")!["Name"]?.ToString()); + } + + [Fact] + public void Settings_UseSecureCookiesForHttps() + { + var (context, store) = CreateStore(); + context.Request.Scheme = "https"; + store.SetSettings("Customers", new GridSettings()); + Assert.True(Assert.Single(GetCookies(context)).Secure); + } + + [Fact] + public void Settings_LastWriteWins() + { + var (context, store) = CreateStore(); + store.SetSettings("Customers", new GridSettings { RecordsPerPage = 10 }); + store.SetSettings("Customers", new GridSettings { RecordsPerPage = 25 }); + Assert.Equal(25, store.GetSettings("Customers")!.RecordsPerPage); + var (_, next) = CreateStore(cookieHeader: GetRequestCookies(context)); + Assert.Equal(25, next.GetSettings("Customers")!.RecordsPerPage); + } + + [Fact] + public void Settings_DeletionRemovesCookie() + { + var (context, store) = CreateStore(); + store.SetSettings("Customers", new GridSettings()); + store.SetSettings("Customers", null); + var cookie = Assert.Single(GetCookies(context)); + Assert.Empty(cookie.Value.ToString()); + Assert.True(cookie.Expires < DateTimeOffset.UtcNow); + } + + [Fact] + public void Registration_PreservesCustomStore() + { + var services = new ServiceCollection(); + var custom = Mock.Of(); + services.AddSingleton(custom); + services.AddJJMasterDataCore(); + using var provider = services.BuildServiceProvider(); + Assert.Same(custom, provider.GetRequiredService()); + } + + private (DefaultHttpContext Context, IGridPreferenceStore Store) CreateStore( + string? userId = "user-1", string? cookieHeader = null, string pathBase = "/app") + { + var context = new DefaultHttpContext(); + context.Request.PathBase = pathBase; + context.Request.Path = "/customers"; + context.Request.Headers.Cookie = cookieHeader; + var user = new Mock(); + user.SetupGet(u => u.Id).Returns(userId!); + var store = new CookieGridPreferenceStore(new HttpContextAccessor { HttpContext = context }, user.Object, _protection); + return (context, store); + } + + private static IList GetCookies(DefaultHttpContext context) => + SetCookieHeaderValue.ParseList(context.Response.Headers.SetCookie.Select(value => value!).ToArray()); + + private static string GetRequestCookies(DefaultHttpContext context) => + string.Join("; ", GetCookies(context) + .GroupBy(cookie => cookie.Name.ToString()) + .Select(group => group.Last()) + .Select(cookie => $"{cookie.Name}={cookie.Value}")); +} diff --git a/test/MasterData.Core.Test/UI/Components/GridPreferencesTests.cs b/test/MasterData.Core.Test/UI/Components/GridPreferencesTests.cs new file mode 100644 index 000000000..4d20b33e7 --- /dev/null +++ b/test/MasterData.Core.Test/UI/Components/GridPreferencesTests.cs @@ -0,0 +1,149 @@ +using JJMasterData.Commons.Security; +using JJMasterData.Core.DataDictionary.Models; +using JJMasterData.Core.DataDictionary.Models.Actions; +using JJMasterData.Core.Extensions; +using JJMasterData.Core.UI.Components; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using Moq; + +namespace JJMasterData.Core.Test.UI.Components; + +public class GridPreferencesTests +{ + [Fact] + public void CustomGridName_UsesOneDocumentAndPreservesOtherPreferences() + { + var saved = new GridPreferences + { + Settings = new GridSettings { RecordsPerPage = 25 }, + Filter = new() { ["Name"] = "Alice" }, + Order = "Name asc" + }; + var store = new Mock(MockBehavior.Strict); + store.Setup(s => s.Get("secondary")).Returns(saved); + store.Setup(s => s.Set("secondary", It.IsAny())); + var (grid, factory, _) = CreateGrid(store.Object); + grid.Name = "secondary"; + + factory.SetGridUiOptions(grid, new GridUI { MaintainValuesOnLoad = true }); + grid.CurrentPage = 4; + + Assert.Same(saved.Settings, grid.CurrentSettings); + store.Verify(s => s.Get("secondary"), Times.Exactly(2)); + store.Verify(s => s.Set("secondary", It.Is(p => + p.CurrentPage == 4 && p.Settings == saved.Settings && p.Filter == saved.Filter && p.Order == "Name asc")), Times.Once); + store.VerifyNoOtherCalls(); + } + + [Fact] + public void Factory_RestoresSavedSettingsWithoutWritingDefaults() + { + var store = new Mock(MockBehavior.Strict); + var saved = new GridSettings { RecordsPerPage = 25, IsCompact = false }; + store.Setup(s => s.Get("customers")).Returns(new GridPreferences { Settings = saved }); + var (grid, factory, _) = CreateGrid(store.Object); + + factory.SetGridUiOptions(grid, new GridUI { MaintainValuesOnLoad = true, RecordsPerPage = 10 }); + + Assert.Same(saved, grid.CurrentSettings); + store.Verify(s => s.Get("customers"), Times.Once); + store.VerifyNoOtherCalls(); + } + + [Fact] + public void Factory_UsesDictionaryDefaultsWithoutPersistingThem() + { + var store = new Mock(MockBehavior.Strict); + store.Setup(s => s.Get("customers")).Returns((GridPreferences?)null); + var (grid, factory, _) = CreateGrid(store.Object); + + factory.SetGridUiOptions(grid, new GridUI { MaintainValuesOnLoad = true, RecordsPerPage = 10, ShowBorder = true }); + + Assert.Equal(10, grid.CurrentSettings.RecordsPerPage); + Assert.True(grid.CurrentSettings.ShowBorder); + store.Verify(s => s.Get("customers"), Times.Once); + store.VerifyNoOtherCalls(); + } + + [Fact] + public void DisabledPreferences_DoNotReadOrWriteStore() + { + var store = new Mock(MockBehavior.Strict); + var (grid, factory, _) = CreateGrid(store.Object); + factory.SetGridUiOptions(grid, new GridUI { MaintainValuesOnLoad = false, RecordsPerPage = 10 }); + Assert.Equal(10, grid.CurrentSettings.RecordsPerPage); + grid.CurrentSettings = new GridSettings { RecordsPerPage = 20 }; + Assert.Equal(20, grid.CurrentSettings.RecordsPerPage); + grid.CurrentPage = 3; + Assert.Equal(3, grid.CurrentPage); + store.VerifyNoOtherCalls(); + } + + [Fact] + public void ConfigAction_PersistsSubmittedDisplaySettings() + { + var store = new Mock(); + var (grid, factory, context) = CreateGrid(store.Object); + var protection = grid.DataProtectionService; + var actionMap = protection.ProtectObject(new ActionMap + { + ElementName = "Customers", ActionName = ConfigAction.ActionName, ActionSource = ActionSource.GridToolbar + }); + context.Request.ContentType = "application/x-www-form-urlencoded"; + context.Request.Form = new FormCollection(new Dictionary + { + ["grid-view-action-map-customers"] = actionMap, + ["customers-table-regperpage"] = "25", + ["customers-table-totalpagebuttons"] = "5", + ["customers-table-rowstriped"] = "1", + ["customers-table-is-compact"] = "1" + }); + + factory.SetGridUiOptions(grid, new GridUI { MaintainValuesOnLoad = true }); + Assert.Equal(25, grid.CurrentSettings.RecordsPerPage); + store.Verify(s => s.Set("customers", It.Is(preferences => + preferences.Settings != null && preferences.Settings.RecordsPerPage == 25 && + preferences.Settings.ShowRowStriped && preferences.Settings.IsCompact)), Times.Once); + } + + [Fact] + public void PostedPage_TakesPrecedenceOverStoredPage() + { + var store = new Mock(MockBehavior.Strict); + store.Setup(s => s.Get("customers")).Returns((GridPreferences?)null); + store.Setup(s => s.Set("customers", It.Is(p => p.CurrentPage == 4))); + var (grid, _, context) = CreateGrid(store.Object); + grid.MaintainValuesOnLoad = true; + context.Request.ContentType = "application/x-www-form-urlencoded"; + context.Request.Form = new FormCollection(new Dictionary { ["grid-view-page-customers"] = "4" }); + Assert.Equal(4, grid.CurrentPage); + store.Verify(s => s.Get("customers"), Times.Once); + store.Verify(s => s.Set("customers", It.Is(p => p.CurrentPage == 4)), Times.Once); + store.VerifyNoOtherCalls(); + } + + [Fact] + public void FirstLoad_RestoresPageFromCustomStore() + { + var store = new Mock(); + store.Setup(s => s.Get("customers")).Returns(new GridPreferences { CurrentPage = 3 }); + var (grid, _, _) = CreateGrid(store.Object); + grid.MaintainValuesOnLoad = true; + Assert.Equal(3, grid.CurrentPage); + store.Verify(s => s.Get("customers"), Times.Once); + } + + private static (JJGridView Grid, GridViewFactory Factory, DefaultHttpContext Context) CreateGrid(IGridPreferenceStore store) + { + var context = new DefaultHttpContext(); + var accessor = new HttpContextAccessor { HttpContext = context }; + var element = new FormElement { Name = "Customers" }; + var grid = new JJGridView(element, accessor, null!, new DataProtectionService(new EphemeralDataProtectionProvider()), null!, null!, null!, null!, null!, null!, + null!, null!, null!, null!, null!, store); + var factory = new GridViewFactory(accessor, null!, null!, null!, null!, null!, null!, null!, null!, + null!, null!, null!, null!, null!, null!, null!, store); + return (grid, factory, context); + } +} diff --git a/test/MasterData.Core.Test/UI/Components/SessionGridPreferenceStoreTests.cs b/test/MasterData.Core.Test/UI/Components/SessionGridPreferenceStoreTests.cs new file mode 100644 index 000000000..3be59bb4e --- /dev/null +++ b/test/MasterData.Core.Test/UI/Components/SessionGridPreferenceStoreTests.cs @@ -0,0 +1,207 @@ +using JJMasterData.Core.Extensions; +using System.Diagnostics.CodeAnalysis; +using JJMasterData.Core.DataManager; +using JJMasterData.Core.UI.Components; +using Microsoft.AspNetCore.Http; +using Moq; + +namespace JJMasterData.Core.Test.UI.Components; + +public class SessionGridPreferenceStoreTests +{ + [Fact] + public void RepeatedReads_ReuseDeserializedPreferencesWithinTheRequest() + { + var session = new MemorySession(); + var (_, writer) = CreateStore(session); + writer.Set("Customers", new GridPreferences { CurrentPage = 3 }); + var (_, reader) = CreateStore(session); + + var preferences = reader.Get("Customers"); + + Assert.NotNull(preferences); + Assert.Equal(3, preferences.CurrentPage); + Assert.Same(preferences, reader.Get("CUSTOMERS")); + reader.Set("Customers", new GridPreferences { CurrentPage = 4 }); + var updated = reader.Get("Customers"); + Assert.NotSame(preferences, updated); + Assert.Same(updated, reader.Get("CUSTOMERS")); + Assert.Equal(4, updated!.CurrentPage); + } + + [Fact] + public void DirectSessionChanges_AppearOnNextRequest() + { + var session = new MemorySession(); + var (_, store) = CreateStore(session); + store.Set("Customers", new GridPreferences { CurrentPage = 3 }); + var original = store.Get("Customers"); + + session.SetString(Assert.Single(session.Keys), "{\"CurrentPage\":4}"); + + Assert.Same(original, store.Get("Customers")); + var (_, next) = CreateStore(session); + var updated = next.Get("Customers"); + Assert.NotNull(updated); + Assert.NotSame(original, updated); + Assert.Equal(4, updated.CurrentPage); + Assert.Same(updated, next.Get("CUSTOMERS")); + } + + [Fact] + public void Preferences_RoundTripAcrossRequestsInTheSameSession() + { + var session = new MemorySession(); + var (context, store) = CreateStore(session); + store.SetSettings("Customers", new GridSettings { RecordsPerPage = 25, IsCompact = false }); + store.SetFilter("Customers", new() { ["Name"] = "Alice", ["Count"] = 3 }); + store.SetOrder("Customers", "Name asc"); + store.SetCurrentPage("Customers", 3); + + var (_, next) = CreateStore(session); + var settings = next.GetSettings("CUSTOMERS"); + Assert.NotNull(settings); + Assert.Equal(25, settings.RecordsPerPage); + Assert.False(settings.IsCompact); + Assert.Equal("Alice", next.GetFilter("CUSTOMERS")!["Name"]); + Assert.Equal("3", next.GetFilter("CUSTOMERS")!["Count"]?.ToString()); + Assert.Equal("Name asc", next.GetOrder("CUSTOMERS")); + Assert.Equal(3, next.GetCurrentPage("CUSTOMERS")); + Assert.Equal(0, context.Response.Headers.SetCookie.Count); + Assert.Single(session.Keys); + } + + [Fact] + public void NewSession_DoesNotRestorePreviousPreferences() + { + var (_, store) = CreateStore(new MemorySession()); + SavePreferences(store); + var (_, next) = CreateStore(new MemorySession()); + AssertNoPreferences(next); + } + + [Fact] + public void ClearingSession_DiscardsPreferences() + { + var session = new MemorySession(); + var (_, store) = CreateStore(session); + SavePreferences(store); + session.Clear(); + var (_, next) = CreateStore(session); + AssertNoPreferences(next); + } + + [Fact] + public void Preferences_AreIsolatedByGrid() + { + var session = new MemorySession(); + var (_, store) = CreateStore(session); + SavePreferences(store); + var (_, other) = CreateStore(session); + AssertNoPreferences(other, "Orders"); + } + + [Fact] + public void WritesAndRemovals_AreVisibleWithinTheRequest() + { + var session = new MemorySession(); + var (_, store) = CreateStore(session); + SavePreferences(store); + store.SetSettings("Customers", new GridSettings { RecordsPerPage = 50 }); + Assert.Equal(50, store.GetSettings("Customers")!.RecordsPerPage); + store.SetSettings("Customers", null); + store.SetFilter("Customers", null); + store.SetOrder("Customers", null); + store.SetCurrentPage("Customers", null); + AssertNoPreferences(store); + Assert.Empty(session.Keys); + } + + [Fact] + public void EmptyNavigationValues_RemoveSavedPreferences() + { + var session = new MemorySession(); + var (_, store) = CreateStore(session); + SavePreferences(store); + store.SetFilter("Customers", new()); + store.SetOrder("Customers", ""); + store.SetCurrentPage("Customers", 0); + Assert.Null(store.GetFilter("Customers")); + Assert.Null(store.GetOrder("Customers")); + Assert.Null(store.GetCurrentPage("Customers")); + Assert.Single(session.Keys); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void MissingUser_DoesNotReadOrWriteSession(string? userId) + { + var session = new MemorySession(); + var (_, store) = CreateStore(session, userId); + SavePreferences(store); + AssertNoPreferences(store); + Assert.Empty(session.Keys); + } + + [Fact] + public void MissingHttpContext_DoesNotReadOrWriteSession() + { + var user = new Mock(); + user.SetupGet(u => u.Id).Returns("user-1"); + var store = new SessionGridPreferenceStore(new HttpContextAccessor { HttpContext = null }, user.Object); + SavePreferences(store); + AssertNoPreferences(store); + } + + [Fact] + public void CorruptSessionValues_AreIgnored() + { + var session = new MemorySession(); + var (_, store) = CreateStore(session); + SavePreferences(store); + foreach (var key in session.Keys.ToArray()) + session.SetString(key, "not-json"); + var (_, next) = CreateStore(session); + AssertNoPreferences(next); + } + + private static void SavePreferences(SessionGridPreferenceStore store) + { + store.SetSettings("Customers", new GridSettings()); + store.SetFilter("Customers", new() { ["Name"] = "Alice" }); + store.SetOrder("Customers", "Name asc"); + store.SetCurrentPage("Customers", 3); + } + + private static void AssertNoPreferences(SessionGridPreferenceStore store, string gridName = "Customers") + { + Assert.Null(store.GetSettings(gridName)); + Assert.Null(store.GetFilter(gridName)); + Assert.Null(store.GetOrder(gridName)); + Assert.Null(store.GetCurrentPage(gridName)); + } + + private static (DefaultHttpContext Context, SessionGridPreferenceStore Store) CreateStore(ISession session, string? userId = "user-1") + { + var context = new DefaultHttpContext { Session = session }; + var user = new Mock(); + user.SetupGet(u => u.Id).Returns(userId!); + return (context, new SessionGridPreferenceStore(new HttpContextAccessor { HttpContext = context }, user.Object)); + } + + private sealed class MemorySession : ISession + { + private readonly Dictionary _values = new(); + public bool IsAvailable => true; + public string Id { get; } = Guid.NewGuid().ToString(); + public IEnumerable Keys => _values.Keys; + public void Clear() => _values.Clear(); + public void Remove(string key) => _values.Remove(key); + public void Set(string key, byte[] value) => _values[key] = value; + public bool TryGetValue(string key, [NotNullWhen(true)] out byte[]? value) => _values.TryGetValue(key, out value); + public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + } +} From d3d0f7c61e891c1df798c4830a677524d3c9d1fc Mon Sep 17 00:00:00 2001 From: gumbarros Date: Wed, 21 Oct 2026 17:03:49 -0300 Subject: [PATCH 2/2] Update version --- Directory.Build.props | 2 +- .../GridView/Preferences/CookieGridPreferenceStore.cs | 2 +- .../GridView/Preferences/SessionGridPreferenceStore.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 175ec7b8f..2236d1936 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ https://www.github.com/JJConsulting/JJMasterData https://www.github.com/JJConsulting/JJMasterData JJMasterData.png - 5.0.14 + 5.0.15 $(VersionPrefix) $(VersionPrefix)-$(VersionSuffix) $(Version) diff --git a/src/MasterData.Core/UI/Components/GridView/Preferences/CookieGridPreferenceStore.cs b/src/MasterData.Core/UI/Components/GridView/Preferences/CookieGridPreferenceStore.cs index 6a5b40e0c..0b6adf38a 100644 --- a/src/MasterData.Core/UI/Components/GridView/Preferences/CookieGridPreferenceStore.cs +++ b/src/MasterData.Core/UI/Components/GridView/Preferences/CookieGridPreferenceStore.cs @@ -96,6 +96,6 @@ private static string GetKey(string user, string gridName) private IDataProtector GetProtector(string key) { - return protectionProvider.CreateProtector("JJMasterData.GridPreferences.v2", key); + return protectionProvider.CreateProtector("JJMasterData.GridPreferences", key); } } diff --git a/src/MasterData.Core/UI/Components/GridView/Preferences/SessionGridPreferenceStore.cs b/src/MasterData.Core/UI/Components/GridView/Preferences/SessionGridPreferenceStore.cs index 9d8e21e49..2b0b5ee93 100644 --- a/src/MasterData.Core/UI/Components/GridView/Preferences/SessionGridPreferenceStore.cs +++ b/src/MasterData.Core/UI/Components/GridView/Preferences/SessionGridPreferenceStore.cs @@ -53,5 +53,5 @@ public void Set(string gridName, GridPreferences? preferences) _values[key] = preferences; } - private static string GetKey(string gridName) => ".JJMasterData.Grid.v2." + gridName.ToLowerInvariant(); + private static string GetKey(string gridName) => ".JJMasterData.Grid." + gridName.ToLowerInvariant(); }