From 2e25ed6cf0581a3beb832c86bcc3505b2ab3abed Mon Sep 17 00:00:00 2001 From: support Date: Sun, 1 Nov 2026 21:10:16 +0100 Subject: [PATCH 1/9] Remove ViewBag values that no view reads AllLanguages, ClosePage, OrderTags' RefreshPage and the popup input names were written and never read: the popup views take the input names from the query string, which their forms keep through asp-route-*. The action parameters that only fed those writes go with them. --- .../Controllers/BaseNewsControllerTests.cs | 22 ------------------- .../Controllers/BaseProductControllerTests.cs | 3 +-- .../Controllers/CourseController.cs | 6 +---- .../Controllers/OrderTagsController.cs | 1 - .../Controllers/PermissionController.cs | 3 --- .../Controllers/BaseBlogController.cs | 8 ------- .../Controllers/BaseNewsController.cs | 5 ----- .../Controllers/BaseProductController.cs | 6 +---- 8 files changed, 3 insertions(+), 51 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseNewsControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseNewsControllerTests.cs index e49c04221..a0d920cbd 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseNewsControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseNewsControllerTests.cs @@ -213,28 +213,6 @@ public async Task CreatePost_GlobalScoped_LeavesModelStoresUntouched() await _controller.Create(submitted, false); } - [TestMethod] - public async Task CreateGet_PopulatesAllLanguages() - { - var languageServiceMock = new Mock(); - languageServiceMock.Setup(l => l.GetAllLanguages(true, It.IsAny())) - .ReturnsAsync(new List { new() { Id = "l1" } }); - var controller = new TestNewsController( - _newsViewModelServiceMock.Object, _newsServiceMock.Object, languageServiceMock.Object, - _translationServiceMock.Object, new Mock().Object, new Mock().Object, - _scopeMock.Object); - controller.ControllerContext = _controller.ControllerContext; - controller.TempData = _controller.TempData; - - await controller.Create(); - - // Confirms the value assigned to ViewBag.AllLanguages is the resolved list, not an unawaited - // Task object (the pre-existing bug this task fixes) - would throw InvalidCastException or - // fail this assertion if the bug were reintroduced. - var allLanguages = (IList)controller.ViewBag.AllLanguages; - Assert.AreEqual(1, allLanguages.Count); - } - [TestMethod] public async Task EditGet_NewsItemNotFound_RedirectsToList() { diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index 3492ba202..6dcf63d09 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -508,11 +508,10 @@ public async Task RequiredProductAddPopup_CallsPrepareAddRequiredProductModel() _productAddPopupViewModelServiceMock.Setup(s => s.PrepareAddRequiredProductModel()) .ReturnsAsync(new ProductModel.AddRequiredProductModel()); - var result = await _controller.RequiredProductAddPopup("input1") as ViewResult; + var result = await _controller.RequiredProductAddPopup() as ViewResult; Assert.IsNotNull(result); _productAddPopupViewModelServiceMock.Verify(s => s.PrepareAddRequiredProductModel(), Times.Once); - Assert.AreEqual("input1", _controller.ViewBag.productIdsInput); } // --- RequiredProductAddPopupList ------------------------------------------------------------------- diff --git a/src/Web/Grand.Web.Admin/Controllers/CourseController.cs b/src/Web/Grand.Web.Admin/Controllers/CourseController.cs index f15c3edbe..51e4f7a09 100644 --- a/src/Web/Grand.Web.Admin/Controllers/CourseController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/CourseController.cs @@ -262,8 +262,7 @@ public async Task AssociateProductToCoursePopupList(DataSourceReq [PermissionAuthorizeAction(PermissionActionName.Edit)] [HttpPost] - public async Task AssociateProductToCoursePopup(string btnId, string productIdInput, - string productNameInput, CourseModel.AssociateProductToCourseModel model, + public async Task AssociateProductToCoursePopup(CourseModel.AssociateProductToCourseModel model, [FromServices] IProductService productService) { var associatedProduct = await productService.GetProductById(model.AssociatedToProductId); @@ -271,9 +270,6 @@ public async Task AssociateProductToCoursePopup(string btnId, str return Content("Cannot load a product"); ViewBag.RefreshPage = true; - ViewBag.productIdInput = productIdInput; - ViewBag.productNameInput = productNameInput; - ViewBag.btnId = btnId; ViewBag.productId = associatedProduct.Id; ViewBag.productName = associatedProduct.Name; return View(model); diff --git a/src/Web/Grand.Web.Admin/Controllers/OrderTagsController.cs b/src/Web/Grand.Web.Admin/Controllers/OrderTagsController.cs index d996a5ac9..c130927cf 100644 --- a/src/Web/Grand.Web.Admin/Controllers/OrderTagsController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/OrderTagsController.cs @@ -113,7 +113,6 @@ public async Task Edit(OrderTagModel model) { orderTag.Name = model.Name; await _orderTagService.UpdateOrderTag(orderTag); - ViewBag.RefreshPage = true; return View(model); } diff --git a/src/Web/Grand.Web.Admin/Controllers/PermissionController.cs b/src/Web/Grand.Web.Admin/Controllers/PermissionController.cs index 72bfd7816..ea9e79e28 100644 --- a/src/Web/Grand.Web.Admin/Controllers/PermissionController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/PermissionController.cs @@ -161,7 +161,6 @@ public async Task PermissionsAction(string systemName, string cus } else { - ViewBag.ClosePage = true; return await PermissionsAction(systemName, customeGroupId); } @@ -173,7 +172,6 @@ public async Task PermissionsAction(string systemName, string cus } else { - ViewBag.ClosePage = true; return await PermissionsAction(systemName, customeGroupId); } @@ -201,7 +199,6 @@ await _permissionService.InsertPermissionAction(new PermissionAction { SystemName = model.SystemName }); - ViewBag.ClosePage = true; return await PermissionsAction(model.SystemName, model.CustomerGroupId); } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseBlogController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseBlogController.cs index 3634c6c57..002ed62d4 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseBlogController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseBlogController.cs @@ -82,7 +82,6 @@ public async Task List(DataSourceRequest command) [PermissionAuthorizeAction(PermissionActionName.Create)] public async Task Create() { - ViewBag.AllLanguages = await languageService.GetAllLanguages(true); var model = new BlogPostModel { AllowComments = true, CreateDate = DateTime.UtcNow @@ -104,7 +103,6 @@ public async Task Create(BlogPostModel model, bool continueEditin return continueEditing ? RedirectToAction("Edit", new { id = blogPost.Id }) : RedirectToAction("List"); } - ViewBag.AllLanguages = await languageService.GetAllLanguages(true); return View(model); } @@ -119,7 +117,6 @@ public async Task Edit(string id) EditWarningCheck(blogPost); if (!await postScope.CanView(blogPost)) return RedirectToAction("List"); - ViewBag.AllLanguages = await languageService.GetAllLanguages(true); var model = blogPost.ToModel(dateTimeService); await AddLocales(languageService, model.Locales, (locale, languageId) => { @@ -156,7 +153,6 @@ public async Task Edit(BlogPostModel model, bool continueEditing) return RedirectToAction("List"); } - ViewBag.AllLanguages = await languageService.GetAllLanguages(true); await AddLocales(languageService, model.Locales, (locale, languageId) => { locale.Title = blogPost.GetTranslation(x => x.Title, languageId, false); @@ -246,7 +242,6 @@ public async Task CategoryList(DataSourceRequest command) [PermissionAuthorizeAction(PermissionActionName.Create)] public async Task CategoryCreate() { - ViewBag.AllLanguages = await languageService.GetAllLanguages(true); var model = new BlogCategoryModel(); await AddLocales(languageService, model.Locales); return View(model); @@ -272,7 +267,6 @@ public async Task CategoryCreate(BlogCategoryModel model, bool co : RedirectToAction(CategoryListAction); } - ViewBag.AllLanguages = await languageService.GetAllLanguages(true); await AddLocales(languageService, model.Locales); return View(model); } @@ -284,7 +278,6 @@ public async Task CategoryEdit(string id) if (blogCategory == null) return RedirectToAction(CategoryListAction); if (!await categoryScope.HasAccess(blogCategory)) return RedirectToAction(CategoryListAction); - ViewBag.AllLanguages = await languageService.GetAllLanguages(true); var model = blogCategory.ToModel(); await AddLocales(languageService, model.Locales, (locale, languageId) => { @@ -319,7 +312,6 @@ public async Task CategoryEdit(BlogCategoryModel model, bool cont return RedirectToAction(CategoryListAction); } - ViewBag.AllLanguages = await languageService.GetAllLanguages(true); await AddLocales(languageService, model.Locales, (locale, languageId) => { locale.Name = blogCategory.GetTranslation(x => x.Name, languageId, false); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseNewsController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseNewsController.cs index 6acfef0a7..80154c3f1 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseNewsController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseNewsController.cs @@ -72,7 +72,6 @@ public async Task List(DataSourceRequest command, NewsItemListMod [PermissionAuthorizeAction(PermissionActionName.Create)] public async Task Create() { - ViewBag.AllLanguages = await languageService.GetAllLanguages(true); var model = new NewsItemModel { Published = true, AllowComments = true @@ -97,8 +96,6 @@ public async Task Create(NewsItemModel model, bool continueEditin return continueEditing ? RedirectToAction("Edit", new { id = newsItem.Id }) : RedirectToAction("List"); } - // Ruled fix: Admin's original assigned the unawaited Task here instead of the resolved list. - ViewBag.AllLanguages = await languageService.GetAllLanguages(true); return View(model); } @@ -111,7 +108,6 @@ public async Task Edit(string id) EditWarningCheck(newsItem); if (!await scope.CanView(newsItem)) return RedirectToAction("List"); - ViewBag.AllLanguages = await languageService.GetAllLanguages(true); var model = newsItem.ToModel(dateTimeService); await AddLocales(languageService, model.Locales, (locale, languageId) => { @@ -148,7 +144,6 @@ public async Task Edit(NewsItemModel model, bool continueEditing) return RedirectToAction("List"); } - ViewBag.AllLanguages = await languageService.GetAllLanguages(true); return View(model); } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 8b5bbc758..75e0822df 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -324,16 +324,12 @@ public async Task LoadProductFriendlyNames(string productIds) } [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task RequiredProductAddPopup(string productIdsInput) + public async Task RequiredProductAddPopup() { // PrepareAddRequiredProductModel() now reads scope internally (Task 9) - the injected // IAdminDataScope already encodes the per-host default exactly: null for Admin // (global) and Vendor (not store-scoped), StaffStoreId for Store. var model = await productAddPopupViewModelService.PrepareAddRequiredProductModel(); - // Unused by any of the three views (all three read productIdsInput straight off the query string - // via Context.Request.Query, not ViewBag), but Admin and Vendor both set it and Store silently - // drops its own parameter - kept here for parity; it is inert either way. - ViewBag.productIdsInput = productIdsInput; return View(model); } From 0907c27799f8306375ce3aa1ad2084e986222719 Mon Sep 17 00:00:00 2001 From: support Date: Sun, 1 Nov 2026 21:17:11 +0100 Subject: [PATCH 2/9] Move the product read-only flag from ViewBag to ProductModel ViewBag.IsReadOnly decides whether a store owner sees the buttons that write to a product, yet a misspelled key compiled and turned into "editable". As a model property the compiler checks every reader; it is never bound, so a posted value cannot reach the re-rendered form. --- ...ProductMappingTests.Product_ToModel.verified.txt | 1 + ...ts.Product_ToModel_WithCalendarData.verified.txt | 1 + .../Controllers/BaseProductControllerTests.cs | 13 +++++++++++-- .../Controllers/BaseProductController.cs | 6 +++--- .../Grand.Web.AdminShared/Mapper/ProductProfile.cs | 1 + .../Models/Catalog/ProductModel.cs | 9 +++++++++ .../Views/AdminShared/Product/Edit.cshtml | 2 +- .../Views/AdminShared/Product/EditPopup.cshtml | 2 +- .../Partials/Additional.RequiredProducts.cshtml | 2 +- .../CreateOrUpdate.AssociatedProducts.cshtml | 2 +- .../Product/Partials/CreateOrUpdate.Bids.cshtml | 2 +- .../Partials/CreateOrUpdate.BundleProducts.cshtml | 2 +- .../Product/Partials/CreateOrUpdate.Calendar.cshtml | 2 +- .../Partials/CreateOrUpdate.Categories.cshtml | 2 +- .../Partials/CreateOrUpdate.Collections.cshtml | 2 +- .../Partials/CreateOrUpdate.CrossSells.cshtml | 2 +- .../Partials/CreateOrUpdate.Documents.cshtml | 2 +- .../Product/Partials/CreateOrUpdate.Pictures.cshtml | 2 +- ...roductAttributes.TabAttributeCombinations.cshtml | 2 +- ...eOrUpdate.ProductAttributes.TabAttributes.cshtml | 2 +- .../Partials/CreateOrUpdate.ProductPrices.cshtml | 2 +- .../Partials/CreateOrUpdate.Recommended.cshtml | 2 +- .../Partials/CreateOrUpdate.RelatedProducts.cshtml | 2 +- .../Product/Partials/CreateOrUpdate.Reviews.cshtml | 2 +- .../Partials/CreateOrUpdate.SimilarProducts.cshtml | 2 +- .../CreateOrUpdate.SpecificationAttributes.cshtml | 2 +- .../Product/Partials/CreateOrUpdate.Prices.cshtml | 4 ++-- .../Store/Views/Shared/Partials/UserFields.cshtml | 4 ++-- 28 files changed, 50 insertions(+), 29 deletions(-) diff --git a/src/Tests/Grand.Mapping.Tests/AdminShared/CatalogProductMappingTests.Product_ToModel.verified.txt b/src/Tests/Grand.Mapping.Tests/AdminShared/CatalogProductMappingTests.Product_ToModel.verified.txt index c57b50cc9..20446f9c0 100644 --- a/src/Tests/Grand.Mapping.Tests/AdminShared/CatalogProductMappingTests.Product_ToModel.verified.txt +++ b/src/Tests/Grand.Mapping.Tests/AdminShared/CatalogProductMappingTests.Product_ToModel.verified.txt @@ -1,5 +1,6 @@ { Id: prod-001, + IsReadOnly: false, AuctionEnded: false, VisibleIndividually: true, ProductLayoutId: pl-001, diff --git a/src/Tests/Grand.Mapping.Tests/AdminShared/CatalogProductMappingTests.Product_ToModel_WithCalendarData.verified.txt b/src/Tests/Grand.Mapping.Tests/AdminShared/CatalogProductMappingTests.Product_ToModel_WithCalendarData.verified.txt index 7cd17a864..8a37d85e3 100644 --- a/src/Tests/Grand.Mapping.Tests/AdminShared/CatalogProductMappingTests.Product_ToModel_WithCalendarData.verified.txt +++ b/src/Tests/Grand.Mapping.Tests/AdminShared/CatalogProductMappingTests.Product_ToModel_WithCalendarData.verified.txt @@ -1,5 +1,6 @@ { Id: prod-002, + IsReadOnly: false, AuctionEnded: false, VisibleIndividually: false, Name: Calendar Product, diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index 6dcf63d09..17c1f0715 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -17,6 +17,7 @@ using Grand.Web.Common.Security.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.Extensions.Logging; @@ -140,7 +141,7 @@ public async Task EditGet_UsesCanViewNotHasAccess_LooserRuleWins() //shown, but read-only: HasAccess no longer gates the page, it only takes the buttons away Assert.IsInstanceOfType(result); - Assert.AreEqual(true, ((ViewResult)result).ViewData["IsReadOnly"]); + Assert.IsTrue(((ProductModel)((ViewResult)result).Model).IsReadOnly); } [TestMethod] @@ -154,7 +155,15 @@ public async Task EditGet_OwnedProduct_IsNotReadOnly() var result = await _controller.Edit("p1"); Assert.IsInstanceOfType(result); - Assert.AreEqual(false, ((ViewResult)result).ViewData["IsReadOnly"]); + Assert.IsFalse(((ProductModel)((ViewResult)result).Model).IsReadOnly); + } + + [TestMethod] + public void IsReadOnly_IsNeverBoundFromTheRequest() + { + // The server decides it in Edit(GET); a posted value must not reach the re-rendered form. + Assert.IsTrue(Attribute.IsDefined(typeof(ProductModel).GetProperty(nameof(ProductModel.IsReadOnly))!, + typeof(BindNeverAttribute))); } // --- Edit (POST) ------------------------------------------------------------------------------- diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 75e0822df..9c253fa34 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -127,11 +127,11 @@ public async Task Edit(string id, bool popup = false) // from EditWarningCheck above); only mutating one is restricted to the exclusive single-store // owner. See IAdminDataScope.CanView's doc comment and StoreAdminDataScope.CanView. if (!await scope.CanView(product)) return RedirectToAction("List"); - //seen but not owned (Store: a product shared with other stores): the tabs render without the - //buttons that write; every write action still checks HasAccess itself - ViewBag.IsReadOnly = !await scope.HasAccess(product); var model = product.ToModel(dateTimeService); + //seen but not owned (Store: a product shared with other stores): the tabs render without the + //buttons that write; every write action still checks HasAccess itself + model.IsReadOnly = !await scope.HasAccess(product); if (scope.DefaultStoreId is not null) model.StoreId = scope.DefaultStoreId; await productViewModelService.PrepareProductModel(model, product, false, false); await AddLocales(languageService, model.Locales, (locale, languageId) => diff --git a/src/Web/Grand.Web.AdminShared/Mapper/ProductProfile.cs b/src/Web/Grand.Web.AdminShared/Mapper/ProductProfile.cs index 846f9f84f..80544b20b 100644 --- a/src/Web/Grand.Web.AdminShared/Mapper/ProductProfile.cs +++ b/src/Web/Grand.Web.AdminShared/Mapper/ProductProfile.cs @@ -11,6 +11,7 @@ public class ProductProfile : Profile, IAutoMapperProfile public ProductProfile() { CreateMap() + .ForMember(dest => dest.IsReadOnly, mo => mo.Ignore()) .ForMember(dest => dest.Locales, mo => mo.Ignore()) .ForMember(dest => dest.ProductTypeName, mo => mo.Ignore()) .ForMember(dest => dest.AssociatedToProductId, mo => mo.Ignore()) diff --git a/src/Web/Grand.Web.AdminShared/Models/Catalog/ProductModel.cs b/src/Web/Grand.Web.AdminShared/Models/Catalog/ProductModel.cs index 981361f0c..1fd3c5e72 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Catalog/ProductModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Catalog/ProductModel.cs @@ -5,6 +5,7 @@ using Grand.Web.Common.Link; using Grand.Web.Common.Models; using Grand.Infrastructure.Validators; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; using System.ComponentModel.DataAnnotations; @@ -15,6 +16,14 @@ public class ProductModel : BaseEntityModel, ILocalizedModel + /// True when the caller may see the product but not change it (Store: a product shared with + /// other stores) - set by BaseProductController.Edit(GET). It only takes the write buttons + /// away; every write action checks HasAccess itself. Never bound: the server decides it. + /// + [BindNever] + public bool IsReadOnly { get; set; } + //picture thumbnail [GrandResourceDisplayName("Admin.Catalog.Products.Fields.PictureThumbnailUrl")] public string PictureThumbnailUrl { get; set; } diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Edit.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Edit.cshtml index a87c77d2c..414c8eaa0 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Edit.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Edit.cshtml @@ -4,7 +4,7 @@ ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.EditProductDetails"]; var area = ViewContext.RouteData.Values["area"]?.ToString(); //a product the store may see but not change: no Save or Delete (copying it is allowed) - bool readOnly = ViewBag.IsReadOnly == true; + bool readOnly = Model.IsReadOnly; } @* The reference detail screen: the header carries the product's name, one primary action (Save) and everything else as an outline button, with Copy and Delete in the overflow menu diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/EditPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/EditPopup.cshtml index 9a5922a40..db401b854 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/EditPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/EditPopup.cshtml @@ -4,7 +4,7 @@ //CreateOrUpdate shows the quick-edit tabs and splits Additional into a tab per card ViewData["QuickTabs"] = true; var area = ViewContext.RouteData.Values["area"]?.ToString(); - bool readOnly = ViewBag.IsReadOnly == true; + bool readOnly = Model.IsReadOnly; } @* The product screen inside the bulk-edit popup (an iframe): the same form, posted to the same Edit action, without the panel's menu and top bar (the empty sections below) and with only the diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Additional.RequiredProducts.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Additional.RequiredProducts.cshtml index 9d7d67f0a..b2a2f7773 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Additional.RequiredProducts.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Additional.RequiredProducts.cshtml @@ -2,7 +2,7 @@ @{ var area = ViewContext.RouteData.Values["area"]?.ToString(); //a product the store may see but not change (see BaseProductController.Edit) - bool readOnly = ViewBag.IsReadOnly == true; + bool readOnly = Model.IsReadOnly; } @* shown or hidden as a whole by the product type script in CreateOrUpdate.Info (group-required-other-products); the picker's popup and refresh script travel with it *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml index ba711d92d..e7689b73b 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml @@ -2,7 +2,7 @@ @{ var area = ViewContext.RouteData.Values["area"]?.ToString(); //a product the store may see but not change (see BaseProductController.Edit) - bool readOnly = ViewBag.IsReadOnly == true; + bool readOnly = Model.IsReadOnly; } @if (!string.IsNullOrEmpty(Model.Id)) { diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Bids.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Bids.cshtml index 04687ae07..92320ee9a 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Bids.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Bids.cshtml @@ -2,7 +2,7 @@ @{ var area = ViewContext.RouteData.Values["area"]?.ToString(); //a product the store may see but not change (see BaseProductController.Edit) - bool readOnly = ViewBag.IsReadOnly == true; + bool readOnly = Model.IsReadOnly; } @if (!string.IsNullOrEmpty(Model.Id)) { diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.BundleProducts.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.BundleProducts.cshtml index 79b12fa5e..4408ec8bd 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.BundleProducts.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.BundleProducts.cshtml @@ -2,7 +2,7 @@ @{ var area = ViewContext.RouteData.Values["area"]?.ToString(); //a product the store may see but not change (see BaseProductController.Edit) - bool readOnly = ViewBag.IsReadOnly == true; + bool readOnly = Model.IsReadOnly; } @if (!string.IsNullOrEmpty(Model.Id)) diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Calendar.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Calendar.cshtml index b2cbccbc4..5e6c07c33 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Calendar.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Calendar.cshtml @@ -2,7 +2,7 @@ @{ var area = ViewContext.RouteData.Values["area"]?.ToString(); //a product the store may see but not change (see BaseProductController.Edit) - bool readOnly = ViewBag.IsReadOnly == true; + bool readOnly = Model.IsReadOnly; } @if (!string.IsNullOrEmpty(Model.Id)) { diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Categories.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Categories.cshtml index f20e16941..bfeab580d 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Categories.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Categories.cshtml @@ -2,7 +2,7 @@ @{ var area = ViewContext.RouteData.Values["area"]?.ToString(); //a product the store may see but not change (see BaseProductController.Edit) - bool readOnly = ViewBag.IsReadOnly == true; + bool readOnly = Model.IsReadOnly; } @if (!string.IsNullOrEmpty(Model.Id)) diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Collections.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Collections.cshtml index 07eb38a77..260588b82 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Collections.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Collections.cshtml @@ -2,7 +2,7 @@ @{ var area = ViewContext.RouteData.Values["area"]?.ToString(); //a product the store may see but not change (see BaseProductController.Edit) - bool readOnly = ViewBag.IsReadOnly == true; + bool readOnly = Model.IsReadOnly; } @if (!string.IsNullOrEmpty(Model.Id)) diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.CrossSells.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.CrossSells.cshtml index 195021ed7..28f662c21 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.CrossSells.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.CrossSells.cshtml @@ -2,7 +2,7 @@ @{ var area = ViewContext.RouteData.Values["area"]?.ToString(); //a product the store may see but not change (see BaseProductController.Edit) - bool readOnly = ViewBag.IsReadOnly == true; + bool readOnly = Model.IsReadOnly; } @if (!string.IsNullOrEmpty(Model.Id)) { diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Documents.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Documents.cshtml index 77532af51..11eea3988 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Documents.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Documents.cshtml @@ -2,7 +2,7 @@ @{ var area = ViewContext.RouteData.Values["area"]?.ToString(); //a product the store may see but not change (see BaseProductController.Edit) - bool readOnly = ViewBag.IsReadOnly == true; + bool readOnly = Model.IsReadOnly; }
diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Pictures.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Pictures.cshtml index 3e5271e55..a59348f1e 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Pictures.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Pictures.cshtml @@ -2,7 +2,7 @@ @{ var area = ViewContext.RouteData.Values["area"]?.ToString(); //a product the store may see but not change (see BaseProductController.Edit) - bool readOnly = ViewBag.IsReadOnly == true; + bool readOnly = Model.IsReadOnly; } @if (!string.IsNullOrEmpty(Model.Id)) { diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml index 13b0745fe..25814226f 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml @@ -2,7 +2,7 @@ @{ var area = ViewContext.RouteData.Values["area"]?.ToString(); //a product the store may see but not change (see BaseProductController.Edit) - bool readOnly = ViewBag.IsReadOnly == true; + bool readOnly = Model.IsReadOnly; }

diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml index e733eee22..94bdca3c2 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml @@ -2,7 +2,7 @@ @{ var area = ViewContext.RouteData.Values["area"]?.ToString(); //a product the store may see but not change (see BaseProductController.Edit) - bool readOnly = ViewBag.IsReadOnly == true; + bool readOnly = Model.IsReadOnly; }

diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductPrices.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductPrices.cshtml index 331ec9a4f..41d9b3905 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductPrices.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductPrices.cshtml @@ -7,7 +7,7 @@ currencies = currencies.Where(x => x.Id != defaultCurrency.Id).ToList(); var area = ViewContext.RouteData.Values["area"]?.ToString(); //a product the store may see but not change (see BaseProductController.Edit) - bool readOnly = ViewBag.IsReadOnly == true; + bool readOnly = Model.IsReadOnly; var currencyOptions = currencies.Select(x => new SelectListItem(x.CurrencyCode, x.CurrencyCode)); } @if (!string.IsNullOrEmpty(Model.Id) && currencies.Count > 0) diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Recommended.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Recommended.cshtml index b1483ae2d..6fd2f821e 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Recommended.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Recommended.cshtml @@ -2,7 +2,7 @@ @{ var area = ViewContext.RouteData.Values["area"]?.ToString(); //a product the store may see but not change (see BaseProductController.Edit) - bool readOnly = ViewBag.IsReadOnly == true; + bool readOnly = Model.IsReadOnly; } @if (!string.IsNullOrEmpty(Model.Id)) { diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml index a5bdfc9d3..d7ba3ee77 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml @@ -2,7 +2,7 @@ @{ var area = ViewContext.RouteData.Values["area"]?.ToString(); //a product the store may see but not change (see BaseProductController.Edit) - bool readOnly = ViewBag.IsReadOnly == true; + bool readOnly = Model.IsReadOnly; } @if (!string.IsNullOrEmpty(Model.Id)) diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Reviews.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Reviews.cshtml index 755fd7380..3dc0cf395 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Reviews.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Reviews.cshtml @@ -2,7 +2,7 @@ @{ var area = ViewContext.RouteData.Values["area"]?.ToString(); //a product the store may see but not change (see BaseProductController.Edit) - bool readOnly = ViewBag.IsReadOnly == true; + bool readOnly = Model.IsReadOnly; }
diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml index 1dc3be56b..9bfe77e9e 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml @@ -2,7 +2,7 @@ @{ var area = ViewContext.RouteData.Values["area"]?.ToString(); //a product the store may see but not change (see BaseProductController.Edit) - bool readOnly = ViewBag.IsReadOnly == true; + bool readOnly = Model.IsReadOnly; } diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml index 9d59ec78d..d3d6f87f7 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml @@ -2,7 +2,7 @@ @{ var area = ViewContext.RouteData.Values["area"]?.ToString(); //a product the store may see but not change (see BaseProductController.Edit) - bool readOnly = ViewBag.IsReadOnly == true; + bool readOnly = Model.IsReadOnly; } @if (!string.IsNullOrEmpty(Model.Id)) diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Prices.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Prices.cshtml index 3f0f532a8..a28dc5dd1 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Prices.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Prices.cshtml @@ -1,7 +1,7 @@ @model ProductModel @{ //a product the store may see but not change (see BaseProductController.Edit) - bool readOnly = ViewBag.IsReadOnly == true; + bool readOnly = Model.IsReadOnly; } @inject AdminAreaSettings adminAreaSettings @@ -13,7 +13,7 @@ pnlPreOrderDateTime, pnlMinEnteredPrice, pnlMaxEnteredPrice, pnlBaseprice*, pnlTaxCategory, pnlIsTelecommunicationsOrBroadcastingOrElectronicServices - sits on the same field as before. The store owner's copy of the Admin tab: the store's own widget zones, and tier prices a - store may only look at when the product is read-only (ViewBag.IsReadOnly). *@ + store may only look at when the product is read-only (Model.IsReadOnly). *@
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Shared/Partials/UserFields.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Shared/Partials/UserFields.cshtml index b9044b55f..e3237db81 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Shared/Partials/UserFields.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Shared/Partials/UserFields.cshtml @@ -3,9 +3,9 @@ @inject IContextAccessor contextAccessor @{ var userFields = Model.UserFields.ToList(); - //set only by the product Edit for a product shared with other stores: its fields are shown, + //only the product Edit can be read-only, for a product shared with other stores: its fields are shown, //not changed (the form has no Save) - bool readOnly = ViewBag.IsReadOnly == true; + bool readOnly = Model is ProductModel { IsReadOnly: true }; var stores = new List(); stores.Add(new SelectListItem { From 53ae6d003d04b18c4c254f3c94b3460fdb9ecea9 Mon Sep 17 00:00:00 2001 From: support Date: Sun, 1 Nov 2026 21:50:17 +0100 Subject: [PATCH 3/9] Leave PermissionController to its own change Its ClosePage writes sit in code that another pull request changes. --- src/Web/Grand.Web.Admin/Controllers/PermissionController.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Web/Grand.Web.Admin/Controllers/PermissionController.cs b/src/Web/Grand.Web.Admin/Controllers/PermissionController.cs index ea9e79e28..86b1a5ae0 100644 --- a/src/Web/Grand.Web.Admin/Controllers/PermissionController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/PermissionController.cs @@ -172,6 +172,7 @@ public async Task PermissionsAction(string systemName, string cus } else { + ViewBag.ClosePage = true; return await PermissionsAction(systemName, customeGroupId); } @@ -199,6 +200,7 @@ await _permissionService.InsertPermissionAction(new PermissionAction { SystemName = model.SystemName }); + ViewBag.ClosePage = true; return await PermissionsAction(model.SystemName, model.CustomerGroupId); } From ad03fa76e4feada7ca50dde3f0cce26628b66ca3 Mon Sep 17 00:00:00 2001 From: support Date: Sun, 1 Nov 2026 21:53:54 +0100 Subject: [PATCH 4/9] Keep the remaining ClosePage write in PermissionController for now --- src/Web/Grand.Web.Admin/Controllers/PermissionController.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Web/Grand.Web.Admin/Controllers/PermissionController.cs b/src/Web/Grand.Web.Admin/Controllers/PermissionController.cs index 86b1a5ae0..72bfd7816 100644 --- a/src/Web/Grand.Web.Admin/Controllers/PermissionController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/PermissionController.cs @@ -161,6 +161,7 @@ public async Task PermissionsAction(string systemName, string cus } else { + ViewBag.ClosePage = true; return await PermissionsAction(systemName, customeGroupId); } From 50332328495249a083b447361f429ddfd56d90b8 Mon Sep 17 00:00:00 2001 From: support Date: Mon, 2 Nov 2026 08:01:48 +0100 Subject: [PATCH 5/9] Keep the posted translations when a form fails validation The Locales list is already bound from the form, so adding the languages again on the invalid path listed each language twice. The next save stored the duplicates. News and the other screens already return the bound model as it is. --- .../BaseAddressAttributeControllerTests.cs | 24 ++++++- .../Controllers/BaseBlogControllerTests.cs | 64 ++++++++++++++++++- .../BaseCustomerAttributeControllerTests.cs | 24 ++++++- .../BaseProductAttributeControllerTests.cs | 24 ++++++- ...seSpecificationAttributeControllerTests.cs | 24 ++++++- .../BaseAddressAttributeController.cs | 4 -- .../Controllers/BaseBlogController.cs | 15 ----- .../BaseCustomerAttributeController.cs | 4 -- .../BaseProductAttributeController.cs | 5 -- .../BaseSpecificationAttributeController.cs | 4 -- 10 files changed, 153 insertions(+), 39 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseAddressAttributeControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseAddressAttributeControllerTests.cs index 129a8225a..24babb02c 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseAddressAttributeControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseAddressAttributeControllerTests.cs @@ -37,6 +37,7 @@ private class TestAddressAttributeController( private Mock _vmService = null!; private Mock _service = null!; private Mock> _scope = null!; + private Mock _languageService = null!; private TestAddressAttributeController _sut = null!; [TestInitialize] @@ -45,9 +46,10 @@ public void Setup() _vmService = new Mock(); _service = new Mock(); _scope = new Mock>(); + _languageService = new Mock(); _sut = new TestAddressAttributeController( _service.Object, _vmService.Object, - Mock.Of(), Mock.Of(), _scope.Object); + _languageService.Object, Mock.Of(), _scope.Object); var httpContext = new DefaultHttpContext(); var loggerFactoryMock = new Mock(); @@ -62,6 +64,26 @@ public void Setup() _sut.TempData = new TempDataDictionary(httpContext, new Mock().Object); } + [TestMethod] + public async Task EditPost_InvalidModel_KeepsThePostedLocales() + { + var entity = new AddressAttribute { Id = "1" }; + _service.Setup(x => x.GetAddressAttributeById("1")).ReturnsAsync(entity); + _scope.Setup(x => x.HasAccess(entity)).ReturnsAsync(true); + _languageService.Setup(l => l.GetAllLanguages(true, It.IsAny())) + .ReturnsAsync(new List { new() { Id = "lang-1" } }); + _sut.ModelState.AddModelError("Name", "required"); + var model = new AddressAttributeModel { + Id = "1", + Locales = [new AddressAttributeLocalizedModel { LanguageId = "lang-1", Name = "typed" }] + }; + + var result = await _sut.Edit(model, false); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("typed", model.Locales.Single().Name); + } + [TestMethod] public async Task List_GlobalScope_ReturnsAllAttributesUnfiltered() { diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseBlogControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseBlogControllerTests.cs index ab7a4ed11..0d4d8530e 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseBlogControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseBlogControllerTests.cs @@ -46,6 +46,7 @@ private class TestBlogController( private Mock _translationServiceMock; private Mock> _postScopeMock; private Mock> _categoryScopeMock; + private Mock _languageServiceMock; [TestInitialize] public void Setup() @@ -67,14 +68,14 @@ public void Setup() _categoryScopeMock = new Mock>(); _categoryScopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - var languageServiceMock = new Mock(); - languageServiceMock.Setup(l => l.GetAllLanguages(true, It.IsAny())) + _languageServiceMock = new Mock(); + _languageServiceMock.Setup(l => l.GetAllLanguages(true, It.IsAny())) .ReturnsAsync(new List()); _controller = new TestBlogController( _blogServiceMock.Object, _blogViewModelServiceMock.Object, - languageServiceMock.Object, + _languageServiceMock.Object, _translationServiceMock.Object, new Mock().Object, new Mock().Object, @@ -152,6 +153,63 @@ public async Task EditPost_ScopeDeniesAccess_RedirectsToEdit() _blogViewModelServiceMock.Verify(v => v.UpdateBlogPostModel(It.IsAny(), It.IsAny()), Times.Never); } + [TestMethod] + public async Task EditPost_InvalidModel_KeepsThePostedLocales() + { + var post = new BlogPost { Id = "p1" }; + _blogServiceMock.Setup(b => b.GetBlogPostById("p1")).ReturnsAsync(post); + _postScopeMock.Setup(s => s.HasAccess(post)).ReturnsAsync(true); + SetOneLanguage(); + _controller.ModelState.AddModelError("Title", "required"); + var model = new BlogPostModel { + Id = "p1", + Locales = [new BlogLocalizedModel { LanguageId = "lang-1", Title = "typed" }] + }; + + var result = await _controller.Edit(model, false); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("typed", model.Locales.Single().Title); + } + + [TestMethod] + public async Task CategoryCreatePost_InvalidModel_KeepsThePostedLocales() + { + SetOneLanguage(); + _controller.ModelState.AddModelError("Name", "required"); + var model = new BlogCategoryModel { + Locales = [new BlogCategoryLocalizedModel { LanguageId = "lang-1", Name = "typed" }] + }; + + var result = await _controller.CategoryCreate(model, false); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("typed", model.Locales.Single().Name); + } + + [TestMethod] + public async Task CategoryEditPost_InvalidModel_KeepsThePostedLocales() + { + var category = new BlogCategory { Id = "c1" }; + _blogServiceMock.Setup(b => b.GetBlogCategoryById("c1")).ReturnsAsync(category); + _categoryScopeMock.Setup(s => s.HasAccess(category)).ReturnsAsync(true); + SetOneLanguage(); + _controller.ModelState.AddModelError("Name", "required"); + var model = new BlogCategoryModel { + Id = "c1", + Locales = [new BlogCategoryLocalizedModel { LanguageId = "lang-1", Name = "typed" }] + }; + + var result = await _controller.CategoryEdit(model, false); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("typed", model.Locales.Single().Name); + } + + private void SetOneLanguage() => + _languageServiceMock.Setup(l => l.GetAllLanguages(true, It.IsAny())) + .ReturnsAsync(new List { new() { Id = "lang-1" } }); + [TestMethod] public async Task Delete_ScopeDeniesAccess_RedirectsToEditWithoutDeleting() { diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCustomerAttributeControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCustomerAttributeControllerTests.cs index ff4ecf347..639de15d4 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCustomerAttributeControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCustomerAttributeControllerTests.cs @@ -34,6 +34,7 @@ private class TestCustomerAttributeController( private Mock _service = null!; private Mock _vmService = null!; private Mock> _scope = null!; + private Mock _languageService = null!; private TestCustomerAttributeController _sut = null!; [TestInitialize] @@ -42,8 +43,9 @@ public void Setup() _service = new Mock(); _vmService = new Mock(); _scope = new Mock>(); + _languageService = new Mock(); _sut = new TestCustomerAttributeController(_service.Object, _vmService.Object, - Mock.Of(), Mock.Of(), _scope.Object); + _languageService.Object, Mock.Of(), _scope.Object); var httpContext = new DefaultHttpContext(); var loggerFactoryMock = new Mock(); @@ -58,6 +60,26 @@ public void Setup() _sut.TempData = new TempDataDictionary(httpContext, new Mock().Object); } + [TestMethod] + public async Task EditPost_InvalidModel_KeepsThePostedLocales() + { + var entity = new CustomerAttribute { Id = "1" }; + _service.Setup(x => x.GetCustomerAttributeById("1")).ReturnsAsync(entity); + _scope.Setup(x => x.HasAccess(entity)).ReturnsAsync(true); + _languageService.Setup(l => l.GetAllLanguages(true, It.IsAny())) + .ReturnsAsync(new List { new() { Id = "lang-1" } }); + _sut.ModelState.AddModelError("Name", "required"); + var model = new CustomerAttributeModel { + Id = "1", + Locales = [new CustomerAttributeLocalizedModel { LanguageId = "lang-1", Name = "typed" }] + }; + + var result = await _sut.Edit(model, false); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("typed", model.Locales.Single().Name); + } + [TestMethod] public async Task Delete_UsesViewModelServiceNotRawService() { diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeControllerTests.cs index af9cc3890..40d8bfd19 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeControllerTests.cs @@ -38,6 +38,7 @@ private class TestProductAttributeController( private Mock _productService = null!; private Mock _attributeService = null!; private Mock> _scope = null!; + private Mock _languageService = null!; private TestProductAttributeController _sut = null!; [TestInitialize] @@ -46,8 +47,9 @@ public void Setup() _productService = new Mock(); _attributeService = new Mock(); _scope = new Mock>(); + _languageService = new Mock(); _sut = new TestProductAttributeController(_productService.Object, _attributeService.Object, - Mock.Of(), Mock.Of(), new SeoSettings(), _scope.Object); + _languageService.Object, Mock.Of(), new SeoSettings(), _scope.Object); var httpContext = new DefaultHttpContext(); var loggerFactoryMock = new Mock(); @@ -62,6 +64,26 @@ public void Setup() _sut.TempData = new TempDataDictionary(httpContext, new Mock().Object); } + [TestMethod] + public async Task EditPost_InvalidModel_KeepsThePostedLocales() + { + var entity = new ProductAttribute { Id = "1" }; + _attributeService.Setup(x => x.GetProductAttributeById("1")).ReturnsAsync(entity); + _scope.Setup(x => x.HasAccess(entity)).ReturnsAsync(true); + _languageService.Setup(l => l.GetAllLanguages(true, It.IsAny())) + .ReturnsAsync(new List { new() { Id = "lang-1" } }); + _sut.ModelState.AddModelError("Name", "required"); + var model = new Grand.Web.AdminShared.Models.Catalog.ProductAttributeModel { + Id = "1", + Locales = [new Grand.Web.AdminShared.Models.Catalog.ProductAttributeLocalizedModel { LanguageId = "lang-1", Name = "typed" }] + }; + + var result = await _sut.Edit(model, false); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("typed", model.Locales.Single().Name); + } + [TestMethod] public async Task List_PassesScopeStoreIdToService() { diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseSpecificationAttributeControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseSpecificationAttributeControllerTests.cs index 5fc3f587a..785c048f2 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseSpecificationAttributeControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseSpecificationAttributeControllerTests.cs @@ -38,6 +38,7 @@ private class TestSpecificationAttributeController( private Mock _service = null!; private Mock _productService = null!; private Mock> _scope = null!; + private Mock _languageService = null!; private TestSpecificationAttributeController _sut = null!; [TestInitialize] @@ -46,7 +47,8 @@ public void Setup() _service = new Mock(); _productService = new Mock(); _scope = new Mock>(); - _sut = new TestSpecificationAttributeController(_service.Object, Mock.Of(), + _languageService = new Mock(); + _sut = new TestSpecificationAttributeController(_service.Object, _languageService.Object, Mock.Of(), _productService.Object, new SeoSettings(), _scope.Object); var httpContext = new DefaultHttpContext(); @@ -62,6 +64,26 @@ public void Setup() _sut.TempData = new TempDataDictionary(httpContext, new Mock().Object); } + [TestMethod] + public async Task EditPost_InvalidModel_KeepsThePostedLocales() + { + var entity = new SpecificationAttribute { Id = "1" }; + _service.Setup(x => x.GetSpecificationAttributeById("1")).ReturnsAsync(entity); + _scope.Setup(x => x.HasAccess(entity)).ReturnsAsync(true); + _languageService.Setup(l => l.GetAllLanguages(true, It.IsAny())) + .ReturnsAsync(new List { new() { Id = "lang-1" } }); + _sut.ModelState.AddModelError("Name", "required"); + var model = new Grand.Web.AdminShared.Models.Catalog.SpecificationAttributeModel { + Id = "1", + Locales = [new Grand.Web.AdminShared.Models.Catalog.SpecificationAttributeLocalizedModel { LanguageId = "lang-1", Name = "typed" }] + }; + + var result = await _sut.Edit(model, false); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("typed", model.Locales.Single().Name); + } + [TestMethod] public async Task List_PassesScopeStoreIdToService() { diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseAddressAttributeController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseAddressAttributeController.cs index e54c710df..8c7abcfb0 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseAddressAttributeController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseAddressAttributeController.cs @@ -116,10 +116,6 @@ public virtual async Task Edit(AddressAttributeModel model, bool } model.IsGlobalAttribute = false; - await AddLocales(languageService, model.Locales, (locale, languageId) => - { - locale.Name = addressAttribute.GetTranslation(x => x.Name, languageId, false); - }); return View(model); } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseBlogController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseBlogController.cs index 002ed62d4..0be6fd653 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseBlogController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseBlogController.cs @@ -153,16 +153,6 @@ public async Task Edit(BlogPostModel model, bool continueEditing) return RedirectToAction("List"); } - await AddLocales(languageService, model.Locales, (locale, languageId) => - { - locale.Title = blogPost.GetTranslation(x => x.Title, languageId, false); - locale.Body = blogPost.GetTranslation(x => x.Body, languageId, false); - locale.BodyOverview = blogPost.GetTranslation(x => x.BodyOverview, languageId, false); - locale.MetaKeywords = blogPost.GetTranslation(x => x.MetaKeywords, languageId, false); - locale.MetaDescription = blogPost.GetTranslation(x => x.MetaDescription, languageId, false); - locale.MetaTitle = blogPost.GetTranslation(x => x.MetaTitle, languageId, false); - locale.SeName = blogPost.GetSeName(languageId, false); - }); return View(model); } @@ -267,7 +257,6 @@ public async Task CategoryCreate(BlogCategoryModel model, bool co : RedirectToAction(CategoryListAction); } - await AddLocales(languageService, model.Locales); return View(model); } @@ -312,10 +301,6 @@ public async Task CategoryEdit(BlogCategoryModel model, bool cont return RedirectToAction(CategoryListAction); } - await AddLocales(languageService, model.Locales, (locale, languageId) => - { - locale.Name = blogCategory.GetTranslation(x => x.Name, languageId, false); - }); return View(model); } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseCustomerAttributeController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseCustomerAttributeController.cs index 3095d9497..e234cf3b6 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseCustomerAttributeController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseCustomerAttributeController.cs @@ -110,10 +110,6 @@ public virtual async Task Edit(CustomerAttributeModel model, bool } model.IsGlobalAttribute = false; - await AddLocales(languageService, model.Locales, (locale, languageId) => - { - locale.Name = customerAttribute.GetTranslation(x => x.Name, languageId, false); - }); return View(model); } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeController.cs index cae1e1036..2dc5785d8 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeController.cs @@ -114,11 +114,6 @@ public async Task Edit(ProductAttributeModel model, bool continue return RedirectToAction("List"); } - await AddLocales(languageService, model.Locales, (locale, languageId) => - { - locale.Name = productAttribute.GetTranslation(x => x.Name, languageId, false); - locale.Description = productAttribute.GetTranslation(x => x.Description, languageId, false); - }); return View(model); } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseSpecificationAttributeController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseSpecificationAttributeController.cs index 21b80b5d2..1555f1a9c 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseSpecificationAttributeController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseSpecificationAttributeController.cs @@ -155,10 +155,6 @@ public async Task Edit(SpecificationAttributeModel model, bool co return RedirectToAction("List"); } - await AddLocales(languageService, model.Locales, (locale, languageId) => - { - locale.Name = specificationAttribute.GetTranslation(x => x.Name, languageId, false); - }); return View(model); } From 39d28b001e1372ca5f62ff9cad2f6b5e3aeed758 Mon Sep 17 00:00:00 2001 From: support Date: Mon, 2 Nov 2026 08:01:49 +0100 Subject: [PATCH 6/9] Confirm saving an order tag and redirect after it A successful save re-rendered the form from the POST, which looked the same as a failed one, and a refresh submitted it again. It now behaves like the product tag screen. --- .../Controllers/OrderTagsControllerTests.cs | 90 ++++++++++++++++++ .../Controllers/OrderTagsController.cs | 9 +- .../App_Data/Resources/DefaultLanguage.xml | Bin 1648866 -> 1649152 bytes .../App_Data/Resources/Upgrade/en_250.xml | 3 + 4 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/OrderTagsControllerTests.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/OrderTagsControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/OrderTagsControllerTests.cs new file mode 100644 index 000000000..bff8cc80c --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/OrderTagsControllerTests.cs @@ -0,0 +1,90 @@ +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Domain.Orders; +using Grand.Web.Admin.Controllers; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class OrderTagsControllerTests +{ + private OrderTagsController _controller; + private Mock _orderTagServiceMock; + private Mock _translationServiceMock; + + [TestInitialize] + public void Setup() + { + _orderTagServiceMock = new Mock(); + _translationServiceMock = new Mock(); + _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); + + _controller = new OrderTagsController( + _orderTagServiceMock.Object, + new Mock().Object, + new Mock().Object, + _translationServiceMock.Object); + + var httpContext = new DefaultHttpContext(); + var loggerFactoryMock = new Mock(); + loggerFactoryMock.Setup(l => l.CreateLogger(It.IsAny())).Returns(new Mock().Object); + var urlHelperFactoryMock = new Mock(); + urlHelperFactoryMock.Setup(f => f.GetUrlHelper(It.IsAny())).Returns(new Mock().Object); + var requestServicesMock = new Mock(); + requestServicesMock.Setup(s => s.GetService(typeof(ILoggerFactory))).Returns(loggerFactoryMock.Object); + requestServicesMock.Setup(s => s.GetService(typeof(IUrlHelperFactory))).Returns(urlHelperFactoryMock.Object); + httpContext.RequestServices = requestServicesMock.Object; + _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + _controller.TempData = new TempDataDictionary(httpContext, new Mock().Object); + } + + [TestMethod] + public async Task EditPost_ValidModel_SavesAndRedirectsToEditWithSuccess() + { + var tag = new OrderTag { Id = "t1", Name = "old" }; + _orderTagServiceMock.Setup(s => s.GetOrderTagById("t1")).ReturnsAsync(tag); + + var result = await _controller.Edit(new OrderTagModel { Id = "t1", Name = "new" }); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("Edit", redirect.ActionName); + Assert.AreEqual("t1", redirect.RouteValues["id"]); + _orderTagServiceMock.Verify(s => s.UpdateOrderTag(It.Is(t => t.Name == "new")), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Orders.OrderTags.Updated"), Times.Once); + } + + [TestMethod] + public async Task EditPost_InvalidModel_RedisplaysTheFormWithoutSaving() + { + _orderTagServiceMock.Setup(s => s.GetOrderTagById("t1")).ReturnsAsync(new OrderTag { Id = "t1", Name = "old" }); + _controller.ModelState.AddModelError("Name", "required"); + + var result = await _controller.Edit(new OrderTagModel { Id = "t1" }); + + Assert.IsInstanceOfType(result); + _orderTagServiceMock.Verify(s => s.UpdateOrderTag(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task EditPost_TagNotFound_RedirectsToListWithoutSaving() + { + _orderTagServiceMock.Setup(s => s.GetOrderTagById("missing")).ReturnsAsync((OrderTag)null); + + var result = await _controller.Edit(new OrderTagModel { Id = "missing", Name = "new" }); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _orderTagServiceMock.Verify(s => s.UpdateOrderTag(It.IsAny()), Times.Never); + } +} diff --git a/src/Web/Grand.Web.Admin/Controllers/OrderTagsController.cs b/src/Web/Grand.Web.Admin/Controllers/OrderTagsController.cs index c130927cf..e1a3b5de4 100644 --- a/src/Web/Grand.Web.Admin/Controllers/OrderTagsController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/OrderTagsController.cs @@ -3,6 +3,7 @@ using Grand.Web.AdminShared.Models.Orders; using Grand.Web.Common.DataSource; using Grand.Web.Common.Extensions; +using Grand.Web.Common.Localization; using Grand.Web.Common.Security.Authorization; using Microsoft.AspNetCore.Mvc; @@ -14,13 +15,15 @@ public class OrderTagsController : BaseAdminController private readonly IOrderService _orderService; private readonly IOrderTagService _orderTagService; private readonly IRequestPermissionService _permissionService; + private readonly IRequestTranslationService _translationService; public OrderTagsController(IOrderTagService orderTagService, IOrderService orderService, - IRequestPermissionService permissionService) + IRequestPermissionService permissionService, IRequestTranslationService translationService) { _orderTagService = orderTagService; _orderService = orderService; _permissionService = permissionService; + _translationService = translationService; } public IActionResult Index() @@ -113,7 +116,9 @@ public async Task Edit(OrderTagModel model) { orderTag.Name = model.Name; await _orderTagService.UpdateOrderTag(orderTag); - return View(model); + Success(_translationService.GetResource("Admin.Orders.OrderTags.Updated")); + + return RedirectToAction("Edit", new { id = model.Id }); } //If we got this far, something failed, redisplay form diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index 7f27d889f67f87d81220ee7077d522e970144892..25b55d51cd997b1cd50c0c07bbceb1e56d6a88a6 100644 GIT binary patch delta 121 zcmaEKF{R;3N<#}{3sVbo3rh=Y3tJ0&3r7oQ3s(#G79Jn9=^K=JM5f=m&*C**Pf(I; zx|<>o-*lS?EVHIhFyJw2FHqwFVqPHT17dz4764*FAQl2*VIUR(Vo@L#17dL?me^jP ICRxM;0LU9A%m4rY delta 96 zcmZp;lJe+cN<#}{3sVbo3rh=Y3tJ0&3r7oQ3s(#G79Jn9=@$%mRNDL0d4QM~i1~n+ iABY8jSP+PXfLIuaMSxfoh{b?d9Ec^h_o+)3F#!Nu5FjQ1 diff --git a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml index 6a4d1bdaf..3a8e21ebc 100644 --- a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml +++ b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml @@ -846,4 +846,7 @@ Server time zone + + The order's tag has been updated successfully. + From 91ef0d6e06dc3530b4802953a89e263d99613b6e Mon Sep 17 00:00:00 2001 From: support Date: Mon, 2 Nov 2026 08:01:50 +0100 Subject: [PATCH 7/9] Serialize the values in the course product popup script A product name ending in a backslash broke the script, so the opener's field stayed empty and the popup stayed open. The name now goes in as text, the same way the required product popup passes its values. --- .../Admin/Views/Course/AssociateProductToCoursePopup.cshtml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Course/AssociateProductToCoursePopup.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Course/AssociateProductToCoursePopup.cshtml index 5e3035710..0f097740a 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Course/AssociateProductToCoursePopup.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Course/AssociateProductToCoursePopup.cshtml @@ -51,9 +51,9 @@ {