Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor RadioButton to unify client/server rendering #1667

Merged
merged 3 commits into from
Sep 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions src/Framework/Framework/Controls/CheckableControlBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -194,13 +194,15 @@ protected virtual void AddAttributesToInput(IHtmlWriter writer)
}

// handle enabled attribute
writer.AddKnockoutDataBind("enable", this, EnabledProperty, () =>
var enabled = this.GetValueRaw(EnabledProperty);
if (enabled is IValueBinding enabledBinding)
{
if (!Enabled)
{
writer.AddAttribute("disabled", "disabled");
}
});
writer.AddKnockoutDataBind("enable", this, enabledBinding);
}
if (false.Equals(KnockoutHelper.TryEvaluateValueBinding(this, enabled)))
{
writer.AddAttribute("disabled", "disabled");
}

if (!string.IsNullOrEmpty(InputCssClass))
{
Expand Down
42 changes: 13 additions & 29 deletions src/Framework/Framework/Controls/HtmlGenericControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -256,16 +256,9 @@ protected virtual void AddVisibleAttributeOrBinding(in RenderState r, IHtmlWrite
writer.AddKnockoutDataBind("visible", valueBinding.GetKnockoutBindingExpression(this));
}

try
if (false.Equals(KnockoutHelper.TryEvaluateValueBinding(this, v)))
{
if (false.Equals(EvalPropertyValue(VisibleProperty, v)))
{
writer.AddAttribute("style", "display:none");
}
}
catch (Exception) when (valueBinding is {})
{
// suppress value binding errors
writer.AddAttribute("style", "display:none");
}
}

