From 018670411085fc783bfdc8faaf3b0949a1e67caa Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 24 Aug 2026 21:22:32 +0200 Subject: [PATCH 1/8] feat(arch001): add RoutedCollectionDataScope and register IAdminDataScope --- .../RoutedCollectionDataScopeTests.cs | 64 +++++++++++++++++++ .../Services/RoutedCollectionDataScope.cs | 56 ++++++++++++++++ .../Startup/StartupApplication.cs | 6 ++ 3 files changed, 126 insertions(+) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedCollectionDataScopeTests.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/RoutedCollectionDataScope.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedCollectionDataScopeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedCollectionDataScopeTests.cs new file mode 100644 index 000000000..320e025d5 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedCollectionDataScopeTests.cs @@ -0,0 +1,64 @@ +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class RoutedCollectionDataScopeTests +{ + private static RoutedCollectionDataScope Build(string area) + { + var httpContext = new DefaultHttpContext(); + if (area != null) + httpContext.Request.RouteValues = new RouteValueDictionary { ["area"] = area }; + + var httpContextAccessorMock = new Mock(); + httpContextAccessorMock.Setup(a => a.HttpContext).Returns(httpContext); + + var global = new GlobalAdminDataScope(); + var store = new StoreAdminDataScope(BuildContextAccessor()); + return new RoutedCollectionDataScope(httpContextAccessorMock.Object, global, store); + } + + private static Grand.Infrastructure.IContextAccessor BuildContextAccessor() + { + var workContext = new Mock(); + workContext.Setup(w => w.CurrentCustomer).Returns(new Grand.Domain.Customers.Customer { StaffStoreId = "store-1" }); + var contextAccessorMock = new Mock(); + contextAccessorMock.Setup(c => c.WorkContext).Returns(workContext.Object); + return contextAccessorMock.Object; + } + + [TestMethod] + public void DefaultStoreId_AdminArea_ResolvesToGlobalScope() + { + var routed = Build("Admin"); + Assert.IsNull(routed.DefaultStoreId); + } + + [TestMethod] + public void DefaultStoreId_StoreArea_ResolvesToStoreScope() + { + var routed = Build("Store"); + Assert.AreEqual("store-1", routed.DefaultStoreId); + } + + [TestMethod] + public void DefaultStoreId_VendorArea_ThrowsFailClosed() + { + var routed = Build("Vendor"); + Assert.ThrowsExactly(() => _ = routed.DefaultStoreId); + } + + [TestMethod] + public void DefaultStoreId_MissingArea_ThrowsFailClosed() + { + var routed = Build(null); + Assert.ThrowsExactly(() => _ = routed.DefaultStoreId); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Services/RoutedCollectionDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/RoutedCollectionDataScope.cs new file mode 100644 index 000000000..7dd4d2a69 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/RoutedCollectionDataScope.cs @@ -0,0 +1,56 @@ +#nullable enable + +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Interfaces; +using Microsoft.AspNetCore.Http; + +namespace Grand.Web.AdminShared.Services; + +/// +/// Resolves the correct per-host implementation at +/// request time, based on the current request's "area" route value — same fix and same reason +/// as / (see those +/// files' doc comments): Grand.Web (the combined host) loads Admin and Store together in one +/// DI container, so a plain AddScoped<IAdminDataScope<Collection>, X>() per host +/// would silently let whichever host's StartupApplication ran last win for every area in that +/// process. +/// +/// Unlike Product, there is no Vendor branch: Vendor has no Collection screen at all, so any +/// "Vendor" (or other unrecognized/missing) area value fails closed. +/// +public class RoutedCollectionDataScope( + IHttpContextAccessor httpContextAccessor, + GlobalAdminDataScope globalScope, + StoreAdminDataScope storeScope) : IAdminDataScope +{ + private IAdminDataScope Resolved + { + get + { + var area = httpContextAccessor.HttpContext?.Request.RouteValues["area"] as string; + return area switch { + "Admin" => globalScope, + "Store" => storeScope, + //fail closed: this object fronts store tenant isolation, so an unrecognized or + //missing area (including "Vendor" - Collection has no Vendor screen) must never + //silently resolve to the unscoped global scope + _ => throw new InvalidOperationException( + $"RoutedCollectionDataScope: unrecognized or missing area '{area}'.") + }; + } + } + + public Task HasAccess(Collection entity) => Resolved.HasAccess(entity); + + public Task CanView(Collection entity) => Resolved.CanView(entity); + + public string? DefaultStoreId => Resolved.DefaultStoreId; + + public string ResourceKeyPrefix => Resolved.ResourceKeyPrefix; + + public bool ShowStoreSelector => Resolved.ShowStoreSelector; + + public string? DefaultVendorId => Resolved.DefaultVendorId; + + public bool CanFeatureOnHomepage => Resolved.CanFeatureOnHomepage; +} diff --git a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs index f2a319048..bd84eb0ac 100644 --- a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs @@ -76,6 +76,12 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped>(); services.AddScoped>(); services.AddScoped, RoutedCategoryDataScope>(); + + // IAdminDataScope: registered once here for the same reason as Category above — see + // RoutedCollectionDataScope's doc comment. No Vendor scope: Collection has no Vendor screen. + services.AddScoped>(); + services.AddScoped>(); + services.AddScoped, RoutedCollectionDataScope>(); } public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) From 2408ec1b908fdfe9cc083cbc20a9b25fc51112a9 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 24 Aug 2026 21:30:09 +0200 Subject: [PATCH 2/8] feat(arch001): add BaseCollectionController skeleton with shared List region Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017ruWBUZPv3BnpPhjQVV8Xf --- .../BaseCollectionControllerTests.cs | 151 ++++++++++++++++++ .../Controllers/BaseCollectionController.cs | 82 ++++++++++ 2 files changed, 233 insertions(+) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCollectionControllerTests.cs create mode 100644 src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCollectionControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCollectionControllerTests.cs new file mode 100644 index 000000000..f8d0b7d27 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCollectionControllerTests.cs @@ -0,0 +1,151 @@ +using Grand.Business.Core.Interfaces.Catalog.Collections; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Domain.Catalog; +using Grand.Domain.Stores; +using Grand.Infrastructure.Mapper; +using Grand.Mapping; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Mapper; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.Common.DataSource; +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; + +// Characterization tests for the merged Collection access-check behavior (ARCH-001 Collection +// consolidation). Parameterized over a mocked IAdminDataScope instead of the two +// different concrete access mechanisms Admin (none) and Store (AccessToEntityByStore) used before. +[TestClass] +public class BaseCollectionControllerTests +{ + // BaseCollectionController is abstract; this minimal subclass exists only so actions under + // test can be invoked directly. No EditWarningCheck override here (Task 3 adds that on the + // real Store subclass) - the base's no-op default is exercised. + private class TestCollectionController( + ICollectionViewModelService collectionViewModelService, + ICollectionService collectionService, + IStoreService storeService, + ILanguageService languageService, + ITranslationService translationService, + IPictureViewModelService pictureViewModelService, + IProductService productService, + IAdminDataScope scope) + : BaseCollectionController(collectionViewModelService, collectionService, storeService, + languageService, translationService, pictureViewModelService, productService, scope); + + private TestCollectionController _controller; + private Mock _collectionServiceMock; + private Mock _collectionViewModelServiceMock; + private Mock _storeServiceMock; + private Mock _translationServiceMock; + private Mock> _scopeMock; + + [TestInitialize] + public void Setup() + { + var mapperConfig = new MapperConfiguration(cfg => cfg.AddProfile()); + AutoMapperConfig.Init(mapperConfig); + + _collectionServiceMock = new Mock(); + _collectionViewModelServiceMock = new Mock(); + _storeServiceMock = new Mock(); + _storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List()); + _translationServiceMock = new Mock(); + _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); + + _scopeMock = new Mock>(); + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + + var languageServiceMock = new Mock(); + languageServiceMock.Setup(l => l.GetAllLanguages(true, It.IsAny())).ReturnsAsync(new List()); + + _controller = new TestCollectionController( + _collectionViewModelServiceMock.Object, + _collectionServiceMock.Object, + _storeServiceMock.Object, + languageServiceMock.Object, + _translationServiceMock.Object, + new Mock().Object, + new Mock().Object, + _scopeMock.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 ListGet_GlobalScope_PopulatesAvailableStores() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _storeServiceMock.Setup(s => s.GetAllStores()) + .ReturnsAsync(new List { new() { Id = "s1", Shortcut = "Store 1" } }); + + var result = await _controller.List(); + + var view = result as ViewResult; + Assert.IsNotNull(view); + var model = (CollectionListModel)view.Model; + // "All" placeholder + the one real store + Assert.AreEqual(2, model.AvailableStores.Count); + } + + [TestMethod] + public async Task ListGet_StoreScope_SkipsAvailableStores() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + + var result = await _controller.List(); + + var view = result as ViewResult; + Assert.IsNotNull(view); + var model = (CollectionListModel)view.Model; + Assert.AreEqual(0, model.AvailableStores.Count); + _storeServiceMock.Verify(s => s.GetAllStores(), Times.Never); + } + + [TestMethod] + public async Task ListPost_ForcesScopeDefaultStoreIdOntoSearchModel() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _collectionServiceMock + .Setup(c => c.GetAllCollections(It.IsAny(), "store-1", 0, 10, true)) + .ReturnsAsync(new Grand.Domain.PagedList(new List(), 0, 10)); + + var model = new CollectionListModel { SearchStoreId = "attacker-supplied-store" }; + await _controller.List(new DataSourceRequest { Page = 1, PageSize = 10 }, model); + + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task ListPost_GlobalScope_LeavesSubmittedSearchStoreIdUntouched() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _collectionServiceMock + .Setup(c => c.GetAllCollections(It.IsAny(), "admin-submitted-store", 0, 10, true)) + .ReturnsAsync(new Grand.Domain.PagedList(new List(), 0, 10)); + + var model = new CollectionListModel { SearchStoreId = "admin-submitted-store" }; + await _controller.List(new DataSourceRequest { Page = 1, PageSize = 10 }, model); + + Assert.AreEqual("admin-submitted-store", model.SearchStoreId); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs new file mode 100644 index 000000000..bd32c6655 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs @@ -0,0 +1,82 @@ +using Grand.Business.Core.Interfaces.Catalog.Collections; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace Grand.Web.AdminShared.Controllers; + +// [AutoValidateAntiforgeryToken] is restated on each concrete host subclass (Admin/Store +// CollectionController) too - ASP.NET Core resolves filters from the concrete controller's full +// type hierarchy at runtime, so every real endpoint is already protected. It's added here as well, +// mirroring BaseProductController/BaseCategoryController, so static analysis that doesn't follow +// the attribute across a base/derived project boundary has something to see in the same file as +// the actions. +[PermissionAuthorize(PermissionSystemName.Collections)] +[AutoValidateAntiforgeryToken] +public abstract class BaseCollectionController( + ICollectionViewModelService collectionViewModelService, + ICollectionService collectionService, + IStoreService storeService, + ILanguageService languageService, + ITranslationService translationService, + IPictureViewModelService pictureViewModelService, + IProductService productService, + IAdminDataScope scope) + : BaseController +{ + /// Hook for host-specific UI-copy warnings that aren't access-scope decisions. + /// Overridden by the Store subclass (Task 3); no-op everywhere else. Mirrors + /// BaseCategoryController.EditWarningCheck. + protected virtual void EditWarningCheck(Collection collection) { } + + // Exposed for host subclasses: primary-constructor parameters are not visible to derived + // classes by name in C#, so Store's EditWarningCheck override needs this. + protected ITranslationService TranslationService => translationService; + protected IAdminDataScope Scope => scope; + + #region List + + public IActionResult Index() => RedirectToAction("List"); + + public async Task List() + { + var model = new CollectionListModel(); + // Admin only: Store never had this dropdown (it's implicitly single-store). + // ShowStoreSelector can't gate this - it's true on both Global and Store scopes. + if (scope.DefaultStoreId is null) + { + model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = "" }); + foreach (var s in await storeService.GetAllStores()) + model.AvailableStores.Add(new SelectListItem { Text = s.Shortcut, Value = s.Id }); + } + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.List)] + [HttpPost] + public async Task List(DataSourceRequest command, CollectionListModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + var collections = await collectionService.GetAllCollections(model.SearchCollectionName, + model.SearchStoreId, command.Page - 1, command.PageSize, true); + var gridModel = new DataSourceResult { + Data = collections.Select(x => x.ToModel()), + Total = collections.TotalCount + }; + + return Json(gridModel); + } + + #endregion +} From d46e9f4d66a564cb23eb590dbcdad8ac1f16fffa Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 24 Aug 2026 21:36:14 +0200 Subject: [PATCH 3/8] feat(arch001): add Create/Edit/Delete region to BaseCollectionController Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017ruWBUZPv3BnpPhjQVV8Xf --- .../BaseCollectionControllerTests.cs | 111 ++++++++++++++++ .../Controllers/BaseCollectionController.cs | 121 ++++++++++++++++++ 2 files changed, 232 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCollectionControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCollectionControllerTests.cs index f8d0b7d27..c91e790af 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCollectionControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCollectionControllerTests.cs @@ -148,4 +148,115 @@ public async Task ListPost_GlobalScope_LeavesSubmittedSearchStoreIdUntouched() Assert.AreEqual("admin-submitted-store", model.SearchStoreId); } + + // --- Edit (GET) -------------------------------------------------------------------------------- + + [TestMethod] + public async Task EditGet_CollectionNotFound_RedirectsToList() + { + _collectionServiceMock.Setup(c => c.GetCollectionById("missing")).ReturnsAsync((Collection)null); + + var result = await _controller.Edit("missing"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _scopeMock.Verify(s => s.CanView(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task EditGet_ScopeDeniesView_RedirectsToList() + { + var collection = new Collection { Id = "c1" }; + _collectionServiceMock.Setup(c => c.GetCollectionById("c1")).ReturnsAsync(collection); + _scopeMock.Setup(s => s.CanView(collection)).ReturnsAsync(false); + + var result = await _controller.Edit("c1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } + + [TestMethod] + public async Task EditGet_ScopeAllowsView_ReturnsViewWithModel() + { + var collection = new Collection { Id = "c1", Name = "Widgets" }; + _collectionServiceMock.Setup(c => c.GetCollectionById("c1")).ReturnsAsync(collection); + _scopeMock.Setup(s => s.CanView(collection)).ReturnsAsync(true); + + var result = await _controller.Edit("c1"); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreEqual("Widgets", ((CollectionModel)view.Model).Name); + } + + // --- Edit (POST) ------------------------------------------------------------------------------- + + [TestMethod] + public async Task EditPost_ScopeDeniesAccess_RedirectsToEdit() + { + var collection = new Collection { Id = "c1" }; + _collectionServiceMock.Setup(c => c.GetCollectionById("c1")).ReturnsAsync(collection); + _scopeMock.Setup(s => s.HasAccess(collection)).ReturnsAsync(false); + + var result = await _controller.Edit(new CollectionModel { Id = "c1" }, false); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("Edit", redirect.ActionName); + _collectionViewModelServiceMock.Verify(v => v.UpdateCollectionModel(It.IsAny(), It.IsAny()), Times.Never); + } + + // --- Delete -------------------------------------------------------------------------------------- + + [TestMethod] + public async Task Delete_ScopeDeniesAccess_RedirectsToEditWithoutDeleting() + { + var collection = new Collection { Id = "c1" }; + _collectionServiceMock.Setup(c => c.GetCollectionById("c1")).ReturnsAsync(collection); + _scopeMock.Setup(s => s.HasAccess(collection)).ReturnsAsync(false); + + var result = await _controller.Delete("c1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("Edit", redirect.ActionName); + Assert.AreEqual("c1", redirect.RouteValues["id"]); + _collectionViewModelServiceMock.Verify(v => v.DeleteCollection(It.IsAny()), Times.Never); + } + + // --- Create (POST) ------------------------------------------------------------------------------ + + [TestMethod] + public async Task CreatePost_StoreScoped_ForcesModelStores() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var inserted = new Collection { Id = "new-1" }; + _collectionViewModelServiceMock + .Setup(v => v.InsertCollectionModel(It.IsAny())) + .ReturnsAsync(inserted) + .Callback(m => Assert.AreSequenceEqual(new[] { "store-1" }, m.Stores)); + + await _controller.Create(new CollectionModel { Name = "N" }, false); + + _collectionViewModelServiceMock.Verify(v => v.InsertCollectionModel(It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task CreatePost_GlobalScoped_LeavesModelStoresUntouched() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + var inserted = new Collection { Id = "new-1" }; + var submitted = new CollectionModel { Name = "N", Stores = ["explicit-store"] }; + _collectionViewModelServiceMock + .Setup(v => v.InsertCollectionModel(It.IsAny())) + .ReturnsAsync(inserted) + .Callback(m => Assert.AreSequenceEqual(new[] { "explicit-store" }, m.Stores)); + + await _controller.Create(submitted, false); + + _collectionViewModelServiceMock.Verify(v => v.InsertCollectionModel(It.IsAny()), Times.Once); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs index bd32c6655..30f94e360 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs @@ -1,3 +1,4 @@ +using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Catalog.Collections; using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Common.Localization; @@ -9,6 +10,7 @@ using Grand.Web.AdminShared.Models.Catalog; using Grand.Web.Common.Controllers; using Grand.Web.Common.DataSource; +using Grand.Web.Common.Filters; using Grand.Web.Common.Security.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; @@ -79,4 +81,123 @@ public async Task List(DataSourceRequest command, CollectionListM } #endregion + + #region Create / Edit / Delete + + [PermissionAuthorizeAction(PermissionActionName.Create)] + public async Task Create([FromServices] CatalogSettings catalogSettings) + { + var model = new CollectionModel(); + await AddLocales(languageService, model.Locales); + await collectionViewModelService.PrepareLayoutsModel(model); + await collectionViewModelService.PrepareDiscountModel(model, null, true); + model.PageSize = catalogSettings.DefaultPageSize; + model.PageSizeOptions = catalogSettings.DefaultPageSizeOptions; + model.Published = true; + model.AllowCustomersToSelectPageSize = true; + collectionViewModelService.PrepareSortOptionsModel(model); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Create(CollectionModel model, bool continueEditing) + { + if (ModelState.IsValid) + { + if (scope.DefaultStoreId is not null) model.Stores = [scope.DefaultStoreId]; + var collection = await collectionViewModelService.InsertCollectionModel(model); + Success(translationService.GetResource("Admin.Catalog.Collections.Added")); + return continueEditing ? RedirectToAction("Edit", new { id = collection.Id }) : RedirectToAction("List"); + } + + await collectionViewModelService.PrepareLayoutsModel(model); + await collectionViewModelService.PrepareDiscountModel(model, null, true); + collectionViewModelService.PrepareSortOptionsModel(model); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task Edit(string id) + { + var collection = await collectionService.GetCollectionById(id); + if (collection == null) return RedirectToAction("List"); + + EditWarningCheck(collection); + // CanView, not HasAccess: viewing a shared/global collection is allowed on Store (with a + // warning from EditWarningCheck above); only mutating one is restricted to the exclusive + // single-store owner. See IAdminDataScope.CanView's doc comment. + if (!await scope.CanView(collection)) return RedirectToAction("List"); + + var model = collection.ToModel(); + await AddLocales(languageService, model.Locales, (locale, languageId) => + { + locale.Name = collection.GetTranslation(x => x.Name, languageId, false); + locale.Description = collection.GetTranslation(x => x.Description, languageId, false); + locale.BottomDescription = collection.GetTranslation(x => x.BottomDescription, languageId, false); + locale.MetaKeywords = collection.GetTranslation(x => x.MetaKeywords, languageId, false); + locale.MetaDescription = collection.GetTranslation(x => x.MetaDescription, languageId, false); + locale.MetaTitle = collection.GetTranslation(x => x.MetaTitle, languageId, false); + locale.SeName = collection.GetSeName(languageId, false); + }); + await collectionViewModelService.PrepareLayoutsModel(model); + await collectionViewModelService.PrepareDiscountModel(model, collection, false); + collectionViewModelService.PrepareSortOptionsModel(model); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Edit(CollectionModel model, bool continueEditing) + { + var collection = await collectionService.GetCollectionById(model.Id); + if (collection == null) return RedirectToAction("List"); + if (!await scope.HasAccess(collection)) return RedirectToAction("Edit", new { id = collection.Id }); + + if (ModelState.IsValid) + { + if (scope.DefaultStoreId is not null) model.Stores = [scope.DefaultStoreId]; + collection = await collectionViewModelService.UpdateCollectionModel(collection, model); + Success(translationService.GetResource("Admin.Catalog.Collections.Updated")); + + if (continueEditing) + { + await SaveSelectedTabIndex(); + return RedirectToAction("Edit", new { id = collection.Id }); + } + return RedirectToAction("List"); + } + + await collectionViewModelService.PrepareLayoutsModel(model); + await collectionViewModelService.PrepareDiscountModel(model, collection, true); + collectionViewModelService.PrepareSortOptionsModel(model); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Delete)] + [HttpPost] + public async Task Delete(string id) + { + var collection = await collectionService.GetCollectionById(id); + if (collection == null) return RedirectToAction("List"); + if (!await scope.HasAccess(collection)) return RedirectToAction("Edit", new { id = collection.Id }); + + if (ModelState.IsValid) + { + await collectionViewModelService.DeleteCollection(collection); + Success(translationService.GetResource("Admin.Catalog.Collections.Deleted")); + return RedirectToAction("List"); + } + + Error(ModelState); + return RedirectToAction("Edit", new { id = collection.Id }); + } + + #endregion } From fd3225d758b6d3692a4d3c0a8359d51032377a5b Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 24 Aug 2026 21:40:53 +0200 Subject: [PATCH 4/8] feat(arch001): add Picture region to BaseCollectionController --- .../BaseCollectionControllerTests.cs | 112 ++++++++++++++++++ .../Controllers/BaseCollectionController.cs | 40 +++++++ 2 files changed, 152 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCollectionControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCollectionControllerTests.cs index c91e790af..a86dbf88c 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCollectionControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCollectionControllerTests.cs @@ -259,4 +259,116 @@ public async Task CreatePost_GlobalScoped_LeavesModelStoresUntouched() _collectionViewModelServiceMock.Verify(v => v.InsertCollectionModel(It.IsAny()), Times.Once); } + + // --- PicturePopup -------------------------------------------------------------------------------- + + [TestMethod] + public async Task PicturePopupGet_ScopeDeniesAccess_ReturnsDeniedContent() + { + var collection = new Collection { Id = "c1", PictureId = "pic-1" }; + _collectionServiceMock.Setup(c => c.GetCollectionById("c1")).ReturnsAsync(collection); + _scopeMock.Setup(s => s.HasAccess(collection)).ReturnsAsync(false); + + var result = await _controller.PicturePopup("c1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("This is not your collection", content.Content); + } + + [TestMethod] + public async Task PicturePopupGet_CollectionHasNoPicture_ReturnsNotExistContent() + { + var collection = new Collection { Id = "c1", PictureId = null }; + _collectionServiceMock.Setup(c => c.GetCollectionById("c1")).ReturnsAsync(collection); + _scopeMock.Setup(s => s.HasAccess(collection)).ReturnsAsync(true); + + var result = await _controller.PicturePopup("c1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("Picture not exist", content.Content); + } + + [TestMethod] + public async Task PicturePopupGet_CollectionNotFound_ReturnsNotExistContent() + { + _collectionServiceMock.Setup(c => c.GetCollectionById("missing")).ReturnsAsync((Collection)null); + + var result = await _controller.PicturePopup("missing"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("Collection not exist", content.Content); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task PicturePopupPost_ScopeDeniesAccess_ReturnsDeniedContent() + { + var collection = new Collection { Id = "c1", PictureId = "pic-1" }; + _collectionServiceMock.Setup(c => c.GetCollectionById("c1")).ReturnsAsync(collection); + _scopeMock.Setup(s => s.HasAccess(collection)).ReturnsAsync(false); + + var model = new Grand.Web.AdminShared.Models.Common.PictureModel { ObjectId = "c1", Id = "pic-1" }; + var result = await _controller.PicturePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("This is not your collection", content.Content); + } + + [TestMethod] + public async Task PicturePopupPost_CollectionNotFound_ThrowsArgumentException() + { + _collectionServiceMock.Setup(c => c.GetCollectionById("missing")).ReturnsAsync((Collection)null); + + var model = new Grand.Web.AdminShared.Models.Common.PictureModel { ObjectId = "missing", Id = "pic-1" }; + + var exception = await Assert.ThrowsExactlyAsync( + async () => await _controller.PicturePopup(model)); + + Assert.AreEqual("No collection found with the specified id", exception.Message); + } + + [TestMethod] + public async Task PicturePopupPost_PictureIdMismatch_ThrowsArgumentException() + { + var collection = new Collection { Id = "c1", PictureId = "pic-1" }; + _collectionServiceMock.Setup(c => c.GetCollectionById("c1")).ReturnsAsync(collection); + _scopeMock.Setup(s => s.HasAccess(collection)).ReturnsAsync(true); + + var model = new Grand.Web.AdminShared.Models.Common.PictureModel { ObjectId = "c1", Id = "pic-2" }; + + var exception = await Assert.ThrowsExactlyAsync( + async () => await _controller.PicturePopup(model)); + + Assert.AreEqual("Picture ident doesn't fit with collection", exception.Message); + } + + [TestMethod] + public async Task PicturePopupPost_ValidRequest_CallsUpdatePicture() + { + var pictureViewModelServiceMock = new Mock(); + var collection = new Collection { Id = "c1", PictureId = "pic-1" }; + _collectionServiceMock.Setup(c => c.GetCollectionById("c1")).ReturnsAsync(collection); + _scopeMock.Setup(s => s.HasAccess(collection)).ReturnsAsync(true); + pictureViewModelServiceMock.Setup(p => p.UpdatePicture(It.IsAny())).Returns(Task.CompletedTask); + + var controller = new TestCollectionController( + _collectionViewModelServiceMock.Object, _collectionServiceMock.Object, _storeServiceMock.Object, + new Mock().Object, _translationServiceMock.Object, + pictureViewModelServiceMock.Object, new Mock().Object, _scopeMock.Object); + controller.ControllerContext = _controller.ControllerContext; + controller.TempData = _controller.TempData; + + var model = new Grand.Web.AdminShared.Models.Common.PictureModel { ObjectId = "c1", Id = "pic-1" }; + + var result = await controller.PicturePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + pictureViewModelServiceMock.Verify(p => p.UpdatePicture(model), Times.Once); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs index 30f94e360..108716970 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs @@ -8,6 +8,7 @@ using Grand.Web.AdminShared.Extensions.Mapping; using Grand.Web.AdminShared.Interfaces; using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Common; using Grand.Web.Common.Controllers; using Grand.Web.Common.DataSource; using Grand.Web.Common.Filters; @@ -200,4 +201,43 @@ public async Task Delete(string id) } #endregion + + #region Picture + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task PicturePopup(string collectionId) + { + var collection = await collectionService.GetCollectionById(collectionId); + if (collection == null) return Content("Collection not exist"); + if (!await scope.HasAccess(collection)) return Content("This is not your collection"); + if (string.IsNullOrEmpty(collection.PictureId)) return Content("Picture not exist"); + + return View("Partials/PicturePopup", + await pictureViewModelService.PreparePictureModel(collection.PictureId, collection.Id)); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task PicturePopup(PictureModel model) + { + if (ModelState.IsValid) + { + var collection = await collectionService.GetCollectionById(model.ObjectId); + if (collection == null) + throw new ArgumentException("No collection found with the specified id"); + if (!await scope.HasAccess(collection)) return Content("This is not your collection"); + if (string.IsNullOrEmpty(collection.PictureId)) + throw new ArgumentException("No picture found with the specified id"); + if (collection.PictureId != model.Id) + throw new ArgumentException("Picture ident doesn't fit with collection"); + + await pictureViewModelService.UpdatePicture(model); + return Content(""); + } + + Error(ModelState); + return View("Partials/PicturePopup", model); + } + + #endregion } From 589dd68c28bc97d972ff2b9a20f29f93918da76b Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 24 Aug 2026 21:44:48 +0200 Subject: [PATCH 5/8] feat(arch001): add Export/Import region to BaseCollectionController Verified Store's original controller had no Export/Import actions (grep, zero hits) - pure addition for Store, same superset approach ARCH-001 Phase 1/3 used for Product/Category. Export is now store-scoped for Store via scope.DefaultStoreId, unlike Admin's original always-global storeId: ''. Permission-provider check: PermissionProvider.cs grants whole StandardPermission.ManageCollections to StoreManager (no PermissionActionName.Export/Import references exist in this file at all - grants are permission-level, not per-action) - so Store gaining Export/Import actions is the same superset exposure Category's Task 5 already accepted for the sibling entity; no per-action exclusion found, proceeding as planned. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017ruWBUZPv3BnpPhjQVV8Xf --- .../Controllers/BaseCollectionController.cs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs index 108716970..db63250d0 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs @@ -1,8 +1,10 @@ +using Grand.Business.Core.Dto; using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Catalog.Collections; using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Interfaces.ExportImport; using Grand.Domain.Catalog; using Grand.Domain.Permissions; using Grand.Web.AdminShared.Extensions.Mapping; @@ -13,6 +15,7 @@ using Grand.Web.Common.DataSource; using Grand.Web.Common.Filters; using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; @@ -240,4 +243,50 @@ public async Task PicturePopup(PictureModel model) } #endregion + + #region Export / Import + + [PermissionAuthorizeAction(PermissionActionName.Export)] + public async Task ExportXlsx([FromServices] IExportManager exportManager) + { + try + { + var bytes = await exportManager.Export(await collectionService.GetAllCollections(collectionName: "", storeId: scope.DefaultStoreId ?? "", showHidden: true)); + return File(bytes, "text/xls", "collections.xlsx"); + } + catch (Exception exc) + { + Error(exc); + return RedirectToAction("List"); + } + } + + [PermissionAuthorizeAction(PermissionActionName.Import)] + [HttpPost] + public async Task ImportFromXlsx(IFormFile importexcelfile, + [FromServices] IImportManager importManager) + { + try + { + if (importexcelfile is { Length: > 0 }) + { + await importManager.Import(importexcelfile.OpenReadStream()); + } + else + { + Error(translationService.GetResource("Admin.Common.UploadFile")); + return RedirectToAction("List"); + } + + Success(translationService.GetResource("Admin.Catalog.Collection.Imported")); + return RedirectToAction("List"); + } + catch (Exception exc) + { + Error(exc); + return RedirectToAction("List"); + } + } + + #endregion } From 48f8c62a0bae1d92c8361f1dc755c7e04d2cf013 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 24 Aug 2026 21:51:29 +0200 Subject: [PATCH 6/8] feat(arch001): add Products region to BaseCollectionController Normalizes ProductList's storeId argument to scope.DefaultStoreId (was StoreContext.CurrentStore.Id in Store's original code, inconsistent with every other call site in the file) - user-approved fix, not pure behavior preservation. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017ruWBUZPv3BnpPhjQVV8Xf --- .../BaseCollectionControllerTests.cs | 233 ++++++++++++++++++ .../Controllers/BaseCollectionController.cs | 122 +++++++++ 2 files changed, 355 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCollectionControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCollectionControllerTests.cs index a86dbf88c..ebadc419c 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCollectionControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCollectionControllerTests.cs @@ -371,4 +371,237 @@ public async Task PicturePopupPost_ValidRequest_CallsUpdatePicture() Assert.AreEqual("", content.Content); pictureViewModelServiceMock.Verify(p => p.UpdatePicture(model), Times.Once); } + + // --- Products tab --------------------------------------------------------------------------------- + + [TestMethod] + public async Task ProductList_ScopeDeniesAccess_ReturnsKendoError() + { + var collection = new Collection { Id = "c1" }; + _collectionServiceMock.Setup(c => c.GetCollectionById("c1")).ReturnsAsync(collection); + _scopeMock.Setup(s => s.HasAccess(collection)).ReturnsAsync(false); + + var result = await _controller.ProductList(new DataSourceRequest { Page = 1, PageSize = 10 }, "c1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = (DataSourceResult)json.Value; + Assert.IsFalse(string.IsNullOrEmpty(gridModel.Errors as string)); + } + + [TestMethod] + public async Task ProductList_ScopeGrantsAccess_PassesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var collection = new Collection { Id = "c1" }; + _collectionServiceMock.Setup(c => c.GetCollectionById("c1")).ReturnsAsync(collection); + _scopeMock.Setup(s => s.HasAccess(collection)).ReturnsAsync(true); + _collectionViewModelServiceMock + .Setup(v => v.PrepareCollectionProductModel("c1", "store-1", 1, 10)) + .ReturnsAsync((Enumerable.Empty(), 0)); + + var result = await _controller.ProductList(new DataSourceRequest { Page = 1, PageSize = 10 }, "c1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + _collectionViewModelServiceMock.Verify(v => v.PrepareCollectionProductModel("c1", "store-1", 1, 10), Times.Once); + } + + [TestMethod] + public async Task ProductList_GlobalScope_PassesEmptyStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + var collection = new Collection { Id = "c1" }; + _collectionServiceMock.Setup(c => c.GetCollectionById("c1")).ReturnsAsync(collection); + _scopeMock.Setup(s => s.HasAccess(collection)).ReturnsAsync(true); + _collectionViewModelServiceMock + .Setup(v => v.PrepareCollectionProductModel("c1", string.Empty, 1, 10)) + .ReturnsAsync((Enumerable.Empty(), 0)); + + await _controller.ProductList(new DataSourceRequest { Page = 1, PageSize = 10 }, "c1"); + + _collectionViewModelServiceMock.Verify(v => v.PrepareCollectionProductModel("c1", string.Empty, 1, 10), Times.Once); + } + + [TestMethod] + public async Task ProductUpdate_ProductNotOwnedByScopeStore_ReturnsKendoError() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var product = new Product { Id = "p1", LimitedToStores = true, Stores = ["other-store"] }; + var productServiceMock = new Mock(); + productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + + var controller = new TestCollectionController( + _collectionViewModelServiceMock.Object, _collectionServiceMock.Object, _storeServiceMock.Object, + new Mock().Object, _translationServiceMock.Object, + new Mock().Object, productServiceMock.Object, _scopeMock.Object); + controller.ControllerContext = _controller.ControllerContext; + controller.TempData = _controller.TempData; + + var result = await controller.ProductUpdate(new CollectionModel.CollectionProductModel { Id = "pc1", ProductId = "p1" }); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = (DataSourceResult)json.Value; + Assert.IsFalse(string.IsNullOrEmpty(gridModel.Errors as string)); + _collectionViewModelServiceMock.Verify(v => v.ProductUpdate(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductDelete_ProductNotOwnedByScopeStore_ReturnsKendoError() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var product = new Product { Id = "p1", LimitedToStores = true, Stores = ["other-store"] }; + var productServiceMock = new Mock(); + productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + + var controller = new TestCollectionController( + _collectionViewModelServiceMock.Object, _collectionServiceMock.Object, _storeServiceMock.Object, + new Mock().Object, _translationServiceMock.Object, + new Mock().Object, productServiceMock.Object, _scopeMock.Object); + controller.ControllerContext = _controller.ControllerContext; + controller.TempData = _controller.TempData; + + var result = await controller.ProductDelete(new CollectionModel.CollectionProductModel { Id = "pc1", ProductId = "p1" }); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = (DataSourceResult)json.Value; + Assert.IsFalse(string.IsNullOrEmpty(gridModel.Errors as string)); + _collectionViewModelServiceMock.Verify(v => v.ProductDelete(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAddPopupGet_PassesScopeDefaultStoreIdOrEmpty() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _collectionViewModelServiceMock + .Setup(v => v.PrepareAddCollectionProductModel(string.Empty)) + .ReturnsAsync(new CollectionModel.AddCollectionProductModel()); + + var result = await _controller.ProductAddPopup("c1"); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreEqual("c1", ((CollectionModel.AddCollectionProductModel)view.Model).CollectionId); + _collectionViewModelServiceMock.Verify(v => v.PrepareAddCollectionProductModel(string.Empty), Times.Once); + } + + [TestMethod] + public async Task ProductAddPopupList_ForcesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _collectionViewModelServiceMock + .Setup(v => v.PrepareProductModel(It.IsAny(), 1, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new CollectionModel.AddCollectionProductModel { SearchStoreId = "attacker-supplied-store" }; + await _controller.ProductAddPopupList(new DataSourceRequest { Page = 1, PageSize = 10 }, model); + + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task ProductAddPopupList_GlobalScope_LeavesSubmittedSearchStoreIdUntouched() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _collectionViewModelServiceMock + .Setup(v => v.PrepareProductModel(It.IsAny(), 1, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new CollectionModel.AddCollectionProductModel { SearchStoreId = "admin-submitted-store" }; + await _controller.ProductAddPopupList(new DataSourceRequest { Page = 1, PageSize = 10 }, model); + + Assert.AreEqual("admin-submitted-store", model.SearchStoreId); + } + + [TestMethod] + public async Task ProductAddPopupInsert_ScopeDeniesCollectionAccess_ReturnsDeniedContent() + { + var collection = new Collection { Id = "c1" }; + _collectionServiceMock.Setup(c => c.GetCollectionById("c1")).ReturnsAsync(collection); + _scopeMock.Setup(s => s.HasAccess(collection)).ReturnsAsync(false); + + var model = new CollectionModel.AddCollectionProductModel { CollectionId = "c1", SelectedProductIds = ["p1"] }; + var result = await _controller.ProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("This is not your collection", content.Content); + _collectionViewModelServiceMock.Verify(v => v.InsertCollectionProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAddPopupInsert_GlobalScope_InsertsWithoutFiltering() + { + var collection = new Collection { Id = "c1" }; + _collectionServiceMock.Setup(c => c.GetCollectionById("c1")).ReturnsAsync(collection); + _scopeMock.Setup(s => s.HasAccess(collection)).ReturnsAsync(true); + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + var model = new CollectionModel.AddCollectionProductModel { CollectionId = "c1", SelectedProductIds = ["p1", "p2"] }; + + await _controller.ProductAddPopup(model); + + _collectionViewModelServiceMock.Verify(v => v.InsertCollectionProductModel( + It.Is(m => m.SelectedProductIds.Length == 2)), Times.Once); + } + + [TestMethod] + public async Task ProductAddPopupInsert_StoreScope_FiltersOutForeignProducts() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var collection = new Collection { Id = "c1" }; + _collectionServiceMock.Setup(c => c.GetCollectionById("c1")).ReturnsAsync(collection); + _scopeMock.Setup(s => s.HasAccess(collection)).ReturnsAsync(true); + + var ownedProduct = new Product { Id = "owned-1", LimitedToStores = true, Stores = ["store-1"] }; + var foreignProduct = new Product { Id = "foreign-1", LimitedToStores = true, Stores = ["other-store"] }; + + var productServiceMock = new Mock(); + productServiceMock.Setup(p => p.GetProductById("owned-1")).ReturnsAsync(ownedProduct); + productServiceMock.Setup(p => p.GetProductById("foreign-1")).ReturnsAsync(foreignProduct); + + var controller = new TestCollectionController( + _collectionViewModelServiceMock.Object, _collectionServiceMock.Object, _storeServiceMock.Object, + new Mock().Object, _translationServiceMock.Object, + new Mock().Object, productServiceMock.Object, _scopeMock.Object); + controller.ControllerContext = _controller.ControllerContext; + controller.TempData = _controller.TempData; + + var model = new CollectionModel.AddCollectionProductModel { CollectionId = "c1", SelectedProductIds = ["owned-1", "foreign-1"] }; + + await controller.ProductAddPopup(model); + + _collectionViewModelServiceMock.Verify(v => v.InsertCollectionProductModel( + It.Is(m => m.SelectedProductIds.Length == 1 && m.SelectedProductIds[0] == "owned-1")), Times.Once); + } + + [TestMethod] + public async Task ProductAddPopupInsert_StoreScope_AllProductsForeign_SkipsInsertEntirely() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var collection = new Collection { Id = "c1" }; + _collectionServiceMock.Setup(c => c.GetCollectionById("c1")).ReturnsAsync(collection); + _scopeMock.Setup(s => s.HasAccess(collection)).ReturnsAsync(true); + + var foreignProduct1 = new Product { Id = "foreign-1", LimitedToStores = true, Stores = ["other-store"] }; + var foreignProduct2 = new Product { Id = "foreign-2", LimitedToStores = true, Stores = ["another-store"] }; + + var productServiceMock = new Mock(); + productServiceMock.Setup(p => p.GetProductById("foreign-1")).ReturnsAsync(foreignProduct1); + productServiceMock.Setup(p => p.GetProductById("foreign-2")).ReturnsAsync(foreignProduct2); + + var controller = new TestCollectionController( + _collectionViewModelServiceMock.Object, _collectionServiceMock.Object, _storeServiceMock.Object, + new Mock().Object, _translationServiceMock.Object, + new Mock().Object, productServiceMock.Object, _scopeMock.Object); + controller.ControllerContext = _controller.ControllerContext; + controller.TempData = _controller.TempData; + + var model = new CollectionModel.AddCollectionProductModel { CollectionId = "c1", SelectedProductIds = ["foreign-1", "foreign-2"] }; + + await controller.ProductAddPopup(model); + + _collectionViewModelServiceMock.Verify(v => v.InsertCollectionProductModel(It.IsAny()), Times.Never); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs index db63250d0..8424bfb88 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseCollectionController.cs @@ -7,6 +7,7 @@ using Grand.Business.Core.Interfaces.ExportImport; using Grand.Domain.Catalog; using Grand.Domain.Permissions; +using Grand.Web.AdminShared.Extensions; using Grand.Web.AdminShared.Extensions.Mapping; using Grand.Web.AdminShared.Interfaces; using Grand.Web.AdminShared.Models.Catalog; @@ -289,4 +290,125 @@ public async Task ImportFromXlsx(IFormFile importexcelfile, } #endregion + + #region Products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductList(DataSourceRequest command, string collectionId) + { + var collection = await collectionService.GetCollectionById(collectionId); + if (!await scope.HasAccess(collection)) return ErrorForKendoGridJson("This is not your collection"); + + var (collectionProductModels, totalCount) = await collectionViewModelService.PrepareCollectionProductModel( + collectionId, scope.DefaultStoreId ?? string.Empty, command.Page, command.PageSize); + + var gridModel = new DataSourceResult { + Data = collectionProductModels.ToList(), + Total = totalCount + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductUpdate(CollectionModel.CollectionProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null || !product.AccessToEntityByStore(scope.DefaultStoreId)) + return ErrorForKendoGridJson("This is not your product"); + + if (ModelState.IsValid) + { + await collectionViewModelService.ProductUpdate(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductDelete(CollectionModel.CollectionProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null || !product.AccessToEntityByStore(scope.DefaultStoreId)) + return ErrorForKendoGridJson("This is not your product"); + + if (ModelState.IsValid) + { + await collectionViewModelService.ProductDelete(model.Id, model.ProductId); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAddPopup(string collectionId) + { + var model = await collectionViewModelService.PrepareAddCollectionProductModel(scope.DefaultStoreId ?? string.Empty); + model.CollectionId = collectionId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAddPopupList(DataSourceRequest command, + CollectionModel.AddCollectionProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + var products = await collectionViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.products.ToList(), + Total = products.totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAddPopup(CollectionModel.AddCollectionProductModel model) + { + var collection = await collectionService.GetCollectionById(model.CollectionId); + if (collection == null || !await scope.HasAccess(collection)) + return Content("This is not your collection"); + + if (ModelState.IsValid) + { + if (model.SelectedProductIds != null) + { + if (scope.DefaultStoreId is null) + { + // Global scope (Admin): no per-product ownership concept, insert as submitted - + // matches Admin's original, unfiltered behavior exactly. + await collectionViewModelService.InsertCollectionProductModel(model); + } + else + { + // Store scope: InsertCollectionProductModel mutates each selected product's + // ProductCollections collection, so every selected id must also belong to the + // current store - matches Store's original filtering loop exactly, including + // its validIds.Count > 0 guard (a fully-filtered-out selection no-ops instead + // of calling the service with an empty array). + var validIds = new List(); + foreach (var id in model.SelectedProductIds) + { + var selected = await productService.GetProductById(id); + if (selected != null && selected.AccessToEntityByStore(scope.DefaultStoreId)) + validIds.Add(id); + } + model.SelectedProductIds = validIds.ToArray(); + if (validIds.Count > 0) await collectionViewModelService.InsertCollectionProductModel(model); + } + } + return Content(""); + } + + Error(ModelState); + return View(model); + } + + #endregion } From 93b78771b0d2ce452f668fd7d10181edbbb03846 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 24 Aug 2026 21:59:22 +0200 Subject: [PATCH 7/8] refactor(arch001): reduce Admin/Store CollectionController to thin BaseCollectionController subclasses --- .../Controllers/CollectionController.cs | 405 +--------------- .../Controllers/CollectionController.cs | 431 ++---------------- 2 files changed, 62 insertions(+), 774 deletions(-) diff --git a/src/Web/Grand.Web.Admin/Controllers/CollectionController.cs b/src/Web/Grand.Web.Admin/Controllers/CollectionController.cs index cd385b8ce..484b9288f 100644 --- a/src/Web/Grand.Web.Admin/Controllers/CollectionController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/CollectionController.cs @@ -1,390 +1,35 @@ -using Grand.Business.Core.Dto; -using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Catalog.Collections; +using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Common.Stores; -using Grand.Business.Core.Interfaces.ExportImport; using Grand.Domain.Catalog; -using Grand.Domain.Permissions; -using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; using Grand.Web.AdminShared.Interfaces; -using Grand.Web.AdminShared.Models.Catalog; -using Grand.Web.AdminShared.Models.Common; -using Grand.Web.Common.DataSource; using Grand.Web.Common.Filters; -using Grand.Web.Common.Security.Authorization; +using Grand.Web.Common.Localization; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Rendering; namespace Grand.Web.Admin.Controllers; -[PermissionAuthorize(PermissionSystemName.Collections)] -public class CollectionController : BaseAdminController -{ - #region Constructors - - public CollectionController( - ICollectionViewModelService collectionViewModelService, - ICollectionService collectionService, - IStoreService storeService, - ILanguageService languageService, - ITranslationService translationService, - IPictureViewModelService pictureViewModelService) - { - _collectionViewModelService = collectionViewModelService; - _collectionService = collectionService; - _storeService = storeService; - _languageService = languageService; - _translationService = translationService; - _pictureViewModelService = pictureViewModelService; - } - - #endregion - - #region Fields - - private readonly ICollectionViewModelService _collectionViewModelService; - private readonly ICollectionService _collectionService; - private readonly IStoreService _storeService; - private readonly ILanguageService _languageService; - private readonly ITranslationService _translationService; - private readonly IPictureViewModelService _pictureViewModelService; - - #endregion - - #region List - - public IActionResult Index() - { - return RedirectToAction("List"); - } - - public async Task List() - { - var model = new CollectionListModel(); - model.AvailableStores.Add(new SelectListItem { Text = _translationService.GetResource("Admin.Common.All"), Value = "" }); - foreach (var s in (await _storeService.GetAllStores())) - model.AvailableStores.Add(new SelectListItem { Text = s.Shortcut, Value = s.Id }); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task List(DataSourceRequest command, CollectionListModel model) - { - var collections = await _collectionService.GetAllCollections(model.SearchCollectionName, - model.SearchStoreId, command.Page - 1, command.PageSize, true); - var gridModel = new DataSourceResult { - Data = collections.Select(x => x.ToModel()), - Total = collections.TotalCount - }; - - return Json(gridModel); - } - - #endregion - - #region Create / Edit / Delete - - [PermissionAuthorizeAction(PermissionActionName.Create)] - public async Task Create([FromServices] CatalogSettings catalogSettings) - { - var model = new CollectionModel(); - //locales - await AddLocales(_languageService, model.Locales); - //layouts - await _collectionViewModelService.PrepareLayoutsModel(model); - //discounts - await _collectionViewModelService.PrepareDiscountModel(model, null, true); - //default values - model.PageSize = catalogSettings.DefaultPageSize; - model.PageSizeOptions = catalogSettings.DefaultPageSizeOptions; - model.Published = true; - model.AllowCustomersToSelectPageSize = true; - //sort options - _collectionViewModelService.PrepareSortOptionsModel(model); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Create(CollectionModel model, bool continueEditing) - { - if (ModelState.IsValid) - { - var collection = await _collectionViewModelService.InsertCollectionModel(model); - Success(_translationService.GetResource("Admin.Catalog.Collections.Added")); - return continueEditing ? RedirectToAction("Edit", new { id = collection.Id }) : RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - //layouts - await _collectionViewModelService.PrepareLayoutsModel(model); - //discounts - await _collectionViewModelService.PrepareDiscountModel(model, null, true); - //sort options - _collectionViewModelService.PrepareSortOptionsModel(model); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task Edit(string id) - { - var collection = await _collectionService.GetCollectionById(id); - if (collection == null) - //No collection found with the specified id - return RedirectToAction("List"); - - var model = collection.ToModel(); - //locales - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.Name = collection.GetTranslation(x => x.Name, languageId, false); - locale.Description = collection.GetTranslation(x => x.Description, languageId, false); - locale.BottomDescription = collection.GetTranslation(x => x.BottomDescription, languageId, false); - locale.MetaKeywords = collection.GetTranslation(x => x.MetaKeywords, languageId, false); - locale.MetaDescription = collection.GetTranslation(x => x.MetaDescription, languageId, false); - locale.MetaTitle = collection.GetTranslation(x => x.MetaTitle, languageId, false); - locale.SeName = collection.GetSeName(languageId, false); - }); - //layouts - await _collectionViewModelService.PrepareLayoutsModel(model); - //discounts - await _collectionViewModelService.PrepareDiscountModel(model, collection, false); - //sort options - _collectionViewModelService.PrepareSortOptionsModel(model); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Edit(CollectionModel model, bool continueEditing) - { - var collection = await _collectionService.GetCollectionById(model.Id); - if (collection == null) - //No collection found with the specified id - return RedirectToAction("List"); - - if (ModelState.IsValid) - { - collection = await _collectionViewModelService.UpdateCollectionModel(collection, model); - Success(_translationService.GetResource("Admin.Catalog.Collections.Updated")); - - if (continueEditing) - { - //selected tab - await SaveSelectedTabIndex(); - return RedirectToAction("Edit", new { id = collection.Id }); - } - return RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - //layouts - await _collectionViewModelService.PrepareLayoutsModel(model); - //discounts - await _collectionViewModelService.PrepareDiscountModel(model, collection, true); - //sort options - _collectionViewModelService.PrepareSortOptionsModel(model); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task Delete(string id) - { - var collection = await _collectionService.GetCollectionById(id); - if (collection == null) - //No collection found with the specified id - return RedirectToAction("List"); - - if (ModelState.IsValid) - { - await _collectionViewModelService.DeleteCollection(collection); - - Success(_translationService.GetResource("Admin.Catalog.Collections.Deleted")); - return RedirectToAction("List"); - } - - Error(ModelState); - return RedirectToAction("Edit", new { id = collection.Id }); - } - - #endregion - - #region Picture - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task PicturePopup(string collectionId) - { - var collection = await _collectionService.GetCollectionById(collectionId); - if (collection == null) - return Content("Collection not exist"); - - if (string.IsNullOrEmpty(collection.PictureId)) - return Content("Picture not exist"); - - return View("Partials/PicturePopup", - await _pictureViewModelService.PreparePictureModel(collection.PictureId, collection.Id)); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task PicturePopup(PictureModel model) - { - if (ModelState.IsValid) - { - var collection = await _collectionService.GetCollectionById(model.ObjectId) ?? throw new ArgumentException("No collection found with the specified id"); - - if (string.IsNullOrEmpty(collection.PictureId)) - throw new ArgumentException("No picture found with the specified id"); - - if (collection.PictureId != model.Id) - throw new ArgumentException("Picture ident doesn't fit with collection"); - - await _pictureViewModelService.UpdatePicture(model); - - return Content(""); - } - - Error(ModelState); - - return View("Partials/PicturePopup", model); - } - - #endregion - - #region Export / Import - - [PermissionAuthorizeAction(PermissionActionName.Export)] - public async Task ExportXlsx([FromServices] IExportManager exportManager) - { - try - { - var bytes = await exportManager.Export(await _collectionService.GetAllCollections(collectionName: "", storeId: "", showHidden: true)); - return File(bytes, "text/xls", "collections.xlsx"); - } - catch (Exception exc) - { - Error(exc); - return RedirectToAction("List"); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Import)] - [HttpPost] - public async Task ImportFromXlsx(IFormFile importexcelfile, - [FromServices] IImportManager importManager) - { - try - { - if (importexcelfile is { Length: > 0 }) - { - await importManager.Import(importexcelfile.OpenReadStream()); - } - else - { - Error(_translationService.GetResource("Admin.Common.UploadFile")); - return RedirectToAction("List"); - } - - Success(_translationService.GetResource("Admin.Catalog.Collection.Imported")); - return RedirectToAction("List"); - } - catch (Exception exc) - { - Error(exc); - return RedirectToAction("List"); - } - } - - #endregion - - #region Products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductList(DataSourceRequest command, string collectionId) - { - var collection = await _collectionService.GetCollectionById(collectionId); - - var (collectionProductModels, totalCount) = await _collectionViewModelService.PrepareCollectionProductModel(collectionId, string.Empty, command.Page, command.PageSize); - - var gridModel = new DataSourceResult { - Data = collectionProductModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductUpdate(CollectionModel.CollectionProductModel model) - { - if (ModelState.IsValid) - { - await _collectionViewModelService.ProductUpdate(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductDelete(CollectionModel.CollectionProductModel model) - { - if (ModelState.IsValid) - { - await _collectionViewModelService.ProductDelete(model.Id, model.ProductId); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAddPopup(string collectionId) - { - var model = await _collectionViewModelService.PrepareAddCollectionProductModel(string.Empty); - model.CollectionId = collectionId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAddPopupList(DataSourceRequest command, - CollectionModel.AddCollectionProductModel model) - { - var products = await _collectionViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.products.ToList(), - Total = products.totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAddPopup(CollectionModel.AddCollectionProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _collectionViewModelService.InsertCollectionProductModel(model); - return Content(""); - } - - Error(ModelState); - return View(model); - } - - #endregion -} \ No newline at end of file +// Reduced to a thin subclass of BaseCollectionController (ARCH-001 Collection consolidation). All +// regions of behavior live in the shared base; this class only supplies Admin's DI wiring plus the +// attributes that used to arrive transitively via BaseAdminController - BaseCollectionController +// can't inherit any single host's base controller (it's shared across Admin/Store, each with a +// different [Area]/[Authorize*] pair), so each subclass restates its own host's attribute set +// explicitly. Same pattern as CategoryController (see that file). +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class CollectionController( + ICollectionViewModelService collectionViewModelService, + ICollectionService collectionService, + IStoreService storeService, + ILanguageService languageService, + ITranslationService translationService, + IPictureViewModelService pictureViewModelService, + IProductService productService, + IAdminDataScope scope) + : BaseCollectionController(collectionViewModelService, collectionService, storeService, + languageService, translationService, pictureViewModelService, productService, scope); diff --git a/src/Web/Grand.Web.Store/Controllers/CollectionController.cs b/src/Web/Grand.Web.Store/Controllers/CollectionController.cs index 0d5103a37..347fb0f3f 100644 --- a/src/Web/Grand.Web.Store/Controllers/CollectionController.cs +++ b/src/Web/Grand.Web.Store/Controllers/CollectionController.cs @@ -1,405 +1,48 @@ -using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Catalog.Collections; using Grand.Business.Core.Interfaces.Catalog.Products; -using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Stores; using Grand.Domain.Catalog; -using Grand.Domain.Permissions; -using Grand.Infrastructure; -using Grand.Web.AdminShared.Extensions; -using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.AdminShared.Controllers; using Grand.Web.AdminShared.Interfaces; -using Grand.Web.AdminShared.Models.Catalog; -using Grand.Web.AdminShared.Models.Common; -using Grand.Web.Common.DataSource; using Grand.Web.Common.Filters; -using Grand.Web.Common.Security.Authorization; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Extensions; using Microsoft.AspNetCore.Mvc; namespace Grand.Web.Store.Controllers; -[PermissionAuthorize(PermissionSystemName.Collections)] -public class CollectionController : BaseStoreController +// Reduced to a thin subclass of BaseCollectionController (ARCH-001 Collection consolidation). All +// regions of behavior live in the shared base; this class only supplies Store's DI wiring, the +// EditWarningCheck hook, and the attributes that used to arrive transitively via +// BaseStoreController. Same pattern as CategoryController (see that file). +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class CollectionController( + ICollectionViewModelService collectionViewModelService, + ICollectionService collectionService, + IStoreService storeService, + ILanguageService languageService, + ITranslationService translationService, + IPictureViewModelService pictureViewModelService, + IProductService productService, + IAdminDataScope scope) + : BaseCollectionController(collectionViewModelService, collectionService, storeService, + languageService, translationService, pictureViewModelService, productService, scope) { - #region Constructors - - public CollectionController( - ICollectionViewModelService collectionViewModelService, - ICollectionService collectionService, - IContextAccessor contextAccessor, - ILanguageService languageService, - ITranslationService translationService, - IGroupService groupService, - IPictureViewModelService pictureViewModelService, - IProductService productService) - { - _collectionViewModelService = collectionViewModelService; - _collectionService = collectionService; - _contextAccessor = contextAccessor; - _languageService = languageService; - _translationService = translationService; - _groupService = groupService; - _pictureViewModelService = pictureViewModelService; - _productService = productService; - } - - #endregion - - #region Fields - - private readonly ICollectionViewModelService _collectionViewModelService; - private readonly ICollectionService _collectionService; - private readonly IContextAccessor _contextAccessor; - private readonly ILanguageService _languageService; - private readonly ITranslationService _translationService; - private readonly IGroupService _groupService; - private readonly IPictureViewModelService _pictureViewModelService; - private readonly IProductService _productService; - - #endregion - - #region List - - public IActionResult Index() - { - return RedirectToAction("List"); - } - - public IActionResult List() - { - var model = new CollectionListModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task List(DataSourceRequest command, CollectionListModel model) - { - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var collections = await _collectionService.GetAllCollections(model.SearchCollectionName, - model.SearchStoreId, command.Page - 1, command.PageSize, true); - var gridModel = new DataSourceResult { - Data = collections.Select(x => x.ToModel()), - Total = collections.TotalCount - }; - - return Json(gridModel); - } - - #endregion - - #region Create / Edit / Delete - - [PermissionAuthorizeAction(PermissionActionName.Create)] - public async Task Create([FromServices] CatalogSettings catalogSettings) - { - var model = new CollectionModel(); - //locales - await AddLocales(_languageService, model.Locales); - //layouts - await _collectionViewModelService.PrepareLayoutsModel(model); - //discounts - await _collectionViewModelService.PrepareDiscountModel(model, null, true); - //default values - model.PageSize = catalogSettings.DefaultPageSize; - model.PageSizeOptions = catalogSettings.DefaultPageSizeOptions; - model.Published = true; - model.AllowCustomersToSelectPageSize = true; - //sort options - _collectionViewModelService.PrepareSortOptionsModel(model); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Create(CollectionModel model, bool continueEditing) - { - if (ModelState.IsValid) - { - model.Stores = [_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId]; - var collection = await _collectionViewModelService.InsertCollectionModel(model); - Success(_translationService.GetResource("Admin.Catalog.Collections.Added")); - return continueEditing ? RedirectToAction("Edit", new { id = collection.Id }) : RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - //layouts - await _collectionViewModelService.PrepareLayoutsModel(model); - //discounts - await _collectionViewModelService.PrepareDiscountModel(model, null, true); - //sort options - _collectionViewModelService.PrepareSortOptionsModel(model); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task Edit(string id) - { - var collection = await _collectionService.GetCollectionById(id); - if (collection == null) - //No collection found with the specified id - return RedirectToAction("List"); - - if (await _groupService.IsStoreManager(_contextAccessor.WorkContext.CurrentCustomer)) - { - if (!collection.LimitedToStores || (collection.LimitedToStores && - collection.Stores.Contains(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) && - collection.Stores.Count > 1)) - { - Warning(_translationService.GetResource("Admin.Catalog.Collections.Permissions")); - } - else - { - if (!collection.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return RedirectToAction("List"); - } - } - - var model = collection.ToModel(); - //locales - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.Name = collection.GetTranslation(x => x.Name, languageId, false); - locale.Description = collection.GetTranslation(x => x.Description, languageId, false); - locale.BottomDescription = collection.GetTranslation(x => x.BottomDescription, languageId, false); - locale.MetaKeywords = collection.GetTranslation(x => x.MetaKeywords, languageId, false); - locale.MetaDescription = collection.GetTranslation(x => x.MetaDescription, languageId, false); - locale.MetaTitle = collection.GetTranslation(x => x.MetaTitle, languageId, false); - locale.SeName = collection.GetSeName(languageId, false); - }); - //layouts - await _collectionViewModelService.PrepareLayoutsModel(model); - //discounts - await _collectionViewModelService.PrepareDiscountModel(model, collection, false); - //sort options - _collectionViewModelService.PrepareSortOptionsModel(model); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Edit(CollectionModel model, bool continueEditing) - { - var collection = await _collectionService.GetCollectionById(model.Id); - if (collection == null) - //No collection found with the specified id - return RedirectToAction("List"); - - if (!collection.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return RedirectToAction("Edit", new { id = collection.Id }); - - if (ModelState.IsValid) - { - model.Stores = [_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId]; - collection = await _collectionViewModelService.UpdateCollectionModel(collection, model); - Success(_translationService.GetResource("Admin.Catalog.Collections.Updated")); - - if (continueEditing) - { - //selected tab - await SaveSelectedTabIndex(); - return RedirectToAction("Edit", new { id = collection.Id }); - } - return RedirectToAction("List"); - } - //If we got this far, something failed, redisplay form - //layouts - await _collectionViewModelService.PrepareLayoutsModel(model); - //discounts - await _collectionViewModelService.PrepareDiscountModel(model, collection, true); - //sort options - _collectionViewModelService.PrepareSortOptionsModel(model); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task Delete(string id) - { - var collection = await _collectionService.GetCollectionById(id); - if (collection == null) - //No collection found with the specified id - return RedirectToAction("List"); - - if (!collection.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return RedirectToAction("Edit", new { id = collection.Id }); - - if (ModelState.IsValid) - { - await _collectionViewModelService.DeleteCollection(collection); - - Success(_translationService.GetResource("Admin.Catalog.Collections.Deleted")); - return RedirectToAction("List"); - } - - Error(ModelState); - return RedirectToAction("Edit", new { id = collection.Id }); - } - - #endregion - - #region Picture - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task PicturePopup(string collectionId) - { - var collection = await _collectionService.GetCollectionById(collectionId); - if (collection == null) - return Content("Collection not exist"); - - if (!collection.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return Content("This is not your collection"); - - if (string.IsNullOrEmpty(collection.PictureId)) - return Content("Picture not exist"); - - return View("Partials/PicturePopup", - await _pictureViewModelService.PreparePictureModel(collection.PictureId, collection.Id)); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task PicturePopup(PictureModel model) - { - if (ModelState.IsValid) - { - var collection = await _collectionService.GetCollectionById(model.ObjectId); - if (collection == null) - throw new ArgumentException("No collection found with the specified id"); - - if (!collection.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return Content("This is not your collection"); - - if (string.IsNullOrEmpty(collection.PictureId)) - throw new ArgumentException("No picture found with the specified id"); - - if (collection.PictureId != model.Id) - throw new ArgumentException("Picture ident doesn't fit with collection"); - - await _pictureViewModelService.UpdatePicture(model); - - return Content(""); - } - - Error(ModelState); - - return View("Partials/PicturePopup", model); - } - - #endregion - - #region Products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductList(DataSourceRequest command, string collectionId) - { - var collection = await _collectionService.GetCollectionById(collectionId); - if (!collection.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return ErrorForKendoGridJson("This is not your collection"); - - var (collectionProductModels, totalCount) = await _collectionViewModelService.PrepareCollectionProductModel(collectionId, _contextAccessor.StoreContext.CurrentStore.Id, command.Page, command.PageSize); - - var gridModel = new DataSourceResult { - Data = collectionProductModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductUpdate(CollectionModel.CollectionProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return ErrorForKendoGridJson("This is not your product"); - - if (ModelState.IsValid) - { - await _collectionViewModelService.ProductUpdate(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductDelete(CollectionModel.CollectionProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return ErrorForKendoGridJson("This is not your product"); - - if (ModelState.IsValid) - { - await _collectionViewModelService.ProductDelete(model.Id, model.ProductId); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAddPopup(string collectionId) - { - var model = await _collectionViewModelService.PrepareAddCollectionProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - model.CollectionId = collectionId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAddPopupList(DataSourceRequest command, - CollectionModel.AddCollectionProductModel model) - { - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var products = await _collectionViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.products.ToList(), - Total = products.totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAddPopup(CollectionModel.AddCollectionProductModel model) - { - var collection = await _collectionService.GetCollectionById(model.CollectionId); - if (collection == null || !collection.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return Content("This is not your collection"); - - if (ModelState.IsValid) - { - //InsertCollectionProductModel mutates each selected product's ProductCollections collection, - //so every selected id must also belong to the current store. - if (model.SelectedProductIds != null) - { - var validIds = new List(); - foreach (var id in model.SelectedProductIds) - { - var selected = await _productService.GetProductById(id); - if (selected != null && selected.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - validIds.Add(id); - } - model.SelectedProductIds = validIds.ToArray(); - if (validIds.Any()) await _collectionViewModelService.InsertCollectionProductModel(model); - } - return Content(""); - } - - Error(ModelState); - return View(model); - } - - #endregion -} \ No newline at end of file + // Re-derived from the original Store CollectionController.Edit(GET) (pre-cutover) - the + // condition is unusual (warns when NOT limited to stores at all, or when limited AND the + // staff member's store is one of several) and easy to get backwards. Scope.DefaultStoreId is + // exactly StaffStoreId for Store (StoreAdminDataScope.DefaultStoreId => + // CurrentCustomer.StaffStoreId). + protected override void EditWarningCheck(Collection collection) + { + if (!collection.LimitedToStores || + (collection.LimitedToStores && + collection.Stores.Contains(Scope.DefaultStoreId) && + collection.Stores.Count > 1)) + Warning(TranslationService.GetResource("Admin.Catalog.Collections.Permissions")); + } +} From 9831e8ebb8850fa543a6b141c83c982fd51d5508 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 24 Aug 2026 22:08:24 +0200 Subject: [PATCH 8/8] refactor(arch001): move shared Collection views under Grand.Web.AdminShared/Views/AdminShared/Collection Diff-and-classify all 10 Admin/Store Collection view pairs before moving anything, per the Product/Category precedent. 6 unified (Create, Edit, CreateOrUpdate + TabDiscounts/TabDocuments/ TabProducts/TabSeo), List/ProductAddPopup/TabInfo kept as host-specific overrides. Also found and fixed the same dead--in-Store tag-helper bug Category's Task 8 found: Store's _ViewImports.cshtml never registers Grand.Web.Admin's tag helper, so all 13 collection_* widget zone calls were silently dead literal HTML in Store - extracted per-host WidgetZone.*.cshtml satellites (vc:store-widget/store_collection_* naming, one per zone name) for the 7 zones in the unified files, and fixed the same bug inline in the 2 override files (List, TabInfo) that Category's own shipped code (PR #792, develop@c1a7ead84) left unfixed - verified by reading Category's merged Store-side List.cshtml/TabInfo.cshtml directly, not assumed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017ruWBUZPv3BnpPhjQVV8Xf --- .../Partials/CreateOrUpdate.TabSeo.cshtml | 73 --------- .../Collection/Partials/CreateOrUpdate.cshtml | 60 ------- .../Partials/WidgetZone.DetailsButtons.cshtml | 2 + .../WidgetZone.Discounts.Bottom.cshtml | 2 + .../Partials/WidgetZone.Discounts.Top.cshtml | 2 + .../WidgetZone.Documents.Bottom.cshtml | 2 + .../Partials/WidgetZone.Documents.Top.cshtml | 2 + .../WidgetZone.Products.Bottom.cshtml | 2 + .../Partials/WidgetZone.Products.Top.cshtml | 2 + .../Partials/WidgetZone.SEO.Top.cshtml | 2 + .../Partials/WidgetZone.Tabs.cshtml | 2 + .../AdminShared}/Collection/Create.cshtml | 5 +- .../Views/AdminShared}/Collection/Edit.cshtml | 5 +- .../CreateOrUpdate.TabDiscounts.cshtml | 4 +- .../CreateOrUpdate.TabDocuments.cshtml | 15 +- .../CreateOrUpdate.TabProducts.cshtml | 17 +- .../Partials/CreateOrUpdate.TabSeo.cshtml | 2 +- .../Collection/Partials/CreateOrUpdate.cshtml | 2 +- .../Store/Views/Collection/Create.cshtml | 37 ----- .../Areas/Store/Views/Collection/Edit.cshtml | 45 ------ .../Areas/Store/Views/Collection/List.cshtml | 2 +- .../CreateOrUpdate.TabDiscounts.cshtml | 27 ---- .../CreateOrUpdate.TabDocuments.cshtml | 80 ---------- .../Partials/CreateOrUpdate.TabInfo.cshtml | 4 +- .../CreateOrUpdate.TabProducts.cshtml | 148 ------------------ .../Partials/WidgetZone.DetailsButtons.cshtml | 2 + .../WidgetZone.Discounts.Bottom.cshtml | 2 + .../Partials/WidgetZone.Discounts.Top.cshtml | 2 + .../WidgetZone.Documents.Bottom.cshtml | 2 + .../Partials/WidgetZone.Documents.Top.cshtml | 2 + .../WidgetZone.Products.Bottom.cshtml | 2 + .../Partials/WidgetZone.Products.Top.cshtml | 2 + .../Partials/WidgetZone.SEO.Top.cshtml | 2 + .../Partials/WidgetZone.Tabs.cshtml | 2 + 34 files changed, 68 insertions(+), 494 deletions(-) delete mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/CreateOrUpdate.TabSeo.cshtml delete mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/CreateOrUpdate.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.DetailsButtons.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Discounts.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Discounts.Top.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Documents.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Documents.Top.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Products.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Products.Top.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.SEO.Top.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Tabs.cshtml rename src/Web/{Grand.Web.Admin/Areas/Admin/Views => Grand.Web.AdminShared/Views/AdminShared}/Collection/Create.cshtml (85%) rename src/Web/{Grand.Web.Admin/Areas/Admin/Views => Grand.Web.AdminShared/Views/AdminShared}/Collection/Edit.cshtml (88%) rename src/Web/{Grand.Web.Admin/Areas/Admin/Views => Grand.Web.AdminShared/Views/AdminShared}/Collection/Partials/CreateOrUpdate.TabDiscounts.cshtml (84%) rename src/Web/{Grand.Web.Admin/Areas/Admin/Views => Grand.Web.AdminShared/Views/AdminShared}/Collection/Partials/CreateOrUpdate.TabDocuments.cshtml (81%) rename src/Web/{Grand.Web.Admin/Areas/Admin/Views => Grand.Web.AdminShared/Views/AdminShared}/Collection/Partials/CreateOrUpdate.TabProducts.cshtml (89%) rename src/Web/{Grand.Web.Store/Areas/Store/Views => Grand.Web.AdminShared/Views/AdminShared}/Collection/Partials/CreateOrUpdate.TabSeo.cshtml (97%) rename src/Web/{Grand.Web.Store/Areas/Store/Views => Grand.Web.AdminShared/Views/AdminShared}/Collection/Partials/CreateOrUpdate.cshtml (96%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Create.cshtml delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Edit.cshtml delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/CreateOrUpdate.TabDiscounts.cshtml delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/CreateOrUpdate.TabDocuments.cshtml delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/CreateOrUpdate.TabProducts.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.DetailsButtons.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Discounts.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Discounts.Top.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Documents.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Documents.Top.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Products.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Products.Top.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.SEO.Top.cshtml create mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Tabs.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/CreateOrUpdate.TabSeo.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/CreateOrUpdate.TabSeo.cshtml deleted file mode 100644 index a117d5668..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/CreateOrUpdate.TabSeo.cshtml +++ /dev/null @@ -1,73 +0,0 @@ -@using Microsoft.AspNetCore.Mvc.Razor -@model CollectionModel - -@{ - Func - template = @
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
- -
; -} - -
- - -
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
-
\ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/CreateOrUpdate.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/CreateOrUpdate.cshtml deleted file mode 100644 index da1a5b18e..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/CreateOrUpdate.cshtml +++ /dev/null @@ -1,60 +0,0 @@ -@using Grand.Business.Core.Interfaces.Common.Security -@using Grand.Domain.Permissions -@model CollectionModel -@inject IPermissionService permissionService -@{ - //has "Manage Documents" permission? - var canManageDocuments = await permissionService.Authorize(StandardPermission.ManageDocuments); -} -
- - - - - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- @if (canManageDocuments) - { - - -
- -
-
-
- } - - -
- -
-
-
- -
-
\ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.DetailsButtons.cshtml new file mode 100644 index 000000000..08be41691 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.DetailsButtons.cshtml @@ -0,0 +1,2 @@ +@model CollectionModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Discounts.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Discounts.Bottom.cshtml new file mode 100644 index 000000000..9f73a89b9 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Discounts.Bottom.cshtml @@ -0,0 +1,2 @@ +@model CollectionModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Discounts.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Discounts.Top.cshtml new file mode 100644 index 000000000..bacc2a4a7 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Discounts.Top.cshtml @@ -0,0 +1,2 @@ +@model CollectionModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Documents.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Documents.Bottom.cshtml new file mode 100644 index 000000000..c81521f4a --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Documents.Bottom.cshtml @@ -0,0 +1,2 @@ +@model CollectionModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Documents.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Documents.Top.cshtml new file mode 100644 index 000000000..f03396a3c --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Documents.Top.cshtml @@ -0,0 +1,2 @@ +@model CollectionModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Products.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Products.Bottom.cshtml new file mode 100644 index 000000000..70875ee4b --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Products.Bottom.cshtml @@ -0,0 +1,2 @@ +@model CollectionModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Products.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Products.Top.cshtml new file mode 100644 index 000000000..1dd199f44 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Products.Top.cshtml @@ -0,0 +1,2 @@ +@model CollectionModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.SEO.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.SEO.Top.cshtml new file mode 100644 index 000000000..fe621b8c2 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.SEO.Top.cshtml @@ -0,0 +1,2 @@ +@model CollectionModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Tabs.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Tabs.cshtml new file mode 100644 index 000000000..326759afe --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/WidgetZone.Tabs.cshtml @@ -0,0 +1,2 @@ +@model CollectionModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Create.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Collection/Create.cshtml similarity index 85% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Create.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Collection/Create.cshtml index 7f5e0aca1..1d2ef42fd 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Create.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Collection/Create.cshtml @@ -2,8 +2,9 @@ @{ //page title ViewBag.Title = Loc["Admin.Catalog.Collections.AddNew"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } -
+
@@ -24,7 +25,7 @@ - +
diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Edit.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Collection/Edit.cshtml similarity index 88% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Edit.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Collection/Edit.cshtml index 345f580a4..825de814a 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Edit.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Collection/Edit.cshtml @@ -2,8 +2,9 @@ @{ //page title ViewBag.Title = Loc["Admin.Catalog.Collections.EditCollectionDetails"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } - +
@@ -31,7 +32,7 @@ @Loc["Admin.Common.Delete"] - +
diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/CreateOrUpdate.TabDiscounts.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Collection/Partials/CreateOrUpdate.TabDiscounts.cshtml similarity index 84% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/CreateOrUpdate.TabDiscounts.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Collection/Partials/CreateOrUpdate.TabDiscounts.cshtml index 723b79fca..415d11682 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/CreateOrUpdate.TabDiscounts.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Collection/Partials/CreateOrUpdate.TabDiscounts.cshtml @@ -1,5 +1,5 @@ @model CollectionModel - + @{ if (Model.AvailableDiscounts is { Count: > 0 }) { @@ -24,4 +24,4 @@ } } - \ No newline at end of file + \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/CreateOrUpdate.TabDocuments.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Collection/Partials/CreateOrUpdate.TabDocuments.cshtml similarity index 81% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/CreateOrUpdate.TabDocuments.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Collection/Partials/CreateOrUpdate.TabDocuments.cshtml index d3061814a..1149c1399 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Collection/Partials/CreateOrUpdate.TabDocuments.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Collection/Partials/CreateOrUpdate.TabDocuments.cshtml @@ -1,16 +1,19 @@ @model CollectionModel @inject AdminAreaSettings adminAreaSettings +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @if (!string.IsNullOrEmpty(Model.Id)) {
- +
- +
-} -else -{ -
- @Loc["Admin.Catalog.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/CreateOrUpdate.TabInfo.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/CreateOrUpdate.TabInfo.cshtml index 70a36cef1..51da70d49 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/CreateOrUpdate.TabInfo.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/CreateOrUpdate.TabInfo.cshtml @@ -46,7 +46,7 @@ }
- +
@@ -209,4 +209,4 @@
- \ No newline at end of file + \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/CreateOrUpdate.TabProducts.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/CreateOrUpdate.TabProducts.cshtml deleted file mode 100644 index 97716379e..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/CreateOrUpdate.TabProducts.cshtml +++ /dev/null @@ -1,148 +0,0 @@ -@model CollectionModel -@inject AdminAreaSettings adminAreaSettings -@if (!string.IsNullOrEmpty(Model.Id)) -{ - - - -} -else -{ -
- @Loc["Admin.Catalog.Collections.Products.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.DetailsButtons.cshtml new file mode 100644 index 000000000..6cf0cc502 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.DetailsButtons.cshtml @@ -0,0 +1,2 @@ +@model CollectionModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Discounts.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Discounts.Bottom.cshtml new file mode 100644 index 000000000..bcc97e5ae --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Discounts.Bottom.cshtml @@ -0,0 +1,2 @@ +@model CollectionModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Discounts.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Discounts.Top.cshtml new file mode 100644 index 000000000..1bb64f770 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Discounts.Top.cshtml @@ -0,0 +1,2 @@ +@model CollectionModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Documents.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Documents.Bottom.cshtml new file mode 100644 index 000000000..3c2935a23 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Documents.Bottom.cshtml @@ -0,0 +1,2 @@ +@model CollectionModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Documents.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Documents.Top.cshtml new file mode 100644 index 000000000..693078440 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Documents.Top.cshtml @@ -0,0 +1,2 @@ +@model CollectionModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Products.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Products.Bottom.cshtml new file mode 100644 index 000000000..17cecd868 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Products.Bottom.cshtml @@ -0,0 +1,2 @@ +@model CollectionModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Products.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Products.Top.cshtml new file mode 100644 index 000000000..3d920be36 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Products.Top.cshtml @@ -0,0 +1,2 @@ +@model CollectionModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.SEO.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.SEO.Top.cshtml new file mode 100644 index 000000000..a9928c704 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.SEO.Top.cshtml @@ -0,0 +1,2 @@ +@model CollectionModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Tabs.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Tabs.cshtml new file mode 100644 index 000000000..48819f0ae --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Collection/Partials/WidgetZone.Tabs.cshtml @@ -0,0 +1,2 @@ +@model CollectionModel +