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/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..0b6adf38a
--- /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", 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..2b0b5ee93
--- /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." + 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;
+ }
+}