diff --git a/src/Framework/Framework/Compilation/ControlTree/Resolved/ResolvedTreeRoot.cs b/src/Framework/Framework/Compilation/ControlTree/Resolved/ResolvedTreeRoot.cs index 12aa8911cb..1255fb66b1 100644 --- a/src/Framework/Framework/Compilation/ControlTree/Resolved/ResolvedTreeRoot.cs +++ b/src/Framework/Framework/Compilation/ControlTree/Resolved/ResolvedTreeRoot.cs @@ -30,7 +30,8 @@ public class ResolvedTreeRoot : ResolvedControl, IAbstractTreeRoot (from ds in Directives from d in ds.Value select (ds.Key, d.Value)).ToImmutableArray(), - GetViewModuleInfo() + GetViewModuleInfo(), + CollectContentPlaceHolderIds() ); private ViewModuleReferenceInfo? GetViewModuleInfo() @@ -41,6 +42,33 @@ from d in ds.Value return null; } + /// + /// Traverses the entire resolved tree (including controls inside templates) to collect + /// all ContentPlaceHolder IDs declared in this page/master page file. + /// + private ImmutableArray CollectContentPlaceHolderIds() + { + var collector = new ContentPlaceHolderIdCollector(); + this.AcceptChildren(collector); + return collector.Ids.ToImmutableArray(); + } + + private sealed class ContentPlaceHolderIdCollector : ResolvedControlTreeVisitor + { + public readonly List Ids = new List(); + + public override void VisitControl(ResolvedControl control) + { + if (control.Metadata.Type == typeof(ContentPlaceHolder) + && control.Properties.TryGetValue(DotvvmControl.IDProperty, out var idSetter) + && idSetter is ResolvedPropertyValue { Value: string id }) + { + Ids.Add(id); + } + DefaultVisit(control); + } + } + public ResolvedTreeRoot(ControlResolverMetadata metadata, DothtmlNode node, DataContextStack dataContext, ImmutableDictionary> directives, ControlBuilderDescriptor? masterPage) : base(metadata, node, null, dataContext) { diff --git a/src/Framework/Framework/Compilation/ViewCompiler/ControlBuilderDescriptor.cs b/src/Framework/Framework/Compilation/ViewCompiler/ControlBuilderDescriptor.cs index 83654e8c32..47b0427ee1 100644 --- a/src/Framework/Framework/Compilation/ViewCompiler/ControlBuilderDescriptor.cs +++ b/src/Framework/Framework/Compilation/ViewCompiler/ControlBuilderDescriptor.cs @@ -27,6 +27,12 @@ public class ControlBuilderDescriptor: IAbstractControlBuilderDescriptor public ViewModuleReferenceInfo? ViewModuleReference { get; } + /// + /// All ContentPlaceHolder IDs declared in this page/master page, including those inside CompositeControl templates. + /// Used to validate Content controls when performing master page composition. + /// + public ImmutableArray ContentPlaceHolderIds { get; } + ITypeDescriptor IAbstractControlBuilderDescriptor.DataContextType => new ResolvedTypeDescriptor(this.DataContextType); ITypeDescriptor IAbstractControlBuilderDescriptor.ControlType => new ResolvedTypeDescriptor(this.ControlType); @@ -39,7 +45,8 @@ public ControlBuilderDescriptor( string? fileName, ControlBuilderDescriptor? masterPage, ImmutableArray<(string name, string value)> directives, - ViewModuleReferenceInfo? viewModuleReference + ViewModuleReferenceInfo? viewModuleReference, + ImmutableArray contentPlaceHolderIds ) { this.DataContextType = dataContextType; @@ -48,6 +55,7 @@ public ControlBuilderDescriptor( this.MasterPage = masterPage; this.Directives = directives; this.ViewModuleReference = viewModuleReference; + this.ContentPlaceHolderIds = contentPlaceHolderIds; } } } diff --git a/src/Framework/Framework/Controls/ContentPlaceHolder.cs b/src/Framework/Framework/Controls/ContentPlaceHolder.cs index 19d223bcb2..d816825cc7 100644 --- a/src/Framework/Framework/Controls/ContentPlaceHolder.cs +++ b/src/Framework/Framework/Controls/ContentPlaceHolder.cs @@ -6,6 +6,7 @@ using DotVVM.Framework.Binding; using DotVVM.Framework.Configuration; using DotVVM.Framework.Hosting; +using DotVVM.Framework.Runtime; namespace DotVVM.Framework.Controls { @@ -19,7 +20,49 @@ public ContentPlaceHolder() { SetValue(Internal.IsNamingContainerProperty, true); } - + + protected internal override void OnInit(IDotvvmRequestContext context) + { + ResolvePendingComposition(); + + base.OnInit(context); + } + + internal void ResolvePendingComposition() + { + if (ID == null) return; + + // find the nearest master page + var masterPage = GetAllAncestors() + .FirstOrDefault(a => a.IsPropertySet(Internal.PendingMasterPageCompositionsProperty, inherit: false)); + if (masterPage == null) + { + throw new DotvvmControlException(this, "The ContentPlaceHolder or SpaContentPlaceHolder control can be used only in a master page. The current page doesn't have the @masterPage directive."); + } + + // check there are not multiple content placeholders with the same ID in the same master page (e.g. due to being inside a template that is instantiated multiple times) + var resolvedIds = (HashSet)masterPage.GetValue(Internal.ResolvedMasterPageCompositionIdsProperty)!; + if (resolvedIds.Contains(ID)) + { + throw new DotvvmControlException(this, + $"The ContentPlaceHolder with ID '{ID}' has already been resolved. " + + $"ContentPlaceHolder controls used for master page composition cannot be placed inside templates that are instantiated multiple times (e.g. Repeater, foreach)."); + } + + // find the pending composition + var pendingList = (List)masterPage.GetValue(Internal.PendingMasterPageCompositionsProperty)!; + var pending = pendingList.SingleOrDefault(p => p.Content.ContentPlaceHolderID == ID); + if (pending != null) + { + // remove it from the master page and from the root + pendingList.Remove(pending); + resolvedIds.Add(ID); + + // perform the deferred composition: wrap Content in a PlaceHolder and add it as our child + DefaultDotvvmViewBuilder.PlaceContentInContentPlaceHolder(pending.DataContextType, this, pending.Content); + } + } + protected override void AddAttributesToRender(IHtmlWriter writer, IDotvvmRequestContext context) { // The ID is used only at runtime to find the PlaceHolder-Content pair. diff --git a/src/Framework/Framework/Controls/Internal.cs b/src/Framework/Framework/Controls/Internal.cs index 4d9a5c4547..df5f5281ba 100644 --- a/src/Framework/Framework/Controls/Internal.cs +++ b/src/Framework/Framework/Controls/Internal.cs @@ -7,6 +7,7 @@ using DotVVM.Framework.Compilation; using DotVVM.Framework.Compilation.ControlTree; using DotVVM.Framework.Compilation.ControlTree.Resolved; +using DotVVM.Framework.Controls.Infrastructure; using DotVVM.Framework.Hosting; namespace DotVVM.Framework.Controls @@ -69,6 +70,16 @@ public class Internal public static DotvvmProperty UsedPropertiesInfoProperty = DotvvmProperty.Register(() => UsedPropertiesInfoProperty); + /// + /// This property is set on the master page root element and points to the root element of the child page. + /// + public static readonly DotvvmProperty MasterPageChildPageProperty = + DotvvmProperty.Register(() => MasterPageChildPageProperty, defaultValue: null, isValueInherited: true); + public static readonly DotvvmProperty PendingMasterPageCompositionsProperty = + DotvvmProperty.Register?, Internal>(() => PendingMasterPageCompositionsProperty, defaultValue: null, isValueInherited: true); + public static readonly DotvvmProperty ResolvedMasterPageCompositionIdsProperty = + DotvvmProperty.Register?, Internal>(() => ResolvedMasterPageCompositionIdsProperty, defaultValue: null, isValueInherited: true); + public static bool IsViewCompilerProperty(DotvvmProperty property) { return property.DeclaringType == typeof(Internal); @@ -105,4 +116,14 @@ public static TControl SetDataContextType(this TControl control, DataC return control; } } + + /// + /// Represents a Content control that has not yet been matched to a ContentPlaceHolder during master page composition. + /// The match is deferred until the ContentPlaceHolder is added to the control tree (e.g. when a CompositeControl builds its contents). + /// The list of instances is maintained on the root of the master page, and must be copied down to all Content controls because the root is discarded during composition. + /// + internal sealed record PendingMasterPageComposition(Content Content, DotvvmView ContentPage, DotvvmView MasterPage, DataContextStack? DataContextType) + { + public string? MasterPageFile => MasterPage.GetValue(Internal.MarkupFileNameProperty)?.ToString(); + } } diff --git a/src/Framework/Framework/Hosting/DotvvmPresenter.cs b/src/Framework/Framework/Hosting/DotvvmPresenter.cs index 6282f79d0d..172002ec82 100644 --- a/src/Framework/Framework/Hosting/DotvvmPresenter.cs +++ b/src/Framework/Framework/Hosting/DotvvmPresenter.cs @@ -13,6 +13,7 @@ using DotVVM.Framework.Compilation.Binding; using DotVVM.Framework.Configuration; using DotVVM.Framework.Controls; +using DotVVM.Framework.Controls.Infrastructure; using DotVVM.Framework.Runtime; using DotVVM.Framework.Runtime.Filters; using DotVVM.Framework.Security; @@ -233,6 +234,10 @@ public async Task ProcessRequestCore(IDotvvmRequestContext context) DotvvmControlCollection.InvokePageLifeCycleEventRecursive(page, LifeCycleEventType.Load, context); await requestTracer.TraceEvent(RequestTracingConstants.LoadCompleted, context); + // After Load, ensure all Content controls have been matched to their ContentPlaceHolders. + // For postback requests, the Repeater creates children in Load (for Commands), so we check here. + ValidateMasterPageComposition(page); + // invoke the postback command var actionInfo = ViewModelSerializer.ResolveCommand(context, page); @@ -267,6 +272,11 @@ public async Task ProcessRequestCore(IDotvvmRequestContext context) // run the prerender phase in the page DotvvmControlCollection.InvokePageLifeCycleEventRecursive(page, LifeCycleEventType.PreRender, context); + // After PreRender, ensure all Content controls have been matched to their ContentPlaceHolders. + // For GET requests, the Repeater creates children in PreRender, so this is the final check point. + // For postbacks, the check above after Load already caught most issues; this is a safety net. + ValidateMasterPageComposition(page); + // run the prerender complete phase in the page DotvvmControlCollection.InvokePageLifeCycleEventRecursive(page, LifeCycleEventType.PreRenderComplete, context); await requestTracer.TraceEvent(RequestTracingConstants.PreRenderCompleted, context); @@ -598,5 +608,29 @@ public static bool DeterminePartialRendering(IHttpContext context) => { return context.Request.Headers[HostingConstants.SpaContentPlaceHolderHeaderName]; } + + /// + /// Validates that all Content controls have been matched to their corresponding ContentPlaceHolder controls + /// after the Load phase. If any Content controls remain unmatched, it means the ContentPlaceHolder + /// was declared in the master page but never instantiated (e.g. a CompositeControl's GetContents was not called). + /// + private static void ValidateMasterPageComposition(DotvvmControl page) + { + var childPage = (DotvvmControl?)page; + while (childPage != null) + { + var pendingList = (List)childPage.GetValue(Internal.PendingMasterPageCompositionsProperty)!; + if (pendingList is { Count: > 0 }) + { + var pending = pendingList[0]; + var masterPageInfo = pending.MasterPageFile is { } masterPageFile ? $" '{masterPageFile}'" : ""; + throw new DotvvmControlException(pending.Content, + $"The ContentPlaceHolder with ID '{pending.Content.ContentPlaceHolderID}' was declared in the master page{masterPageInfo} but was never instantiated. " + + $"Make sure the ContentPlaceHolder is always added to the control tree (e.g. it is not inside a conditional template)."); + } + + childPage = (DotvvmControl?)childPage.GetValue(Internal.MasterPageChildPageProperty); + } + } } } diff --git a/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs b/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs index f9814573b9..960bc3704e 100644 --- a/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs +++ b/src/Framework/Framework/Runtime/DefaultDotvvmViewBuilder.cs @@ -7,7 +7,9 @@ using System.Threading.Tasks; using DotVVM.Framework.Binding; using DotVVM.Framework.Compilation; +using DotVVM.Framework.Compilation.ControlTree; using DotVVM.Framework.Compilation.Parser; +using DotVVM.Framework.Compilation.ViewCompiler; using DotVVM.Framework.Configuration; using DotVVM.Framework.Controls; using DotVVM.Framework.Controls.Infrastructure; @@ -51,7 +53,7 @@ public DotvvmView BuildView(IDotvvmRequestContext context) var masterPage = (DotvvmView)pageBuilder.Value.BuildControl(controlBuilderFactory, context.Services); FillsDefaultDirectives(masterPage); - PerformMasterPageComposition(contentPage, masterPage); + PerformMasterPageComposition(contentPage, masterPage, pageDescriptor); masterPage.ViewModelType = contentPage.ViewModelType; contentPage = masterPage; @@ -99,40 +101,61 @@ private void FillsDefaultDirectives(DotvvmView page) /// /// Performs the master page nesting. /// - private void PerformMasterPageComposition(DotvvmView childPage, DotvvmView masterPage) + private void PerformMasterPageComposition(DotvvmView childPage, DotvvmView masterPage, ControlBuilderDescriptor masterPageDescriptor) { if (!masterPage.ViewModelType.IsAssignableFrom(childPage.ViewModelType)) throw new DotvvmControlException(childPage, $"Master page requires viewModel of type '{masterPage.ViewModelType}' and it is not assignable from '{childPage.ViewModelType}'."); - + // find content place holders var placeHolders = GetMasterPageContentPlaceHolders(masterPage); // find contents var (contents, auxControls) = GetChildPageContents(childPage, placeHolders); + + // prepare the pending compositions list + // NB: this list is stored in the master page, so ContentPlaceHolders can access it easily thanks to DotVVM property inheritance - it will look up + var pendingCompositions = new List(); + masterPage.SetValue(Internal.PendingMasterPageCompositionsProperty, pendingCompositions); + masterPage.SetValue(Internal.ResolvedMasterPageCompositionIdsProperty, new HashSet(StringComparer.Ordinal)); + + // save the reference to the child page + // NB: after the Load phase, we need to check the root master page and all nested master pages that all contents are resolved. + // This allows to jump from the root master page to the next nested one. + masterPage.SetValue(Internal.MasterPageChildPageProperty, childPage); // perform the composition foreach (var content in contents) { + // NB: when Contents are placed inside ContentPlaceHolders, the childPage is not part of the control tree anymore - it gets abandoned. + // Therefore, we need to copy all the important properties from the child page to the content control, so they are not lost during the composition. + // This will let potential ContentPlaceHolders inside the Content finding the list of pending compositions and the reference to the child page. + content.SetValue(DotvvmView.DirectivesProperty, childPage.Directives); + content.SetValue(Internal.MarkupFileNameProperty, childPage.GetValue(Internal.MarkupFileNameProperty)); + content.SetValue(Internal.ReferencedViewModuleInfoProperty, childPage.GetValue(Internal.ReferencedViewModuleInfoProperty)); + content.SetValue(Internal.MasterPageChildPageProperty, childPage.GetValue(Internal.MasterPageChildPageProperty)); + content.SetValue(Internal.PendingMasterPageCompositionsProperty, childPage.GetValue(Internal.PendingMasterPageCompositionsProperty)); + content.SetValue(Internal.ResolvedMasterPageCompositionIdsProperty, childPage.GetValue(Internal.ResolvedMasterPageCompositionIdsProperty)); + // find the corresponding placeholder + var dataContextType = content.Parent!.GetDataContextType(); + var placeHolder = placeHolders.SingleOrDefault(p => p.ID == content.ContentPlaceHolderID); if (placeHolder == null) { - throw new DotvvmControlException(content, $"The placeholder with ID '{content.ContentPlaceHolderID}' was not found in the master page '{masterPage.GetValue(Internal.MarkupFileNameProperty)}'!"); + if (!masterPageDescriptor.ContentPlaceHolderIds.Contains(content.ContentPlaceHolderID!)) + { + var masterPageInfo = masterPageDescriptor.FileName is { } masterPageFile ? $" '{masterPageFile}'" : ""; + throw new DotvvmControlException(content, + $"The ContentPlaceHolder with ID '{content.ContentPlaceHolderID}' was not found in the master page{masterPageInfo}. " + + $"Make sure that each Content element has a corresponding ContentPlaceHolder in the master page."); + } + + pendingCompositions.Add(new PendingMasterPageComposition(content, childPage, masterPage, dataContextType)); + continue; } // replace the contents - var contentPlaceHolder = new PlaceHolder(); - contentPlaceHolder.SetDataContextType(content.Parent!.GetDataContextType()); - (content.Parent as DotvvmControl)?.Children.Remove(content); - - placeHolder.Children.Clear(); - placeHolder.Children.Add(contentPlaceHolder); - - contentPlaceHolder.Children.Add(content); - content.SetValue(Internal.IsMasterPageCompositionFinishedProperty, true); - content.SetValue(DotvvmView.DirectivesProperty, childPage.Directives); - content.SetValue(Internal.MarkupFileNameProperty, childPage.GetValue(Internal.MarkupFileNameProperty)); - content.SetValue(Internal.ReferencedViewModuleInfoProperty, childPage.GetValue(Internal.ReferencedViewModuleInfoProperty)); + PlaceContentInContentPlaceHolder(dataContextType, placeHolder, content); } foreach (var control in auxControls) @@ -142,6 +165,21 @@ private void PerformMasterPageComposition(DotvvmView childPage, DotvvmView maste masterPage.ViewModelType = childPage.ViewModelType; } + internal static void PlaceContentInContentPlaceHolder(DataContextStack? dataContextType, ContentPlaceHolder placeHolder, Content content) + { + var contentPlaceHolder = new PlaceHolder(); + contentPlaceHolder.SetDataContextType(dataContextType); + + ((DotvvmControl)content.Parent!).Children.Remove(content); + + placeHolder.Children.Clear(); + placeHolder.Children.Add(contentPlaceHolder); + + contentPlaceHolder.Children.Add(content); + + content.SetValue(Internal.IsMasterPageCompositionFinishedProperty, true); + } + /// /// Gets the content place holders. /// diff --git a/src/Samples/Common/DotvvmStartup.cs b/src/Samples/Common/DotvvmStartup.cs index d263487d3e..3add781bba 100644 --- a/src/Samples/Common/DotvvmStartup.cs +++ b/src/Samples/Common/DotvvmStartup.cs @@ -33,6 +33,7 @@ using DotVVM.Framework.ResourceManagement; using DotVVM.Samples.Common.Presenters; using DotVVM.Samples.Common.ViewModels.FeatureSamples.CustomPrimitiveTypes; +using DotVVM.Samples.Common.Views.FeatureSamples.LateContentPlaceHolders; namespace DotVVM.Samples.BasicSamples { @@ -269,6 +270,7 @@ private static void AddControls(DotvvmConfiguration config) config.Markup.AddCodeControls("cc", typeof(Controls.TextRepeater)); config.Markup.AddCodeControls("cc", typeof(DerivedControlUsageValidationTestControl)); config.Markup.AddCodeControls("PropertyUpdate", typeof(Controls.ServerRenderedLabel)); + config.Markup.AddCodeControls("sample", typeof(TemplateContainerControl)); config.Markup.AddMarkupControl("IdGeneration", "Control", "Views/FeatureSamples/IdGeneration/IdGeneration_control.dotcontrol"); config.Markup.AddMarkupControl("FileUploadInRepeater", "FileUploadWrapper", "Views/ComplexSamples/FileUploadInRepeater/FileUploadWrapper.dotcontrol"); config.Markup.AddMarkupControl("cc", "RecursiveTextRepeater", "Views/FeatureSamples/PostBack/RecursiveTextRepeater.dotcontrol"); diff --git a/src/Samples/Common/ViewModels/FeatureSamples/LateContentPlaceHolders/LateContentPlaceHolderViewModel.cs b/src/Samples/Common/ViewModels/FeatureSamples/LateContentPlaceHolders/LateContentPlaceHolderViewModel.cs new file mode 100644 index 0000000000..27385c76e6 --- /dev/null +++ b/src/Samples/Common/ViewModels/FeatureSamples/LateContentPlaceHolders/LateContentPlaceHolderViewModel.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using DotVVM.Framework.ViewModel; + +namespace DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders +{ + public class LateContentPlaceHolderViewModel : DotvvmViewModelBase + { + } + + /// Viewmodel for Repeater tests - items list is set per subclass. + public abstract class RepeaterContentPlaceHolderViewModel : LateContentPlaceHolderViewModel + { + public List Items { get; set; } = new List(); + } + + public class RepeaterOneItemViewModel : RepeaterContentPlaceHolderViewModel + { + public RepeaterOneItemViewModel() { Items = new List { "Item 1" }; } + } + + public class RepeaterZeroItemsViewModel : RepeaterContentPlaceHolderViewModel + { + public RepeaterZeroItemsViewModel() { Items = new List(); } + } + + public class RepeaterMultipleItemsViewModel : RepeaterContentPlaceHolderViewModel + { + public RepeaterMultipleItemsViewModel() { Items = new List { "Item 1", "Item 2" }; } + } +} diff --git a/src/Samples/Common/Views/ControlSamples/RouteLink/RouteLinkSpaUrlGen.dothtml b/src/Samples/Common/Views/ControlSamples/RouteLink/RouteLinkSpaUrlGen.dothtml index 43f6751433..44b16e8661 100644 --- a/src/Samples/Common/Views/ControlSamples/RouteLink/RouteLinkSpaUrlGen.dothtml +++ b/src/Samples/Common/Views/ControlSamples/RouteLink/RouteLinkSpaUrlGen.dothtml @@ -1,17 +1,6 @@ @viewModel DotVVM.Samples.BasicSamples.ViewModels.ControlSamples.RouteLink.RouteLinkUrlGenViewModel - - - - - Hello from DotVVM! - - - - +@masterPage Views/ControlSamples/RouteLink/RouteLinkSpaUrlGen.dotmaster +

