Support for IFormattable (#1318)

* Support IFormattable, if TValue implements it

* Include demo for custom numeric types

* improve method name

* In cases where the value is already of the correct type, do not perform conversion.  Refactor SetParametersAsync to honor custom numeric support

* perform the Min/Max check before the equality shortcut

* remove unnecessary type conversion logic

* fixup: if type implements IComparable, be sure to return value immediately, if in range

* PR feedback: this resolves the issue, in that the value is pushed to the dom via JS.  I couldn't understand why this existing statement had the other conditionals.  Why would you want to trigger the ValueChanged/Changed callbacks, if the value indeed hasn't changed, or if there's a Format.  However, admittedly I don't know the background of why it was this way.

https://github.com/radzenhq/radzen-blazor/pull/1318#issuecomment-1886478912

---------

Co-authored-by: Josh H <josh@inventivecoders.com>
This commit is contained in:
Josh
2024-01-12 01:13:16 -08:00
committed by GitHub
parent f1f99a1d92
commit 2f17f0d4ae
7 changed files with 203 additions and 20 deletions

View File

@@ -479,5 +479,24 @@ namespace Radzen.Blazor.Tests
Assert.Contains($" value=\"{maxDollars.ToString()}\"", component.Markup);
Assert.Equal(component.Instance.Value, maxDollars);
}
[Fact]
public void Numeric_Supports_IFormattable()
{
using var ctx = new TestContext();
var valueToTest = new Temperature(60.23m);
const string format = "F";
var component = ctx.RenderComponent<RadzenNumeric<Temperature>>(
ComponentParameter.CreateParameter(nameof(RadzenNumeric<Temperature>.Format), format),
ComponentParameter.CreateParameter(nameof(RadzenNumeric<Temperature>.Value), valueToTest)
);
component.Render();
var input = component.Find("input").GetAttribute("value");
input.MarkupMatches(valueToTest.ToString(format));
}
}
}

View File

@@ -0,0 +1,35 @@
using System;
using System.Globalization;
namespace Radzen.Blazor.Tests;
public record struct Temperature(decimal DegreesCelsius)
: IFormattable
{
public decimal Celsius => DegreesCelsius;
public decimal Fahrenheit => DegreesCelsius * 9 / 5 + 32;
public decimal Kelvin => DegreesCelsius + 273.15m;
public override string ToString() => ToString("G");
public string ToString(string format) => ToString(format, CultureInfo.CurrentCulture);
public string ToString(string format, IFormatProvider provider)
{
provider ??= CultureInfo.CurrentCulture;
if (string.IsNullOrEmpty(format))
format = "G";
switch (format.ToUpperInvariant())
{
case "G":
case "C":
return $"{Celsius.ToString("F2", provider)} °C";
case "F":
return $"{Fahrenheit.ToString("F2", provider)} °F";
case "K":
return $"{Kelvin.ToString("F2", provider)} K";
default:
throw new FormatException($"The {format} format string is not supported.");
}
}
}

View File

@@ -83,9 +83,9 @@ namespace Radzen.Blazor
return;
}
var step = string.IsNullOrEmpty(Step) || Step == "any" ? 1 : decimal.Parse(Step.Replace(",", "."), System.Globalization.CultureInfo.InvariantCulture);
var step = decimal.TryParse(Step?.Replace(",", "."), out var stepVal) ? stepVal : 1;
var valueToUpdate = Value != null ? ConvertToDecimal(Value) : default;
var valueToUpdate = ConvertToDecimal(Value);
var newValue = valueToUpdate + (stepUp ? step : -step);
@@ -135,6 +135,10 @@ namespace Radzen.Blazor
{
if (Format != null)
{
if (Value is IFormattable formattable)
{
return formattable.ToString(Format, Culture);
}
decimal decimalValue = ConvertToDecimal(Value);
return decimalValue.ToString(Format, Culture);
}
@@ -297,7 +301,11 @@ namespace Radzen.Blazor
TValue newValue;
try
{
if (ConvertValue != null)
if (value is TValue typedValue)
{
newValue = typedValue;
}
else if (ConvertValue != null)
{
newValue = ConvertValue($"{value}");
}
@@ -311,14 +319,14 @@ namespace Radzen.Blazor
newValue = default(TValue);
}
if (object.Equals(Value, newValue) && (!ValueChanged.HasDelegate || !string.IsNullOrEmpty(Format)))
newValue = ApplyMinMax(newValue);
if (EqualityComparer<TValue>.Default.Equals(Value, newValue))
{
await JSRuntime.InvokeAsync<string>("Radzen.setInputValue", input, FormattedValue);
return;
}
newValue = CheckBounds(newValue);
Value = newValue;
if (!ValueChanged.HasDelegate)
{
@@ -330,7 +338,7 @@ namespace Radzen.Blazor
await Change.InvokeAsync(Value);
}
private TValue CheckBounds(TValue newValue)
private TValue ApplyMinMax(TValue newValue)
{
if (Max == null && Min == null)
{
@@ -343,6 +351,7 @@ namespace Radzen.Blazor
return ConvertFromDecimal(Max.Value);
if (Min.HasValue && c.CompareTo(Min.Value) < 0)
return ConvertFromDecimal(Min.Value);
return newValue;
}
decimal? newValueAsDecimal;
@@ -352,7 +361,7 @@ namespace Radzen.Blazor
}
catch
{
newValueAsDecimal = default(TValue) == null ? default(decimal?) : (decimal)ConvertType.ChangeType(default(TValue), typeof(decimal));
newValueAsDecimal = default;
}
if (newValueAsDecimal > Max)
@@ -415,22 +424,14 @@ namespace Radzen.Blazor
await base.SetParametersAsync(parameters);
if (minChanged && Min.HasValue && Value != null && IsJSRuntimeAvailable)
if (minChanged && IsJSRuntimeAvailable)
{
decimal decimalValue = (decimal)Convert.ChangeType(Value, typeof(decimal));
if (decimalValue < Min.Value)
{
await InternalValueChanged(Min.Value);
}
await InternalValueChanged(Value);
}
if (maxChanged && Max.HasValue && Value != null && IsJSRuntimeAvailable)
if (maxChanged && IsJSRuntimeAvailable)
{
decimal decimalValue = (decimal)Convert.ChangeType(Value, typeof(decimal));
if (decimalValue > Max.Value)
{
await InternalValueChanged(Max.Value);
}
await InternalValueChanged(Value);
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.ComponentModel;
using System.Globalization;
namespace RadzenBlazorDemos;
[TypeConverter(typeof(DollarsTypeConverter))]
public readonly record struct Dollars(decimal Amount) : IComparable<decimal>
{
public int CompareTo(decimal other)
{
return Amount.CompareTo(other);
}
public string ToString(string format, CultureInfo culture = null) => Amount.ToString(format, culture ?? CultureInfo.CreateSpecificCulture("en-US"));
public override string ToString() => Amount.ToString("C2", CultureInfo.CreateSpecificCulture("en-US"));
}
public class DollarsTypeConverter : TypeConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
{
if (sourceType == typeof(decimal) ||
sourceType == typeof(string))
{
return true;
}
return base.CanConvertFrom(context, sourceType);
}
public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType)
{
if (destinationType == typeof(decimal))
return true;
return base.CanConvertTo(context, destinationType);
}
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
{
if (value is decimal d)
return new Dollars(d);
if (value is string s)
return decimal.TryParse(s, out var val) ? new Dollars(val) : null;
return base.ConvertFrom(context, culture, value);
}
public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType)
{
if (destinationType == typeof(decimal) && value is Dollars d)
return d.Amount;
return base.ConvertTo(context, culture, value, destinationType);
}
}

