diff --git a/src/Framework/Framework/Controls/GridView.cs b/src/Framework/Framework/Controls/GridView.cs index 959ca80fe0..83df7b4e9f 100644 --- a/src/Framework/Framework/Controls/GridView.cs +++ b/src/Framework/Framework/Controls/GridView.cs @@ -431,7 +431,7 @@ protected override void RenderContents(IHtmlWriter writer, IDotvvmRequestContext head?.Render(writer, context); // render body - var foreachBinding = TryGetKnockoutForeachingExpression().NotNull("GridView does not support DataSource={resource: ...} at this moment."); + var foreachBinding = TryGetKnockoutForeachExpression().NotNull("GridView does not support DataSource={resource: ...} at this moment."); if (RenderOnServer) { writer.AddKnockoutDataBind("dotvvm-SSR-foreach", "{data:" + foreachBinding + "}"); @@ -536,7 +536,7 @@ protected override void AddAttributesToRender(IHtmlWriter writer, IDotvvmRequest var mapping = userColumnMappingService.GetMapping(itemType!); var mappingJson = JsonConvert.SerializeObject(mapping); - var dataBinding = TryGetKnockoutForeachingExpression(unwrapped: true).NotNull("GridView does not support DataSource={resource: ...} at this moment."); + var dataBinding = TryGetKnockoutForeachExpression(unwrapped: true).NotNull("GridView does not support DataSource={resource: ...} at this moment."); writer.AddKnockoutDataBind("dotvvm-gridviewdataset", $"{{'mapping':{mappingJson},'dataSet':{dataBinding}}}"); base.AddAttributesToRender(writer, context); } diff --git a/src/Framework/Framework/Controls/HierarchyRepeater.cs b/src/Framework/Framework/Controls/HierarchyRepeater.cs index 27f92d3285..6eaf114276 100644 --- a/src/Framework/Framework/Controls/HierarchyRepeater.cs +++ b/src/Framework/Framework/Controls/HierarchyRepeater.cs @@ -7,7 +7,9 @@ using DotVVM.Framework.Binding; using DotVVM.Framework.Binding.Expressions; using DotVVM.Framework.Binding.Properties; +using DotVVM.Framework.Compilation.ControlTree.Resolved; using DotVVM.Framework.Compilation.Javascript; +using DotVVM.Framework.Compilation.Validation; using DotVVM.Framework.Controls; using DotVVM.Framework.Hosting; using DotVVM.Framework.ResourceManagement; @@ -39,14 +41,14 @@ public HierarchyRepeater() : base("div") [ControlPropertyBindingDataContextChange(nameof(DataSource))] [BindingCompilationRequirements(new[] { typeof(DataSourceAccessBinding) }, new[] { typeof(DataSourceLengthBinding) })] [MarkupOptions(Required = true)] - public IValueBinding>? ItemChildrenBinding + public IStaticValueBinding>? ItemChildrenBinding { - get => (IValueBinding>?)GetValue(ItemChildrenBindingProperty); + get => (IStaticValueBinding>?)GetValue(ItemChildrenBindingProperty); set => SetValue(ItemChildrenBindingProperty, value); } public static readonly DotvvmProperty ItemChildrenBindingProperty - = DotvvmProperty.Register>?, HierarchyRepeater>(t => t.ItemChildrenBinding); + = DotvvmProperty.Register>?, HierarchyRepeater>(t => t.ItemChildrenBinding); /// /// Gets or sets the template for each HierarchyRepeater item. @@ -147,7 +149,7 @@ protected override void RenderEndTag(IHtmlWriter writer, IDotvvmRequestContext c protected override void RenderContents(IHtmlWriter writer, IDotvvmRequestContext context) { - if (RenderOnServer) + if (clientRootLevel is null) { foreach (var child in Children.Except(new[] { emptyDataContainer! })) { @@ -156,7 +158,7 @@ protected override void RenderContents(IHtmlWriter writer, IDotvvmRequestContext } else { - clientRootLevel!.Render(writer, context); + clientRootLevel.Render(writer, context); } } @@ -166,12 +168,17 @@ private void SetChildren(IDotvvmRequestContext context, bool renderClientTemplat emptyDataContainer = null; clientItemTemplate = null; - if (DataSource is not null) + if (GetIEnumerableFromDataSource() is {} enumerable) { - this.AppendChildren(CreateServerLevel(context, GetIEnumerableFromDataSource()!)); + this.AppendChildren(CreateServerLevel( + context, + enumerable, + parentPath: ImmutableArray.Empty, + foreachExpression: this.TryGetKnockoutForeachExpression() + )); } - if (renderClientTemplate) + if (renderClientTemplate && GetDataSourceBinding() is IValueBinding) { // whenever possible, we use the dotvvm deterministic ids, but if we are in a client-side template, // we'd get a binding... so we just generate a random Guid, not ideal but it will work. @@ -184,7 +191,7 @@ private void SetChildren(IDotvvmRequestContext context, bool renderClientTemplat Children.Add(clientRootLevel); clientRootLevel.AppendChildren(new HierarchyRepeaterLevel { IsRoot = true, - ForeachExpression = this.TryGetKnockoutForeachingExpression(), + ForeachExpression = this.TryGetKnockoutForeachExpression().NotNull(), ItemTemplateId = clientItemTemplateId, }); } @@ -198,19 +205,9 @@ private void SetChildren(IDotvvmRequestContext context, bool renderClientTemplat private DotvvmControl CreateServerLevel( IDotvvmRequestContext context, IEnumerable items, - ImmutableArray parentPath = default, - string? foreachExpression = default) + ImmutableArray parentPath, + string? foreachExpression) { - if (parentPath.IsDefault) - { - parentPath = ImmutableArray.Empty; - } - - foreachExpression ??= ((IValueBinding)GetDataSourceBinding() - .GetProperty() - .Binding) - .GetKnockoutBindingExpression(this); - var dataContextLevelWrapper = new HierarchyRepeaterLevel { ForeachExpression = foreachExpression }; @@ -220,7 +217,7 @@ private DotvvmControl CreateServerLevel( var index = 0; foreach (var item in items) { - levelWrapper.AppendChildren(CreateServerItem(context, item, parentPath, index)); + levelWrapper.AppendChildren(CreateServerItem(context, item, parentPath, index, foreachExpression is null)); index++; } return dataContextLevelWrapper; @@ -230,10 +227,11 @@ private DotvvmControl CreateServerItem( IDotvvmRequestContext context, object item, ImmutableArray parentPath, - int index) + int index, + bool serverOnly) { var itemWrapper = ItemWrapperCapability.GetWrapper(); - var dataItem = new DataItemContainer { DataItemIndex = index }; + var dataItem = new DataItemContainer { DataItemIndex = index, RenderItemBinding = !serverOnly }; itemWrapper.Children.Add(dataItem); dataItem.SetDataContextTypeFromDataSource(GetDataSourceBinding()); // NB: the placeholder is needed because during data context resolution DataItemContainers are looked up @@ -252,7 +250,8 @@ private DotvvmControl CreateServerItem( var itemChildren = GetItemChildren(item); if (itemChildren.Any()) { - var foreachExpression = ((IValueBinding)ItemChildrenBinding! + var foreachExpression = serverOnly ? null : ((IValueBinding)ItemChildrenBinding + .NotNull("ItemChildrenBinding property is required") .GetProperty() .Binding) .GetParametrizedKnockoutExpression(dataItem) @@ -336,6 +335,36 @@ private IEnumerable GetItemChildren(object item) return ItemChildrenBinding!.Evaluate(tempContainer) ?? Enumerable.Empty(); } + [ControlUsageValidator] + public static IEnumerable ValidateUsage(ResolvedControl control) + { + if (!control.TryGetProperty(DataSourceProperty, out var dataSource)) + { + yield return new("DataSource is required on HierarchyRepeater"); + yield break; + } + if (dataSource is not ResolvedPropertyBinding { Binding: var dataSourceBinding }) + { + yield return new("HierarchyRepeater.DataSource must be a binding"); + yield break; + } + if (!control.TryGetProperty(ItemChildrenBindingProperty, out var itemChildren) || + itemChildren is not ResolvedPropertyBinding { Binding: var itemChildrenBinding }) + { + yield break; + } + + if (dataSourceBinding.ParserOptions.BindingType != itemChildrenBinding.ParserOptions.BindingType) + { + yield return new( + "HierarchyRepeater.DataSource and HierarchyRepeater.ItemChildrenBinding must have the same binding type, use `value` or `resource` binding for both properties.", + dataSourceBinding.DothtmlNode, + itemChildrenBinding.DothtmlNode + ); + } + } + + /// /// An internal control for a level of the that renders /// the appropriate foreach binding. diff --git a/src/Framework/Framework/Controls/ItemsControl.cs b/src/Framework/Framework/Controls/ItemsControl.cs index 1037e8e102..1eb1935d1d 100644 --- a/src/Framework/Framework/Controls/ItemsControl.cs +++ b/src/Framework/Framework/Controls/ItemsControl.cs @@ -81,7 +81,7 @@ protected IValueBinding GetItemBinding() protected IStaticValueBinding GetForeachDataBindExpression() => (IStaticValueBinding)GetDataSourceBinding().GetProperty().Binding; - protected string? TryGetKnockoutForeachingExpression(bool unwrapped = false) => + protected string? TryGetKnockoutForeachExpression(bool unwrapped = false) => (GetForeachDataBindExpression() as IValueBinding)?.GetKnockoutBindingExpression(this, unwrapped); protected string GetPathFragmentExpression() @@ -98,6 +98,10 @@ protected string GetPathFragmentExpression() return stringified; } + /// Returns data context which is expected in the ItemTemplate + protected DataContextStack GetChildDataContext() => + GetDataSourceBinding().GetProperty().DataContext; + [ApplyControlStyle] public static void OnCompilation(ResolvedControl control, BindingCompilationService bindingService) { @@ -125,7 +129,7 @@ protected IBinding GetIndexBinding(IDotvvmRequestContext context) { // slower path: create the _index binding at runtime var bindingService = context.Services.GetRequiredService(); - var dataContext = GetDataSourceBinding().GetProperty().DataContext; + var dataContext = GetChildDataContext(); return bindingService.Cache.CreateCachedBinding("_index", new object[] { dataContext }, () => new ValueBindingExpression(bindingService, new object?[] { dataContext, diff --git a/src/Framework/Framework/Controls/Repeater.cs b/src/Framework/Framework/Controls/Repeater.cs index 57aa495516..1407b58f10 100644 --- a/src/Framework/Framework/Controls/Repeater.cs +++ b/src/Framework/Framework/Controls/Repeater.cs @@ -169,7 +169,7 @@ protected override void RenderBeginTag(IHtmlWriter writer, IDotvvmRequestContext private KnockoutBindingGroup GetServerSideForeachBindingGroup() => new KnockoutBindingGroup { - { "data", TryGetKnockoutForeachingExpression().NotNull() } + { "data", TryGetKnockoutForeachExpression().NotNull() } }; private (string bindingName, KnockoutBindingGroup bindingValue) GetForeachKnockoutBindingGroup(IDotvvmRequestContext context) @@ -178,7 +178,7 @@ private KnockoutBindingGroup GetServerSideForeachBindingGroup() => var value = new KnockoutBindingGroup(); - var javascriptDataSourceExpression = TryGetKnockoutForeachingExpression().NotNull(); + var javascriptDataSourceExpression = TryGetKnockoutForeachExpression().NotNull(); value.Add( useTemplate ? "foreach" : "data", javascriptDataSourceExpression); diff --git a/src/Tests/ControlTests/ResourceDataContextTests.cs b/src/Tests/ControlTests/ResourceDataContextTests.cs index 249f6a1caa..86163436d5 100644 --- a/src/Tests/ControlTests/ResourceDataContextTests.cs +++ b/src/Tests/ControlTests/ResourceDataContextTests.cs @@ -138,13 +138,40 @@ public async Task DataContextRevert() check.CheckString(r.FormattedHtml, fileExtension: "html"); } + [TestMethod] + public async Task HierarchyRepeater_SimpleTemplate() + { + var r = await cth.RunPage(typeof(TestViewModel), @" + + + + This would be here if the Customers.Items were empty + {{resource: Name}} + + + + + {{resource: Name}} + + " + ); + + check.CheckString(r.FormattedHtml, fileExtension: "html"); + } + + public class TestViewModel: DotvvmViewModelBase { public string NullableString { get; } = null; [Bind(Direction.None)] - public CustomerData ServerOnlyCustomer { get; set; } = new CustomerData(100, "Server o. Customer"); + public CustomerData ServerOnlyCustomer { get; set; } = new CustomerData(100, "Server o. Customer", new()); public GridViewDataSet Customers { get; set; } = new GridViewDataSet() { RowEditOptions = { @@ -152,8 +179,12 @@ public class TestViewModel: DotvvmViewModelBase PrimaryKeyPropertyName = nameof(CustomerData.Id) }, Items = { - new CustomerData(1, "One"), - new CustomerData(2, "Two") + new CustomerData(1, "One", new()), + new CustomerData(2, "Two", new() { + new CustomerData(21, "first pyramid customer", new() { + new CustomerData(211, "second pyramid customer", new()) + }) + }) } }; @@ -167,7 +198,8 @@ public record CustomerData( int Id, [property: Required] string Name, - bool Enabled = true + // software for running MLM 😂 + List NextLevelCustomers ); public string CommandData { get; set; } diff --git a/src/Tests/ControlTests/testoutputs/ResourceDataContextTests.HierarchyRepeater_SimpleTemplate.html b/src/Tests/ControlTests/testoutputs/ResourceDataContextTests.HierarchyRepeater_SimpleTemplate.html new file mode 100644 index 0000000000..230c44aafc --- /dev/null +++ b/src/Tests/ControlTests/testoutputs/ResourceDataContextTests.HierarchyRepeater_SimpleTemplate.html @@ -0,0 +1,33 @@ + + + + + + One + Two + first pyramid customer + second pyramid customer + + +
+
    +
  • + One +
  • +
  • + Two +
      +
    • + first pyramid customer +
        +
      • + second pyramid customer +
      • +
      +
    • +
    +
  • +
+
+ + diff --git a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json index 2f68098f08..8113638ef4 100644 --- a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json +++ b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json @@ -615,7 +615,7 @@ "mappingMode": "InnerElement" }, "ItemChildrenBinding": { - "type": "DotVVM.Framework.Binding.Expressions.IValueBinding`1[[System.Collections.Generic.IEnumerable`1[[System.Object, System.Private.CoreLib, Version=***, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]], System.Private.CoreLib, Version=***, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]], DotVVM.Framework", + "type": "DotVVM.Framework.Binding.Expressions.IStaticValueBinding`1[[System.Collections.Generic.IEnumerable`1[[System.Object, System.Private.CoreLib, Version=***, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]], System.Private.CoreLib, Version=***, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]], DotVVM.Framework", "dataContextChange": [ { "$type": "DotVVM.Framework.Binding.CollectionElementDataContextChangeAttribute, DotVVM.Framework",