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; +} +
+ +
+
+
+
+
+ + @Loc["Admin.Content.Pages.AddNew"] + + @Html.ActionLink(Loc["Admin.Content.Pages.BackToList"], "List") + +
+
+
+ + +
+
+
+
+ +
+
+
+
+
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; +} +
+ +
+
+
+
+
+ + @Loc["Admin.Content.Pages.EditPageDetails"] - @Model.SystemName + + @Html.ActionLink(Loc["Admin.Content.Pages.BackToList"], "List") + +
+
+
+ @if (!string.IsNullOrEmpty(Model.SeName)) + { + + } + + + + @Loc["Admin.Common.Delete"] + +
+
+
+
+ +
+
+
+
+
+ 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; +} + +
+
+
+
+
+ + @Loc["Admin.Content.Pages"] +
+ +
+
+
+
+
+
+
+ + +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ + + + 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 = @
+
+ +
+ + +
+
+
+ +
+ + +
+
+ +
; +} + +
+ +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ +
+
+ +
+ + +
+
+ @if (!string.IsNullOrEmpty(Model.Id)) + { +
+ +
+ +
+
+ } +
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
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 @@