RouteLink SPA url generation demo

@@ -61,7 +50,4 @@ Text="Server rendered: Optional prefixed parameter (at start)" data-ui="optional-prefix-parameter-at-start-server" />

- - - - +
diff --git a/src/Samples/Common/Views/ControlSamples/RouteLink/RouteLinkSpaUrlGen.dotmaster b/src/Samples/Common/Views/ControlSamples/RouteLink/RouteLinkSpaUrlGen.dotmaster new file mode 100644 index 0000000000..a43d5d5599 --- /dev/null +++ b/src/Samples/Common/Views/ControlSamples/RouteLink/RouteLinkSpaUrlGen.dotmaster @@ -0,0 +1,17 @@ +@viewModel DotVVM.Samples.BasicSamples.ViewModels.ControlSamples.RouteLink.RouteLinkUrlGenViewModel + + + + + Hello from DotVVM! + + + + + + + diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/AuthViewContent.dothtml b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/AuthViewContent.dothtml new file mode 100644 index 0000000000..9ab53d0e84 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/AuthViewContent.dothtml @@ -0,0 +1,6 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.LateContentPlaceHolderViewModel, DotVVM.Samples.Common +@masterPage Views/FeatureSamples/LateContentPlaceHolders/AuthViewMaster.dotmaster + + +