View File

@@ -0,0 +1,35 @@
using System;
using System.Globalization;
namespace RadzenBlazorDemos;
public record struct Temperature(decimal DegreesCelsius)
: IFormattable
{
public decimal Celsius => DegreesCelsius;
public decimal Fahrenheit => DegreesCelsius * 9 / 5 + 32;
public decimal Kelvin => DegreesCelsius + 273.15m;
public override string ToString() => ToString("G");
public string ToString(string format) => ToString(format, CultureInfo.CurrentCulture);
public string ToString(string format, IFormatProvider provider)
{
provider ??= CultureInfo.CurrentCulture;
if (string.IsNullOrEmpty(format))
format = "G";
switch (format.ToUpperInvariant())
{
case "G":
case "C":
return $"{Celsius.ToString("F2", provider)} °C";
case "F":
return $"{Fahrenheit.ToString("F2", provider)} °F";
case "K":
return $"{Kelvin.ToString("F2", provider)} K";
default:
throw new FormatException($"The {format} format string is not supported.");
}
}
}

View File

@@ -0,0 +1,25 @@
<div class="rz-p-12 rz-text-align-center">
<RadzenStack Orientation="Orientation.Horizontal">
<RadzenStack>
<div>A <code>Temperature</code> type that implements <code>IFormattable</code>. Value in Celsius: @value</div>
<RadzenNumeric TValue="Temperature?" Format="F" @bind-Value=@value ConvertValue="ParseToTemperature" ShowUpDown="false"
InputAttributes="@(new Dictionary<string, object>() { { "aria-label", "enter value" } })"/>
</RadzenStack>
<RadzenStack>
<div>A <code>Dollars</code> type that provides a <code>TypeConverter</code>, therefore <code>Min</code>, <code>Max</code>, and <code>Step</code> are supported.</div>
<RadzenNumeric TValue="Dollars?" @bind-Value="@dollarValue" Min="1" Max="250" Step="5"
InputAttributes="@(new Dictionary<string, object>() { { "aria-label", "enter value" } })"/>
</RadzenStack>
</RadzenStack>
</div>
@code{
Temperature? value = new(50.5m);
Temperature? ParseToTemperature(string input)
{
return decimal.TryParse(input, out var val) ? new Temperature(val) : null;
}
Dollars? dollarValue = new Dollars(2.50m);
}

View File

@@ -67,4 +67,14 @@
</RadzenText>
<RadzenExample ComponentName="Numeric" Example="NumericConvertValue">
<NumericConvertValue />
</RadzenExample>
<RadzenText TextStyle="TextStyle.H5" TagName="TagName.H2" class="rz-pt-8">
Custom Numeric Type Support
</RadzenText>
<RadzenText TextStyle="TextStyle.Body1" class="rz-mb-8">
Types that can be converted from a string, via a <code>TypeConverter</code> or assigning <code>ConvertValue</code>, get basic support from <strong>Numeric</strong>. If the type implements a TypeConverter that can convert to/from decimal, then <code>Step</code> and <code>Min</code>/<code>Max</code> are supported. If the type implements <code>IFormattable</code>, <code>Format</code> strings will be passed to it.
</RadzenText>
<RadzenExample ComponentName="Numeric" Example="NumericCustomType">
<NumericCustomType />
</RadzenExample>