From 747506bc54cb820f67dfb0db5b417c146208f068 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 10 May 2026 18:04:31 +0000
Subject: [PATCH 01/17] feat: add Pages (Topics) management to Grand.Web.Store
- Add PageController with CRUD operations, filtering by store, and
automatic storeId assignment on create/edit
- Add List, Create, Edit views with Partials (TabInfo, TabSeo)
- Add Grand.Web.AdminShared.Models.Pages namespace to _ViewImports.cshtml"
Agent-Logs-Url: https://github.com/grandnode/grandnode2/sessions/e126ff73-2407-4da7-ae12-fd982e28a981
Co-authored-by: KrzysztofPajak <16772986+KrzysztofPajak@users.noreply.github.com>
---
.../Areas/Store/Views/Page/Create.cshtml | 37 +++
.../Areas/Store/Views/Page/Edit.cshtml | 48 ++++
.../Areas/Store/Views/Page/List.cshtml | 141 +++++++++++
.../Partials/CreateOrUpdate.TabInfo.cshtml | 193 +++++++++++++++
.../Partials/CreateOrUpdate.TabSeo.cshtml | 72 ++++++
.../Views/Page/Partials/CreateOrUpdate.cshtml | 22 ++
.../Areas/Store/Views/_ViewImports.cshtml | 1 +
.../Controllers/PageController.cs | 222 ++++++++++++++++++
8 files changed, 736 insertions(+)
create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Page/Create.cshtml
create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Page/Edit.cshtml
create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Page/List.cshtml
create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Page/Partials/CreateOrUpdate.TabInfo.cshtml
create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Page/Partials/CreateOrUpdate.TabSeo.cshtml
create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Page/Partials/CreateOrUpdate.cshtml
create mode 100644 src/Web/Grand.Web.Store/Controllers/PageController.cs
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Create.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Create.cshtml
new file mode 100644
index 000000000..26b02c71f
--- /dev/null
+++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Create.cshtml
@@ -0,0 +1,37 @@
+@model PageModel
+@{
+ //page title
+ ViewBag.Title = Loc["Admin.Content.Pages.AddNew"];
+ Layout = Constants.LayoutStore;
+}
+
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Edit.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Edit.cshtml
new file mode 100644
index 000000000..002c0eef4
--- /dev/null
+++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Edit.cshtml
@@ -0,0 +1,48 @@
+@model PageModel
+@{
+ //page title
+ ViewBag.Title = Loc["Admin.Content.Pages.EditPageDetails"];
+ Layout = Constants.LayoutStore;
+}
+
+
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Page/List.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Page/List.cshtml
new file mode 100644
index 000000000..480e8d093
--- /dev/null
+++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Page/List.cshtml
@@ -0,0 +1,141 @@
+@model PageListModel
+@inject AdminAreaSettings adminAreaSettings
+@{
+ //page title
+ ViewBag.Title = Loc["Admin.Content.Pages"];
+ Layout = Constants.LayoutStore;
+}
+
+
+
+
+
+
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Partials/CreateOrUpdate.TabInfo.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Partials/CreateOrUpdate.TabInfo.cshtml
new file mode 100644
index 000000000..a090bfe7d
--- /dev/null
+++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Partials/CreateOrUpdate.TabInfo.cshtml
@@ -0,0 +1,193 @@
+@using Microsoft.AspNetCore.Mvc.Razor
+@model PageModel
+
+
+
+@{
+ Func
+ template = @;
+}
+
+
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Partials/CreateOrUpdate.TabSeo.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Partials/CreateOrUpdate.TabSeo.cshtml
new file mode 100644
index 000000000..0c7e64b27
--- /dev/null
+++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Partials/CreateOrUpdate.TabSeo.cshtml
@@ -0,0 +1,72 @@
+@using Microsoft.AspNetCore.Mvc.Razor
+@model PageModel
+
+@{
+ Func
+ template = @;
+}
+
+
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Partials/CreateOrUpdate.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Partials/CreateOrUpdate.cshtml
new file mode 100644
index 000000000..8ceb8a57e
--- /dev/null
+++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Partials/CreateOrUpdate.cshtml
@@ -0,0 +1,22 @@
+@model PageModel
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/_ViewImports.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/_ViewImports.cshtml
index bb0768c2b..af197fbf1 100644
--- a/src/Web/Grand.Web.Store/Areas/Store/Views/_ViewImports.cshtml
+++ b/src/Web/Grand.Web.Store/Areas/Store/Views/_ViewImports.cshtml
@@ -32,6 +32,7 @@
@using Grand.Web.AdminShared.Models.News
@using Grand.Web.AdminShared.Models.Blogs
@using Grand.Web.AdminShared.Models.Messages
+@using Grand.Web.AdminShared.Models.Pages
@inject LocService Loc
@inject IEnumTranslationService EnumTranslationService
\ No newline at end of file
diff --git a/src/Web/Grand.Web.Store/Controllers/PageController.cs b/src/Web/Grand.Web.Store/Controllers/PageController.cs
new file mode 100644
index 000000000..418bf4b44
--- /dev/null
+++ b/src/Web/Grand.Web.Store/Controllers/PageController.cs
@@ -0,0 +1,222 @@
+using Grand.Business.Core.Extensions;
+using Grand.Business.Core.Interfaces.Cms;
+using Grand.Business.Core.Interfaces.Common.Directory;
+using Grand.Business.Core.Interfaces.Common.Localization;
+using Grand.Domain.Permissions;
+using Grand.Infrastructure;
+using Grand.Web.AdminShared.Extensions;
+using Grand.Web.AdminShared.Extensions.Mapping;
+using Grand.Web.AdminShared.Interfaces;
+using Grand.Web.AdminShared.Models.Pages;
+using Grand.Web.Common.DataSource;
+using Grand.Web.Common.Filters;
+using Grand.Web.Common.Security.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Grand.Web.Store.Controllers;
+
+[PermissionAuthorize(PermissionSystemName.Pages)]
+public class PageController : BaseStoreController
+{
+ #region Constants
+
+ private const string NoAccessToPageMessage = "You don't have access to this page";
+
+ #endregion
+
+ #region Constructors
+
+ public PageController(
+ IPageViewModelService pageViewModelService,
+ IPageService pageService,
+ ILanguageService languageService,
+ ITranslationService translationService,
+ IContextAccessor contextAccessor,
+ IDateTimeService dateTimeService)
+ {
+ _pageViewModelService = pageViewModelService;
+ _pageService = pageService;
+ _languageService = languageService;
+ _translationService = translationService;
+ _contextAccessor = contextAccessor;
+ _dateTimeService = dateTimeService;
+ }
+
+ #endregion
+
+ #region Fields
+
+ private readonly IPageViewModelService _pageViewModelService;
+ private readonly IPageService _pageService;
+ private readonly ILanguageService _languageService;
+ private readonly ITranslationService _translationService;
+ private readonly IContextAccessor _contextAccessor;
+ private readonly IDateTimeService _dateTimeService;
+
+ #endregion
+
+ #region List
+
+ public IActionResult Index()
+ {
+ return RedirectToAction("List");
+ }
+
+ public IActionResult List()
+ {
+ return View();
+ }
+
+ [PermissionAuthorizeAction(PermissionActionName.List)]
+ [HttpPost]
+ public async Task List(DataSourceRequest command, PageListModel model)
+ {
+ var storeId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId;
+ var pages = await _pageService.GetAllPages(storeId, true);
+ var pageModels = pages
+ .Select(x => x.ToModel(_dateTimeService))
+ .ToList();
+
+ if (!string.IsNullOrEmpty(model.Name))
+ pageModels = pageModels.Where(x =>
+ x.SystemName.ToLowerInvariant().Contains(model.Name.ToLowerInvariant()) ||
+ (x.Title != null && x.Title.ToLowerInvariant().Contains(model.Name.ToLowerInvariant()))).ToList();
+
+ foreach (var page in pageModels) page.Body = "";
+
+ var gridModel = new DataSourceResult {
+ Data = pageModels,
+ Total = pageModels.Count
+ };
+ return Json(gridModel);
+ }
+
+ #endregion
+
+ #region Create / Edit / Delete
+
+ [PermissionAuthorizeAction(PermissionActionName.Create)]
+ public async Task Create()
+ {
+ ViewBag.AllLanguages = await _languageService.GetAllLanguages(true);
+ var model = new PageModel {
+ DisplayOrder = 1,
+ Published = true
+ };
+ await _pageViewModelService.PrepareLayoutsModel(model);
+ await AddLocales(_languageService, model.Locales);
+ return View(model);
+ }
+
+ [PermissionAuthorizeAction(PermissionActionName.Edit)]
+ [HttpPost]
+ [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")]
+ public async Task Create(PageModel model, bool continueEditing)
+ {
+ if (ModelState.IsValid)
+ {
+ model.Stores = [_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId];
+ var page = await _pageViewModelService.InsertPageModel(model);
+ Success(_translationService.GetResource("Admin.Content.Pages.Added"));
+ return continueEditing ? RedirectToAction("Edit", new { id = page.Id }) : RedirectToAction("List");
+ }
+
+ //If we got this far, something failed, redisplay form
+ ViewBag.AllLanguages = await _languageService.GetAllLanguages(true);
+ await _pageViewModelService.PrepareLayoutsModel(model);
+ return View(model);
+ }
+
+ [PermissionAuthorizeAction(PermissionActionName.Preview)]
+ public async Task Edit(string id)
+ {
+ var page = await _pageService.GetPageById(id);
+ if (page == null)
+ return RedirectToAction("List");
+
+ if (!page.LimitedToStores || (page.LimitedToStores &&
+ page.Stores.Contains(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) &&
+ page.Stores.Count > 1))
+ {
+ Warning(_translationService.GetResource("Admin.Content.Pages.Permissions"));
+ }
+ else
+ {
+ if (!page.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId))
+ return RedirectToAction("List");
+ }
+
+ ViewBag.AllLanguages = await _languageService.GetAllLanguages(true);
+ var model = page.ToModel(_dateTimeService);
+ model.Url = Url.RouteUrl("Page", new { SeName = page.GetSeName(_contextAccessor.WorkContext.WorkingLanguage.Id) }, "http");
+ await _pageViewModelService.PrepareLayoutsModel(model);
+ await AddLocales(_languageService, model.Locales, (locale, languageId) =>
+ {
+ locale.Title = page.GetTranslation(x => x.Title, languageId, false);
+ locale.Body = page.GetTranslation(x => x.Body, languageId, false);
+ locale.MetaKeywords = page.GetTranslation(x => x.MetaKeywords, languageId, false);
+ locale.MetaDescription = page.GetTranslation(x => x.MetaDescription, languageId, false);
+ locale.MetaTitle = page.GetTranslation(x => x.MetaTitle, languageId, false);
+ locale.SeName = page.GetSeName(languageId, false);
+ });
+ return View(model);
+ }
+
+ [PermissionAuthorizeAction(PermissionActionName.Edit)]
+ [HttpPost]
+ [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")]
+ public async Task Edit(PageModel model, bool continueEditing)
+ {
+ var page = await _pageService.GetPageById(model.Id);
+ if (page == null)
+ return RedirectToAction("List");
+
+ if (!page.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId))
+ return RedirectToAction("Edit", new { id = page.Id });
+
+ if (ModelState.IsValid)
+ {
+ model.Stores = [_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId];
+ page = await _pageViewModelService.UpdatePageModel(page, model);
+ Success(_translationService.GetResource("Admin.Content.Pages.Updated"));
+
+ if (continueEditing)
+ {
+ await SaveSelectedTabIndex();
+ return RedirectToAction("Edit", new { id = page.Id });
+ }
+
+ return RedirectToAction("List");
+ }
+
+ //If we got this far, something failed, redisplay form
+ ViewBag.AllLanguages = await _languageService.GetAllLanguages(true);
+ model.Url = Url.RouteUrl("Page", new { SeName = page.GetSeName(_contextAccessor.WorkContext.WorkingLanguage.Id) }, "http");
+ await _pageViewModelService.PrepareLayoutsModel(model);
+ return View(model);
+ }
+
+ [PermissionAuthorizeAction(PermissionActionName.Delete)]
+ [HttpPost]
+ public async Task Delete(string id)
+ {
+ var page = await _pageService.GetPageById(id);
+ if (page == null)
+ return RedirectToAction("List");
+
+ if (!page.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId))
+ return RedirectToAction("List");
+
+ if (ModelState.IsValid)
+ {
+ await _pageViewModelService.DeletePage(page);
+ Success(_translationService.GetResource("Admin.Content.Pages.Deleted"));
+ return RedirectToAction("List");
+ }
+
+ Error(ModelState);
+ return RedirectToAction("Edit", new { id });
+ }
+
+ #endregion
+}
From f5bbe6f78bf7eea1fb14b6486acfdf5058f6e9ec Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 10 May 2026 18:05:59 +0000
Subject: [PATCH 02/17] fix: address code review comments in PageController
- Remove unused NoAccessToPageMessage constant
- Simplify redundant LimitedToStores condition in Edit GET
- Remove unnecessary ModelState check in Delete action
Agent-Logs-Url: https://github.com/grandnode/grandnode2/sessions/e126ff73-2407-4da7-ae12-fd982e28a981
Co-authored-by: KrzysztofPajak <16772986+KrzysztofPajak@users.noreply.github.com>
---
.../Controllers/PageController.cs | 21 ++++---------------
1 file changed, 4 insertions(+), 17 deletions(-)
diff --git a/src/Web/Grand.Web.Store/Controllers/PageController.cs b/src/Web/Grand.Web.Store/Controllers/PageController.cs
index 418bf4b44..9bebfda14 100644
--- a/src/Web/Grand.Web.Store/Controllers/PageController.cs
+++ b/src/Web/Grand.Web.Store/Controllers/PageController.cs
@@ -18,12 +18,6 @@ namespace Grand.Web.Store.Controllers;
[PermissionAuthorize(PermissionSystemName.Pages)]
public class PageController : BaseStoreController
{
- #region Constants
-
- private const string NoAccessToPageMessage = "You don't have access to this page";
-
- #endregion
-
#region Constructors
public PageController(
@@ -134,8 +128,7 @@ public async Task Edit(string id)
if (page == null)
return RedirectToAction("List");
- if (!page.LimitedToStores || (page.LimitedToStores &&
- page.Stores.Contains(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) &&
+ if (!page.LimitedToStores || (page.Stores.Contains(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) &&
page.Stores.Count > 1))
{
Warning(_translationService.GetResource("Admin.Content.Pages.Permissions"));
@@ -207,15 +200,9 @@ public async Task Delete(string id)
if (!page.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId))
return RedirectToAction("List");
- if (ModelState.IsValid)
- {
- await _pageViewModelService.DeletePage(page);
- Success(_translationService.GetResource("Admin.Content.Pages.Deleted"));
- return RedirectToAction("List");
- }
-
- Error(ModelState);
- return RedirectToAction("Edit", new { id });
+ await _pageViewModelService.DeletePage(page);
+ Success(_translationService.GetResource("Admin.Content.Pages.Deleted"));
+ return RedirectToAction("List");
}
#endregion
From 7effd7cf5eb86b42ff6b3d8034db555bdc5fc780 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 11 May 2026 18:15:31 +0000
Subject: [PATCH 03/17] feat: add Copy button and action for
multistore/unrestricted pages
When a topic is multistore (LimitedToStores + multiple stores) or has no
store restriction, a "Copy" button is shown on the Edit page. Clicking
it creates a store-specific copy (all fields + locales preserved) for
the current store, guarded by a duplicate SystemName check. Redirects
to the new page's Edit view on success.
Agent-Logs-Url: https://github.com/grandnode/grandnode2/sessions/623ad074-c18a-43f8-9a53-00b7f6df747c
Co-authored-by: KrzysztofPajak <16772986+KrzysztofPajak@users.noreply.github.com>
---
.../Areas/Store/Views/Page/Edit.cshtml | 12 +++++
.../Controllers/PageController.cs | 44 +++++++++++++++++++
2 files changed, 56 insertions(+)
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Edit.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Edit.cshtml
index 002c0eef4..4262faa6d 100644
--- a/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Edit.cshtml
+++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Edit.cshtml
@@ -32,6 +32,12 @@
+ @if (ViewBag.ShowCopyButton == true)
+ {
+
+ }
@Loc["Admin.Common.Delete"]
@@ -46,3 +52,9 @@
+@if (ViewBag.ShowCopyButton == true)
+{
+
+}
diff --git a/src/Web/Grand.Web.Store/Controllers/PageController.cs b/src/Web/Grand.Web.Store/Controllers/PageController.cs
index 9bebfda14..b72bbbf0c 100644
--- a/src/Web/Grand.Web.Store/Controllers/PageController.cs
+++ b/src/Web/Grand.Web.Store/Controllers/PageController.cs
@@ -140,6 +140,7 @@ public async Task Edit(string id)
}
ViewBag.AllLanguages = await _languageService.GetAllLanguages(true);
+ ViewBag.ShowCopyButton = !page.LimitedToStores || page.Stores.Count > 1;
var model = page.ToModel(_dateTimeService);
model.Url = Url.RouteUrl("Page", new { SeName = page.GetSeName(_contextAccessor.WorkContext.WorkingLanguage.Id) }, "http");
await _pageViewModelService.PrepareLayoutsModel(model);
@@ -205,5 +206,48 @@ public async Task Delete(string id)
return RedirectToAction("List");
}
+ [PermissionAuthorizeAction(PermissionActionName.Create)]
+ [HttpPost]
+ public async Task Copy(string id)
+ {
+ var storeId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId;
+ var page = await _pageService.GetPageById(id);
+ if (page == null)
+ return RedirectToAction("List");
+
+ // Only allow copy for multistore or store-unrestricted topics
+ if (page.LimitedToStores && page.Stores.Count <= 1)
+ return RedirectToAction("Edit", new { id });
+
+ // Check if a page with the same SystemName already exists for the current store
+ var storePages = await _pageService.GetAllPages(storeId, true);
+ if (storePages.Any(p => p.Id != page.Id &&
+ p.SystemName.Equals(page.SystemName, StringComparison.OrdinalIgnoreCase)))
+ {
+ Error("A page with the same system name already exists for this store.");
+ return RedirectToAction("Edit", new { id });
+ }
+
+ // Build copy model from original page
+ var model = page.ToModel(_dateTimeService);
+ model.Id = "";
+ model.Stores = [storeId];
+
+ // Preserve localized content
+ await AddLocales(_languageService, model.Locales, (locale, languageId) =>
+ {
+ locale.Title = page.GetTranslation(x => x.Title, languageId, false);
+ locale.Body = page.GetTranslation(x => x.Body, languageId, false);
+ locale.MetaKeywords = page.GetTranslation(x => x.MetaKeywords, languageId, false);
+ locale.MetaDescription = page.GetTranslation(x => x.MetaDescription, languageId, false);
+ locale.MetaTitle = page.GetTranslation(x => x.MetaTitle, languageId, false);
+ locale.SeName = page.GetSeName(languageId, false);
+ });
+
+ var newPage = await _pageViewModelService.InsertPageModel(model);
+ Success(_translationService.GetResource("Admin.Content.Pages.Added"));
+ return RedirectToAction("Edit", new { id = newPage.Id });
+ }
+
#endregion
}
From 7f0366ba9ddb458ed6602c35c39b721591347701 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 11 May 2026 18:32:22 +0000
Subject: [PATCH 04/17] feat: add EN translations for Copy button and Pages
store-area actions
- admin.common.copy: Copy
- admin.content.pages.copy.duplicatesystemname: duplicate SystemName error
- admin.content.pages.permissions: multistore warning
- Update PageController to use translation resource instead of hardcoded string
Agent-Logs-Url: https://github.com/grandnode/grandnode2/sessions/e594e525-bd03-400b-9b1f-f634b62e6301
Co-authored-by: KrzysztofPajak <16772986+KrzysztofPajak@users.noreply.github.com>
---
.../Controllers/PageController.cs | 2 +-
.../App_Data/Resources/DefaultLanguage.xml | Bin 1501412 -> 1502284 bytes
2 files changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Web/Grand.Web.Store/Controllers/PageController.cs b/src/Web/Grand.Web.Store/Controllers/PageController.cs
index b72bbbf0c..226b3ca48 100644
--- a/src/Web/Grand.Web.Store/Controllers/PageController.cs
+++ b/src/Web/Grand.Web.Store/Controllers/PageController.cs
@@ -224,7 +224,7 @@ public async Task Copy(string id)
if (storePages.Any(p => p.Id != page.Id &&
p.SystemName.Equals(page.SystemName, StringComparison.OrdinalIgnoreCase)))
{
- Error("A page with the same system name already exists for this store.");
+ Error(_translationService.GetResource("Admin.Content.Pages.Copy.DuplicateSystemName"));
return RedirectToAction("Edit", new { id });
}
diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml
index 743f71b17a9d46b417435dda5aa1c53b2dcfa202..543a0e6436102b1dc117929e5e76467475ecb14a 100644
GIT binary patch
delta 384
zcmXAk&q@Me6veMPGv7FwR_YK$F{^M9ZF+!;7P)9KM4NEqG-(BvgJTP+g^*x>7DW@?
zHwXz@2!fjo7vjoAe;}$|&;$4gounZdcOky^aLrcCVvc52kD?l0y}mr)@ffD$pL;WKo%N{Ozf$x0|XY929m?
zXx^zwKKJ4pzdp;lcmF=a{ZuHyUuH~W^DJ(1^;D#IE-R-!^F-puhM455g1F%!iwLh-
zvfAFd5YcJYpMq&NEJ@+)x*l2Bpk3T`$v^`C6x9FQMaQBN<&e*SvZ0G`x@>)hlZ!5b
z7X7x1FBr7q+YkQI`s?;FQt=1#lt;~m-@rr}z8sbK@=ZDN!_}RJ04fLr4UB*Y7zI%f
T17lzuOn^8@fMnZg=;ftfTnBs$
delta 127
zcmX?eBl^k3=!O=?7N!>FEiA|OwioPU0b+kRs;I}mdKF((jn0Wmia^8hg~5c2^sKM)H5u^
Date: Mon, 11 May 2026 18:42:08 +0000
Subject: [PATCH 05/17] fix: handle null ReservedEntityUrlSlugs in
SeNameService.ValidateSeName
The property can be null when loaded from MongoDB in older installations.
Use null-conditional operator to avoid ArgumentNullException when Copy
(or any page insert) is triggered.
Also adds a regression test for the null-settings case.
Agent-Logs-Url: https://github.com/grandnode/grandnode2/sessions/e444e392-5111-4a1b-a6a1-297c8f3f5c98
Co-authored-by: KrzysztofPajak <16772986+KrzysztofPajak@users.noreply.github.com>
---
.../Services/Seo/SeNameService.cs | 2 +-
.../Services/Seo/SeNameServiceTests.cs | 22 +++++++++++++++++++
2 files changed, 23 insertions(+), 1 deletion(-)
diff --git a/src/Business/Grand.Business.Common/Services/Seo/SeNameService.cs b/src/Business/Grand.Business.Common/Services/Seo/SeNameService.cs
index b549f3678..ee249f850 100644
--- a/src/Business/Grand.Business.Common/Services/Seo/SeNameService.cs
+++ b/src/Business/Grand.Business.Common/Services/Seo/SeNameService.cs
@@ -43,7 +43,7 @@ public async Task ValidateSeName(T entity, string seName, string name
entityUrl.EntityName.Equals(entityName,
StringComparison.OrdinalIgnoreCase));
- var reserved2 = seoSettings.ReservedEntityUrlSlugs.Contains(tempSeName, StringComparer.OrdinalIgnoreCase);
+ var reserved2 = seoSettings.ReservedEntityUrlSlugs?.Contains(tempSeName, StringComparer.OrdinalIgnoreCase) ?? false;
var reserved3 = (await languageService.GetAllLanguages(true)).Any(language =>
language.UniqueSeoCode.Equals(tempSeName, StringComparison.OrdinalIgnoreCase));
diff --git a/src/Tests/Grand.Business.Common.Tests/Services/Seo/SeNameServiceTests.cs b/src/Tests/Grand.Business.Common.Tests/Services/Seo/SeNameServiceTests.cs
index d386a0f40..2f22c469d 100644
--- a/src/Tests/Grand.Business.Common.Tests/Services/Seo/SeNameServiceTests.cs
+++ b/src/Tests/Grand.Business.Common.Tests/Services/Seo/SeNameServiceTests.cs
@@ -35,6 +35,28 @@ public void Setup()
_seNameService = new SeNameService(_mockSlugService.Object, _mockLanguageService.Object, _seoSettings);
}
+ [Test]
+ public async Task ValidateSeName_NullReservedSlugs_DoesNotThrow()
+ {
+ // Arrange - simulate a MongoDB installation where ReservedEntityUrlSlugs was stored as null
+ var settingsWithNullSlugs = new SeoSettings {
+ ReservedEntityUrlSlugs = null,
+ ConvertNonWesternChars = false,
+ AllowUnicodeCharsInUrls = false,
+ AllowSlashChar = false,
+ SeoCharConversion = null
+ };
+ var serviceWithNullSettings = new SeNameService(
+ _mockSlugService.Object, _mockLanguageService.Object, settingsWithNullSlugs);
+
+ var entity = new TestEntity { Id = "123" };
+ _mockSlugService.Setup(s => s.GetBySlug(It.IsAny())).ReturnsAsync((EntityUrl)null);
+
+ // Act & Assert – should not throw ArgumentNullException
+ var result = await serviceWithNullSettings.ValidateSeName(entity, "my-page", "My Page", false);
+ ClassicAssert.AreEqual("my-page", result);
+ }
+
[Test]
public async Task ValidateSeName_ShouldReturnName_WhenSeNameIsEmpty()
{
From 7972cb6cdd20eaa8727c6c583d2c6b833f6fdcf3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 12 May 2026 03:12:04 +0000
Subject: [PATCH 06/17] =?UTF-8?q?feat:=20two-tab=20page=20list=20=E2=80=94?=
=?UTF-8?q?=20store-specific=20vs=20global/multistore=20pages?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Controller: replace single List POST with StorePagesList + GlobalPagesList POST actions
- StorePagesList: pages exclusively limited to this store (LimitedToStores && Stores.Count == 1)
- GlobalPagesList: global (no-store) or multistore pages (!LimitedToStores || Stores.Count > 1)
- List.cshtml: two-tab layout using admin-tabstrip with separate Kendo grids per tab
- DefaultLanguage.xml: add admin.content.pages.list.storepages and admin.content.pages.list.globalpages
Agent-Logs-Url: https://github.com/grandnode/grandnode2/sessions/5f8af9bf-18a7-4d55-b50b-0f6aa8fdf36f
Co-authored-by: KrzysztofPajak <16772986+KrzysztofPajak@users.noreply.github.com>
---
.../Areas/Store/Views/Page/List.cshtml | 116 +++++++++++-------
.../Controllers/PageController.cs | 34 ++++-
.../App_Data/Resources/DefaultLanguage.xml | Bin 1502284 -> 1502728 bytes
3 files changed, 99 insertions(+), 51 deletions(-)
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Page/List.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Page/List.cshtml
index 480e8d093..0155b2cf6 100644
--- a/src/Web/Grand.Web.Store/Areas/Store/Views/Page/List.cshtml
+++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Page/List.cshtml
@@ -40,7 +40,20 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -50,11 +63,40 @@