From a465d8f9f615b2750e8a3b84bc289af5514d215f Mon Sep 17 00:00:00 2001 From: support Date: Sun, 25 Oct 2026 12:19:46 +0100 Subject: [PATCH 1/2] Render Nordic product-page view components by name #922 dropped the theme's reference to Grand.Web and its `@addTagHelper *, Grand.Web`, so `` tags for components that live in Grand.Web stopped compiling into tag helpers. Razor emitted them as unknown HTML elements: the productdetails_* widget zones and the also-purchased/related/similar/recommended sections never ran, and the empty elements took a full row inside .nordic-pd__main, pushing the product overview below the gallery. Component.InvokeAsync resolves the component at runtime, as the rest of the theme already does, so the theme keeps depending only on Grand.Web.Models. --- .../Product/ProductLayout.Grouped.cshtml | 4 ++-- .../Product/ProductLayout.Simple.cshtml | 22 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Plugins/Theme.Nordic/Views/Nordic/Product/ProductLayout.Grouped.cshtml b/src/Plugins/Theme.Nordic/Views/Nordic/Product/ProductLayout.Grouped.cshtml index 1038aa3f8..3cb4045fc 100644 --- a/src/Plugins/Theme.Nordic/Views/Nordic/Product/ProductLayout.Grouped.cshtml +++ b/src/Plugins/Theme.Nordic/Views/Nordic/Product/ProductLayout.Grouped.cshtml @@ -59,10 +59,10 @@
- + @await Component.InvokeAsync("Widget", new { widgetZone = "productdetails_before_pictures", additionalData = Model.Id }) - + @await Component.InvokeAsync("Widget", new { widgetZone = "productdetails_after_pictures", additionalData = Model.Id })
@if (!string.IsNullOrEmpty(Model.Flag)) { diff --git a/src/Plugins/Theme.Nordic/Views/Nordic/Product/ProductLayout.Simple.cshtml b/src/Plugins/Theme.Nordic/Views/Nordic/Product/ProductLayout.Simple.cshtml index ef3f28956..ddbfe8a76 100644 --- a/src/Plugins/Theme.Nordic/Views/Nordic/Product/ProductLayout.Simple.cshtml +++ b/src/Plugins/Theme.Nordic/Views/Nordic/Product/ProductLayout.Simple.cshtml @@ -52,18 +52,18 @@ } - +@await Component.InvokeAsync("Widget", new { widgetZone = "productdetails_after_breadcrumb", additionalData = Model.Id })
- + @await Component.InvokeAsync("Widget", new { widgetZone = "productdetails_top", additionalData = Model.Id })
- + @await Component.InvokeAsync("Widget", new { widgetZone = "productdetails_before_pictures", additionalData = Model.Id }) - + @await Component.InvokeAsync("Widget", new { widgetZone = "productdetails_after_pictures", additionalData = Model.Id }) @*Nordic: the info column in editorial order - brand, title, rating and codes, price, delivery notes, short description, choices, buy row, then a perks list*@
@@ -92,7 +92,7 @@
- + @await Component.InvokeAsync("Widget", new { widgetZone = "productdetails_overview_top", additionalData = Model.Id })
@if (Model.TierPrices.Any()) @@ -192,7 +192,7 @@
} - + @await Component.InvokeAsync("Widget", new { widgetZone = "productdetails_overview_bottom", additionalData = Model.Id })
@@ -236,10 +236,10 @@
- - - - + @await Component.InvokeAsync("ProductsAlsoPurchased", new { productId = Model.Id }) + @await Component.InvokeAsync("RelatedProducts", new { productId = Model.Id }) + @await Component.InvokeAsync("SimilarProducts", new { productId = Model.Id }) + @await Component.InvokeAsync("RecommendedProducts", new { productId = Model.Id }) @if (Model.RenderCaptcha) { @@ -251,5 +251,5 @@ { @await Component.InvokeAsync("ProductEmailAFriend", new { productId = Model.Id }) } - + @await Component.InvokeAsync("Widget", new { widgetZone = "productdetails_bottom", additionalData = Model.Id })
\ No newline at end of file From 267d2aacc6c85834edd4bd5f48cedef82e386da6 Mon Sep 17 00:00:00 2001 From: support Date: Sun, 25 Oct 2026 13:05:35 +0100 Subject: [PATCH 2/2] Move WidgetViewComponent to Grand.Web.Common so works in themes #922 removed `@addTagHelper *, Grand.Web` from plugin and theme _ViewImports and made a plugin reference to Grand.Web a build error. View-component tag helpers are generated per registered assembly, so in any theme or plugin view compiled to a literal unknown element: its widget zones rendered nothing, with no build error. Every plugin and theme registers Grand.Web.Common, which is also an application part of the host, so the component works there for all of them. WidgetModel keeps its Grand.Web.Models.Cms namespace, which storefront and theme _ViewImports import, and the cache key moves to CacheKey. Nordic's widget zones go back to ; the product-list components still live in Grand.Web and stay invoked by name. --- .ai/skills/theme-creation.md | 3 ++- .../Caching/Constants/CommonCacheKey.cs | 14 ++++++++++++++ .../Nordic/Product/ProductLayout.Grouped.cshtml | 4 ++-- .../Nordic/Product/ProductLayout.Simple.cshtml | 14 +++++++------- .../Components/WidgetViewComponentTests.cs | 4 ++-- .../Components/WidgetViewComponent.cs} | 7 +++---- .../Models}/Cms/WidgetModel.cs | 3 ++- src/Web/Grand.Web/Events/Cache/CacheKeyConst.cs | 10 ---------- 8 files changed, 32 insertions(+), 27 deletions(-) rename src/Tests/{Grand.Web.Tests => Grand.Web.Common.Tests}/Components/WidgetViewComponentTests.cs (97%) rename src/Web/{Grand.Web/Components/Widget.cs => Grand.Web.Common/Components/WidgetViewComponent.cs} (90%) rename src/Web/{Grand.Web.Models => Grand.Web.Common/Models}/Cms/WidgetModel.cs (73%) diff --git a/.ai/skills/theme-creation.md b/.ai/skills/theme-creation.md index 64b662ce9..c7cda45d1 100644 --- a/.ai/skills/theme-creation.md +++ b/.ai/skills/theme-creation.md @@ -84,7 +84,8 @@ Do not use this skill for changes to `Grand.Web/Views` themselves — that is `. 16. Add `Views/{ThemeName}/_ViewStart.cshtml`. 17. Copy **only** the views the theme actually changes. Everything else resolves through the fallback locations. Each copied view is a file that must be re-reconciled on every upgrade. 18. A copied view keeps the same `@model`, the same route names, and the same storefront data attributes — `data-cart-action`, product IDs, quick-view URLs, wishlist, compare, add-to-cart, image `alt` / `title` / `loading`. -19. Keep widget zones (`@await Component.InvokeAsync("Widget", new { widgetZone = "..." })`) present in copied views. Dropping a zone silently disables every installed widget on that page. +19. Keep widget zones (`` or `@await Component.InvokeAsync("Widget", new { widgetZone = "..." })`) present in copied views. Dropping a zone silently disables every installed widget on that page. + A `` tag only works for a view component in an assembly the theme registers with `@addTagHelper`, which here is `Grand.Web.Common` (``). A theme cannot reference `Grand.Web` (`GRAND001`), so every other `` copied from a `Grand.Web` view, such as ``, renders as an empty unknown element with no build error. Rewrite those as `@await Component.InvokeAsync("RelatedProducts", new { productId = Model.Id })`. 20. A theme must not change view models, controller actions, or route definitions. If a theme needs different data, the change belongs in `Grand.Web`, not the theme. #### Assets diff --git a/src/Core/Grand.Infrastructure/Caching/Constants/CommonCacheKey.cs b/src/Core/Grand.Infrastructure/Caching/Constants/CommonCacheKey.cs index 7b6a30d16..e83df6ba9 100644 --- a/src/Core/Grand.Infrastructure/Caching/Constants/CommonCacheKey.cs +++ b/src/Core/Grand.Infrastructure/Caching/Constants/CommonCacheKey.cs @@ -217,4 +217,18 @@ public static partial class CacheKey public static string PAGES_PATTERN_KEY => "Grand.pages."; #endregion + + #region Widgets + + /// + /// Key for widget info + /// + /// + /// {0} : current store ID + /// {1} : customer groups + /// {2} : widget zone + /// + public static string WIDGET_MODEL_KEY => "Grand.pres.widget-{0}-{1}-{2}"; + + #endregion } \ No newline at end of file diff --git a/src/Plugins/Theme.Nordic/Views/Nordic/Product/ProductLayout.Grouped.cshtml b/src/Plugins/Theme.Nordic/Views/Nordic/Product/ProductLayout.Grouped.cshtml index 3cb4045fc..1038aa3f8 100644 --- a/src/Plugins/Theme.Nordic/Views/Nordic/Product/ProductLayout.Grouped.cshtml +++ b/src/Plugins/Theme.Nordic/Views/Nordic/Product/ProductLayout.Grouped.cshtml @@ -59,10 +59,10 @@
- @await Component.InvokeAsync("Widget", new { widgetZone = "productdetails_before_pictures", additionalData = Model.Id }) + - @await Component.InvokeAsync("Widget", new { widgetZone = "productdetails_after_pictures", additionalData = Model.Id }) +
@if (!string.IsNullOrEmpty(Model.Flag)) { diff --git a/src/Plugins/Theme.Nordic/Views/Nordic/Product/ProductLayout.Simple.cshtml b/src/Plugins/Theme.Nordic/Views/Nordic/Product/ProductLayout.Simple.cshtml index ddbfe8a76..720dba939 100644 --- a/src/Plugins/Theme.Nordic/Views/Nordic/Product/ProductLayout.Simple.cshtml +++ b/src/Plugins/Theme.Nordic/Views/Nordic/Product/ProductLayout.Simple.cshtml @@ -52,18 +52,18 @@ } -@await Component.InvokeAsync("Widget", new { widgetZone = "productdetails_after_breadcrumb", additionalData = Model.Id }) +
- @await Component.InvokeAsync("Widget", new { widgetZone = "productdetails_top", additionalData = Model.Id }) +
- @await Component.InvokeAsync("Widget", new { widgetZone = "productdetails_before_pictures", additionalData = Model.Id }) + - @await Component.InvokeAsync("Widget", new { widgetZone = "productdetails_after_pictures", additionalData = Model.Id }) + @*Nordic: the info column in editorial order - brand, title, rating and codes, price, delivery notes, short description, choices, buy row, then a perks list*@
@@ -92,7 +92,7 @@
- @await Component.InvokeAsync("Widget", new { widgetZone = "productdetails_overview_top", additionalData = Model.Id }) +
@if (Model.TierPrices.Any()) @@ -192,7 +192,7 @@
} - @await Component.InvokeAsync("Widget", new { widgetZone = "productdetails_overview_bottom", additionalData = Model.Id }) +
@@ -251,5 +251,5 @@ { @await Component.InvokeAsync("ProductEmailAFriend", new { productId = Model.Id }) } - @await Component.InvokeAsync("Widget", new { widgetZone = "productdetails_bottom", additionalData = Model.Id }) +
\ No newline at end of file diff --git a/src/Tests/Grand.Web.Tests/Components/WidgetViewComponentTests.cs b/src/Tests/Grand.Web.Common.Tests/Components/WidgetViewComponentTests.cs similarity index 97% rename from src/Tests/Grand.Web.Tests/Components/WidgetViewComponentTests.cs rename to src/Tests/Grand.Web.Common.Tests/Components/WidgetViewComponentTests.cs index d2ba6c715..2794a0b3d 100644 --- a/src/Tests/Grand.Web.Tests/Components/WidgetViewComponentTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Components/WidgetViewComponentTests.cs @@ -4,14 +4,14 @@ using Grand.Infrastructure.Caching; using Grand.Infrastructure.Configuration; using Grand.Mediator; -using Grand.Web.Components; +using Grand.Web.Common.Components; using Grand.Web.Models.Cms; using Microsoft.AspNetCore.Mvc.ViewComponents; using Microsoft.Extensions.Caching.Memory; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; -namespace Grand.Web.Tests.Components; +namespace Grand.Web.Common.Tests.Components; [TestClass] public class WidgetViewComponentTests diff --git a/src/Web/Grand.Web/Components/Widget.cs b/src/Web/Grand.Web.Common/Components/WidgetViewComponent.cs similarity index 90% rename from src/Web/Grand.Web/Components/Widget.cs rename to src/Web/Grand.Web.Common/Components/WidgetViewComponent.cs index 8cd3d5841..91f9a3a45 100644 --- a/src/Web/Grand.Web/Components/Widget.cs +++ b/src/Web/Grand.Web.Common/Components/WidgetViewComponent.cs @@ -2,12 +2,11 @@ using Grand.Domain.Customers; using Grand.Infrastructure; using Grand.Infrastructure.Caching; -using Grand.Web.Common.Components; -using Grand.Web.Events.Cache; +using Grand.Infrastructure.Caching.Constants; using Grand.Web.Models.Cms; using Microsoft.AspNetCore.Mvc; -namespace Grand.Web.Components; +namespace Grand.Web.Common.Components; public class WidgetViewComponent : BaseViewComponent { @@ -24,7 +23,7 @@ public WidgetViewComponent(IContextAccessor contextAccessor, ICacheBase cacheBas public async Task InvokeAsync(string widgetZone, object additionalData = null) { - var cacheKey = string.Format(CacheKeyConst.WIDGET_MODEL_KEY, _contextAccessor.StoreContext.CurrentStore.Id, + var cacheKey = string.Format(CacheKey.WIDGET_MODEL_KEY, _contextAccessor.StoreContext.CurrentStore.Id, string.Join(",", _contextAccessor.WorkContext.CurrentCustomer.GetCustomerGroupIds()), widgetZone); diff --git a/src/Web/Grand.Web.Models/Cms/WidgetModel.cs b/src/Web/Grand.Web.Common/Models/Cms/WidgetModel.cs similarity index 73% rename from src/Web/Grand.Web.Models/Cms/WidgetModel.cs rename to src/Web/Grand.Web.Common/Models/Cms/WidgetModel.cs index 0dcccd91c..318b4debb 100644 --- a/src/Web/Grand.Web.Models/Cms/WidgetModel.cs +++ b/src/Web/Grand.Web.Common/Models/Cms/WidgetModel.cs @@ -1,5 +1,6 @@ using Grand.Infrastructure.Models; +//the namespace stays from Grand.Web.Models: storefront and theme _ViewImports import it namespace Grand.Web.Models.Cms; public class WidgetModel : BaseModel @@ -7,4 +8,4 @@ public class WidgetModel : BaseModel public string WidgetZone { get; set; } public string ViewComponentName { get; set; } public object AdditionalData { get; set; } -} \ No newline at end of file +} diff --git a/src/Web/Grand.Web/Events/Cache/CacheKeyConst.cs b/src/Web/Grand.Web/Events/Cache/CacheKeyConst.cs index 7752ef63a..b72d36efb 100644 --- a/src/Web/Grand.Web/Events/Cache/CacheKeyConst.cs +++ b/src/Web/Grand.Web/Events/Cache/CacheKeyConst.cs @@ -288,16 +288,6 @@ public static class CacheKeyConst /// public const string SITEMAP_PAGE_MODEL_KEY = "Grand.pres.sitemap.page-{0}-{1}-{2}"; - /// - /// Key for widget info - /// - /// - /// {0} : current store ID - /// {1} : customer groups - /// {2} : widget zone - /// - public const string WIDGET_MODEL_KEY = "Grand.pres.widget-{0}-{1}-{2}"; - /// /// Key for caching ///