Content from ContentPlaceHolder inside AuthenticatedView template

+
diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/AuthViewMaster.dotmaster b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/AuthViewMaster.dotmaster new file mode 100644 index 0000000000..a238c1bfa4 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/AuthViewMaster.dotmaster @@ -0,0 +1,27 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.LateContentPlaceHolderViewModel, DotVVM.Samples.Common + + + + + + Late ContentPlaceHolder - AuthenticatedView Master + + +

AuthenticatedView Master Page

+ + <%-- Same ContentPlaceHolder ID in both templates - only one will be instantiated --%> + + +
+ +
+
+ +
+ +
+
+
+ + + diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Content.dothtml b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Content.dothtml new file mode 100644 index 0000000000..c1f7baddbc --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Content.dothtml @@ -0,0 +1,6 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.LateContentPlaceHolderViewModel, DotVVM.Samples.Common +@masterPage Views/FeatureSamples/LateContentPlaceHolders/Nested.dotmaster + + +

Page Content - Late ContentPlaceHolder works!

+
diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/ContentWithDefault.dothtml b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/ContentWithDefault.dothtml new file mode 100644 index 0000000000..cadf393170 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/ContentWithDefault.dothtml @@ -0,0 +1,2 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.LateContentPlaceHolderViewModel, DotVVM.Samples.Common +@masterPage Views/FeatureSamples/LateContentPlaceHolders/Nested.dotmaster diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/MismatchedContent.dothtml b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/MismatchedContent.dothtml new file mode 100644 index 0000000000..1ed24283d1 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/MismatchedContent.dothtml @@ -0,0 +1,9 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.LateContentPlaceHolderViewModel, DotVVM.Samples.Common +@masterPage Views/FeatureSamples/LateContentPlaceHolders/Nested.dotmaster + + +

