diff --git a/Directory.Packages.props b/Directory.Packages.props index f22ad1651..510f84898 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,7 +9,7 @@ - + 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() { diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Page/List.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Page/List.cshtml index 76dfa92c5..060c3bedb 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Page/List.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Page/List.cshtml @@ -1,4 +1,5 @@ -@model PageListModel +@inject AdminAreaSettings adminAreaSettings +@model PageListModel @{ //page title ViewBag.Title = Loc["Admin.Content.Pages"]; @@ -77,15 +78,14 @@ // Cancel the changes this.cancelChanges(); }, + pageSize: @(adminAreaSettings.DefaultGridPageSize), serverPaging: true, serverFiltering: true, serverSorting: true }, pageable: { refresh: true, - numeric: false, - previousNext: false, - info: false + pageSizes: [@(adminAreaSettings.GridPageSizes)] }, editable: { confirmation: false, @@ -97,12 +97,9 @@ title: "@Loc["Admin.Content.Pages.Fields.SystemName"]", template: '#=SystemName#', }, { - field: "IsPasswordProtected", - title: "@Loc["Admin.Content.Pages.Fields.IsPasswordProtected"]", - width: 100, - headerAttributes: { style: "text-align:center" }, - attributes: { style: "text-align:center" }, - template: '# if(IsPasswordProtected) {# #} else {# #} #' + field: "Title", + title: "@Loc["Admin.Content.Pages.Fields.Title"]", + template: '#=kendo.htmlEncode(Title == null ? "" : Title)#', }, { field: "IncludeInMenu", title: "@Loc["Admin.Content.Pages.Fields.IncludeInMenu"]", @@ -110,6 +107,13 @@ headerAttributes: { style: "text-align:center" }, attributes: { style: "text-align:center" }, template: '# if(IncludeInMenu) {# #} else {# #} #' + }, { + field: "Published", + title: "@Loc["Admin.Content.Pages.Fields.Published"]", + width: 100, + headerAttributes: { style: "text-align:center" }, + attributes: { style: "text-align:center" }, + template: '# if(Published) {# #} else {# #} #' }, { field: "DisplayOrder", title: "@Loc["Admin.Content.Pages.Fields.DisplayOrder"]", diff --git a/src/Web/Grand.Web.Admin/Controllers/PageController.cs b/src/Web/Grand.Web.Admin/Controllers/PageController.cs index 01ae93cf3..e5e91301e 100644 --- a/src/Web/Grand.Web.Admin/Controllers/PageController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/PageController.cs @@ -75,9 +75,11 @@ public async Task List(DataSourceRequest command, PageListModel m (x.Title != null && x.Title.ToLowerInvariant().Contains(model.Name.ToLowerInvariant()))).ToList(); //"Error during serialization or deserialization using the JSON JavaScriptSerializer. The length of the string exceeds the value set on the maxJsonLength property. " foreach (var page in pageModels) page.Body = ""; + var total = pageModels.Count; + var pagedData = pageModels.Skip((command.Page - 1) * command.PageSize).Take(command.PageSize).ToList(); var gridModel = new DataSourceResult { - Data = pageModels, - Total = pageModels.Count + Data = pagedData, + Total = total }; return Json(gridModel); 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..4262faa6d --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Edit.cshtml @@ -0,0 +1,60 @@ +@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)) + { + + } + + + @if (ViewBag.ShowCopyButton == true) + { + + } + + @Loc["Admin.Common.Delete"] + +
+
+
+
+ +
+
+
+
+
+ +@if (ViewBag.ShowCopyButton == true) +{ +
+ +
+} 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..1e9f4124c --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Page/List.cshtml @@ -0,0 +1,166 @@ +@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..1af17dfb3 --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/PageController.cs @@ -0,0 +1,283 @@ +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 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 StorePagesList(DataSourceRequest command, PageListModel model) + { + var storeId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + var pages = await _pageService.GetAllPages(storeId, true); + + // Store-specific: exclusively assigned to this one store + var pageModels = pages + .Where(x => x.LimitedToStores && x.Stores.Count == 1) + .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 total = pageModels.Count; + var pagedData = pageModels.Skip((command.Page - 1) * command.PageSize).Take(command.PageSize).ToList(); + return Json(new DataSourceResult { Data = pagedData, Total = total }); + } + + [PermissionAuthorizeAction(PermissionActionName.List)] + [HttpPost] + public async Task GlobalPagesList(DataSourceRequest command, PageListModel model) + { + var storeId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + var pages = await _pageService.GetAllPages(storeId, true); + + // Global: no store restriction, or shared across multiple stores + var pageModels = pages + .Where(x => !x.LimitedToStores || x.Stores.Count > 1) + .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 total = pageModels.Count; + var pagedData = pageModels.Skip((command.Page - 1) * command.PageSize).Take(command.PageSize).ToList(); + return Json(new DataSourceResult { Data = pagedData, Total = total }); + } + + #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.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); + 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) }, Request.Scheme); + 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]; + model.CustomerGroups = page.CustomerGroups.ToArray(); + 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"); + + await _pageViewModelService.DeletePage(page); + Success(_translationService.GetResource("Admin.Content.Pages.Deleted")); + return RedirectToAction("List"); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [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"); + + if (!page.AccessToEntityByStore(storeId)) + 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(_translationService.GetResource("Admin.Content.Pages.Copy.DuplicateSystemName")); + 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 +} diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index 743f71b17..4ed1399d6 100644 Binary files a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml and b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml differ