Expand Down Expand Up @@ -311,19 +304,15 @@ protected override void RenderEndTag(IHtmlWriter writer, IDotvvmRequestContext c
private void AddCssClassesToRender(IHtmlWriter writer)
{
KnockoutBindingGroup cssClassBindingGroup = new KnockoutBindingGroup();
foreach (var cssClass in CssClasses.Properties)
foreach (var (cssClass, rawValue) in CssClasses.RawValues)
{
if (HasValueBinding(cssClass))
if (rawValue is IValueBinding binding)
{
cssClassBindingGroup.Add(cssClass.GroupMemberName, this, cssClass);
cssClassBindingGroup.Add(cssClass, this, binding);
}

try
{
if (true.Equals(this.GetValue(cssClass)))
writer.AddAttribute("class", cssClass.GroupMemberName, append: true, appendSeparator: " ");
}
catch when (HasValueBinding(cssClass)) { }
if (true.Equals(KnockoutHelper.TryEvaluateValueBinding(this, rawValue)))
writer.AddAttribute("class", cssClass, append: true, appendSeparator: " ");
}

if (!cssClassBindingGroup.IsEmpty) writer.AddKnockoutDataBind("css", cssClassBindingGroup);
Expand All @@ -332,24 +321,19 @@ private void AddCssClassesToRender(IHtmlWriter writer)
private void AddCssStylesToRender(IHtmlWriter writer)
{
KnockoutBindingGroup? cssStylesBindingGroup = null;
foreach (var styleProperty in CssStyles.Properties)
foreach (var (style, rawValue) in CssStyles.RawValues)
{
if (HasValueBinding(styleProperty))
if (rawValue is IValueBinding binding)
{
if (cssStylesBindingGroup == null) cssStylesBindingGroup = new KnockoutBindingGroup();
cssStylesBindingGroup.Add(styleProperty.GroupMemberName, this, styleProperty);
cssStylesBindingGroup.Add(style, this, binding);
}

try
var value = KnockoutHelper.TryEvaluateValueBinding(this, rawValue)?.ToString();
if (!string.IsNullOrEmpty(value))
{
var value = GetValue(styleProperty)?.ToString();
if (!string.IsNullOrEmpty(value))
{
writer.AddStyleAttribute(styleProperty.GroupMemberName, value!);
}
writer.AddStyleAttribute(style, value!);
}
// suppress all errors when we have rendered the value binding anyway
catch when (HasValueBinding(styleProperty)) { }
}

if (cssStylesBindingGroup != null)
Expand Down
6 changes: 6 additions & 0 deletions src/Framework/Framework/Controls/KnockoutBindingGroup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ public void Add(string name, KnockoutBindingGroup nestedGroup)
Add(name, nestedGroup.ToString());
}

public void Add(string name, DotvvmBindableObject contextControl, IValueBinding binding)
{
var expression = binding.GetKnockoutBindingExpression(contextControl);
Add(name, expression);
}

[Obsolete("Use Add or AddValue instead")]
public virtual void Add(string name, string expression, bool surroundWithDoubleQuotes)
{
Expand Down
18 changes: 18 additions & 0 deletions src/Framework/Framework/Controls/KnockoutHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,24 @@ namespace DotVVM.Framework.Controls
{
public static class KnockoutHelper
{
/// <summary> If value is a binding, evaluates it. If the binding is a value binding, any thrown exceptions are suppressed </summary>
internal static object? TryEvaluateValueBinding(DotvvmBindableObject control, object? valueOrBinding)
{
if (valueOrBinding is IStaticValueBinding b)
{
try
{
return b.Evaluate(control);
}
catch when (b is IValueBinding)
{
return null;
}
}
return valueOrBinding;
}


/// <summary>
/// Adds the data-bind attribute to the next HTML element that is being rendered. The binding expression is taken from the specified property. If in server rendering mode, the binding is also not rendered.
/// </summary>
Expand Down
78 changes: 61 additions & 17 deletions src/Framework/Framework/Controls/RadioButton.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using DotVVM.Framework.Hosting;
using DotVVM.Framework.Utils;
using System.Collections.Generic;
using DotVVM.Framework.Binding.Expressions;

namespace DotVVM.Framework.Controls
{
Expand All @@ -18,12 +19,14 @@ public class RadioButton : CheckableControlBase
/// Gets or sets whether the control is checked.
/// </summary>
[MarkupOptions(AllowHardCodedValue = false)]
[Obsolete("Checked property probably does not work as a reasonable person would expect. Use CheckedItem and CheckedValue instead.")]
public bool Checked
{
get { return (bool)GetValue(CheckedProperty)!; }
set { SetValue(CheckedProperty, value); }
}

[Obsolete("Checked property probably does not work as a reasonable person would expect. Use CheckedItem and CheckedValue instead.")]
public static readonly DotvvmProperty CheckedProperty =
DotvvmProperty.Register<bool, RadioButton>(t => t.Checked, false);

Expand All @@ -42,7 +45,6 @@ public object? CheckedItem
/// <summary>
/// Gets or sets an unique name of the radio button group.
/// </summary>
[MarkupOptions(AllowBinding = false)]
public string GroupName
{
get { return (string)GetValue(GroupNameProperty)!; }
Expand All @@ -63,11 +65,14 @@ protected override void RenderInputTag(IHtmlWriter writer)

protected virtual void RenderGroupNameAttribute(IHtmlWriter writer)
{
var group = new KnockoutBindingGroup();
group.Add("name", this, GroupNameProperty, () => {
writer.AddAttribute("name", GroupName);
});
writer.AddKnockoutDataBind("attr", group);
var valueRaw = GetValueRaw(GroupNameProperty);
if (valueRaw is IValueBinding valueBinding)
{
writer.AddKnockoutDataBind("attr", new KnockoutBindingGroup { { "name", this, valueBinding } });
}
var value = KnockoutHelper.TryEvaluateValueBinding(this, valueRaw);
if (value is not null)
writer.AddAttribute("name", (string)value);
}

protected virtual void RenderTypeAttribute(IHtmlWriter writer)
Expand All @@ -78,26 +83,35 @@ protected virtual void RenderTypeAttribute(IHtmlWriter writer)

protected virtual void RenderCheckedValueAttribute(IHtmlWriter writer)
{
writer.AddKnockoutDataBind("checkedValue", this, CheckedValueProperty, () => {
var checkedValue = (CheckedValue ?? string.Empty).ToString();
if (!string.IsNullOrEmpty(checkedValue))
{
writer.AddKnockoutDataBind("checkedValue", KnockoutHelper.MakeStringLiteral(checkedValue));
}
});
var checkedValue = GetValueOrBinding<object?>(CheckedValueProperty);
if (!checkedValue.ValueIsNull())
{
var checkedValueExpr = checkedValue.GetJsExpression(this);
writer.AddKnockoutDataBind("checkedValue", checkedValueExpr);
}
RenderCheckedValueComparerAttribute(writer);
}

protected virtual void RenderCheckedAttribute(IHtmlWriter writer)
{
if (!IsPropertySet(CheckedValueProperty))
{
throw new DotvvmControlException(this, "The 'CheckedValue' of the RadioButton control must be set when CheckedItem is used. Remember that all RadioButtons with the same GroupName should be bound to the same property in the viewmodel.");
}
var checkedItemBinding = GetValueBinding(CheckedItemProperty);
if (checkedItemBinding == null)
if (checkedItemBinding is null)
{
writer.AddKnockoutDataBind("checked", this, CheckedProperty, () => { });
if (!IsPropertySet(CheckedValueProperty))
#pragma warning disable CS0618 // CheckedProperty is obsolete
var @checked = GetValueRaw(CheckedProperty);
if (@checked is IValueBinding checkedBinding)
{
writer.AddKnockoutDataBind("checked", this, checkedBinding);
}
if (true.Equals(KnockoutHelper.TryEvaluateValueBinding(this, @checked)))
{
throw new DotvvmControlException(this, "The 'CheckedValue' of the RadioButton control must be set. Remember that all RadioButtons with the same GroupName have to be bound to the same property in the viewmodel.");
writer.AddAttribute("checked", "");
}
#pragma warning restore CS0618
}
else
{
Expand All @@ -123,6 +137,36 @@ protected virtual void RenderCheckedAttribute(IHtmlWriter writer)
control.GetValue(CheckedValueProperty)?.DothtmlNode
);
}

if (!control.Properties.ContainsKey(CheckedValueProperty))
{
yield return new ControlUsageError(
"The 'CheckedValue' of the RadioButton control must be set when CheckedItem is used. Remember that all RadioButtons with the same GroupName should be bound to the same property in the viewmodel.",
control.GetValue(CheckedItemProperty)?.DothtmlNode
);
}

#pragma warning disable CS0618 // CheckedProperty is obsolete
if (control.Properties.ContainsKey(CheckedProperty))
{
if (control.Properties.ContainsKey(CheckedItemProperty))
{
yield return new ControlUsageError(
"The Checked and CheckedItem properties cannot be both used on the same RadioButton.",
control.GetValue(CheckedProperty)?.DothtmlNode,
control.GetValue(CheckedItemProperty)?.DothtmlNode
);
}
}

if (!control.Properties.ContainsKey(CheckedItemProperty) && !control.Properties.ContainsKey(CheckedProperty))
{
yield return new ControlUsageError(
"The CheckedItem property must be set on the RadioButton.",
control.DothtmlNode
);
}
#pragma warning restore CS0618
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@
<dot:ListBox ID="lb1-enabled" Enabled="{value: true}" DataSource="{value: Items}" SelectedValue="{value: SelectedItem}" />
<dot:ListBox ID="lb1-disabled" Enabled="{value: false}" DataSource="{value: Items}" SelectedValue="{value: SelectedItem}" />
<br />
<dot:RadioButton ID="rb1-default" CheckedValue="{value: false}" />
<dot:RadioButton ID="rb1-enabled" CheckedValue="{value: false}" Enabled="{value: true}" />
<dot:RadioButton ID="rb1-disabled" CheckedValue="{value: false}" Enabled="{value: false}" />
<dot:RadioButton ID="rb1-default" CheckedItem={value: false} CheckedValue={value: true} />
<dot:RadioButton ID="rb1-enabled" CheckedItem={value: false} CheckedValue={value: true} Enabled={value: true} />
<dot:RadioButton ID="rb1-disabled" CheckedItem={value: false} CheckedValue={value: true} Enabled={value: false} />
<br />
<dot:TextBox ID="tb1-default" Text="Test" />
<dot:TextBox ID="tb1-enabled" Enabled="{value: true}" Text="Test" />
Expand Down Expand Up @@ -64,9 +64,9 @@
<dot:ListBox ID="lb2-enabled" Enabled="{value: true}" DataSource="{value: _root.Items}" SelectedValue="{value: _root.SelectedItem}" />
<dot:ListBox ID="lb2-disabled" Enabled="{value: false}" DataSource="{value: _root.Items}" SelectedValue="{value: _root.SelectedItem}" />
<br />
<dot:RadioButton ID="rb2-default" CheckedValue="{value: false}" />
<dot:RadioButton ID="rb2-enabled" CheckedValue="{value: false}" Enabled="{value: true}" />
<dot:RadioButton ID="rb2-disabled" CheckedValue="{value: false}" Enabled="{value: false}" />
<dot:RadioButton ID="rb2-default" CheckedItem={value: false} CheckedValue={value: true} />
<dot:RadioButton ID="rb2-enabled" CheckedItem={value: false} CheckedValue={value: true} Enabled={value: true} />
<dot:RadioButton ID="rb2-disabled" CheckedItem={value: false} CheckedValue={value: true} Enabled={value: false} />
<br />
<dot:TextBox ID="tb2-default" Text="Test" />
<dot:TextBox ID="tb2-enabled" Enabled="{value: true}" Text="Test" />
Expand Down Expand Up @@ -99,9 +99,9 @@
<dot:ListBox ID="lb-enabled" Enabled="{value: true}" DataSource="{value: _root.Items}" SelectedValue="{value: _root.SelectedItem}" />
<dot:ListBox ID="lb-disabled" Enabled="{value: false}" DataSource="{value: _root.Items}" SelectedValue="{value: _root.SelectedItem}" />
<br />
<dot:RadioButton ID="rb-default" CheckedValue="{value: false}" />
<dot:RadioButton ID="rb-enabled" CheckedValue="{value: false}" Enabled="{value: true}" />
<dot:RadioButton ID="rb-disabled" CheckedValue="{value: false}" Enabled="{value: false}" />
<dot:RadioButton ID="rb-default" CheckedItem={value: false} CheckedValue={value: true} />
<dot:RadioButton ID="rb-enabled" CheckedItem={value: false} CheckedValue={value: true} Enabled={value: true} />
<dot:RadioButton ID="rb-disabled" CheckedItem={value: false} CheckedValue={value: true} Enabled={value: false} />
<br />
<dot:TextBox ID="tb-default" Text="Test" />
<dot:TextBox ID="tb-enabled" Enabled="{value: true}" Text="Test" />
Expand All @@ -112,4 +112,4 @@
</fieldset>

</body>
</html>
</html>
30 changes: 30 additions & 0 deletions src/Tests/ControlTests/SimpleControlTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,34 @@ public async Task TextBox()
check.CheckString(r.FormattedHtml, fileExtension: "html");
}

[TestMethod]
[DataRow(RenderMode.Server)]
[DataRow(RenderMode.Client)]
public async Task RadioButton(RenderMode mode)
{
var r = await cth.RunPage(typeof(BasicTestViewModel), $$"""
<span RenderSettings.Mode={{mode}}>
<!-- basic CheckedValue usage -->
<dot:RadioButton CheckedItem={value: NullableString} GroupName=g1 CheckedValue=A />
<!-- disabled + label -->
<dot:RadioButton CheckedItem={value: NullableString} GroupName=g1 Text="Radio with label" Enabled=false CheckedValue=B />
<!-- disabled dynamically -->
<dot:RadioButton CheckedItem={value: NullableString} GroupName=g1 Enabled={value: Integer < 0} CheckedValue=C />

<!-- checked boolean -->
<dot:RadioButton Checked={value: Bool} CheckedValue={resource:true} />
<!-- checked readonly -->
<dot:RadioButton Checked={value: Integer > 0} Enabled=false CheckedValue={resource:true} />
<!-- dynamic group name -->
<dot:RadioButton CheckedItem={value: NullableString} GroupName={value: "G" + Integer} Enabled={value: Integer < 0} CheckedValue=C another-bound-attribute={value: Integer} />
</span>
""",
fileName: $"SimpleControls_RadioButton_{mode}.dothtml"
);

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

[TestMethod]
public async Task CommandBinding()
{
Expand Down Expand Up @@ -415,6 +443,8 @@ public class BasicTestViewModel: DotvvmViewModelBase

public string UrlSuffix { get; set; } = "#something";

public bool Bool { get; set; } = true;

public GridViewDataSet<CustomerData> Customers { get; set; } = new GridViewDataSet<CustomerData>() {
RowEditOptions = {
EditRowId = 1,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@


<head></head>
<body>
<span>
<!-- basic CheckedValue usage -->
<input type=radio name=g1 data-bind='checked: NullableString, checkedValue: "A"' />
<!-- disabled + label -->
<label><input disabled=disabled type=radio name=g1 data-bind='checked: NullableString, checkedValue: "B"' /><span>Radio with label</span></label>
<!-- disabled dynamically -->
<input disabled=disabled type=radio name=g1 data-bind='enable: int() &lt; 0, checked: NullableString, checkedValue: "C"' />

<!-- checked boolean -->
<input checked type=radio name data-bind="checked: Bool, checkedValue: true" />
<!-- checked readonly -->
<input disabled=disabled checked type=radio name data-bind="checked: int() &gt; 0, checkedValue: true" />
<!-- dynamic group name -->
<input another-bound-attribute=10000000 disabled=disabled type=radio name=G10000000 data-bind='attr: { "another-bound-attribute": int, name: "G" + int() }, enable: int() &lt; 0, checked: NullableString, checkedValue: "C"' />
</span>


</body>
Original file line number Diff line number Diff line change
Expand Up @@ -1239,8 +1239,7 @@
},
"GroupName": {
"type": "System.String",
"defaultValue": "",
"onlyHardcoded": true
"defaultValue": ""
}
},
"DotVVM.Framework.Controls.RenderSettings": {
Expand Down
Loading