This content matches correctly

+
+ +

This content does NOT have a matching ContentPlaceHolder - should throw!

+
diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Nested.dotmaster b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Nested.dotmaster new file mode 100644 index 0000000000..823771db4a --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Nested.dotmaster @@ -0,0 +1,13 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.LateContentPlaceHolderViewModel, DotVVM.Samples.Common +@masterPage Views/FeatureSamples/LateContentPlaceHolders/Root.dotmaster + + +

Nested Master Page

+ + + +

Default nested content

+
+
+
+
diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/NestedSameId.dotmaster b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/NestedSameId.dotmaster new file mode 100644 index 0000000000..aea58987ed --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/NestedSameId.dotmaster @@ -0,0 +1,14 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.LateContentPlaceHolderViewModel, DotVVM.Samples.Common +@masterPage Views/FeatureSamples/LateContentPlaceHolders/Root.dotmaster + + +

Nested Master Page (shared ID)

+ + + <%-- Same ID as the root ContentPlaceHolder to test same-ID nesting --%> + +

Default shared-ID content

+
+
+
+
diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster new file mode 100644 index 0000000000..b9d1a1663d --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster @@ -0,0 +1,11 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.RepeaterContentPlaceHolderViewModel, DotVVM.Samples.Common +@masterPage Views/FeatureSamples/LateContentPlaceHolders/Root.dotmaster + + +

