Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
747506b
feat: add Pages (Topics) management to Grand.Web.Store
Copilot May 10, 2026
f5bbe6f
fix: address code review comments in PageController
Copilot May 10, 2026
7effd7c
feat: add Copy button and action for multistore/unrestricted pages
Copilot May 11, 2026
7f0366b
feat: add EN translations for Copy button and Pages store-area actions
Copilot May 11, 2026
c0dbedf
fix: handle null ReservedEntityUrlSlugs in SeNameService.ValidateSeName
Copilot May 11, 2026
7972cb6
feat: two-tab page list — store-specific vs global/multistore pages
Copilot May 12, 2026
0093d26
fix: apply server-side pagination (Skip/Take) in StorePagesList and G…
Copilot May 12, 2026
052a00d
fix: apply server-side pagination in Grand.Web.Admin PageController L…
Copilot May 12, 2026
98c37d9
fix: restore pagination bar in Grand.Web.Admin Page list view
Copilot May 12, 2026
71b2789
Fix url.routeurl scheme
KrzysztofPajak May 12, 2026
ad4e17a
Potential fix for pull request finding
KrzysztofPajak May 12, 2026
214c5b1
Update Scryber.Core to version 9.5.0
KrzysztofPajak May 12, 2026
03a0688
Update Copy action to require Edit permission
KrzysztofPajak May 12, 2026
12d4189
Merge branch 'copilot/implement-page-management-ui' of https://github…
KrzysztofPajak May 12, 2026
990cc89
Potential fix for pull request finding
KrzysztofPajak May 12, 2026
a420d2e
Convert CustomerGroups to array; remove LimitedToGroups
KrzysztofPajak May 12, 2026
c424873
feat: widen Title column in Store list; add Title column to Admin list
Copilot May 12, 2026
3a642a4
feat: replace IsPasswordProtected with IncludeInMenu + Published in A…
Copilot May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
<PackageVersion Include="Microsoft.FeatureManagement.AspNetCore" Version="4.5.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.14.5" />
<PackageVersion Include="Scryber.Core" Version="9.3.1" />
<PackageVersion Include="Scryber.Core" Version="9.5.0" />
<PackageVersion Include="Scryber.Core.OpenType" Version="9.2.0" />
<PackageVersion Include="MailKit" Version="4.16.0" />
<PackageVersion Include="AWSSDK.Core" Version="4.0.4" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public async Task<string> ValidateSeName<T>(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));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>())).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()
{
Expand Down
24 changes: 14 additions & 10 deletions src/Web/Grand.Web.Admin/Areas/Admin/Views/Page/List.cshtml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@model PageListModel
@inject AdminAreaSettings adminAreaSettings
@model PageListModel
@{
//page title
ViewBag.Title = Loc["Admin.Content.Pages"];
Expand Down Expand Up @@ -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,
Expand All @@ -97,19 +97,23 @@
title: "@Loc["Admin.Content.Pages.Fields.SystemName"]",
template: '<a class="k-link" href="Edit/#=Id#">#=SystemName#</a>',
}, {
field: "IsPasswordProtected",
title: "@Loc["Admin.Content.Pages.Fields.IsPasswordProtected"]",
width: 100,
headerAttributes: { style: "text-align:center" },
attributes: { style: "text-align:center" },
template: '# if(IsPasswordProtected) {# <i class="fa fa-check" aria-hidden="true" style="color:green"></i> #} else {# <i class="fa fa-times" aria-hidden="true" style="color:red"></i> #} #'
field: "Title",
title: "@Loc["Admin.Content.Pages.Fields.Title"]",
template: '#=kendo.htmlEncode(Title == null ? "" : Title)#',
}, {
field: "IncludeInMenu",
title: "@Loc["Admin.Content.Pages.Fields.IncludeInMenu"]",
width: 100,
headerAttributes: { style: "text-align:center" },
attributes: { style: "text-align:center" },
template: '# if(IncludeInMenu) {# <i class="fa fa-check" aria-hidden="true" style="color:green"></i> #} else {# <i class="fa fa-times" aria-hidden="true" style="color:red"></i> #} #'
}, {
field: "Published",
title: "@Loc["Admin.Content.Pages.Fields.Published"]",
width: 100,
headerAttributes: { style: "text-align:center" },
attributes: { style: "text-align:center" },
template: '# if(Published) {# <i class="fa fa-check" aria-hidden="true" style="color:green"></i> #} else {# <i class="fa fa-times" aria-hidden="true" style="color:red"></i> #} #'
}, {
field: "DisplayOrder",
title: "@Loc["Admin.Content.Pages.Fields.DisplayOrder"]",
Expand Down
6 changes: 4 additions & 2 deletions src/Web/Grand.Web.Admin/Controllers/PageController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,11 @@ public async Task<IActionResult> 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);
Expand Down
37 changes: 37 additions & 0 deletions src/Web/Grand.Web.Store/Areas/Store/Views/Page/Create.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
@model PageModel
@{
//page title
ViewBag.Title = Loc["Admin.Content.Pages.AddNew"];
Layout = Constants.LayoutStore;
}
<form asp-area="@Constants.AreaStore" asp-controller="Page" asp-action="Create" method="post">

<div class="row">
<div class="col-md-12">
<div class="x_panel light form-fit">
<div class="x_title">
<div class="caption">
<i class="fa fa-fw fa-file-o"></i>
@Loc["Admin.Content.Pages.AddNew"]
<small>
<i class="fa fa-arrow-circle-left"></i>@Html.ActionLink(Loc["Admin.Content.Pages.BackToList"], "List")
</small>
</div>
<div class="actions">
<div class="btn-group btn-group-devided">
<button class="btn btn-success" type="submit" name="save">
<i class="fa fa-check"></i> @Loc["Admin.Common.Save"]
</button>
<button class="btn btn-success" type="submit" name="save-continue">
<i class="fa fa-check-circle"></i> @Loc["Admin.Common.SaveContinue"]
</button>
</div>
</div>
</div>
<div class="x_content form">
<partial name="Partials/CreateOrUpdate" model="Model"/>
</div>
</div>
</div>
</div>
</form>
60 changes: 60 additions & 0 deletions src/Web/Grand.Web.Store/Areas/Store/Views/Page/Edit.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
@model PageModel
@{
//page title
ViewBag.Title = Loc["Admin.Content.Pages.EditPageDetails"];
Layout = Constants.LayoutStore;
}
<form asp-area="@Constants.AreaStore" asp-controller="Page" asp-action="Edit" method="post">

<div class="row">
<div class="col-md-12">
<div class="x_panel light form-fit">
<div class="x_title">
<div class="caption">
<i class="fa fa-fw fa-file-o"></i>
@Loc["Admin.Content.Pages.EditPageDetails"] - @Model.SystemName
<small>
<i class="fa fa-arrow-circle-left"></i>@Html.ActionLink(Loc["Admin.Content.Pages.BackToList"], "List")
</small>
</div>
<div class="actions">
<div class="btn-group btn-group-devided">
@if (!string.IsNullOrEmpty(Model.SeName))
{
<button type="button" onclick="window.open('@Url.RouteUrl("Page", new { Model.SeName })','_blank');" class="btn purple">
Comment thread
KrzysztofPajak marked this conversation as resolved.
<i class="fa fa-eye"></i>
@Loc["Admin.Common.Preview"]
</button>
}
<button class="btn btn-success" type="submit" name="save">
<i class="fa fa-check"></i> @Loc["Admin.Common.Save"]
</button>
<button class="btn btn-success" type="submit" name="save-continue">
<i class="fa fa-check-circle"></i> @Loc["Admin.Common.SaveContinue"]
</button>
@if (ViewBag.ShowCopyButton == true)
{
<button type="submit" form="page-copy-form" class="btn blue">
<i class="fa fa-copy"></i> @Loc["Admin.Common.Copy"]
</button>
}
<span id="page-delete" class="btn red">
<i class="fa fa-trash-o"></i> @Loc["Admin.Common.Delete"]
</span>
</div>
</div>
</div>
<div class="x_content form">
<partial name="Partials/CreateOrUpdate" model="Model"/>
</div>
</div>
</div>
</div>
</form>
<admin-delete-confirmation button-id="page-delete"/>
@if (ViewBag.ShowCopyButton == true)
{
<form id="page-copy-form" asp-area="@Constants.AreaStore" asp-controller="Page" asp-action="Copy" method="post">
<input type="hidden" name="id" value="@Model.Id"/>
</form>
}
166 changes: 166 additions & 0 deletions src/Web/Grand.Web.Store/Areas/Store/Views/Page/List.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
@model PageListModel
@inject AdminAreaSettings adminAreaSettings
@{
//page title
ViewBag.Title = Loc["Admin.Content.Pages"];
Layout = Constants.LayoutStore;
}

<div class="row">
<div class="col-md-12">
<div class="x_panel light form-fit">
<div class="x_title">
<div class="caption">
<i class="fa fa-fw fa-file-o"></i>
@Loc["Admin.Content.Pages"]
</div>
<div class="actions btn-group btn-group-devided">
<a href="@Url.Action("Create", new { area = Constants.AreaStore })" class="btn green">
<i class="fa fa-plus"></i><span class="d-none d-sm-inline"> @Loc["Admin.Common.AddNew"] </span>
</a>
</div>
</div>
<div class="x_content">
<div class="form-horizontal">
<div class="form-body">
<div class="row align-items-end mb-1">
<div class="col-md-9 col-sm-12 col-12">
<div class="form-group mb-0">
<admin-label asp-for="Name" class="d-flex flex-column align-items-start mw-100 px-0 text-left"/>
<admin-input asp-for="Name"/>
</div>
</div>
<div class="col-md-3 col-sm-12 col-12 mt-md-0 mt-3">
<div class="form-actions">
<button class="btn btn-success filter-submit" id="search-pages">
<i class="fa fa-search"></i> @Loc["Admin.Common.Search"]
</button>
</div>
</div>
</div>
</div>
<div class="x_content">
<admin-tabstrip name="pages-list-tabs" BindGrid="true">
<items>
<tabstrip-item text="@Loc["Admin.Content.Pages.List.StorePages"]" tab-index="0">
<content>
<div id="store-pages-grid"></div>
</content>
</tabstrip-item>
<tabstrip-item text="@Loc["Admin.Content.Pages.List.GlobalPages"]" tab-index="1">
<content>
<div id="global-pages-grid"></div>
</content>
</tabstrip-item>
</items>
</admin-tabstrip>
</div>
</div>
</div>
</div>
</div>
</div>

<script>
$(document).ready(function () {
var gridColumns = [{
field: "SystemName",
title: "@Loc["Admin.Content.Pages.Fields.SystemName"]",
template: '<a class="k-link" href="Edit/#=Id#">#=SystemName#</a>',
}, {
field: "Title",
title: "@Loc["Admin.Content.Pages.Fields.Title"]",
template: '<a class="k-link" href="Edit/#=Id#">#=kendo.htmlEncode(Title == null ? "" : Title)#</a>',
}, {
field: "IncludeInMenu",
title: "@Loc["Admin.Content.Pages.Fields.IncludeInMenu"]",
width: 100,
headerAttributes: { style: "text-align:center" },
attributes: { style: "text-align:center" },
template: '# if(IncludeInMenu) {# <i class="fa fa-check" aria-hidden="true" style="color:green"></i> #} else {# <i class="fa fa-times" aria-hidden="true" style="color:red"></i> #} #'
}, {
field: "Published",
title: "@Loc["Admin.Content.Pages.Fields.Published"]",
width: 100,
headerAttributes: { style: "text-align:center" },
attributes: { style: "text-align:center" },
template: '# if(Published) {# <i class="fa fa-check" aria-hidden="true" style="color:green"></i> #} else {# <i class="fa fa-times" aria-hidden="true" style="color:red"></i> #} #'
}, {
field: "DisplayOrder",
title: "@Loc["Admin.Content.Pages.Fields.DisplayOrder"]",
width: 100
}];

function makeDataSource(url) {
return {
transport: {
read: {
url: url,
type: "POST",
dataType: "json",
data: additionalData
}
},
schema: {
data: "Data",
total: "Total",
errors: "Errors"
},
error: function(e) {
display_kendoui_grid_error(e);
this.cancelChanges();
},
pageSize: @(adminAreaSettings.DefaultGridPageSize),
serverPaging: true,
serverFiltering: true,
serverSorting: true
};
}

function makeGrid(selector, url) {
$(selector).kendoGrid({
dataSource: makeDataSource(url),
pageable: {
refresh: true,
pageSizes: [@(adminAreaSettings.GridPageSizes)]
},
editable: {
confirmation: false,
mode: "inline"
},
scrollable: false,
columns: gridColumns
});
}

makeGrid("#store-pages-grid", "@Html.Raw(Url.Action("StorePagesList", "Page", new { area = Constants.AreaStore }))");
makeGrid("#global-pages-grid", "@Html.Raw(Url.Action("GlobalPagesList", "Page", new { area = Constants.AreaStore }))");
});
</script>

<script>
$(document).ready(function () {
$('#search-pages').click(function () {
var sg = $('#store-pages-grid').data('kendoGrid');
if (sg) sg.dataSource.read();
var gg = $('#global-pages-grid').data('kendoGrid');
if (gg) gg.dataSource.read();
return false;
});

$("#@Html.IdFor(model => model.Name)").keydown(function (event) {
if (event.keyCode == 13) {
$("#search-pages").click();
return false;
}
});
});

function additionalData() {
var data = {
Name: $('#@Html.IdFor(model => model.Name)').val()
};
addAntiForgeryToken(data);
return data;
}
</script>
Loading
Loading