Skip to content

Commit

Permalink
Merge pull request #1878 from riganti/bug/postbackhandler-binding-tra…
Browse files Browse the repository at this point in the history
…nslation

Binding translation issue in PostBackHandler properties
  • Loading branch information
exyi authored Oct 31, 2024
2 parents a4adf91 + 6a75b42 commit a546262
Show file tree
Hide file tree
Showing 13 changed files with 386 additions and 37 deletions.
84 changes: 64 additions & 20 deletions src/Framework/Framework/Compilation/Javascript/ParametrizedCode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,25 @@ public ParametrizedCode AssignParameters(Func<CodeSymbolicParameter, CodeParamet
if (stringParts == null) return this;
Debug.Assert(parameters is object);

var assignment = FindAssignment(parameterAssignment, optional: true, allIsDefault: out bool allIsDefault);
var (assignment, newDefaults) = FindAssignment(parameterAssignment);

if (allIsDefault) return this;
if (assignment is null)
{
if (newDefaults is null)
return this;
else
{
// just change the content of parameters, no need to rebuild strings
var newParams = parameters.AsSpan().ToArray();
for (int i = 0; i < newParams.Length; i++)
{
ref var p = ref newParams[i];
if (newDefaults[i] is { } newDefault)
p = new CodeParameterInfo(p.Parameter, p.OperatorPrecedence, p.IsSafeMemberAccess, newDefault);
}
return new ParametrizedCode(stringParts, newParams, OperatorPrecedence);
}
}

// PERF: reduce allocations here, used at runtime
var builder = new Builder();
Expand All @@ -146,20 +162,26 @@ public ParametrizedCode AssignParameters(Func<CodeSymbolicParameter, CodeParamet
for (int i = 0; i < assignment.Length; i++)
{
var a = assignment[i];
if (a.Code == null)
var param = parameters![i];
if (a.Code == null) // not assigned by `parameterAssignment`
{
builder.Add(parameters![i]);
// assign recursively in the default assignment
if (newDefaults is {} && newDefaults[i] is { } newDefault)
builder.Add(new CodeParameterInfo(param.Parameter, param.OperatorPrecedence, param.IsSafeMemberAccess, newDefault));
else
builder.Add(param);

builder.Add(stringParts[1 + i]);
}
else
{
var isGlobalContext = a.IsGlobalContext && parameters![i].IsSafeMemberAccess;
var isGlobalContext = a.IsGlobalContext && param.IsSafeMemberAccess;

if (isGlobalContext)
builder.Add(stringParts[1 + i].AsSpan(1, stringParts[i].Length - 1).DotvvmInternString()); // skip `.`
builder.Add(stringParts[1 + i].AsSpan(1, stringParts[1 + i].Length - 1).DotvvmInternString()); // skip `.`
else
{
builder.Add(a.Code, parameters![i].OperatorPrecedence);
builder.Add(a.Code, param.OperatorPrecedence);
builder.Add(stringParts[1 + i]);
}
}
Expand Down Expand Up @@ -188,35 +210,57 @@ public void CopyTo(Builder builder)

private (CodeParameterAssignment parameter, string code)[] FindStringAssignment(Func<CodeSymbolicParameter, CodeParameterAssignment> parameterAssigner, out bool allIsDefault)
{
var pp = FindAssignment(parameterAssigner, optional: false, allIsDefault: out allIsDefault);
allIsDefault = true;
var codes = new(CodeParameterAssignment parameter, string code)[parameters!.Length];
for (int i = 0; i < parameters.Length; i++)
{
codes[i] = (pp[i], pp[i].Code!.ToString(parameterAssigner, out bool allIsDefault_local));
var assignment = parameterAssigner(parameters[i].Parameter);
if (assignment.Code == null)
{
assignment = parameters[i].DefaultAssignment;
if (assignment.Code == null)
throw new InvalidOperationException($"Assignment of parameter '{parameters[i].Parameter}' was not found.");
}
else
allIsDefault = false;

codes[i] = (assignment, assignment.Code!.ToString(parameterAssigner, out bool allIsDefault_local));
allIsDefault &= allIsDefault_local;
}
return codes;
}

private CodeParameterAssignment[] FindAssignment(Func<CodeSymbolicParameter, CodeParameterAssignment> parameterAssigner, bool optional, out bool allIsDefault)
private (CodeParameterAssignment[]? assigned, ParametrizedCode?[]? newDefaults) FindAssignment(Func<CodeSymbolicParameter, CodeParameterAssignment> parameterAssigner)
{
allIsDefault = true;
var pp = new CodeParameterAssignment[parameters!.Length];
if (parameters is null)
return (null, null);

// these are different variables, as we have to preserve the tree-like structure of the ParametrizedCodes,
// when we assign parameters in the default values.
// newDefaults -> we will change the code in the parameters[i].DefaultAssignment
// assigned -> this parameter will be removed and its assignment inlined into stringParts[i] and parameters[i]
CodeParameterAssignment[]? assigned = null; // when null, all are default
ParametrizedCode[]? newDefaults = null; // when null, no defaults were changed
for (int i = 0; i < parameters.Length; i++)
{
if ((pp[i] = parameterAssigner(parameters[i].Parameter)).Code == null)
var p = parameterAssigner(parameters[i].Parameter);
if (p.Code is not null)
{
assigned ??= new CodeParameterAssignment[parameters.Length];
assigned[i] = p;
}
else if (parameters[i].DefaultAssignment is { Code: { HasParameters: true } } defaultAssignment)
{
if (!optional)
// check if the default assignment contains any of the assigned parameters, and adjust the default if necessary
var newDefault = defaultAssignment.Code.AssignParameters(parameterAssigner);
if (newDefault != defaultAssignment.Code)
{
pp[i] = parameters[i].DefaultAssignment;
if (pp[i].Code == null)
throw new InvalidOperationException($"Assignment of parameter '{parameters[i].Parameter}' was not found.");
newDefaults ??= new ParametrizedCode[parameters.Length];
newDefaults[i] = newDefault;
}
}
else
allIsDefault = false;
}
return pp;
return (assigned, newDefaults);
}

public IEnumerable<CodeSymbolicParameter> EnumerateAllParameters()
Expand Down
4 changes: 2 additions & 2 deletions src/Framework/Framework/Controls/KnockoutHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -355,8 +355,8 @@ private static JsExpression TransformOptionValueToExpression(DotvvmBindableObjec
{
case IValueBinding binding: {
var adjustedCode = binding.GetParametrizedKnockoutExpression(handler, unwrapped: true).AssignParameters(o =>
o == JavascriptTranslator.KnockoutContextParameter ? new ParametrizedCode("c") :
o == JavascriptTranslator.KnockoutViewModelParameter ? new ParametrizedCode("d") :
o == JavascriptTranslator.KnockoutContextParameter ? CodeParameterAssignment.FromIdentifier("c") :
o == JavascriptTranslator.KnockoutViewModelParameter ? CodeParameterAssignment.FromIdentifier("d") :
default(CodeParameterAssignment)
);
return new JsSymbolicParameter(new CodeSymbolicParameter("tmp symbol", defaultAssignment: adjustedCode));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using DotVVM.Framework.ViewModel;
using DotVVM.Framework.Hosting;

namespace DotVVM.Samples.Common.ViewModels.FeatureSamples.PostBack
{
public class PostBackHandlerBindingViewModel : DotvvmViewModelBase
{
public bool Enabled { get; set; } = false;

public int Counter { get; set; } = 0;

public string[] Items { get; set; } = new string[] { "Item 1", "Item 2", "Item 3" };
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.PostBack.PostBackHandlerBindingViewModel, DotVVM.Samples.Common

<!DOCTYPE html>

<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
<title></title>
</head>
<body>

<p>
<dot:CheckBox Text="Suppress postbacks" Checked="{value: Enabled}" />
</p>
<p>Counter: <span class="result">{{value: Counter}}</span></p>

<p>Click on grid rows to increment the counter.</p>

<dot:GridView DataSource="{value: Items}">
<Columns>
<dot:GridViewTextColumn HeaderText="Column 1" ValueBinding="{value: _this}" />
</Columns>
<RowDecorators>
<dot:Decorator Events.Click="{command: _parent.Counter = _parent.Counter + 1}" style="cursor: pointer">
<PostBack.Handlers>
<dot:SuppressPostBackHandler Suppress="{value: _parent.Enabled}" />
</PostBack.Handlers>
</dot:Decorator>
</RowDecorators>
</dot:GridView>

</body>
</html>


Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions src/Samples/Tests/Tests/Feature/PostBackTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,24 @@ public void Feature_PostBack_PostBackHandlers_Localization()
});
}

[Fact]
public void Feature_PostBack_PostBackHandlerBinding()
{
RunInAllBrowsers(browser => {
browser.NavigateToUrl(SamplesRouteUrls.FeatureSamples_PostBack_PostBackHandlerBinding);

var counter = browser.Single(".result");
AssertUI.TextEquals(counter, "0");

browser.ElementAt("td", 0).Click();
AssertUI.TextEquals(counter, "1");

browser.Single("input[type=checkbox]").Click();
browser.ElementAt("td", 0).Click();
AssertUI.TextEquals(counter, "1");
});
}

private void ValidatePostbackHandlersComplexSection(string sectionSelector, IBrowserWrapper browser)
{
IElementWrapper section = null;
Expand Down
2 changes: 1 addition & 1 deletion src/Tests/Binding/BindingCompilationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1493,7 +1493,7 @@ public override string ToString()
return SomeString + ": " + MyProperty;
}
}
class TestViewModel3 : DotvvmViewModelBase
public class TestViewModel3 : DotvvmViewModelBase
{
public string SomeString { get; set; }
}
Expand Down
34 changes: 34 additions & 0 deletions src/Tests/Binding/JavascriptCompilationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1434,6 +1434,40 @@ public void JavascriptCompilation_CustomPrimitiveParse()
Assert.AreEqual("VehicleNumber()==\"123\"", result);
}

[TestMethod]
public void JavascriptCompilation_ParametrizedCode_ParentContexts()
{
// root: TestViewModel
// parent: TestViewModel2 + _index + _collection
// this: TestViewModel3 + _index
var context0 = DataContextStack.Create(typeof(TestViewModel));
var context1 = DataContextStack.Create(typeof(TestViewModel2), parent: context0, extensionParameters: [ new CurrentCollectionIndexExtensionParameter(), new BindingCollectionInfoExtensionParameter("_collection") ]);
var context2 = DataContextStack.Create(typeof(TestViewModel3), parent: context1, extensionParameters: [ new CurrentCollectionIndexExtensionParameter() ]);

var result = bindingHelper.ValueBindingToParametrizedCode("_root.IntProp + _parent._index + _parent._collection.Index + _parent.MyProperty + _index + SomeString.Length", context2);

Assert.AreEqual("$parents[1].IntProp() + $parentContext.$index() + $parentContext.$index() + $parent.MyProperty() + $index() + SomeString().length", result.ToDefaultString());

// assign `context` and `vm` variables
var formatted2 = result.ToString(o =>
o == JavascriptTranslator.KnockoutContextParameter ? new ParametrizedCode("context", OperatorPrecedence.Max) :
o == JavascriptTranslator.KnockoutViewModelParameter ? new ParametrizedCode("vm", OperatorPrecedence.Max) :
default(CodeParameterAssignment)
);
Console.WriteLine(formatted2);

var symbolicParameters = result.Parameters.ToArray();
Assert.AreEqual(6, symbolicParameters.Length);
Assert.AreEqual(2, XAssert.IsAssignableFrom<JavascriptTranslator.ViewModelSymbolicParameter>(symbolicParameters[0].Parameter).ParentIndex);
Assert.AreEqual(JavascriptTranslator.KnockoutContextParameter, symbolicParameters[1].Parameter); // JavascriptTranslator.ParentKnockoutContextParameter would also work
Assert.AreEqual(JavascriptTranslator.KnockoutContextParameter, symbolicParameters[2].Parameter); // JavascriptTranslator.ParentKnockoutContextParameter would also work
Assert.AreEqual(JavascriptTranslator.ParentKnockoutViewModelParameter, symbolicParameters[3].Parameter);
Assert.AreEqual(JavascriptTranslator.KnockoutContextParameter, symbolicParameters[4].Parameter);
Assert.AreEqual(JavascriptTranslator.KnockoutViewModelParameter, symbolicParameters[5].Parameter);

Assert.AreEqual("context.$parents[1].IntProp() + context.$parentContext.$index() + context.$parentContext.$index() + context.$parent.MyProperty() + context.$index() + vm.SomeString().length", formatted2);
}

public class TestMarkupControl: DotvvmMarkupControl
{
public string SomeProperty
Expand Down
62 changes: 62 additions & 0 deletions src/Tests/ControlTests/PostbackHandlerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Threading.Tasks;
using CheckTestOutput;
using DotVVM.Framework.Compilation;
using DotVVM.Framework.Controls;
using DotVVM.Framework.Tests.Binding;
using DotVVM.Framework.ViewModel;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using DotVVM.Framework.Testing;
using System.Security.Claims;
using System.Collections;
using DotVVM.Framework.Binding;
using DotVVM.Framework.Compilation.ControlTree;
using DotVVM.Framework.Hosting;

namespace DotVVM.Framework.Tests.ControlTests
{
[TestClass]
public class PostbackHandlerTests
{
static readonly ControlTestHelper cth = new ControlTestHelper(config: config => {
});
readonly OutputChecker check = new OutputChecker("testoutputs");


[TestMethod]
public async Task ButtonHandlers()
{
var r = await cth.RunPage(typeof(BasicTestViewModel), """
<!-- suppress postback -->
<dot:Button DataContext={value: Nested} Click={staticCommand: 0} Text="Test supress">
<Postback.Handlers>
<dot:SuppressPostBackHandler Suppress={value: _parent.Integer > 100} />
<dot:SuppressPostBackHandler Suppress={value: SomeString.Length < 5} />
</Postback.Handlers>
</dot:Button>
<!-- confirm -->
<dot:Button DataContext={value: Nested} Click={staticCommand: 0} Text="Test confirm">
<Postback.Handlers>
<dot:ConfirmPostBackHandler Message={value: $"String={_root.String} SomeString={SomeString}"} />
</Postback.Handlers>
</dot:Button>
"""
);

check.CheckString(r.FormattedHtml, fileExtension: "html");
}

public class BasicTestViewModel: DotvvmViewModelBase
{
public int Integer { get; set; } = 123;
public bool Boolean { get; set; } = false;
public string String { get; set; } = "some-string";

public TestViewModel3 Nested { get; set; } = new TestViewModel3 { SomeString = "a" };
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<html>
<head></head>
<body>

<!-- suppress postback -->
<!-- ko with: Nested -->
<input onclick="dotvvm.applyPostbackHandlers((options) => {
0;
},this,[[&quot;suppress&quot;,(c,d)=>({suppress:c.$parent.Integer() > 100})],[&quot;suppress&quot;,(c,d)=>({suppress:d.SomeString()?.length < 5})]]).catch(dotvvm.log.logPostBackScriptError);event.stopPropagation();return false;" type="button" value="Test supress">
<!-- /ko -->
<!-- confirm -->
<!-- ko with: Nested -->
<input onclick="dotvvm.applyPostbackHandlers((options) => {
0;
},this,[[&quot;confirm&quot;,(c,d)=>({message:dotvvm.translations.string.format(&quot;String={0} SomeString={1}&quot;, [
c.$parent.String()
, d.SomeString()
])})]]).catch(dotvvm.log.logPostBackScriptError);event.stopPropagation();return false;" type="button" value="Test confirm">
<!-- /ko -->
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
Two handlers
<input data-msg="ahoj" onclick="dotvvm.postBack(this,[],&quot;2suRIvLX7+StPC7x&quot;,&quot;&quot;,null,[[&quot;confirm&quot;,{message:&quot;a&quot;}],[&quot;confirm&quot;,{message:&quot;ahoj&quot;}]],[],undefined).catch(dotvvm.log.logPostBackScriptError);event.stopPropagation();return false;" type="button" value="">
Two handlers, value binding message
<input data-bind="attr: { &quot;data-msg&quot;: Label }" data-msg="My Label" onclick="dotvvm.postBack(this,[],&quot;b80b5Lh9K50jj3q2&quot;,&quot;&quot;,null,[[&quot;confirm&quot;,{message:&quot;a&quot;}],[&quot;confirm&quot;,(c,d)=>({message:(d).Label()})]],[],undefined).catch(dotvvm.log.logPostBackScriptError);event.stopPropagation();return false;" type="button" value="">
<input data-bind="attr: { &quot;data-msg&quot;: Label }" data-msg="My Label" onclick="dotvvm.postBack(this,[],&quot;b80b5Lh9K50jj3q2&quot;,&quot;&quot;,null,[[&quot;confirm&quot;,{message:&quot;a&quot;}],[&quot;confirm&quot;,(c,d)=>({message:d.Label()})]],[],undefined).catch(dotvvm.log.logPostBackScriptError);event.stopPropagation();return false;" type="button" value="">
Two handlers, resource binding message
<input data-msg="My Label" onclick="dotvvm.postBack(this,[],&quot;mH2HraWjqc1sx3p+&quot;,&quot;&quot;,null,[[&quot;confirm&quot;,{message:&quot;a&quot;}],[&quot;confirm&quot;,{message:&quot;My Label&quot;}]],[],undefined).catch(dotvvm.log.logPostBackScriptError);event.stopPropagation();return false;" type="button" value="">
Two handlers, default message
Expand Down
Loading

0 comments on commit a546262

Please sign in to comment.