Repeater Master Page

+ + + + + +
diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMultipleItems.dothtml b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMultipleItems.dothtml new file mode 100644 index 0000000000..ace24a3580 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterMultipleItems.dothtml @@ -0,0 +1,6 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.RepeaterMultipleItemsViewModel, DotVVM.Samples.Common +@masterPage Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster + + +

{{value: Items.Count}}

+
diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterOneItem.dothtml b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterOneItem.dothtml new file mode 100644 index 0000000000..6366bbe407 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterOneItem.dothtml @@ -0,0 +1,6 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.RepeaterOneItemViewModel, DotVVM.Samples.Common +@masterPage Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster + + +

{{value: Items[0]}}

+
diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterZeroItems.dothtml b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterZeroItems.dothtml new file mode 100644 index 0000000000..57da701002 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/RepeaterZeroItems.dothtml @@ -0,0 +1,6 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.RepeaterZeroItemsViewModel, DotVVM.Samples.Common +@masterPage Views/FeatureSamples/LateContentPlaceHolders/RepeaterMaster.dotmaster + + +

{{value: Items.Count}}

+
diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Root.dotmaster b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Root.dotmaster new file mode 100644 index 0000000000..34683aa83b --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/Root.dotmaster @@ -0,0 +1,19 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.LateContentPlaceHolderViewModel, DotVVM.Samples.Common + + + + + + Late ContentPlaceHolder - Root + + +

Root Master Page

+ + + + + + + + + diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/SharedIdContent.dothtml b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/SharedIdContent.dothtml new file mode 100644 index 0000000000..afcce0b900 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/SharedIdContent.dothtml @@ -0,0 +1,6 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.LateContentPlaceHolders.LateContentPlaceHolderViewModel, DotVVM.Samples.Common +@masterPage Views/FeatureSamples/LateContentPlaceHolders/NestedSameId.dotmaster + + +

Shared ID Page Content

+
diff --git a/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/TemplateContainerControl.cs b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/TemplateContainerControl.cs new file mode 100644 index 0000000000..b9fbee8b65 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/LateContentPlaceHolders/TemplateContainerControl.cs @@ -0,0 +1,23 @@ +using DotVVM.Framework.Binding; +using DotVVM.Framework.Controls; + +namespace DotVVM.Samples.Common.Views.FeatureSamples.LateContentPlaceHolders +{ + /// + /// A simple CompositeControl with an ITemplate property. + /// The template is instantiated in GetContents (Load phase), allowing ContentPlaceHolder + /// controls defined inside the template to be created after the initial master page composition. + /// + public class TemplateContainerControl : CompositeControl + { + public static DotvvmControl GetContents( + ITemplate contentTemplate + ) + { + return new HtmlGenericControl("div") + .AppendChildren( + new TemplateHost() { Template = contentTemplate } + ); + } + } +} diff --git a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs index d962e32e67..89b8e38d88 100644 --- a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs +++ b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs @@ -316,6 +316,14 @@ public partial class SamplesRouteUrls public const string FeatureSamples_MarkupControl_StaticCommandInMarkupControlCallingRegularCommand = "FeatureSamples/MarkupControl/StaticCommandInMarkupControlCallingRegularCommand"; public const string FeatureSamples_ModalDialog_ModalDialog = "FeatureSamples/ModalDialog/ModalDialog"; public const string FeatureSamples_NestedMasterPages_Content = "FeatureSamples/NestedMasterPages/Content"; + public const string FeatureSamples_LateContentPlaceHolders_Content = "FeatureSamples/LateContentPlaceHolders/Content"; + public const string FeatureSamples_LateContentPlaceHolders_MismatchedContent = "FeatureSamples/LateContentPlaceHolders/MismatchedContent"; + public const string FeatureSamples_LateContentPlaceHolders_SharedIdContent = "FeatureSamples/LateContentPlaceHolders/SharedIdContent"; + public const string FeatureSamples_LateContentPlaceHolders_ContentWithDefault = "FeatureSamples/LateContentPlaceHolders/ContentWithDefault"; + public const string FeatureSamples_LateContentPlaceHolders_RepeaterOneItem = "FeatureSamples/LateContentPlaceHolders/RepeaterOneItem"; + public const string FeatureSamples_LateContentPlaceHolders_RepeaterZeroItems = "FeatureSamples/LateContentPlaceHolders/RepeaterZeroItems"; + public const string FeatureSamples_LateContentPlaceHolders_RepeaterMultipleItems = "FeatureSamples/LateContentPlaceHolders/RepeaterMultipleItems"; + public const string FeatureSamples_LateContentPlaceHolders_AuthViewContent = "FeatureSamples/LateContentPlaceHolders/AuthViewContent"; public const string FeatureSamples_NoJsForm_NoJsForm = "FeatureSamples/NoJsForm/NoJsForm"; public const string FeatureSamples_NullHandling_Button_Enabled = "FeatureSamples/NullHandling/Button_Enabled"; public const string FeatureSamples_ParameterBinding_OptionalParameterBinding = "FeatureSamples/ParameterBinding/OptionalParameterBinding"; diff --git a/src/Samples/Tests/Tests/Feature/LateContentPlaceHoldersTests.cs b/src/Samples/Tests/Tests/Feature/LateContentPlaceHoldersTests.cs new file mode 100644 index 0000000000..4fe3124575 --- /dev/null +++ b/src/Samples/Tests/Tests/Feature/LateContentPlaceHoldersTests.cs @@ -0,0 +1,125 @@ +using DotVVM.Samples.Tests.Base; +using DotVVM.Testing.Abstractions; +using Riganti.Selenium.Core; +using Xunit; +using Xunit.Abstractions; + +namespace DotVVM.Samples.Tests.Feature +{ + public class LateContentPlaceHoldersTests : AppSeleniumTest + { + [Fact] + [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_Content))] + public void Feature_LateContentPlaceHolders_ContentPlaceHolderInTemplate() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_Content); + + browser.First("h1"); // root master page + browser.First("h2"); // nested master page + var pageContent = browser.First("[data-ui='page-content']"); + AssertUI.InnerTextEquals(pageContent, "Page Content - Late ContentPlaceHolder works!"); + }); + } + + [Fact] + [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_SharedIdContent))] + public void Feature_LateContentPlaceHolders_SameContentPlaceHolderIdInRootAndNested() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_SharedIdContent); + + browser.First("h1"); // root master page + browser.First("h2"); // nested master page (shared ID) + var pageContent = browser.First("[data-ui='shared-id-page-content']"); + AssertUI.InnerTextEquals(pageContent, "Shared ID Page Content"); + // Default content from the shared ID placeholder should NOT be shown + browser.FindElements("[data-ui='default-shared-content']").ThrowIfDifferentCountThan(0); + }); + } + + [Fact] + [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_ContentWithDefault))] + public void Feature_LateContentPlaceHolders_DefaultContentUsedWhenNoContentProvided() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_ContentWithDefault); + + browser.First("h1"); // root master page + browser.First("h2"); // nested master page + // Default content from the NestedContent placeholder should be shown + var defaultContent = browser.First("[data-ui='default-nested-content']"); + AssertUI.InnerTextEquals(defaultContent, "Default nested content"); + }); + } + + [Fact] + [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_MismatchedContent))] + [Trait("Category", "dev-only")] // tests the error page behavior + public void Feature_LateContentPlaceHolders_MismatchedContent_ThrowsError() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_MismatchedContent); + // The page should show an error because Content(NonExistentPlaceHolder) has no matching ContentPlaceHolder + AssertUI.InnerText(browser.First(".exceptionMessage"), s => s.Contains("NonExistentPlaceHolder")); + }); + } + + [Fact] + [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_RepeaterOneItem))] + public void Feature_LateContentPlaceHolders_ContentPlaceHolderInRepeater_OneItem_Works() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_RepeaterOneItem); + + browser.First("[data-ui='master-heading']"); // master page rendered + var items = browser.FindElements("[data-ui='repeater-item']"); + Assert.Equal(1, items.Count); + AssertUI.InnerTextEquals(items[0], "Item 1"); + }); + } + + [Fact] + [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_RepeaterZeroItems))] + [Trait("Category", "dev-only")] // tests the error page behavior + public void Feature_LateContentPlaceHolders_ContentPlaceHolderInRepeater_ZeroItems_ThrowsError() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_RepeaterZeroItems); + // Should throw because ContentPlaceHolder is never instantiated (Repeater has no items) + AssertUI.InnerText(browser.First(".exceptionMessage"), s => s.Contains("RepeaterContent")); + }); + } + + [Fact] + [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_RepeaterMultipleItems))] + [Trait("Category", "dev-only")] // tests the error page behavior + public void Feature_LateContentPlaceHolders_ContentPlaceHolderInRepeater_MultipleItems_ThrowsError() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_RepeaterMultipleItems); + // Should throw because ContentPlaceHolder is instantiated more than once (Repeater has 2+ items) + AssertUI.InnerText(browser.First(".exceptionMessage"), s => s.Contains("RepeaterContent") && s.Contains("already been resolved")); + }); + } + + [Fact] + [SampleReference(nameof(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_AuthViewContent))] + public void Feature_LateContentPlaceHolders_SameContentPlaceHolderIdInAuthenticatedViewTemplates_Works() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_LateContentPlaceHolders_AuthViewContent); + + browser.First("[data-ui='master-heading']"); // master page rendered + // In unauthenticated state, the NotAuthenticatedTemplate is instantiated + var section = browser.First("[data-ui='not-authenticated-section']"); + var content = section.First("[data-ui='auth-content']"); + AssertUI.InnerText(content, s => s.Contains("Content from ContentPlaceHolder inside AuthenticatedView template")); + }); + } + + public LateContentPlaceHoldersTests(ITestOutputHelper output) : base(output) + { + } + } +} diff --git a/src/Samples/Tests/Tests/Feature/MasterPageTests.cs b/src/Samples/Tests/Tests/Feature/MasterPageTests.cs index d1e9bf014b..6159fb5204 100644 --- a/src/Samples/Tests/Tests/Feature/MasterPageTests.cs +++ b/src/Samples/Tests/Tests/Feature/MasterPageTests.cs @@ -1,5 +1,6 @@ using DotVVM.Samples.Tests.Base; using DotVVM.Testing.Abstractions; +using Riganti.Selenium.Core; using Xunit; using Xunit.Abstractions; diff --git a/src/Tests/Runtime/ConfigurationSerializationTests.cs b/src/Tests/Runtime/ConfigurationSerializationTests.cs index 8433a2ea33..9408e8248f 100644 --- a/src/Tests/Runtime/ConfigurationSerializationTests.cs +++ b/src/Tests/Runtime/ConfigurationSerializationTests.cs @@ -39,6 +39,7 @@ void checkConfig(DotvvmConfiguration config, bool includeProperties = false, str serialized = serialized.Replace("System.Private.CoreLib, Version=***, Culture=neutral, PublicKeyToken=7cec85d7bea7798e", "CoreLibrary"); serialized = serialized.Replace("mscorlib", "CoreLibrary"); serialized = serialized.Replace("System.Private.CoreLib", "CoreLibrary"); + serialized = serialized.Replace(", System.Core", ""); // Special case - unify IServiceProvider serialized = serialized.Replace("System.IServiceProvider, CoreLibrary", "System.IServiceProvider, ComponentLibrary"); serialized = serialized.Replace("System.IServiceProvider, System.ComponentModel, Version=***, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a", "System.IServiceProvider, ComponentLibrary"); diff --git a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json index 5d893cd5ce..8463a4f3f9 100644 --- a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json +++ b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json @@ -1192,9 +1192,17 @@ "type": "System.Int32", "defaultValue": -1 }, + "MasterPageChildPage": { + "type": "DotVVM.Framework.Controls.DotvvmControl, DotVVM.Framework", + "isValueInherited": true + }, "PathFragment": { "type": "System.String" }, + "PendingMasterPageCompositions": { + "type": "System.Collections.Generic.List`1[[DotVVM.Framework.Controls.PendingMasterPageComposition, DotVVM.Framework, Version=***, Culture=neutral, PublicKeyToken=23f3607db32275da]]", + "isValueInherited": true + }, "ReferencedViewModuleInfo": { "type": "DotVVM.Framework.Binding.ViewModuleReferenceInfo, DotVVM.Framework" }, @@ -1202,6 +1210,10 @@ "type": "DotVVM.Framework.Hosting.IDotvvmRequestContext, DotVVM.Framework", "isValueInherited": true }, + "ResolvedMasterPageCompositionIds": { + "type": "System.Collections.Generic.HashSet`1[[System.String, CoreLibrary]]", + "isValueInherited": true + }, "UniqueID": { "type": "System.String" },